User:Opencooper/highlightStrings.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// Highlight some errors
// License: CC0
// Attribution for configure icon (MIT license): https://commons.wikimedia.org/wiki/File:OOjs_UI_icon_advanced_apex.svg
// Attribution for report icon (MIT license): https://commons.wikimedia.org/wiki/File:OOjs_UI_icon_feedback-ltr.svg

// TODO: Create a configuration page where rules can be enabled/disabled.
// TODO: test this and other scripts on other skins, including redesign
// TODO: Check out TreeWalker API; maybe useful

/*
    Principles:
        * Focus on formatting, not content (e.g. spelling) – testing out if necessary
        * Minimize false positives, and make highlights optional if they are
          too noisy
        * Preserve original formatting of article if possible
        * Try to do something in the DOM first rather than matching the HTML
          if it can be done elegantly
*/

/* jshint esversion: 10 */
/* jshint jquery: true */
/* jshint laxbreak: true */
/* global mw */
/* global document */
/* global window */
/* global navigator */
/* global location */
/* global console */
/* global alert */
/* global CSS */
// <nowiki>

// TODO: compare to https://en.wikipedia.org/wiki/Wikipedia:WikiProject_Check_Wikipedia/List_of_errors
//       and https://en.wikipedia.org/wiki/Wikipedia:AutoWikiBrowser/General_fixes

"use strict";

function printError(message, source, lineno, colno, error) {
	if (source.includes("highlightStrings")) {
	    $("#contentSub").after("<div class='oHL_error'>highlightStrings.js: Error: "
	                           + mw.html.escape(message) + " [line: " + lineno
	                           + ", column: " + colno + "]</div>");
	    addReportButton();
	}

	return false;
}

const matchDescriptions = {};
const filterList = [];
const refSectionsSelector = "#References, #Notes, #Citations, #Bibliography, #Endnotes, #Notes_and_references, #Sources, #Works_cited, #General_sources, #General_references, #Footnotes";
function highlightStrings() {
	window.onerror = printError;
	mw.loader.load("//en.wikipedia.org/w/index.php?title=User:Opencooper/highlightStrings.css&action=raw&ctype=text/css", "text/css");
    preClean();
    manipulateDOM();
    prepHTML();
    replaceHTML();
    postClean();
    displayMatches();
    getWikitext();
    getItalics();
    getDeadInterwikis();
    tweakDisplay();

}

function addReportButton() {
	$(".oHL_error, .oHL_warning").each(function addButton() {
		if ($(this).next().is(".oHL_reportButton")) {
			return;
		}
		
		const error = $(this).text();
		const article = mw.config.get("wgTitle");
		const currentRevision = mw.config.get("wgRevisionId");
		// const latestRevision = mw.config.get("wgCurRevisionId");
		const skin = mw.config.get("skin");
		const userAgent = navigator.userAgent;
		const signature = "~~~~";
		const reportLink = "/wiki/User_talk:Opencooper/highlightStrings?action=edit&section=new&preloadtitle=Bug%20report&preload=User:Opencooper/highlightStringsReportPreload.js"
		                   + "&preloadparams[]=" + encodeURIComponent(article).replaceAll("'", "%27")
		                   + "&preloadparams[]=" + currentRevision
		                   + "&preloadparams[]=" + encodeURIComponent(error).replaceAll("'", "%27")
		                   + "&preloadparams[]=" + encodeURIComponent(skin).replaceAll("'", "%27")
		                   + "&preloadparams[]=" + encodeURIComponent(userAgent).replaceAll("'", "%27")
		                   + "&preloadparams[]=" + signature;
		const feedbackIconURL = "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/OOjs_UI_icon_feedback-ltr.svg/20px-OOjs_UI_icon_feedback-ltr.svg.png";
		$(this).after("<button class='oHL_reportButton cdx-button'><a target='_blank' href='"
		              + reportLink + "'><span class='cdx-icon'><img src='" + feedbackIconURL
		              + "'></span> Report Problem</a></button>");
	});
}

// Sanitize to avoid false positives
function preClean() {
	// Prefetch
	$("head").append("<link rel='prefetch' href='" + wordListURL + "'/>");
	
    // Remove highlight button so we don't run twice
    document.getElementById("hStrings")?.remove();
    
    // Link element
    document.querySelectorAll("#mw-content-text link")?.forEach(e => e.remove());

    // Links
    document.querySelectorAll(".Z3988")?.forEach(e => e.remove());

    // Workaround for coordinates
    document.getElementById("coordinates")?.remove();
    document.querySelectorAll(".geo-nondefault, .geo, .geo-inline-hidden")?.forEach(e => e.remove());
    // Rm template for discussion template
    document.getElementById("tfd")?.remove();
    // Rm redirect notice
    document.querySelectorAll(".mw-redirectedfrom")?.forEach(e => e.remove());
    // Old revision header
    // document.querySelectorAll(".mw-revision")?.forEach(e => e.remove());
    // Draft submission box
    document.querySelectorAll(".ombox")?.forEach(e => e.remove());
    // {{As of}} "[update]"
    document.querySelectorAll(".asof-tag")?.forEach(e => e.remove());
    // Audio boxes
    document.querySelectorAll(".ui-icon-play")?.forEach(e => e.closest(".haudio")?.remove());
    document.querySelectorAll("audio")?.forEach(e => e.removeAttribute("data-durationhint"));
    // Img srcset
    document.querySelectorAll("#mw-content-text img")?.forEach(e => e.removeAttribute("srcset"));
    // Video payload
    document.querySelectorAll("[videopayload]")?.forEach(e => e.removeAttribute("videopayload"));
    // Table sorting
    document.querySelectorAll("[data-sort-value]")?.forEach(e => e.removeAttribute("data-sort-value"));
    // Empty paragraphs added by the parser
    document.querySelectorAll(".mw-empty-elt")?.forEach(e => e.remove());
    // Hidden footer
    document.querySelectorAll(".printfooter")?.forEach(e => e.remove());
    // Maps
    document.querySelectorAll(".mw-graph")?.forEach(e => e.removeAttribute("data-graph-id"));
    document.querySelectorAll(".mw-kartographer-link")?.forEach(e => e.removeAttribute("data-overlays"));
    // MIDI files
    document.querySelectorAll(".mw-ext-score")?.forEach(e => e.removeAttribute("data-midi"));
    // Infobox wrapping
    document.querySelectorAll(".infobox .nowrap")?.forEach(e => e.classList.remove("nowrap"));

    // ARIA attributes
    document.querySelectorAll("[aria-label]")?.forEach(e => e.removeAttribute("aria-label"));
    document.querySelectorAll("[aria-labelledby]")?.forEach(e => e.removeAttribute("aria-labelledby"));

    // Talk page section subscription
    document.querySelectorAll("[data-mw-thread-id]")?.forEach(e => e.removeAttribute("data-mw-thread-id"));
    document.querySelectorAll("[data-mw-comment-end]")?.forEach(e => e.removeAttribute("data-mw-comment-end"));

    // Remove userscript-added stuff
    document.getElementById("siteSub")?.remove();
    // document.getElementById("lastEdit")?.remove(); // Redundant to above
    document.getElementById("wikidataDescription")?.remove();
    document.getElementById("kanjiInfo")?.remove();
    document.getElementById("xtools")?.remove();
    document.getElementById("otherImage")?.remove();
    
    // Use one consistent class for all references
    document.querySelectorAll(".reflist")?.forEach(e => e.classList.add("mw-references-wrap"));
    // Add class to section anchors
    if (mw.config.get("skin") != "minerva") {
        document.querySelectorAll("a[id^='sectiontitlecopy']")?.forEach(e => e.classList.add("oHL_anchorLink"));
    } else {
    	document.querySelectorAll("a[id^='sectiontitlecopy']")?.forEach(e => e.remove());
    }
    // Sometimes the ToC is wrapped in toclimit-
    document.querySelectorAll("[class*='toclimit'] #toc")?.forEach(e => e.parentElement.before(e));
    // Remove show/hide toggle for collapsed elements
    document.querySelectorAll(".mw-collapsible-toggle")?.forEach(e => e.remove());
    // Navboxes contain their own ids which can clash with those on the page,
    // causing issues when the HTML is reparsed, such as CSS being switched
    document.querySelectorAll(".navbox div[id]")?.forEach(e => e.removeAttribute("id"));
    
    document.querySelectorAll("#mw-content-text a[href^='/wiki/']")?.forEach(e => e.classList.add("oHL_wikilink"));
    document.querySelectorAll(".oHL_anchorLink")?.forEach(e => e.classList.remove("oHL_wikilink"));
    document.querySelectorAll(".image")?.forEach(e => e.classList.remove("oHL_wikilink"));
}

function manipulateDOM() {
    const isNonDisambigPage = $(".dmbox").length === 0;
    const isNonSandboxPage = mw.config.get("wgPageName") != "User:Opencooper/sandbox";

    // Portal in See also
    matchDescriptions["oHL-seeAlso-portal"] = ["Portal bar misplacement", "Portal bars should not be placed in the See also section. ([[MOS:ORDER]])"];
    $("#See_also").parent().nextUntil("h2").filter(".navbox").after("<span class='oHL oHL-seeAlso-portal oHL_added'>[Portal↓]</span>");

    // Redlinks in see also
    matchDescriptions["oHL-seeAlso-redlink"] = ["Red link in See also", "The See also section should not contain red links. ([[MOS:NOTSEEALSO]])"];
    $("#See_also").parent().nextUntil("h2").filter("ul").find("a.new").addClass("oHL oHL-seeAlso-redlink");

    // Title case headers
    matchDescriptions["oHL oHL-header-titlecase"] = ["Header case", "Section headers should use title case. ([[MOS:HEADINGS]])"];
    $("#See_Also, #External_Links").addClass("oHL oHL-header-titlecase");

    // Bolded pseudoheader
    matchDescriptions["oHL-pseudoheader"] = ["Pseudoheader", "False headers should not be created using bolding or definition list markup, instead using equal signs. ([[MOS:PSEUDOHEAD]])"];
    $("p b:only-child").each(function findPseudoheaders() {
        if (this.previousSibling === null && this.nextSibling?.textContent === "\n") {
            $(this).addClass("oHL oHL-pseudoheader");
        }
    });
    $("dl dt:only-child").addClass("oHL oHL-pseudoheader");
    filterList.push(".navbox .oHL-pseudoheader", ".sidebar .oHL-pseudoheader");

    // Unattached inline template
    matchDescriptions["oHL-lone-inline"] = ["Unattached inline template", "Inline tags should be preceded by text. ([[WP:CITEFOOT]])"];
    $("p .reference:only-child, p .Inline-Template:only-child").each(function findLoneInlines() {
        if (this.previousSibling === null && this.nextSibling?.textContent === "\n") {
            $(this).addClass("oHL oHL-lone-inline");
        }
    });
    filterList.push("blockquote .oHL-lone-inline");

    // Sections without list item
    matchDescriptions["oHL-non-list"] = ["Section needing list", "Sections such as See also should contain a bulleted list. ([[MOS:SEEALSO]], [[MOS:ELLAYOUT]])"];
    $("#See_also, #External_links").each(function findNonLists() {
        const list = $(this).parent().nextUntil("h2").filter("ul");
        if (list.length === 0) {
            $(this).parent().nextUntil("h2").filter("p").prepend("<span class='oHL oHL-non-list oHL_added'>[*]</span> ");
        }
    });

    // Missing Commons template
    matchDescriptions["oHL-commons-template"] = ["Missing Commons template", "The page has a linked Commons category, but lacks a {{Commons category}} template."];
    if ($(".wb-otherproject-commons").length !== 0
        && $("img[src*='Commons-logo.svg']").length === 0) {
        $("#mw-content-text h2").last().after("<div class='oHL oHL-commons-template oHL_added'>[Needs Commons template]</div>");
    }
    
    // Missing Wikisource template
    matchDescriptions["oHL-wikisource-template"] = ["Missing Wikisource template", "The page has a linked Wikisource page, but lacks a {{Wikisource}} template."];
    if ($(".wb-otherproject-wikisource").length !== 0
        && $("img[src*='Wikisource-logo.svg']").length === 0) {
        $("#mw-content-text h2").last().after("<div class='oHL oHL-wikisource-template oHL_added'>[Needs Wikisource template]</div>");
    }

    // Commons template that should be made inline
    matchDescriptions["oHL-commons-inline"] = ["Lone block template", "The External links section only has a single template, so it should use an inline equivalent, e.g. `* {{Commons-inline}}`."];
    $("#External_links").parent().siblings(".sistersitebox").each(function checkCommonsTemplate() {
    	const sibling = this.nextElementSibling;
    	if (sibling === null || sibling.className.includes("navbox-styles")) {
    		$(this).after("<span class='oHL oHL-commons-inline oHL_added'>[* Make template inline]</span>");
    	}
    });

    // Captions with bolding
    matchDescriptions["oHL-bold-caption"] = ["Bolding in caption", "Captions should not be normally specially formatted, including bolded. ([[MOS:CAPTION]])"];
    $(".thumbcaption b, .thumbcaption .selflink,"
      + " figcaption b, figcaption .selflink").addClass("oHL oHL-bold-caption");
    
    // Empty captions
    matchDescriptions["oHL-missing-caption"] = ["Missing caption", "Images should usually have captions. ([[MOS:CAPTION]])"];
    $(".thumbcaption, figcaption").each(function findEmptyCaptions() {
    	if ($(this).text() == "") {
    		$(this).append("<span class='oHL oHL-missing-caption oHL_added'>[Needs caption]</span>");
    	}
    });

    // Wikilinks in bold text
    matchDescriptions["oHL-bolded-link"] = ["Bolded wikilink", "Bolded text should not contain wikilinks. ([[MOS:BOLDLINK]])"];
    $("b .oHL_wikilink").addClass("oHL oHL-bolded-link");
    filterList.push(".infobox .oHL-bolded-link", ".sidebar .oHL-bolded-link",
                    ".navbox .oHL-bolded-link", ".succession-box .oHL-bolded-link",
                    ".subjectbar .oHL-bolded-link", ".ambox .oHL-bolded-link",
                    ".side-box .oHL-bolded-link");
    

    // Improper header progression
    matchDescriptions["oHL-nonlinear-header"] = ["Improper header progression", "Section headers should be nested sequentially. ([[MOS:BADHEAD]])"];
    $("h2 + h4, h2 + h5, h2 + h6, h3 + h5, h3 + h6, h4 + h6").before("<div class='oHL oHL-nonlinear-header oHL_added'>[Header level]</div>");

    // Thumbnails in infoboxes (uncommon?)
    matchDescriptions["oHL-infobox-thumbnail"] = ["Infobox thumbnail", "Instead of embedding another thumbnail, infoboxes support the image_size/image_upright parameters to modify the thumbnail size."];
    // $(".infobox .thumb").not(".tmulti").not(".mw-kartographer-container").addClass("oHL oHL-infobox-thumbnail");
    $(".infobox figure").addClass("oHL oHL-infobox-thumbnail");

    // External links in body
    matchDescriptions["oHL-body-external"] = ["External links in body", "External links do not belong in the body of an article. ([[WP:ELPOINTS]])"];
    $("#mw-content-text .external").addClass("oHL_external");
    $(".plainlinks .oHL_external, .oHL_external[class*='mw-magiclink'],"
      + " .infobox .oHL_external, .sidebar .oHL_external,"
      + " .mw-references-wrap .oHL_external, .navbox .oHL_external").removeClass("oHL_external");
    $(refSectionsSelector + ", #Further_reading, #Additional_reading,"
      + " #External_links, #Publications").parent().nextUntil("h2").find(".oHL_external").removeClass("oHL_external");
    $(".oHL_external").addClass("oHL oHL-body-external");

    // Unformatted external links
    matchDescriptions["oHL-unformatted-external"] = ["Unformatted external link", "The links in the External links section should have descriptions instead of being plain links. ([[MOS:ELLAYOUT]])"];
    $("#External_links").parent().nextUntil("h2").filter("ul").find(".external.free").addClass("oHL oHL-unformatted-external");

    // Auto-numbered links
    matchDescriptions["oHL-numbered-reflink"] = ["Numbered reference link without title", "Links in citations should have a title and other information for verification. ([[WP:CS:EMBED]])"];
    $(".mw-references-wrap .autonumber").addClass("oHL oHL-numbered-reflink");
    matchDescriptions["oHL-bare-URL"] = ["Bare reference link without title", "Links in citations should have a title and other information for verification. ([[WP:CS:EMBED]])"];
    $(".mw-references-wrap .free").addClass("oHL oHL-bare-URL");
    matchDescriptions["oHL-numbered-extlink"] = ["External link without title", "Links should contain a title. ([[WP:ELCITE]])"];
    $("#External_links").parent().nextUntil("h2").find(".autonumber").addClass("oHL oHL-numbered-extlink");

    // Wikilinks in headers
    matchDescriptions["oHL-header-wikilink"] = ["Header wikilink", "Headers should not contain wikilinks. ([[MOS:NOSECTIONLINKS]])"];
    $(".mw-headline .oHL_wikilink").addClass("oHL oHL-header-wikilink");
    filterList.push(".oHL_anchorLink.oHL-header-wikilink");

    // Big text
    matchDescriptions["oHL-big-text"] = ["Big text", "The HTML &ltbig&gt; element is deprecated and changes to font size should be avoided. ([[MOS:FONTSIZE]])"];
    $("#mw-content-text big").addClass("oHL oHL-big-text");
    // Underlined text
    matchDescriptions["oHL-underlined"] = ["Underlining", "Italics or headers should be used instead of underlining. ([[MOS:UNDERLINE]])"];
    $("#mw-content-text u").addClass("oHL oHL-underlined");
    // Struck out text
    matchDescriptions["oHL-striked-text"] = ["Struck text", "Strikethrough should not be used. ([[MOS:STRIKETHROUGH]])"];
    $("#mw-content-text s, #mw-content-text strike").addClass("oHL oHL-striked-text");
    // Monospaced text
    matchDescriptions["oHL-tt-tag"] = ["tt tag", "The <code>&lt;tt&gt;</code> is deprecated. (see [[MOS:CODE]] for alternatives)"];
    $("#mw-content-text tt").addClass("oHL oHL-tt-tag");

    // Text marked as en
    matchDescriptions["oHL-lang-en"] = ['Text marked "en"', "The <code>&lt;html&gt;</code> tag of every Wikipedia article already identifies the language of the content as English. (Exception: English text embedded within text marked as another lanuage)"];
    $("#mw-content-text span[lang=en]").addClass("oHL oHL-lang-en");
    filterList.push(".mw-ext-cite-error.oHL-lang-en");

    // Sister templates next to reflist
    matchDescriptions["oHL-misplaced-sisterbox"] = ["Sister template next to reflist", "Floating templates cause layout issues with reference lists and should be relocated. (see [[Template:Sister_project#Location]])"];
    $(".sistersitebox + .mw-references-wrap,"
      + " .mw-references-wrap + .sistersitebox").before("<div style='float: right; clear: both;' class='oHL oHL-misplaced-sisterbox oHL_added'>[Relocate box↕]</div>");

    // Floated template after and not before list
    matchDescriptions["oHL-misplaced-template"] = ["Floating template placement", "Floating templates should go before the content they displace."];
    $("ul + .sistersitebox, ul + style + .portal").after("<div style='float: right;' class='oHL oHL-misplaced-template oHL_added'>[Move template up↑]</div>");

    // Quote boxes at end of sections
    matchDescriptions["oHL-misplaced-quotebox"] = ["Floating quote placement", "Quote boxes should be placed after section headers and not before."];
    $(".quotebox + h2").prev().append("<div class='oHL oHL-misplaced-quotebox oHL_added'>[Relocate quote box↕]</div>");

    // Horizontal rules
    matchDescriptions["oHL-hr"] = ["Horizontal rule", "Horizontal rules should not be used for separation. Instead, use section headings."];
    $("hr").before("<span class='oHL-opt oHL-hr oHL_added'>[horizontal rule]</span>");
    filterList.push(".sidebar .oHL-hr", ".infobox .oHL-hr", ".navbox .oHL-hr",
                    ".listen .oHL-hr");

    // Sites using http
    matchDescriptions["oHL-insecure-site"] = ["Insecure site", "Most modern websites support the [[HTTPS]] protocol and external links should be updated to use it."];
    const httpsMarkup = " <span class='oHL-opt oHL-insecure-site oHL_added'>[http]</span>";
    $(".infobox .url a[href^='http:']").after(httpsMarkup);
    $("#External_links").parent().nextUntil("h2").filter("ul").find(".external[href^='http:']").after(httpsMarkup);

    // Flag icons in infoboxes
    matchDescriptions["oHL-infobox-flagicon"] = ["Infobox flag icon", "Flag icons in infoboxes are deprecated. ([[MOS:INFOBOXFLAG]])"];
    $(".infobox .flagicon, .infobox-data img[src*='Flag_of']").addClass("oHL oHL-infobox-flagicon");

    // Breaks in infobox titles
    matchDescriptions["oHL-infobox-title-br"] = ["Infobox title break", "Content should not be manually line-broken, instead letting the browser word-wrap to the appropriate width. Infoboxes usually have dedicated parameters for alternate names."];
    $(".infobox tr").first().find("th br").before(" <span class='oHL-opt oHL-infobox-title-br oHL_added' style='font-size:55%;'>&lt;br&gt;</span>");
    $(".oHL-infobox-title-br + br + .honorific-suffix").prev().prev().remove();

    // Redundant bolding
    matchDescriptions["oHL-redundant-bold"] = ["Redundant bolding", "Definition lists and table headers are already bolded."];
    $("dt b, th b").addClass("oHL oHL-redundant-bold");
    
    // External links which should be internal
    matchDescriptions["oHL-external-wikilink"] = ["External wikilink", "Links to Wikipedia pages should use internal linking syntax (square brackets)."];
    $(".oHL_external[href*='wikipedia.org']").addClass("oHL oHL-external-wikilink");
    filterList.push(".hatnote .oHL-external-wikilink", ".nv-edit .oHL-external-wikilink",
                    ".stub .oHL-external-wikilink", ".dmbox-body .oHL-external-wikilink",
                    ".ambox .oHL-external-wikilink");

    // Cross-namespace wikilinks
    matchDescriptions["oHL-x-namespace-wl"] = ["Cross-namespace wikilink", "Article content should not link to other namespaces. ([[MOS:LINKSTYLE]])"];
    $(".oHL_wikilink[href^='/wiki/Wikipedia:']").addClass("oHL oHL-x-namespace-wl");
    filterList.push(".hatnote .oHL-x-namespace-wl", ".stub .oHL-x-namespace-wl",
                    "#setindexbox .oHL-x-namespace-wl", ".sidebar .oHL-x-namespace-wl",
                    ".navbox-abovebelow .oHL-x-namespace-wl", ".sistersitebox .oHL-x-namespace-wl",
                    ".Inline-Template .oHL-x-namespace-wl", ".portal-bar .oHL-x-namespace-wl",
                    ".spoken-wikipedia .oHL-x-namespace-wl", ".sister-bar .oHL-x-namespace-wl",
                    ".ambox .oHL-x-namespace-wl");

    // Dab links
    matchDescriptions["oHL-dab-link"] = ["Disambiguation link", "Articles should not link to disambiguation pages outside of hatnotes. ([[MOS:LINK#What_generally_should_not_be_linked]])"];
    $(".mw-disambig").each(function findDabLinks() {
        if ($(this).attr("title")?.includes("(disambiguation)")
            || $(this).text().includes("(disambiguation)")) {
            return true;
        }
        
        $(this).addClass("oHL oHL-dab-link");
    });
    
    // Japanese romanization
    matchDescriptions["oHL-romaji"] = ["Japanese romanization", "Unless in the title of a work or a common name, modern romanization should be used for Japanese. ([[WP:ROMAJI]])"];
    const romajiRe = /(o[ou]|uu|aa|ī|wo|cch|m[bp]|ô|ê|î|é)/g;
    $("[lang='ja-Latn']").each(function findRomaji() {
    	const romaji = this.textContent;
        if (romajiRe.test(romaji)) {
        	const romajiHighlight = romaji.replace(romajiRe, "<span class='oHL-opt oHL-romaji'>$1</span>");
            this.innerHTML = this.innerHTML.replace(">" + romaji + "<",
                                                    ">" + romajiHighlight + "<");
        }
    });

    // Thumbnails with link=
    matchDescriptions["oHL-thumbnail-link"] = ["Thumbnail link", "For proper attribution, the links in thumbnails should not be overridden."];
    $("figure > a:not(.mw-file-description, .mw-file-magnify)").parent().addClass("oHL oHL-thumbnail-link");
    // $(".thumbimage").each(function findLinkedThumbs() { // Don't want divs from CSS crop
    //     if ($(this).children(".mw-graph").length) {
    //         return true;
    //     }
    
    //     // .tsingle ignores multi-images
    //     if (!$(this).parent().hasClass("image") && !$(this).parent().hasClass("tsingle")
    //         && !$(this).parent().hasClass("video") && !$(this).parent().hasClass("audio")) {
    //         $(this).parents(".thumb").addClass("oHL oHL-thumbnail-link");
    //     }
    // });

    // Piped interlanguage links
    matchDescriptions["oHL-interlang"] = ["Piped interlanguage link", "Links to non-English articles should not be obscured. ([[MOS:EGG]]) Use {{ill}} instead."];
    const interlanguageRe = /[a-z]{2}\.wikipedia.org/;
    $(".extiw").each(function findPipedInterlangLinks() {
        if ($(this).text().length === 2) {
            return true;
        }

        if (interlanguageRe.test(this.href)) {
            $(this).addClass("oHL oHL-interlang");
        }
    });
    filterList.push(".mw-references-wrap .oHL-interlang", ".navbox .oHL-interlang",
                    ".ambox .oHL-interlang");
                    
    matchDescriptions["oHL-piped-image"] = ["Piped image link", "Links to images should not be obscured ([[MOS:EGG]]). Either embed the image itself or move the link to a parenthetical, making it clear that it's not to an article."];
    $(".oHL_wikilink[href*='File:'], .extiw[href*='File:']").addClass("oHL oHL-piped-image");
    filterList.push(".ambox .oHL-piped-image", ".mw-references-wrap .oHL-piped-image");
    $(".mw-file-element").parent(".oHL-piped-image").removeClass("oHL oHL-opt");

    // Internal links that should be external
    matchDescriptions["oHL-masked-link"] = ["Masked external link", "Links to external websites should not be obscured. ([[MOS:EGG]])"];
    $(".extiw[href^='//doi.org']").addClass("oHL oHL-masked-link");

    // Poem not inside blockquote or verse translation
    matchDescriptions["oHL-unwrapped-poem"] = ["Unwrapped poem", "Quoted poem content needs to be wrapped in {{quote}} or {{verse translation}}. (see [[MOS:BLOCKQUOTE]])"];
    $(".poem").each(function findUnwrappedPoems() {
        if ($(this).parent().is(":not(blockquote):not(td)")) {
            $(this).addClass("oHL oHL-unwrapped-poem");
        }
    });

    const leadSection = $("#mw-content-text h2").first().prevUntil("#mw-content-text");

    // See also hatnote at top
    matchDescriptions["oHL-hatnote-misuse"] = ["See also hatnote", "The see also template is not meant to be used as a hatnote, but rather for subsections. ([[Template:See also]])"];
    let hatnoteLead;
    if ($("#mw-content-text h2").length) {
        hatnoteLead = leadSection
    } else {
        hatnoteLead = $("#mw-content-text .hatnote");
    }
    hatnoteLead.filter(".hatnote").each(function findHatnoteMisuse() {
        if (!isNonSandboxPage) { return false; }
        if ($(this).text().startsWith("See also:")) {
            $(this).prepend("<span class='oHL oHL-hatnote-misuse oHL_added'>[rm]</span> ");
        }
    });
    
    // Sidebar in the lead
    matchDescriptions["oHL-sidebar-placement"] = ["Sidebar placement", "Sidebars in the lead are discouraged, and if placed there, preferably after the infobox or lead image. ([[MOS:LEAD#Sidebars]])"];
    leadSection.filter(".sidebar").each(function findSidebarMisplacement() {
    	$(this).after("<div class='oHL oHL-sidebar-placement oHL_added'>[Move sidebar after lead↓]</div>");
    });
    
    // Hatnote not at top of a section
    matchDescriptions["oHL-low-hatnote"] = ["Low hatnote", "Hatnotes should be placed at the top of subsections. ([[WP:HNP]])"];
    $("p + .hatnote").after("<div class='oHL oHL-low-hatnote oHL_added'>[Move hatnote up↑]</div>");

    // Hatnote below maintenance template
    matchDescriptions["oHL-hatnote-placement"] = ["Hatnote placement", "Hatnotes should be placed above maintenance templates. ([[WP:HNP]])"];
    $(".ambox + .hatnote").after("<div class='oHL oHL-hatnote-placement oHL_added'>[Move hatnote up↑]</div>");

    // Italics for long quotes
    matchDescriptions["oHL-italquote"] = ["Italicized quote", "Quotations should not be italicized. ([[MOS:NOITALQUOTE]])"];
    $("#mw-content-text p i").each(function findItalQuotes() {
        if ($(this).text().length >= 80) {
            let target = this;
            if ($(this).parent("a").length) {
                target = this.parentElement;
            }
            
            $(target).after(" <span class='oHL oHL-italquote oHL_added'>[noitalquote]</span>");
        }
    });

    // Empty sections
    matchDescriptions["oHL-empty-section"] = ["Empty section", "Empty sections should be deleted or filled by a placeholder. (e.g. {{Empty section}})"];
    const emptySectionMarkup = "<span class='oHL oHL-empty-section oHL_added'>[Empty section]</span>";
    $("#mw-content-text h2, #mw-content-text h3, #mw-content-text h4,"
      + " #mw-content-text h5, #mw-content-text h6").each(function findEmptySections() {
        // We don't count headers that only contain subheaders
        const headerLevel = this.tagName[1];
        const nextHeader = $(this).next("h2, h3, h4, h5, h6");
        if (nextHeader.length) {
            const nextHeaderLevel = nextHeader[0].tagName[1];
            if (nextHeaderLevel > headerLevel) {
                return true;
            }
        }

        if ($(this).nextUntil(nextHeader).not("style, span").length === 0) {
            $(this).after(emptySectionMarkup);
        }
    });
    const lastSection = $("#mw-content-text h2").last();
    if (lastSection.next(".navbox, .navbox-styles").length) {
        lastSection.after(emptySectionMarkup);
    }
    $(".reflist").each(function findEmptyReflists() {
    	if ($(this).children().length == 0) {
    		$(this).after(emptySectionMarkup);
    	}
    });
    filterList.push(".toc .oHL-empty-section");

    // Anchor links inside article itself
    matchDescriptions["oHL-anchor-link"] = ["Self anchor link", "Piped links that lead to subsections within the same article should not be hidden, but indicated with a section marker such as by using {{Section link}}. (see [[MOS:EGG]] and [[principle of least surprise]])"];
    $("#mw-content-text a[href^='#']").addClass("oHL_sl");
    $("#toc .oHL_sl, sup .oHL_sl, .mw-cite-backlink .oHL_sl,"
      + " .reference-text .oHL_sl, .mw-kartographer-map.oHL_sl,"
      + " .mw-kartographer-link.oHL_sl, .oHL_sl[href^='#CITEREF'],"
      + " #catlinks .oHL_sl, .navbox .oHL_sl, .sidebar .oHL_sl").removeClass("oHL_sl");
    $(".oHL_sl").each(function findAnchorLinks() {
        if (!$(this).text().includes("§")) {
            $(this).before("<span class='oHL-opt oHL-anchor-link oHL_added'>[§]</span> ");
        }
    });
    filterList.push(".NavHead .oHL-anchor-link", ".wikicite .oHL-anchor-link");

    // See also section links for other articles
    matchDescriptions["oHL-seeAlso-section-link"] = ["See also section link", "Links to subsections of other articles can be indicated using {{Section link}}. (see [[MOS:EGG]] and [[principle of least surprise]])"];
    $("#See_also").parent().nextUntil("h2").find(".oHL_wikilink[href*='#']").each(function findSeeAlsoSectionLinks() {
        if (!$(this).text().includes("§")) {
            $(this).after(" <span class='oHL oHL-seeAlso-section-link oHL_added'>[§]</span>");
        }
    });
    filterList.push(".navbox .oHL-seeAlso-section-link", ".mw-headline .oHL-seeAlso-section-link");

    // Find broken section links
    matchDescriptions["oHL-broken-section-link"] = ["Broken section link", "A link points to a subsection that was renamed or removed."];
    $("#mw-content-text [href^='#']").each(function findBrokenSectionLinks() {
        const target = $(this).attr("href");
        if (target == "#" || target.startsWith("#cite_")) {
            return true;
        }

        const targetId = target.substring(1);
        const targetSelector = $("#" + $.escapeSelector(targetId));
        if (targetSelector.length === 0) {
            $(this).addClass("oHL oHL-broken-section-link");
        }
    });
    filterList.push("#toc .oHL-broken-section-link",
                    ".mw-kartographer-link.oHL-broken-section-link");

    // Images in see also or external links sections
    matchDescriptions["oHL-misplaced-image"] = ["Misplaced images", "Images should be placed in the body of an article, supporting the text. ([[MOS:IMAGERELEVANCE]])"];
    $("#See_also, #External_links").each(function findMisplacedImages() {
        const images = $(this).parent().nextUntil("h2").find("figure, .tmulti, .gallery");
        if (images.length) {
            $(this).parent().after("<div class='oHL oHL-misplaced-image oHL_added'>[Move images]</div>");
        }
    });

    // External links section formatted as a reflist
    matchDescriptions["oHL-ext-reflist"] = ["External links w/ reflist format", "The external links section should not use citation templates. ([[WP:ELCITE]])"];
    const externalLinksWrapped = $("#External_links").parent().nextUntil("h2").filter(".refbegin");
    if (externalLinksWrapped) {
        externalLinksWrapped.before("<div class='oHL oHL-ext-reflist oHL_added'>[Wrapped in Refbegin]</div>");
    }

    // Adjacent lists
    matchDescriptions["oHL-spaced-list"] = ["Adjacent lists", "Blank lines between list items creates separate lists. ([[MOS:BULLETLIST]])"];
    $("#bodyContent ul + ul, dl + dl").addClass("oHL_adj_li");
    $(".gallery.oHL_adj_li, .portalbox + .oHL_adj_li").removeClass("oHL_adj_li");
    $(".oHL_adj_li").each(function findSpacedLists() {
        const previousSibling = $(this).prev();
        if (!previousSibling.hasClass("oHL_adj_li")) {
            previousSibling.before("<div class='oHL oHL-spaced-list oHL_added'>[Spaced list]</div>");
        }
    });
    filterList.push(".navbox .oHL-spaced-list", ".sidebar .oHL-spaced-list");

    // Lowercase in infobox values
    matchDescriptions["oHL-lower-infobox"] = ["Infobox lowercase", "Text in infoboxes should not be arbritrarily lowercased."];
    $(".infobox td").each(function findLowercasedInfoboxes() {
        const text = $(this).text().trim();
        if (text.length === 0) { return true; }
        if (text.includes(".")) { return true; }

        const firstLetter = text[0];
        if (/[a-z]/.test(firstLetter)) {
            $(this).prepend("<span class='oHL oHL-lower-infobox oHL_added'>[↑]</span>");
        }
    });

    // Lowercase See also items
    matchDescriptions["oHL-lower-seeAlso"] = ["See also lowercase", "The links in See also sections are normally capitalized."];
    $("#See_also").parent().next("ul").children("li").each(function findLowercasedSeeAlsos() {
        const text = $(this).text().trim();
        const firstLetter = text[0];
        if (/[a-z]/.test(firstLetter)) {
            $(this).prepend("<span class='oHL oHL-lower-seeAlso oHL_added'>[↑]</span>");
        }
    });

    // Infobox website not using {{URL}}
    matchDescriptions["oHL-plain-site"] = ["Plain infobox website", "External links in infoboxes should be wrapped in {{URL}}. (e.g. see the website parameter at [[Template:Infobox person]])"];
    $(".infobox .external").each(function findFullURLs() {
        const text = $(this).text();
        if (/^http/.test(text)) {
            $(this).prepend("<span class='oHL oHL-plain-site oHL_added'>[URL]</span> ");
        }
    });

    // Redundant quote marks in blockquotes
    matchDescriptions["oHL-redundant-quotes"] = ["Redundant quote marks", "Block quotes should not use enclosing quote marks. ([[MOS:BLOCKQUOTE]])"];
    $("blockquote p, .templatequote p, .quotebox-quote").each(function findRedundantQuotes() {
        const html = $(this).html();
        if (html.includes(`"</span>'`)) { // {{" '}} template
        	return true;
        }
        const text = $(this).text();
        if (text.charAt(0) == '"') {
            $(this).html(html.slice(1));
            $(this).prepend("<span class='oHL oHL-redundant-quotes'>\"</span>");
        }
    });

    // Wikilinked parenthesis or punctuation
    matchDescriptions["oHL-wikilink-punc"] = ["Wikilinked punctuation", "Punctuation should not be wikilinked, as it is not part of the link."];
    $(".oHL_wikilink").each(function findWikilinkedPunctuation() {
        if (!isNonDisambigPage) { return false; }
        const text = $(this).text();
        const finalChar = text.at(-1);
        if (text.substring(text.length-2) == "..") { return true; } // ellipses
        if ('.,;:")]/'.includes(finalChar)) {
            if (finalChar == ".") {
                if (text.endsWith("Inc.") || text.endsWith("Sr.")
                    || text.endsWith("Jr.") || text.endsWith("Bros.")
                    || text.endsWith("Co.")) { return true; }
                if (/\.[A-Z]\./.test(text)) { return true; }
                if (/ [A-Z]\.$/.test(text)) { return true; }
                if (/\..*\./.test(text)) { return true; }
            }

            const html = $(this).html(); // use HTML to preserve formatting, e.g. ''Inception'' (film)
            const replaceRe = new RegExp("(.*)\\" + finalChar);
            $(this).html(html.replace(replaceRe, "$1"));
            $(this).append("<span class='oHL-opt oHL-wikilink-punc'>" + finalChar + "</span>");         
        }
    });
    $("#See_also").parent().nextUntil("h2").filter("ul, .columns, .div-col").find(".oHL-wikilink-punc").removeClass("oHL-opt");
    filterList.push(".reference .oHL-wikilink-punc", ".external .oHL-wikilink-punc",
                    ".hatnote .oHL-wikilink-punc", ".mw-kartographer-link .oHL-wikilink-punc",
                    ".IPA .oHL-wikilink-punc", ".infobox-label .oHL-wikilink-punc",
                    ".listen .oHL-wikilink-punc");
    
    // Underscore in wikilink
    matchDescriptions["oHL-wl-underscore"] = ["Wikilink underscore", "Article text should not contain underscores."];
    $(".oHL_wikilink").each(function findUnderscoredWikilinks() {
        if ($(this).text().includes("_")) {
            $(this).addClass("oHL oHL-wl-underscore");
        }
    });
    
    // Check proper sections
    matchDescriptions["oHL-missing-ref-section"] = ["Missing ref section", "Articles should have a References section. ([[MOS:LAYOUT]])"];
    if (isNonDisambigPage && isNonSandboxPage) {
        if ($(refSectionsSelector).length === 0) {
            $("#mw-content-text").append("<h2 class='oHL oHL-missing-ref-section oHL_added'>[References]</h2>");
        }

        checkSectionOrder();
    }
    
    // Tables without headers
    matchDescriptions["oHL-table-header"] = ["Table without headers", "Tables should have headers for the columns."];
    $("table").each(function findHeaderlessTables() {
        if ($(this).hasClass("succession-box")
            || $(this).hasClass("sistersitebox")
            || $(this).hasClass("ambox")
            || $(this).hasClass("ombox")
            || $(this).parent().hasClass("stub")
            || $(this).attr("role") == "presentation") {
            return true; 
        }
        
        if ($(this).find("th").length === 0) {
            $(this).prepend("<span class='oHL oHL-table-header oHL_added'>[Missing table headers]</span>");
        }
    });
    filterList.push("table .oHL-table-header");

    // Unindented math
    matchDescriptions["oHL-math-indent"] = ["Unindented math", "Math placed on its own line should be indented using <code>&lt;math display=block&gt;></code> ([[MOS:MATH#Using_LaTeX_markup]])"];
    $(".mwe-math-element").each(function findUnindentedMath() {
        if (this.previousSibling === null && this.parentElement.tagName == "P") {
            $(this).prepend("<span class='oHL oHL-math-indent oHL_added'>[→]</span> ");
        }
    });
    
    // Sentences missing a period
    matchDescriptions["oHL-missing-sentence-period"] = ["Missing sentence period", "Sentences should end with a full stop."];
    $(".reference").each(function findUnterminatedSentences() {
        const prevChar = this.previousSibling?.textContent.slice(-1);
        const nextChars = this.nextSibling?.textContent.slice(0, 2);
        if (prevChar  === null || nextChars === null) { return true; }
        
        if (/[a-z]/.test(prevChar) && / [A-Z]/.test(nextChars)) {
            $(this).before("<span class='oHL oHL-missing-sentence-period oHL_added'>[.]</span>");
        }
    });
    
    // Paragraphs missing a period
    matchDescriptions["oHL-missing-paragraph-period"] = ["Missing paragraph period", "Paragraphs should end with a full stop."];
    $("#mw-content-text p").each(function findUnterminatedParagraphs() {
        if ($(this).children(".oHL-pseudoheader").length) { return true; }

        const element = this.cloneNode(true);
        element.querySelectorAll("style, sup")?.forEach(e => e.remove());
        const paragraph = $(element).text();

        // Delete quotes and trailing whitespace
        const paragraphCleaned = paragraph.replace(/["”'’]/g, "").replace(/\s+$/, "");
        const lastCharacter = paragraphCleaned.slice(-1);

        const isFinalParagraph = $(this).next().is("h2, h3, h4, h5, h6, #toc");
        let searchChars = ".?!";
        if (!isFinalParagraph || !isNonDisambigPage) {
        	searchChars += ",:)";
        }

        if (!searchChars.includes(lastCharacter)) {
            $(this).append("<span class='oHL oHL-missing-paragraph-period oHL_added'>[.]</span>");
        }
    });
    filterList.push("blockquote .oHL-missing-paragraph-period", ".quotebox .oHL-missing-paragraph-period",
                    ".gallerytext .oHL-missing-paragraph-period", "th .oHL-missing-paragraph-period",
                    ".poem .oHL-missing-paragraph-period", ".infobox .oHL-missing-paragraph-period");

    // Breaks between paragraphs
    matchDescriptions["oHL-br"] = ["Paragraph breaks", "Paragraph breaks should use newlines (not the <code>&lt;br&gt;</code> element) and there should only be a single break between paragraphs."];
    // $("p br").each(function findParagraphBreaks() {
    //  if (this.previousSibling === null) {
    //      $(this).before("<span class='oHL oHL-br'>&lt;br&gt;</span>");
    //  }
    // });
    $("#mw-content-text p br").before("<span class='oHL oHL-br oHL_added'>&lt;br&gt;</span>");
    // Filter out stub templates
    $(".oHL-br").each(function filterStubBreaks() {
        if ($(this).parent().next("style").next(".stub").length) {
            $(this).remove();
        }
    });
    filterList.push(".poem .oHL-br", ".chemf .oHL-br", ".music-symbol .oHL-br");

    // Improper infobox lists
    matchDescriptions["oHL-infobox-br"] = ["Improper infobox list", "Embedded lists in infoboxes should use semantic markup with {{ubl}}. ([[MOS:UBLIST]])"];
    $(".infobox-data br, .infobox br + a, .infobox br + .url").before("<span class='oHL-opt oHL-infobox-br oHL_added' style='font-size:55%;'>&lt;br&gt;</span> ");
    filterList.push("b + .oHL-infobox-br", ".infobox-header .oHL-infobox-br",
                    ".nickname + .oHL-infobox-br");
    $(".oHL-infobox-br + br + .birthplace").prev().prev().remove();
    $(".oHL-infobox-br + br + .deathplace").prev().prev().remove();
    $(".oHL-infobox-br + br + b").prev().prev().remove(); // Pseudoheaders
    $(".oHL-infobox-br + br + .oHL-infobox-br").prev().prev().remove(); // double matches
    $("a[title='Least Concern']").prev(".oHL-infobox-br").remove(); // Endangered status

    // Double quotes which should be nested
    matchDescriptions["oHL-nested-quote"] = ["Nested quote marks", "Nested quote marks should alternate between double and single. ([[MOS:QWQ]])"];
    $("cite, q").each(function findDupeDoubleQuotesKerned() {
    	const text = this.textContent;
    	if (text.includes('"')) {
    		let html = this.innerHTML;
    		html = html.replaceAll('<span class="cs1-kern-left"></span>"',
    		                       '<span class="cs1-kern-left"></span><span class="oHL oHL-nested-quote">"</span>');
    		html = html.replaceAll('"<span class="cs1-kern-right"></span>',
    		                       '<span class="oHL oHL-nested-quote">"</span><span class="cs1-kern-right"></span>');
    		this.innerHTML = html;
    	}
    });
    $("cite .external, q").each(function findDupeDoubleQuotes() {
    	const text = this.textContent;
    	if (text.includes('"')) {
    		let html = this.innerHTML;
    		html = html.replace(/(?<=<[^>]*)"(?=[^<]*>)/g, "€€"); // guard
    		html = html.replace(/^""/, '"<span class="oHL oHL-nested-quote">"</span>');
    		html = html.replace(/""$/, '<span class="oHL oHL-nested-quote">"</span>"');
    		html = html.replace(/ "/g, ' <span class="oHL oHL-nested-quote">"</span>');
    		html = html.replace(/" /g, '<span class="oHL oHL-nested-quote">"</span> ');
    		html = html.replaceAll("€€", '"'); // unguard
    		this.innerHTML = html;
    	}
    });

    // Images without |thumb|
    matchDescriptions["oHL-frameless-img"] = ["Frameless image", "Most image thumbnails should use <code>|thumb|</code>, along with a caption."];
    $("span[typeof='mw:File']").addClass("oHL oHL-frameless-img");
    // $(".image").each(function findFramelessImages() {
    //     if ($(this).closest(".thumb, table").length === 0) {
    //         $(this).children("img").addClass("oHL oHL-frameless-img");
    //     }
    // });
    // filterList.push(".noviewer.oHL-frameless-img", ".noviewer .oHL-frameless-img",
    //                 ".navbox .oHL-frameless-img", ".dmbox .oHL-frameless-img");

    // Improper indentation
    matchDescriptions["oHL-bad-indent"] = ["Improper indentation", "Quotations, tables, formulas, and generic lists are not definition lists and should use proper semantic markup. (see: [[User:Opencooper/Proper indentation]])"];
    $("dd, dd ul, dd ol").addClass("oHL_indent");
    $("dt + .oHL_indent, dd + .oHL_indent, .oHL_indent dl .oHL_indent").removeClass("oHL_indent");
    $(".oHL_indent sub").parent().removeClass("oHL_indent"); // Chem formulas
    $(".oHL_indent").parent("dl").addClass("oHL_bad-indent");

    $(".oHL-spaced-list + .oHL_bad-indent").prev().remove();
    $(".mwe-math-element").closest(".oHL_bad-indent").removeClass("oHL_bad-indent");

    $(".oHL_bad-indent").each(function findBadIndents() {
        const previousSibling = $(this).prev();
        if (!previousSibling.hasClass("oHL_bad-indent")) {
            $(this).before("<div class='oHL oHL-bad-indent oHL_added'>[Improper indent]</div>");
        }
    });

    // Unindented definition terms
    matchDescriptions["oHL-dl-indent"] = ["Unindented term definition", "Definition lists consist of term–definition pairs, the latter of which should be indented. (see: [[MOS:DEFLIST]])"];
    $("dl + p + dl, dl + p + h2").prev().prepend("<span class='oHL oHL-dl-indent oHL_added'>[→]</span> ");

    // <center> tag
    matchDescriptions["oHL-center"] = ["Center tag", "The HTML <code>&lt;center&gt;</code> tag is deprecated. Content, such as captions, should not be arbitrarily centered."];
    $("center").each(function findCenterTags() {
        $(this).before("<div class='oHL oHL-center oHL_added'>[center tag]</div>");
    });
    
    // Centered captions
    matchDescriptions["oHL-caption-center"] = ["Centered caption", "Content, such as captions, should not be arbitrarily centered."];
    $("figcaption center, figcaption .center, .thumb .center").addClass("oHL oHL-caption-center");

    // Unnecessary ToC
    matchDescriptions["oHL-toc"] = ["Unnecessary ToC", "Stubs without unique subsections do not need a table of contents."];
    const firstSection = $(".toctext").first().text();
    if (firstSection && "See also, References, Sources, External links".includes(firstSection)) {
        $("#toc").after("<div class='oHL-opt oHL-toc oHL_added'>[Hide ToC]</div>");
    }

    // Missing lead
    matchDescriptions["oHL-missing-lead"] = ["Missing lead", "The article is missing a lead. ([[MOS:LEAD]])"];
    if ($("#toc").length && $("#toc").prev().length === 0) {
        $("#toc").before("<div class='oHL oHL-missing-lead oHL_added'>[Missing lead]</div>");
    }
    
    // Table with missing cells
    matchDescriptions["oHL-missing-cells"] = ["Missing table cells", "Tables should not have missing cells. Add empty cells with placeholders (<code>—</code>) instead."];
    $(".wikitable").each(function findTables() {
        const tableElement = this;
        const rows = $(this).find("tr");
        const firstRow = $(rows).first();
        const columnsCount = $(firstRow).find("th").length;
        
        rows.each(function findMissingCells() {
            const cellsCount = $(this).children().length;
            if (cellsCount < columnsCount) {
                $(tableElement).after("<div class='oHL-opt oHL-missing-cells oHL_added'>[Table missing cells]</div>");
                return false;
            }
        });
    });
    
    // Table with both vertical and horizontal headers
    // matchDescriptions["oHL-redundant-headers"] = ["Table header misuse", "Tables should not have both horizontal and vertical headers."];
    // $(".wikitable").each(function findTableHeaderMisuse() {
    //     const hasVerticalHeader = $(this).find("tr:first-child th").length != 0;
    //     const hasHorizontalHeader = $(this).find("tr:not(:first-child) th").length != 0;
        
    //     if (hasVerticalHeader && hasHorizontalHeader) {
    //         $(this).after("<div class='oHL-opt oHL-redundant-headers oHL_added'>[Table misuses headers]</div>");
    //     }
    // });
    
    // Authority control before navboxes
    matchDescriptions["oHL-auth-placement"] = ["Authority control placement", "Authority control templates should go after navboxes. ([[MOS:ORDER]])"];
    $(".authority-control + .navbox").before("<div class='oHL oHL-auth-placement oHL_added'>[Move authority control down↓]</div>");

    // Long lists
    // $("#bodyContent ul").addClass("oHL_list");
    // $("#toc .oHL_list, .navbox .oHL_list, #catlinks .oHL_list,"
    //   + " .refbegin .oHL_list, .div-col .oHL_list, .sidebar .oHL_list").removeClass("oHL_list");
    // $(".oHL_list").each(function findLongLists() {
    //  const items = $(this).children("li").length;
    //  if (items >= 8) {
    //      $(this).before("<div class='oHL-opt oHL-div-col oHL_added'>[Split into columns]</div>");
    //  }
    // });

    // Lists broken up by an image
    matchDescriptions["oHL-list-img"] = ["Image in list", "Images inside lists break them up into separate lists, and should go before the list. ([[MOS:LIST#Images_and_lists]])"];
    $("ul + figure + ul, ul + .tmulti + ul").before("<div class='oHL oHL-list-img oHL_added'>[Move image interrupting list]</div>");

    // Duplicate references
    matchDescriptions["oHL-duplicated-ref"] = ["Duplicated ref", "References should not be duplicated, instead using named references. (see: [[WP:NAMEDREFS]])"];
    const refLinks = [];
    $(".mw-references-wrap cite").each(function findReferenceLinks() {
        const firstLink = $(this).find(".external").first();
        if (!firstLink.length) { return true; }
        
        const href = $(firstLink).attr("href");
        const hrefCleaned = href.replace(/https?:\/\//, "");
        refLinks.push(hrefCleaned);
    });
    const refLinksUnique = new Set(refLinks);
    if (refLinksUnique.size != refLinks.length) {
        for (const link of refLinksUnique) {
            const count = refLinks.filter(l => l === link).length;
            if (count > 1) {
                const dupes = $(".mw-references-wrap a[href*='" + link + "']");
                dupes.first().addClass("oHL oHL-duplicated-ref");
                dupes.slice(1).addClass("oHL_dupe_ref");
            }
        }
    }

    // Duplicate images
    matchDescriptions["oHL-duplicate-img"] = ["Duplicate image", "In most cases, images should not be repeated."];
    let imageLinks = [];
    $(".mw-file-description").each(function getImages() {
    	imageLinks.push($(this).attr("href"));
    });
    const imagesUnique = new Set(imageLinks);
    if (imagesUnique.size != imageLinks.length) {
        for (const link of imagesUnique) {
            const count = imageLinks.filter(l => l === link).length;
            if (count > 1) {
                $(".mw-file-description[href='" + link + "']").not(":first").children("img").addClass("oHL oHL-duplicate-img");
            }
        }
    }
    filterList.push(".stub .oHL-duplicate-img", ".navbox .oHL-duplicate-img",
                    ".sidebar .oHL-duplicate-img", ".ambox .oHL-duplicate-img",
                    ".chess-board .oHL-duplicate-img");

    // Thumbnails at end end of sections
    matchDescriptions["oHL-thumb-placement"] = ["Thumbnail placement", "Floating content, such as thumbnails, should go before the content they displace."];
    $("p + figure + h2, ul + figure + h2").before("<div class='oHL oHL-thumb-placement oHL_added'>[Relocate image]</div>");
    filterList.push(".mw-halign-center + .oHL-thumb-placement");

    // Thumbnails larger than actual size
    matchDescriptions["oHL-overlarge-img"] = ["Overlarge image", "Thumbnails should not be set to sizes larger than the actual image file, resulting in upscaling."];
    $("[typeof^='mw:File'] img").not(".noviewer").each(function findOverlargeThumbnails() {
    	const src = $(this).attr("src");
    	if (src.endsWith(".svg.png")) { return true; }
    	
        const displayWidth = $(this).attr("width");
        const originalWidth = $(this).attr("data-file-width");
        if (parseInt(displayWidth) > parseInt(originalWidth)) {
            $(this).addClass("oHL oHL-overlarge-img");
        }
    });
    
    // Dash in front of quote attributions
    // Maybe not. Reference: https://en.wikipedia.org/wiki/Wikipedia:Manual_of_Style#Other_uses_(em_dash_only)
    // $(".quotebox cite").each(function findMissingQuoteDashes() {
    //     const text = $(this).text();
    //     if (!text.startsWith("–") && !text.startsWith("—")) {
    //         $(this).prepend("<span class='oHL oHL-attrib-dash oHL_added'>[—]</span> ");
    //     }
    // });
    
    // Fake footnotes
    matchDescriptions["oHL-pseudo-footnotes"] = ["Pseudo footnote", "The {{ref}} template is deprecated for citing sources. ([[Template:Ref]])"];
    $(".reference.plainlinks").addClass("oHL oHL-pseudo-footnotes");

    // Short descriptions
    matchDescriptions["oHL-description-uppercase"] = ["Short description case", "Short descriptions should begin with an uppercase letter. ([[WP:SDFORMAT]])"];
    matchDescriptions["oHL-description-punc"] = ["Short description period", "Short descriptions should not use a full stop. ([[WP:SDFORMAT]])"];
    matchDescriptions["oHL-description-length"] = ["Short description period", "Short descriptions should usually use less than 40 characters. ([[WP:SDLENGTH]])"];
	matchDescriptions["oHL-description-dupe"] = ["Short description duplication", "Short descriptions should not be the same as the page title. ([[WP:SDDUPLICATE]])"];
	const shortDescriptions = $(".shortdescription");
    shortDescriptions.first().each(function analyzeShortDescription() {
    	let text = $(this).text();
    	const length = text.length;
    	if (length == 0) { return false; }
    	
    	const pageTitle = mw.config.get("wgTitle").replace(/ \(.*\)$/, "");
    	if (text.toLowerCase() == pageTitle.toLowerCase()) {
    		$(this).append(" <span class='oHL oHL-description-dupe oHL_added'>[duplicates title]</span>");
    	}

    	const firstChar = text[0];
    	if (/[a-z]/.test(firstChar)) {
    		text = text.slice(1);
    		$(this).text(text);
    		$(this).prepend("<span class='oHL oHL-description-uppercase'>"
    		                + firstChar + "</span>");
    	}

    	const finalChar = text.at(-1);
    	if (finalChar == ".") {
    		text = text.slice(0, -1);
    		$(this).text(text);
    		$(this).append("<span class='oHL oHL-description-punc'>" + finalChar + "</span>");
    	}

    	if (length > 100) {
    		$(this).append(" <span class='oHL-opt oHL-description-length oHL_added'>[too long] ("
    		               + length + " chars)</span>");
    	}
    });
    // Missing short description
    matchDescriptions["oHL-missing-desc"] = ["Missing short description", "All mainspace articles should have a short description. ([[WP:SHORTDESC]])"];
    if (shortDescriptions.length == 0 && mw.config.get("skin") != "minerva") {
    	$("#mw-content-text").prepend("<div class='oHL oHL-missing-desc oHL_added'>[Missing short description]</div>");
    }

    // Ref section without list
    matchDescriptions["oHL-nonlist-ref"] = ["Non-list ref section", "References sections should contain unordered lists."];
    $(refSectionsSelector).each(function findNonlistRefsections() {
    	const ref = $(this).parent().next();
    	if ($(ref).prop("tagName") == "P") {
    		$(ref).prepend("<span class='oHL oHL-nonlist-ref oHL_added'>[*]</span> ");
    	}
    });

    // Superscripts in headers
    matchDescriptions["oHL-header-superscript"] = ["Header tag", "Headers should not have references or other inline templates. ([[MOS:HEADINGS]])"];
    $(".mw-headline .Inline-Template, .mw-headline .reference").addClass("oHL oHL-header-superscript");

    // Redundant italicization
    matchDescriptions["oHL-redundant-italics"] = ["Redundant italicization", "The {{lang}} template already italicizes text, so italics markup is not necessary."];
    $("i > span > i[lang]").addClass("oHL oHL-redundant-italics");

    // Emphasis
    matchDescriptions["oHL-emph"] = ["Emphasis", "When italics are used to emphasize text, the {{em}} template is more semantic. ([[MOS:EMPHASIS]])"];
    $("#mw-content-text i").each(function findEmphasis() {
    	if (this.parentElement.tagName == "SUP" || this.firstChild?.tagName == "A"
    	    || this.hasAttribute("lang")
    	    || (this.firstChild?.tagName == "SPAN" && this.firstChild.hasAttribute("lang"))
    	    || $(this).closest("a").length) {
    		return true;
    	}
    	const text = $(this).text();
    	if (text.length == 0 || text.includes(" ")) {
    		return true;
    	}
    	
    	// Try excluding math variables
    	if (text.length == 1 && text != "a") {
    		return true;
    	}
    	
    	const firstLetter = text[0];
    	if (/[A-Z]/.test(firstLetter)) {
    		return true;
    	}
    	
    	// Require a preceding space
    	const previousSibling = this.previousSibling;
    	if (previousSibling != null && previousSibling.nodeName == "#text" && !previousSibling.textContent.endsWith(" ")) {
    		return true;
    	}
    	
    	$(this).addClass("oHL-opt oHL-emph");
    });
    filterList.push(".mw-references-wrap .oHL-emph", ".texhtml .oHL-emph", ".side-box .oHL-emph");

    // Multi-column lists with too many columns
    matchDescriptions["oHL-col-count"] = ["Column count", "A list should not have so many columns that it hampers scannability. (the list would have more than three columns on a 1920px display at the default Vector font size)"];
    $(".div-col").each(function inspectColumnWidths() {
    	const colWidthCSS = this.style["column-width"];
    	if (colWidthCSS == null || !/em$/.test(colWidthCSS)) { return true; }
    	const colWidthEm = parseFloat(colWidthCSS.replace("em", ""));
    	if (colWidthEm <= 29.3) {
    		$(this).before("<div class='oHL oHL-col-count oHL_added'>[Too many columns]</div>");
    	}
    	
    });

    // Self-ref hatnotes
    matchDescriptions["oHL-self-ref"] = ["Self-ref hatnote", "Hatnotes that link to Wikipedia pages should use the <code>|selfref=yes</code> parameter. ([[WP:ITSELF]])"];
    $(".hatnote:not(.selfreference) a[href^='/wiki/Wikipedia']").parent().addClass("oHL oHL-self-ref");

    // Stub template spacing
    matchDescriptions["oHL-stub-space"] = ["Stub template spacing", "Stub templates should be preceded by two blank lines. ([[WP:STUBSPACING]])"];
    $(".mw-parser-output > :not(p) + style + .stub").before("<div class='oHL-opt oHL-stub-space oHL_added'>[&lt;br&gt;]</div>");

    // Non-romanized text outside of parenthesis
    matchDescriptions["oHL-non-Latin-prose"] = ["Non-Latin Prose", "Article prose should primarily use romanized text, with the non-Latin text in parenthesis. (see [[MOS:TEXT#Foreign_terms]])"];
    $("[lang]").each(function findNonLatinProse() {
    	if (this.lang.includes("Latn")) {
    		return true;
    	}
    	const sibling = this.parentElement?.nextSibling;
    	if (sibling && sibling.nodeType == 3 && sibling.textContent == " (") {
    		const nextElement = sibling?.nextSibling;
    		if (nextElement && nextElement.hasAttribute("lang")) {
    			$(this).addClass("oHL oHL-non-Latin-prose");
    		}
    	}
    });
    
    // Italicized non-Latin text
    matchDescriptions["oHL-non-Latin-italics"] = ["Non-Latin Italics", "Italics should not be used with non-Latin scripts that don't use them. ([[MOS:BADITALICS]])"];
    const nonItalicLangs = ["ja", "zh", "ko", "cmn", "ar", "ur", "hi", "sa"];
    $("#mw-content-text i [lang]").each(function findItalicizedNonLatin() {
    	if (nonItalicLangs.includes(this.lang)) {
    		$(this).addClass("oHL oHL-non-Latin-italics");
    	}
    });

    // Bolding title after lead
    matchDescriptions["oHL-overbolding"] = ["Overbolding", "Only the first occurence of the article title should be bolded. ([[MOS:BOLDSYN]])"];
    let leadMarker;
    if ($("#toc").length) {
        leadMarker = $("#toc");
    } else {
        leadMarker = $("#mw-content-text h2").first();
    }
    const leadBolded = leadMarker.prevAll().not(".infobox").not(".ambox, .sidebar, .side-box, .navbox").find("b");
    leadBolded.addClass("oHL_title");
    const leadNames = leadBolded.toArray().map(e => e.textContent.toLowerCase());
    $("#mw-content-text b").not(".oHL_title").each(function findOverbolding() {
    	const boldText = this.textContent.toLowerCase();
    	if (leadNames.includes(boldText)) {
    		$(this).addClass("oHL oHL-overbolding");
    	}
    });
    filterList.push(".infobox .oHL-overbolding", ".ambox .oHL-overbolding",
                    ".navbox .oHL-overbolding", ".sistersitebox .oHL-overbolding",
                    ".sidebar .oHL-overbolding", ".side-box .oHL-overbolding");
    
    // Duplicate bolded lead items
    const boldLeadTitles = [];
    $(".oHL_title").each(function findDuplicateLeadBolding() {
    	const title = $(this).text();
    	if (boldLeadTitles.includes(title)) {
    		$(this).addClass("oHL oHL-overbolding");
    	} else {
    		boldLeadTitles.push(title);
    	}
    });
    
    // Bolded quote marks in lead
    matchDescriptions["oHL-title-quote-bold"] = ["Bolded title quote mark", "Quote marks in the subject's name should not be bolded. ([[MOS:QUOTENAME]])"];
    $(".oHL_title").each(function findBoldedNameQuotes() {
    	const text = this.textContent;
    	if (!text.includes('"')) { return true; }
    	let html = this.innerHTML;
    	html = html.replaceAll(' "', ' <span class="oHL oHL-title-quote-bold">"</span>');
    	html = html.replaceAll('" ', '<span class="oHL oHL-title-quote-bold">"</span> ');
    	this.innerHTML = html;
    })
    
    // Citation overkill
    matchDescriptions["oHL-overciting"] = ["Overciting", "Use of more than three adjacent citations should be trimmed or bundled. ([[WP:OVERCITE]])"];
    $(".reference").each(function findOverciting() {
    	// Make sure we're at the first of adjacent citations
    	if (this.previousSibling?.nodeName == "SUP"
    	    && this.previousSibling.classList.contains("reference")) {
    		return true;
    	}
	
    	let citeCount = 1;
    	let nextElement = this.nextSibling;
    	while (nextElement?.classList?.contains("reference")) {
    		citeCount += 1;
    		const neighbor = nextElement.nextSibling;
    		if (neighbor?.nodeName != "SUP") {
    			break;
    		}
    		nextElement = neighbor;
    	}

    	if (citeCount > 3) {
    		$(nextElement).after(" <span class='oHL oHL-overciting oHL_added'>[Overciting]</span>");
    	}
    });
    
    // Sandwiched images
    let sandwichSelector = "";
    const sandwichVariants = [".tright", ".infobox", ".sidebar", ".quotebox"];
    for (const variant of sandwichVariants) {
    	sandwichSelector += " .tleft + " + variant + ", ";
    	sandwichSelector += variant + " + .tleft,";
    }
    sandwichSelector = sandwichSelector.replace(/,$/, "");
    matchDescriptions["oHL-image-sandwich"] = ["Sandwiched images", "Avoid squishing text between a left-floating image. ([[MOS:SANDWICH]])"];
    $(sandwichSelector).after("<div class='oHL oHL-image-sandwich oHL_added'>[Sandwiched images]</div>");

    // Orphaned references
    matchDescriptions["oHL-orphaned-refs"] = ["Orphaned references", "References should normally be housed in the References section. These references were likely cited after the {{reflist}} appears."];
    if ($(".references").length > 1) {
	    const lastElement = $(".mw-parser-output").children().last();
	    if (lastElement.hasClass("mw-references-wrap")) {
	    	lastElement.before("<div class='oHL oHL-orphaned-refs oHL_added'>[Orphaned references]</div>");
	    }
	    $(refSectionsSelector).parent().nextUntil("h2").filter(".oHL-orphaned-refs").remove();
    }
    
    // Floating elements clashing with a reflist
    matchDescriptions["oHL-obstructed-reflist"] = ["Obstructed reflist", "Floating templates should not encroach the space of a multi-column reflist or they will cause layout problems. To fix this, a {{clear}} should be placed at the end of the section before the references section."];
    const columnarReflists = $(".mw-references-columns");
    if (columnarReflists.length > 0) {
    	const bodyElement = document.getElementById("mw-content-text");
        const bodyWidth = window.getComputedStyle(bodyElement)["width"];
        columnarReflists.each(function findObstructedReflists() {
    	    const reflistWidth = window.getComputedStyle(this)["width"];
    	    if (reflistWidth != bodyWidth) {
    	    	$(this).before("<div class='oHL oHL-obstructed-reflist oHL_added'>[Obstructed reflist]</div>");
    	    }
        });
    }


    // Special rules for disambiguation pages
    if (!isNonDisambigPage) {
    	matchDescriptions["oHL-disambig-multi-links"] = ["Multiple wikilinks", "Disambiguation listings should only have one blue link. ([[MOS:DABONE]])"];
    	$("li .oHL_wikilink:nth-child(2)").addClass("oHL oHL-disambig-multi-links");
    }

	// Emitted citation errors
    matchDescriptions["oHL-citation-error"] = ["Citation error", "The article contains a citation error."];
    $(".cs1-maint").show();
    $(".cs1-visible-error:first-of-type, .cs1-maint, .mw-ext-cite-error").addClass("oHL oHL-citation-error");

    // Find overlinking
    checkOverlinking();

    // Italics title for works
    checkTitleItalicization();
    
    // Colored backgrounds with poor contrast
    checkContrast();

    // Check ALT text and show full size of images
    showImageInfo();
}

function prepHTML() {
    expandCollapsed();

    // Mark ISBNs
    $(".oHL_wikilink[href^='/wiki/Special:BookSources']").addClass("oHL_ISBN");

    // Temporarily remove elements from the DOM
    $("#toc, .mw-editsection, .mwe-math-element, .mw-cite-backlink, #catlinks,"
      + " #mw-content-text style, .IPA, .mw-highlight, code, .oHL_ISBN,"
      + " .external[href*='doi.org'], .external[href*='worldcat.org'],"
      + " .navbox .uid, .barbox, .mw-kartographer-map, .texhtml, .external.free,"
      + " video, canvas, oHL_anchorLink, .mw-tmh-player, .ext-phonos,"
      + " .lazy-image-placeholder").each(detachTemp);

    /*
     * Replace attributes so they don't get caught in our highlighting
     * Note: id needs to come first because we insert our own ids for the others
     */
    document.querySelectorAll("#mw-content-text [id]")?.forEach(e => mangle(e, "id"));
    document.querySelectorAll("#mw-content-text [style]")?.forEach(e => mangle(e, "style"));
    document.querySelectorAll("#mw-content-text [href]")?.forEach(e => mangle(e, "href"));
    document.querySelectorAll("#mw-content-text [title]")?.forEach(e => mangle(e, "title"));
    document.querySelectorAll("#mw-content-text img[src]")?.forEach(e => mangle(e, "src"));
    document.querySelectorAll("#mw-content-text img[alt]")?.forEach(e => mangle(e, "alt"));
    document.querySelectorAll("#mw-content-text img[resource]")?.forEach(e => mangle(e, "resource"));
    document.querySelectorAll("h2, h3, h4, h5, h6")?.forEach(e => {
    	mangle(e, "onmouseover");
    	mangle(e, "onmouseout");
    });
}

function expandCollapsed() {
    $(".mw-collapsible").children().children("tr").css("display", "");
    $(".mw-collapsible-content").css("display", "");

    $(".NavFrame .NavToggle").each(function expandNavs() {
        if ($(this).text() == "[show]") {
            $(this).click();
        }
    });
    
    $(".collapsible-heading").not(".open-block").click();
}

function replaceHTML() {
    const contentElement = document.getElementById('mw-content-text');
    let html = contentElement.innerHTML;

    // Delete comments
    html = html.replace(/<!--.*?-->/gs, '');

    // Keep track of Euro symbols, which we use to guard text we don't want matched
    const euroCountBefore = (html.match(/€/g) || []).length;

    // p. in refs with multiple pages
    matchDescriptions["oHL-pp"] = ["Multipage cite", "Citations containing multiple pages should use \"pp.\"."];
    html = html.replace(/ p.((?: |&nbsp;|&#160;)[0-9]+[-–])/g, ' <span class="oHL oHL-pp">p.</span>$1');

    // Dashes
    matchDescriptions["oHL-rangedash"] = ["Range dash", "Ranges should use an en dash. ([[MOS:ENDASH]])"];
    html = html.replace(/(\w+)-(\w+)-(\w+)/g, '$1€-€$2€-€$3'); // Guard YYYY-MM-DD, 9-1-1, etc.
    html = html.replace(/(\d)-(\d)/g, '$1<span class="oHL oHL-rangedash">-</span>$2');
    html = html.replace(/(\d)-present/g, '$1<span class="oHL oHL-rangedash">-present</span>');
    html = html.replaceAll('€-€', '-'); // Unguard
    filterList.push(".external .oHL-rangedash");

    matchDescriptions["oHL-typewriter-dash"] = ["Typewriter dash", "Dashes should use the proper Unicode character instead of typewriter dashes. (<code>–</code> or <code>—</code>; [[MOS:DASH]])"];
    html = html.replace(/(---|--|–-|-–|-—|—-)/g, '<span class="oHL oHL-typewriter-dash">$1</span>');
    filterList.push(".mw-references-wrap .oHL-typewriter-dash");
    
    matchDescriptions["oHL-spaced-endash"] = ["Spaced dash", "Spaced dashes should use the proper Unicode character for en dashes. (<code>–</code>; [[MOS:ENDASH]])"];
    html = html.replaceAll(' - ', '<span class="oHL oHL-spaced-endash"> - </span>');
    html = html.replaceAll('&nbsp;-', '&nbsp;<span class="oHL oHL-spaced-endash">-</span>');
    filterList.push(".mw-references-wrap .oHL-spaced-endash");

    matchDescriptions["oHL-spaced-emdash"] = ["Spaced em dash", "Em dashes should be unspaced. ([[MOS:EMDASH]])"];
    html = html.replace(/( —|(?<!—)— )/g, '<span class="oHL oHL-spaced-emdash">$1</span>');
    filterList.push(".mw-references-wrap .oHL-spaced-emdash");
    
    matchDescriptions["oHL-bad-rangedash"] = ["Bad range dash", "Dashes in ranges should use an en dash. ([[MOS:ENDASH]])"];
    html = html.replace(/(\d)—(\d)/g, '$1<span class="oHL oHL-bad-rangedash">—</span>$2');
    filterList.push(".mw-references-wrap .oHL-bad-rangedash");

    matchDescriptions["oHL-spaced-range"] = ["Spaced range", "The en dash in numerical ranges should be unspaced. ([[MOS:ENDASH]])"];
    html = html.replace(/(\d{4}) – (\d{1,2} [A-Z])/g, '$1€–€$2'); // Guard YYYY – DD Month YYYY
    html = html.replace(/(\d{1,2} [A-Z][a-z]+ \d{4}) – (\d{4})/g, '$1€–€$2'); // Guard DD Month YYYY – YYYY 
    html = html.replace(/(\d) – (\d)/g, '$1 <span class="oHL oHL-spaced-range">–</span> $2');
    html = html.replaceAll('€–€', ' – '); // Unguard
    filterList.push(".mw-references-wrap .oHL-spaced-range");

    matchDescriptions["oHL-unspaced-endash"] = ["Unspaced en dash", "En dashes should usually have spaces surrounding them. ([[MOS:ENDASH]]; exception: [[MOS:ENBETWEEN]])"];
    html = html.replace(/([A-Z][^A-Z \n]+)–([A-Z])/g, '$1€–€$2'); // Guard Capital–Capital
    html = html.replace(/([Ww]in)–([Ll]oss)/g, '$1€–€$2'); // Guard Win–loss
    html = html.replace(/([0-9]{2}s)–([0-9]{2})/g, '$1€–€$2'); // Guard 60s–70s
    html = html.replace(/([0-9]th)–([0-9]+th)/g, '$1€–€$2'); // e.g. 12th–13th century
    html = html.replace(/([A-Za-z])–/g, '$1<span class="oHL-opt oHL-unspaced-endash">–</span>');
    html = html.replaceAll('€–€', '–'); // Unguard
    filterList.push(".mw-references-wrap .oHL-unspaced-endash", ".stub .oHL-unspaced-endash");

    matchDescriptions["oHL-bad-minus"] = ["Bad minus sign", "Instead of a hyphen, minus signs should use the proper Unicode character. (<code>−</code>; [[MOS:NEGATIVE]])"];
    html = html.replace(/([ >(])([-–—])(\d)/g, '$1<span class="oHL oHL-bad-minus">$2</span>$3');
    html = html.replace(/ ([A-FO]{1,3})([-–—])([.,;:\)\n<"' ])/g, ' $1<span class="oHL oHL-bad-minus">$2</span>$3');
    filterList.push(".mw-references-wrap .oHL-bad-minus");

    // Quotes
    matchDescriptions["oHL-bad-quote"] = ["Bad quote mark", "Wikipedia only uses straight quote marks. ([[MOS:STRAIGHT]])"];
    html = html.replace(/([‘’“”`´]|'')/g, '<span class="oHL oHL-bad-quote">$1</span>');
    html = html.replaceAll('′s', '<span class="oHL oHL-bad-quote">′</span>s');
    filterList.push(".oHL_img_info .oHL-bad-quote");
    matchDescriptions["oHL-foreign-quote"] = ["Non-English quote mark", "Wikipedia only uses straight quote marks. ([[MOS:STRAIGHT]])"];
    html = html.replace(/([„«»‹›])/g, '<span class="oHL oHL-foreign-quote">$1</span>');
    filterList.push(".mw-references-wrap .oHL-foreign-quote");
    // Nested quote mark
    html = html.replace(/([.,;:] ?)""/g, '$1<span class="oHL oHL-nested-quote">""</span>');

    matchDescriptions["oHL-adj-quote"] = ["Adjacent quote marks", "Adjacent quote marks should have their kerning adjusted. (see {{\" '}} and {{' \"}})"];
    html = html.replace(/((?<= )"'|'"(?!>))/g, '<span class="oHL-opt oHL-adj-quote">$1</span>');
    filterList.push(".mw-references-wrap .oHL-adj-quote");

    matchDescriptions["oHL-punc-in-quote"] = ["Punctuation in quotes", "Punctuation in quotations should use the logical quotation style. ([[MOS:LQ]])"];
    html = html.replace(/(["']\w+)([.,;:])(["'])/g, '$1<span class="oHL oHL-punc-in-quote">$2</span>$3');
    filterList.push("blockquote .oHL-punc-in-quote", ".mw-references-wrap .oHL-punc-in-quote");
    
    // Italics for ''' and '''s
    matchDescriptions["oHL-aposS-italics"] = ["Apostrophe italics", "Apostrophes after italics should have their kerning adjusted. (see {{'s}} and {{'}})"];
    html = html.replaceAll(/('s?)<\/i>/g, '<span class="oHL oHL-aposS-italics">$1</span></i>');

    // Punctuation in italics and bold
    matchDescriptions["oHL-italpunc"] = ["Italicized punctuation", "Punctuation should not be italicized if it is not part of the title."];
    matchDescriptions["oHL-boldpunc"] = ["Bolded punctuation", "Punctuation should not be bolded if it is not part of the title."];
    html = html.replace(/(i\.e\.|e\.g\.|et al\.|etc\.)/g, '$1€'); // Guard
    html = html.replace(/([A-Z])\./g, '$1€'); // Guard
    html = html.replaceAll('...', '..€'); // Guard
    html = html.replaceAll('&nbsp;', '&nbsp€'); // Guard
    html = html.replace(/([.,;:"\])/])<\/i>/g, '<span class="oHL-opt oHL-italpunc">$1</span></i>');
    html = html.replace(/([,;:\])/])<\/b>/g, '<span class="oHL-opt oHL-boldpunc">$1</span></b>');
    html = html.replace(/(i\.e\.|e\.g\.|et al\.|etc\.)€/g, '$1'); // Unguard
    html = html.replace(/([A-Z])€/g, '$1.'); // Unguard
    html = html.replaceAll('..€', '...'); // Unguard
    html = html.replaceAll('&nbsp€', '&nbsp;'); // Unguard
    filterList.push(".mw-references-wrap .oHL-italpunc", ".stub .oHL-italpunc",
                    ".listen .oHL-italpunc", ".ambox .oHL-italpunc");
    filterList.push(".infobox td .oHL-boldpunc");

    matchDescriptions["oHL-formatted-quotemark"] = ["Formatted quote mark", "Quotation marks should not be italicized or bolded. (except when part of a work's title)"];
    html = html.replace(/(<[ib]>)(["'])/g, '$1<span class="oHL-opt oHL-formatted-quotemark">$2</span>');
    matchDescriptions["oHL-formatted-bracket"] = ["Formatted bracket", "Brackets should not be italicized or bolded. (except when part of a work's title)"];
    html = html.replace(/(<[ib]>)([[(])/g, '$1<span class="oHL-opt oHL-formatted-bracket">$2</span>');
    filterList.push(".ambox .oHL-formatted-bracket");
    // Text which should use <em>
    // html = html.replace(/(<i>[a-z]{2,}<\/i>)/g, '$1 <span class="oHL-opt oHL-emph-italic oHL_added">[em]</span>');
    // Quote marks in bolded title
    matchDescriptions["oHL-bold-quotemark"] = ["Bolded quote mark", "Quote marks around a bolded title should not be bolded themselves. (except when they are part of the title)"];
    html = html.replace(/(<b>[^<\n]*)"/g, '$1<span class="oHL oHL-bold-quotemark">"</span>');
    // Bolded parenthesis
    matchDescriptions["oHL-boldparen"] = ["Bolded parenthesis", "Parentheses should not be italicized or bolded. (except when part of a work's title)"];
    html = html.replaceAll(')</b>', '<span class="oHL oHL-boldparen">)</span></b>');
    // Bolded single letters
    matchDescriptions["oHL-bolded-letter"] = ["Bolded letter", "Avoid boldface for emphasis or variables. ([[MOS:NOBOLD]]; [[MOS:TEXT#Mathematics_variables]])"];
    html = html.replace(/<b>(\w)<\/b>/g, '<b><span class="oHL oHL-bolded-letter">$1</span></b>');
    filterList.push(".mw-references-wrap .oHL-bolded-letter",
                    "cite .oHL-bolded-letter", ".navbox .oHL-bolded-letter",
                    ".music-symbol .oHL-bolded-letter");

    // Text without "lang"
    // See: https://www.unicode.org/Public/UCD/latest/ucd/Scripts.txt
    matchDescriptions["oHL-lang-han"] = ["Script missing lang tag (CJK)", "Non-English text should be tagged with its language. ([[MOS:FOREIGN]])"];
    html = html.replace(/([\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+)([\),])(?!<)/gu, '<span class="oHL oHL-lang-han">$1</span>$2');
    matchDescriptions["oHL-lang-kor"] = ["Script missing lang tag (Korean)", "Non-English text should be tagged with its language. ([[MOS:FOREIGN]])"];
    html = html.replace(/(\p{Script=Hangul}+)([\),])(?!<)/gu, '<span class="oHL oHL-lang-kor">$1</span>$2');
    matchDescriptions["oHL-lang-cyrl"] = ["Script missing lang tag (Cyrillic)", "Non-English text should be tagged with its language. ([[MOS:FOREIGN]])"];
    html = html.replace(/(\p{Script=Cyrillic}+)([\),])(?!<)/gu, '<span class="oHL oHL-lang-cyrl">$1</span>$2');
    matchDescriptions["oHL-lang-grk"] = ["Script missing lang tag (Greek)", "Non-English text should be tagged with its language. ([[MOS:FOREIGN]])"];
    html = html.replace(/(\p{Script=Greek}+)([\),])(?!<)/gu, '<span class="oHL oHL-lang-grk">$1</span>$2');
    matchDescriptions["oHL-lang-deva"] = ["Script missing lang tag (Devanagari)", "Non-English text should be tagged with its language. ([[MOS:FOREIGN]])"];
    html = html.replace(/(\p{Script=Devanagari}+)([\),])(?!<)/gu, '<span class="oHL oHL-lang-deva">$1</span>$2');
    matchDescriptions["oHL-lang-ara"] = ["Script missing lang tag (Arabic)", "Non-English text should be tagged with its language. ([[MOS:FOREIGN]])"];
    html = html.replace(/(\p{Script=Arabic}+)([\),])(?!<)/gu, '<span class="oHL oHL-lang-ara">$1</span>$2');
    matchDescriptions["oHL-lang-heb"] = ["Script missing lang tag (Hebrew)", "Non-English text should be tagged with its language. ([[MOS:FOREIGN]])"];
    html = html.replace(/(\p{Script=Hebrew}+)([\),])(?!<)/gu, '<span class="oHL oHL-lang-heb">$1</span>$2');
    matchDescriptions["oHL-lang-tam"] = ["Script missing lang tag (Tamil)", "Non-English text should be tagged with its language. ([[MOS:FOREIGN]])"];
    html = html.replace(/(\p{Script=Tamil}+)([\),])(?!<)/gu, '<span class="oHL oHL-lang-tam">$1</span>$2');
    matchDescriptions["oHL-lang-arm"] = ["Script missing lang tag (Armenian)", "Non-English text should be tagged with its language. ([[MOS:FOREIGN]])"];
    html = html.replace(/(\p{Script=Armenian}+)([\),])(?!<)/gu, '<span class="oHL oHL-lang-arm">$1</span>$2');
    matchDescriptions["oHL-lang-thai"] = ["Script missing lang tag (Thai)", "Non-English text should be tagged with its language. ([[MOS:FOREIGN]])"];
    html = html.replace(/(\p{Script=Thai}+)([\),])(?!<)/gu, '<span class="oHL oHL-lang-thai">$1</span>$2');

    // Ellipses
    // Two ellipses
    matchDescriptions["oHL-two-dots"] = ["Incomplete ellipsis", "Ellipses should have three dots."];
    html = html.replace(/([^.])\.\.([^.])/g, '$1<span class="oHL oHL-two-dots">..</span>$2');
    filterList.push(".mw-references-wrap .oHL-two-dots");
    // html = html.replaceAll('…', '<span class="oHL oHL-ellipsis-char">…</span>');
    // filterList.push(".mw-references-wrap .oHL-ellipsis-char");
    // Interspaced ellipses
    matchDescriptions["oHL-spaced-ellipsis"] = ["Interspaced ellipsis", "Ellipses should not have spaces in between. ([[MOS:ELLIPSES]])"];
    html = html.replace(/(\. \. \.|\.&nbsp;\.&nbsp;\.)/g, '<span class="oHL oHL-spaced-ellipsis">. . .</span>');
    // Bracketed ellipses
    matchDescriptions["oHL-bracketed-ellipsis"] = ["Bracketed ellipsis", "Ellipses indicating omission in a quote should not be enclosed by square brackets. ([[MOS:BRACKET]]; Exception: if the quote itself already uses ellipses.)"];
    html = html.replaceAll(/([[(]\.\.\.[)\]])/g, '<span class="oHL oHL-bracketed-ellipsis">$1</span>');
    // Unspaced ellipses
    matchDescriptions["oHL-unspaced-ellipsis"] = ["Unspaced ellipsis", "Ellipses should have spaces surrounding them. ([[MOS:ELLIPSES]])"];
    html = html.replaceAll("&nbsp;", "€€"); // guard
    html = html.replaceAll(/([^ "'€])\.\.\.([^ .?!:;,)\]])/g, '$1<span class="oHL oHL-unspaced-ellipsis">...</span>$2');
    html = html.replaceAll(/(&[^ "'€])\.\.\. /g, '$1<span class="oHL oHL-unspaced-ellipsis">...</span> ');
    html = html.replaceAll(/ \.\.\.([^ .?!:;,)\]])/g, ' <span class="oHL oHL-unspaced-ellipsis">...</span>$1');
    html = html.replaceAll("€€", "&nbsp;"); // unguard
    filterList.push(".mw-references-wrap .oHL-unspaced-ellipsis", ".oHL_wikilink .oHL-unspaced-ellipsis",
                    "a.new .oHL-unspaced-ellipsis");

    // Non-breaking spaces
    matchDescriptions["oHL-nbsp-multi"] = ["Multiple non-breaking spaces", "Only a single NBSP should be between words. They should also not be used to force formatting, instead, semantic elements or CSS should be used."];
    html = html.replace(/((?:&nbsp;){2,})/g, '<span class="oHL oHL-nbsp-multi">$1</span>');
    filterList.push(".poem .oHL-nbsp-multi", "table .oHL-nbsp-multi",
                    ".quotebox .oHL-nbsp-multi");
    
    matchDescriptions["oHL-bad-nbsp"] = ["Spaced non-breaking-space", "A non-breaking space should not have any spaces around it."];
    html = html.replace(/(&nbsp; | &nbsp;)/g, '<span class="oHL oHL-bad-nbsp">$1</span>');

    // Note: units, am/pm handled elsewhere
    matchDescriptions["oHL-nbsp"] = ["Non-breaking-space", "Numbers, ellipses, etc. should be preceded by <code>&amp;nbsp;</code>. ([[MOS:NBSP]])"];
    html = html.replace(/([0-9]) (dozen|hundred|thousand|million|billion|trillion)/g, "$1<span class='oHL-opt oHL-nbsp'> </span>$2");
    html = html.replaceAll(" ...", "<span class='oHL-opt oHL-nbsp'> </span>...");
    filterList.push(".mw-references-wrap .oHL-nbsp", ".infobox .oHL-nbsp",
                    ".navbox .oHL-nbsp", "th .oHL-nbsp", ".nowrap .oHL-nbsp",
                    ".texhtml .oHL-nbsp");

    // Whitespace
    matchDescriptions["oHL-unspaced-period"] = ["Unspaced period", "A full stop should be followed by a space."];
    html = html.replaceAll('Ph.D.', 'Ph€D.'); // Guard "Ph.D."
    html = html.replace(/([a-z]\.[A-Z][^A-Z])/g, '<span class="oHL oHL-unspaced-period">$1</span>');
    html = html.replaceAll('Ph€D.', 'Ph.D.'); // Unguard
    
    matchDescriptions["oHL-spaced-punc"] = ["Spaced punctuation", "Punctuation should not be preceded by a space. ([[MOS:PUNCTSPACE]])"];
    html = html.replace(/( |&nbsp;)\.\.\./g, '$1€.€€.€€.€'); // Guard ellipses
    html = html.replaceAll('. . .', '€.€ €.€ €.€'); // Guard spaced ellipses
    html = html.replace(/\.([0-9]{2,})/g, '€€$1'); // Guard gun calibers
    html = html.replace(/(( |&nbsp;)[,;:.?!%])/g, '<span class="oHL oHL-spaced-punc">$1</span>');
    html = html.replaceAll('€.€', '.'); // Unguard
    html = html.replaceAll('€€', '.'); // Unguard
    
    matchDescriptions["oHL-full-space"] = ["Fullwidth space", "Half-width spaces should be used instead of full-width ones. (Exception: inside Japanese text)"];
    html = html.replaceAll(' ', '<span class="oHL oHL-full-space"> </span>');
    
    matchDescriptions["oHL-spaced-chars"] = ["Spaced characters", "Characters should not have spacing between them."];
    html = html.replace(/ (["'\[\]()]) /g, ' <span class="oHL oHL-spaced-chars">$1</span> ');

    matchDescriptions["oHL-spaced-ref"] = ["Spaced reference", "References should not be preceded by a space. ([[MOS:REFSPACE]])"];
    html = html.replaceAll(' <sup', '<span class="oHL oHL-spaced-ref"> </span><sup');
    matchDescriptions["oHL-unspaced-ref"] = ["Unspaced reference", "References should be followed by a space."];
    html = html.replace(/\/sup>(\w)/g, '/sup><span class="oHL oHL-unspaced-ref">$1</span>');
    html = html.replace(/\/sup><i>(\w)/g, '/sup><i><span class="oHL oHL-unspaced-ref">$1</span>');

    // Inches/feet marks
    matchDescriptions["oHL-ft-inch"] = ["Height notation", "The symbols for feet and inches should be spelled out. ([[MOS:NUM#Specific_units]])"];
    html = html.replace(/(\d' [1-9]\d?")/g, '<span class="oHL oHL-ft-inch">$1</span>');

    // Multiplication
    matchDescriptions["oHL-mult-sign"] = ["Multiplication sign", "Multiplication should use the proper Unicode character, <code>×</code>, instead of the Latin letter <i>x</i>. ([[MOS:MATH#Multiplication_sign]])"];
    html = html.replace(/ x(64|86)/g, ' €€$1'); // Guard
    html = html.replaceAll(' 0x', ' 0€€'); // Guard
    html = html.replace(/(annotation_+[0-9]+)x/g, '$1€€'); // Guard
    html = html.replace(/(\d ?)x/g, '$1<span class="oHL oHL-mult-sign">x</span>');
    html = html.replace(/ x /g, ' <span class="oHL oHL-mult-sign">x</span> ');
    html = html.replaceAll('€€', 'x'); // Unguard

    // Parenthesis
    matchDescriptions["oHL-unspaced-paren"] = ["Unspaced parenthesis", "Parenthesis should have spaces around them. ([[MOS:PAREN]]; exception: function names in programming)"];
    // Trying to match parenthesis hitting words like this(
    html = html.replaceAll('()', '€€)'); // Guard function names
    html = html.replace(/([a-z"]\()/g, '<span class="oHL oHL-unspaced-paren">$1</span>');
    html = html.replaceAll('€€)', '()'); // Unguard
    filterList.push(".Inline-Template .oHL-unspaced-paren", ".infobox-label .oHL-unspaced-paren");
    // Trying to match parenthesis hitting words like )this
    html = html.replace(/(\)[A-Za-z])/g, '<span class="oHL oHL-unspaced-paren">$1</span>');
    
    matchDescriptions["oHL-adj-parens"] = ["Adjacent parentheses", "Avoid adjacent parenthesis. ([[MOS:PAREN]])"];
    html = html.replace(/(\) ?\()/g, '<span class="oHL oHL-adj-parens">$1</span>');
    filterList.push(".mw-references-wrap .oHL-adj-parens");
    matchDescriptions["oHL-nested-parens"] = ["Nested parentheses", "Nested parentheticals should utilize square brackets (<code>[]</code>)."];
    html = html.replace(/(\([^)\n]+)\(/g, '$1<span class="oHL oHL-nested-parens">(</span>');
//    html = html.replaceAll('))', '<span class="oHL oHL-nested-parens">))</span>');
    filterList.push(".oHL_img_info .oHL-nested-parens");
    
    // Minus sign
    matchDescriptions["oHL-minus-score"] = ["Minus sign", "The proper Unicode minus sign, <code>−</code>, should be used instead of dashes. ([[MOS:MINUS]])"];
    html = html.replace(/( [A-F])([-–—])([.,;: ])/g, ' $1<span class="oHL oHL-minus-score">$2</span>$3');
    // Note: have never encountered this highlight.
    matchDescriptions["oHL-plus-minus"] = ["Plus/minus sign", "The proper Unicode plus-minus sign, <code>±</code>, should be used."];
    html = html.replace(/(\+\/[-–—−])/g, '<span class="oHL oHL-plus-minus">$1</span>');
    
    // Exponents and subscripts
    matchDescriptions["oHL-subsup"] = ["Precomposed sub/superscript", "Instead of precomposed Unicode characters for subscripts or superscripts, use <code>&ltref&gt;</code> tags, or <code>&ltsub&gt</code>; and <code>&ltsup&gt;</code> tags. ([[MOS:SUPERSCRIPT]])"];
    html = html.replace(/([²³¹⁰ⁱ⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿ₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₐₑₒₓₔₕₖₗₘₙₚₛₜᴬᴮᴰᴱᴳᴴᴵᴶᴷᴸᴹᴺᴼᴾᴿᵀᵁⱽᵂᶦᶫᶰᶸᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏˡᵐⁿᵒᵖʳˢᵗᵘᵛʷˣʸᶻₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓᵝᵞᵟᶿᶥᵠᵡᵦᵧᵨᵩᵪ⏨])/g, '<span class="oHL oHL-subsup">$1</span>');
    // Fractions
    // html = html.replace(/([½¼¾⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞⅟↉])/g, '<span class="oHL oHL-frac-char">$1</span> ');
    matchDescriptions["oHL-frac-slash"] = ["Fraction slash", "Fractions should use the {{frac}} template. ([[MOS:FRAC]])"];
    html = html.replace(/([0-9])\/([0-9])/g, '$1<span class="oHL oHL-frac-slash">/</span>$2');
    html = html.replaceAll("sup>/<sub", 'sup><span class="oHL oHL-frac-slash">/</span><sub');
    filterList.push(".mw-references-wrap .oHL-frac-slash", ".video-game-reviews .oHL-frac-slash",
                    ".external .oHL-frac-slash");

    // Ordinals
    matchDescriptions["oHL-ordinal"] = ["Ordinal", "Do not superscript ordinals. ([[MOS:ORDINAL]])"];
    html = html.replace(/<sup>(th|st|[nr]d)/g, '<sup><span class="oHL oHL-ordinal">$1</span>');
    html = html.replace(/([ªº])/g, '<span class="oHL oHL-ordinal">$1</span>');

    // Substed nihongo question mark
    matchDescriptions["oHL-nihongo-question"] = ["Substed nihongo template", "The {{nihongo}} template should be be substituted."];
    html = html.replaceAll('<sup>?', '<sup><span class="oHL oHL-nihongo-question">?</span>');
    // Precomposed units
    matchDescriptions["oHL-unit-char"] = ["Precomposed unit", "Do not use precomposed unit symbols. ([[MOS:UNITSYMBOLS]])"];
    html = html.replace(/([㎚㎛㎜㎝㎞㏌㎟㎠㎡㎢㎣㎤㎥㎦㎕㎖㎗㎘㏄㎰㎱㎲㎳㎍㎎㎏㎅㎆㎇㎐㎑㎒㎓㎔㎴㎵㎶㎷㎸㎹㎺㎻㎼㎽㎾㎿㏀㏁㎀㎁㎂㎃㎄㎧㎨㎭㎮㎯㎩㎪㎫㎬㎈㎉㍷㍸㍹㎙㍱㍲㍳㍴㍵㍶㍺㎊㎋㎌㏃㏅㏆㏇㏈㏉㏊㏋㏍㏎㏏㏐㏑㏒㏓㏔㏕㏖㏗㏚㏛㏜㏝㏞㏟㏿㏂㏘㏙])/g, '<span class="oHL oHL-unit-char">$1</span>');
    // Unspaced unit
    matchDescriptions["oHL-unit-space"] = ["Unspaced unit", "Unit symbols should usually be preceded by a non-breaking (<code>&amp;nbsp;</code>) space. ([[MOS:UNITSYMBOLS]])"];
    html = html.replace(/([0-9])(in|ft|mi|mph|cm|μm|mm|km|mg|kg|g|m|psi|oz|qt|gal|lb|yr|kcal|cal|hz|sq|cu|W|kW|Ah|mAh|ohm)/g, '$1<span class="oHL oHL-unit-space oHL_added">[ ]</span>$2');
    filterList.push(".mw-references-wrap .oHL-unit-space");
    // Hyphenated unit
    matchDescriptions["oHL-unit-hyphen"] = ["Hyphenated unit", "Unit symbols should not be preceded by a hyphen. ([[MOS:UNITSYMBOLS]])"];
    html = html.replace(/([0-9])-(in|ft|mi|mph|cm|μm|mm|km|mg|kg|g|m|psi|oz|qt|gal|lb|yr|kcal|cal|hz|sq|cu|W|kW|Ah|mAh|ohm)([ .,;:'"\)])/g, '$1<span class="oHL oHL-unit-hyphen">-</span>$2$3');
    // Dotted unit name
    matchDescriptions["oHL-dotted-unit"] = ["Dotted unit", "Unit symbols should not have a dot. ([[MOS:UNITSYMBOLS]])"];
    html = html.replace(/([0-9])( |&nbsp;)(in|ft|mi|mph|cm|μm|mm|km|mg|kg|g|m|psi|oz|qt|gal|lb|yr|kcal|cal|hz|sq|cu|W|kW|Ah|mAh|ohm)\./g, '$1$2$3<span class="oHL oHL-dotted-unit">.</span>');

    // Brackets
    matchDescriptions["oHL-bracket"] = ["Unparsed brackets", "Unparsed brackets likely indicate a broken template or wikilink."];
    html = html.replace(/(\{\{|\[\[|\]\]|\}\}|\{\||\|\})/g, '<span class="oHL oHL-bracket">$1</span>');
    // Malformed tags
    matchDescriptions["oHL-bad-tag"] = ["Malformed tag", "A tag was not properly closed."];
    html = html.replace(/(&lt;\/?(ref|blockquote|poem|math|chem))/g, '<span class="oHL oHL-bad-tag">$1</span>');
    // Unspaced comma
    matchDescriptions["oHL-unspaced-comma"] = ["Unspaced comma", "Commas should usually have a space after them."];
    html = html.replace(/([a-z]),([A-Za-z])/g, '$1<span class="oHL oHL-unspaced-comma">,</span>$2');
    // Commas in money
    matchDescriptions["oHL-money-comma"] = ["Thousands separator (money)", "In general, digits are grouped by commas. ([[MOS:DIGITS]])"];
    html = html.replace(/([$€£¥₣₹])(\d{4})/g, '$1<span class="oHL oHL-money-comma">$2</span>');
    filterList.push(".mw-references-wrap .oHL-money-comma");
    // Commas in five digit plus numbers
    matchDescriptions["oHL-digit-comma"] = ["Thousands separator", "Digits should be grouped by commas. ([[MOS:DIGITS]])"];
    html = html.replace(/(\d{2,})(\d{3})/g, '$1€€$2');
    html = html.replace(/(\.[0-9]+)€€/g, '$1'); // filter decimals out
    html = html.replace(/([A-Za-z]\w+)€€/g, '$1'); // filter model numbers out
    html = html.replace(/(="[0-9]+)€€([0-9]+")/g, '$1$2'); // filter HTML attributes out
    html = html.replaceAll('€€', '<span class="oHL oHL-digit-comma oHL_added">[,]</span>');
    filterList.push(".external .oHL-digit-comma",
                    ".mw-references-wrap .oHL-digit-comma",
                    ".extiw .oHL-digit-comma",
                    ".navbox .oHL-digit-comma",
                    ".oHL_img_info_dimensions .oHL-digit-comma");
    // 9s at the end of prices, a form of psychological pricing
    matchDescriptions["oHL-excess-precision"] = ["Excess precision (money)", "In most cases, large monetary figures do not necessitate a lot of precision. ([[MOS:LARGENUM]])"];
    html = html.replace(/([$€£¥₣₹][0-9,]+)(9{2,})([^0-9])/g, '$1<span class="oHL oHL-excess-precision">$2</span>$3');

    // Double punctuation
    matchDescriptions["oHL-doublepunc"] = ["Double punctuation", "Punctuation should only be present once."];
    html = html.replaceAll('&nbsp;', '€nbsp€'); // Guard
    html = html.replace(/(,,|;;|::)/g, '<span class="oHL oHL-doublepunc">$1</span>');
    html = html.replaceAll('€nbsp€', '&nbsp;'); // Unguard

    // Punctation after citations
    matchDescriptions["oHL-cite-punc"] = ["Citation punctuation", "References should be placed after punctuation. ([[MOS:CITEPUNCT]])"];
    html = html.replace(/<\/sup>([.,;:])/g, '</sup><span class="oHL oHL-cite-punc">$1</span>');
    filterList.push("sup:not(.reference):not(.Inline-Template) + .oHL-cite-punc"); // Actual exponents

    // Extra punctuation after inline quote
    matchDescriptions["oHL-extra-punc"] = ["Extra punctuation", "Quotations with terminal punctuation shouldn't usually have another punctuation mark after them. ([[MOS:CONSECUTIVE]])"];
    // html = html.replace(/(\?["'])\./g, '$1<span class="oHL oHL-extra-punc">.</span>');
    html = html.replace(/(\.["'])([,.])/g, '$1<span class="oHL oHL-extra-punc">$2</span>');
    filterList.push(".mw-references-wrap .oHL-extra-punc");

    // Date comma
    matchDescriptions["oHL-datecomma"] = ["Date comma", "Dates in MDY forma require a comma after the year. ([[MOS:DATECOMMA]])"];
    html = html.replace(/((?:(?:Jan|Febr)uary|March|April|May|June|July|August|(?:Septem|Octo|Novem|Decem)ber) [0-9]{1,2})( [0-9])/g, '$1<span class="oHL oHL-datecomma oHL_added">[,]</span>$2');
    html = html.replace(/((?:(?:Jan|Febr)uary|March|April|May|June|July|August|(?:Septem|Octo|Novem|Decem)ber) [0-9]{1,2}, [0-9]{4})( \w|<sup)/g, '$1<span class="oHL oHL-datecomma oHL_added">[,]</span>$2');
    filterList.push(".mw-references-wrap .oHL-datecomma", ".wikitable .oHL-datecomma");
    
    // "In $year" comma
    matchDescriptions["oHL-yearcomma"] = ["Year comma", "This type of clause should probably have a comma after it."];
    html = html.replace(/((?:In|As of) ?(?:(?:Jan|Febr)uary|March|April|May|June|July|August|(?:Septem|Octo|Novem|Decem)ber)? [0-9]{4})( \w|<sup)/g, '$1<span class="oHL oHL-yearcomma oHL_added">[,]</span>$2');
    
    // Start of sentence comma
    matchDescriptions["oHL-word-comma"] = ["Word comma", "This type of clause should probably have a comma after it."];
    html = html.replaceAll('Recently ', 'Recently<span class="oHL oHL-word-comma oHL_added">[,]</span> ');
    html = html.replaceAll(/Originally (?!known)/g, 'Originally<span class="oHL oHL-word-comma oHL_added">[,]</span> ');
    // html = html.replace(/([a-z]) (but|whereas) /g, '$1<span class="oHL oHL oHL-word-comma oHL_added">[,]</span> $2 ');
    filterList.push(".mw-references-wrap .oHL-word-comma", ".oHL_wikilink .oHL-word-comma");

    // Double conjunction 
    // html = html.replace(/ and ([^"'.,;:!()[\]—–\n]+) and/g, ' and $1 <span class="oHL oHL-double-conjunc">and</span>');
    // html = html.replace(/ or ([^"'.,;:!()[\]—–\n]+) or/g, ' or $1 <span class="oHL oHL-double-conjunc">or</span>');

    // Short months
    matchDescriptions["oHL-month-abbr"] = ["Abbreviated month", "Abbreviations for months should only be used where space is limited. ([[WP:MOS#Months]])"];
    html = html.replace(/((?:Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\.?) ([0-9]{1,2})/g, '<span class="oHL oHL-month-abbr">$1</span> $2');
    filterList.push(".mw-references-wrap .oHL-month-abbr",
                    "table .oHL-month-abbr"); // table includes .infobox
    // Short days
    matchDescriptions["oHL-day-abbr"] = ["Abbreviated day", "Abbreviations should only be used where space is limited."];
    html = html.replace(/(Mon|Tues|Wed|Thur|Fri|Sat|Sun)([^\w])/g, '<span class="oHL oHL-day-abbr">$1</span>$2');
    filterList.push(".mw-references-wrap .oHL-day-abbr", ".external .oHL-day-abbr",
                    ".oHL_wikilink .oHL-day-abbr", "a.new .oHL-day-abbr");
    // All caps
    matchDescriptions["oHL-allcaps"] = ["All caps", "Avoid using all capital letters for things other than acronyms. ([[MOS:ALLCAPS]])"];
    html = html.replace(/([A-Z]{3,} [ "',;:.?!A-Z]*[A-Z]{3,})/g, '<span class="oHL-opt oHL-allcaps">$1</span>');
    filterList.push(".smallcaps .oHL-allcaps", ".navbox .oHL-allcaps", ".Inline-Template .oHL-allcaps");
    // Hyphen table placeholders
    matchDescriptions["oHL-table-hyphen"] = ["Table placeholder hyphen", "Placeholders should use em dashes instead of hyphens."];
    html = html.replaceAll('<td>-', '<td><span class="oHL oHL-table-hyphen">-</span>');
    // Empty cells
    matchDescriptions["oHL-missing-placeholder"] = ["Table cell w/o placeholder", "Empty cells in a table should probably use em dashes as placeholders."];
    html = html.replace(/<td>\s*<\/td>/g, '<td><span class="oHL oHL-missing-placeholder oHL_added">[—]</span></td>');
    // Circa
    matchDescriptions["oHL-circa"] = ["Circa", "The preferred formatting for circa is the {{circa}} template. ([[MOS:CIRCA]])"];
    html = html.replaceAll(' ca.', ' <span class="oHL oHL-circa">ca.</span>');
    html = html.replaceAll(' ca ', ' <span class="oHL oHL-circa">ca</span> ');
    html = html.replace(/([ \(])c\.(\w)/g, '$1<span class="oHL oHL-circa">c.</span>$2');
    // Missing dots in abbreviations
    matchDescriptions["oHL-abbr-period"] = ["Abbreviation dot", "Abbreviations should end with periods. ([[MOS:POINTS]])"];
    html = html.replace(/([ (])(etc|i\.e|e\.g|cf|et al|viz|vs|Inc|Jr|Sr)([ ),:;'"])/g, ' $1$2<span class="oHL oHL-abbr-period oHL_added">[.]</span>$3');
    filterList.push(".mw-references-wrap .oHL-abbr-period", ".oHL_wikilink .oHL-abbr-period",
                    "a.new .oHL-abbr-period");
    // Inconsistent slash spacing
    matchDescriptions["oHL-slash-space"] = ["Slash spacing", "Slashes, when considered proper to use, should have consistent spacing on both sides. ([[MOS:SLASH]])"];
    html = html.replaceAll('&nbsp;/ ', '&nbsp;/€'); // Guard
    html = html.replace(/([^\/ ])\/ /g, '$1<span class="oHL oHL-slash-space">/ </span>');
    html = html.replace(/ \/([^\/ ])/g, '<span class="oHL oHL-slash-space"> /</span>$1');
    html = html.replaceAll('&nbsp;/€', '&nbsp;/ '); // Unguard
    // Fullwidth characters
    matchDescriptions["oHL-fullwidth"] = ["Fullwidth characters", "Fullwidth characters should be replaced with their halfwidth equivalents."];
    html = html.replace(/([:;?!$%()&+*@])/g, '<span class="oHL oHL-fullwidth">$1</span>');
    filterList.push("[lang='ja'] .oHL-fullwidth",
                    ".mw-references-wrap .oHL-fullwidth");
    // Time
    matchDescriptions["oHL-time-space"] = ["Time space", "Times should have a non-breaking space (<code>&amp;nbsp;</code>) before a.m./p.m. ([[MOS:AMPM]])"];
    html = html.replace(/([0-9])([ap]\.?m)/gi, '$1<span class="oHL oHL-time-space oHL_added">[ ]</span>$2');
    filterList.push(".mw-references-wrap .oHL-time-space");
    matchDescriptions["oHL-time-uppercase"] = ["Time uppercase", "a.m./p.m. notation should be lowercase. ([[MOS:AMPM]])"];
    html = html.replace(/([0-9]) ([AP](M|\.M\.))/g, '$1 <span class="oHL oHL-time-uppercase">$2</span>');
    filterList.push(".mw-references-wrap .oHL-time-uppercase");
    matchDescriptions["oHL-time-dot"] = ["Time dot", "a.m./p.m. notation should have two dots. ([[MOS:AMPM]])"];
    html = html.replace(/(a\.m|p\.m)([ ),:;'"])/g, '$1<span class="oHL oHL-time-dot oHL_added">[.]</span>$2');


    // Commas after place names
    const stateNames = ["Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut", "Delaware", "Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa", "Kansas", "Kentucky", "Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota", "Mississippi", "Missouri", "Montana", "Nebraska", "Nevada", "New Hampshire", "New Jersey", "New Mexico", "New York", "North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania", "Rhode Island", "South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont", "Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming", "D.C."];
    const countryNames = ["Afghanistan", "Albania", "Algeria", "Andorra", "Angola", "Antigua and Barbuda", "Argentina", "Armenia", "Australia", "Austria", "Azerbaijan", "Bahamas", "Bahrain", "Bangladesh", "Barbados", "Belarus", "Belgium", "Belize", "Benin", "Bhutan", "Bolivia", "Bosnia and Herzegovina", "Botswana", "Brazil", "Brunei", "Bulgaria", "Burkina Faso", "Burundi", "Cabo Verde", "Cambodia", "Cameroon", "Canada", "Central African Republic", "CAR", "Chad", "Chile", "China", "Colombia", "Comoros", "Democratic Republic of the Congo", "Republic of the Congo", "Costa Rica", "Cote d'Ivoire", "Croatia", "Cuba", "Cyprus", "Czechia", "Denmark", "Djibouti", "Dominica", "Dominican Republic", "Ecuador", "Egypt", "El Salvador", "Equatorial Guinea", "Eritrea", "Estonia", "Eswatini", "Ethiopia", "Fiji", "Finland", "France", "Gabon", "Gambia", "Georgia", "Germany", "Ghana", "Greece", "Grenada", "Guatemala", "Guinea", "Guinea-Bissau", "Guyana", "Haiti", "Honduras", "Hungary", "Iceland", "India", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", "Italy", "Jamaica", "Japan", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "Kosovo", "Kuwait", "Kyrgyzstan", "Laos", "Latvia", "Lebanon", "Lesotho", "Liberia", "Libya", "Liechtenstein", "Lithuania", "Luxembourg", "Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands", "Mauritania", "Mauritius", "Mexico", "Micronesia", "Moldova", "Monaco", "Mongolia", "Montenegro", "Morocco", "Mozambique", "Myanmar", "Namibia", "Nauru", "Nepal", "Netherlands", "New Zealand", "Nicaragua", "Niger", "Nigeria", "North Korea", "North Macedonia", "Norway", "Oman", "Pakistan", "Palau", "Palestine", "Panama", "Papua New Guinea", "Paraguay", "Peru", "Philippines", "Poland", "Portugal", "Qatar", "Romania", "Russia", "Rwanda", "Saint Kitts and Nevis", "Saint Lucia", "Saint Vincent and the Grenadines", "Samoa", "San Marino", "Sao Tome and Principe", "Saudi Arabia", "Senegal", "Serbia", "Seychelles", "Sierra Leone", "Singapore", "Slovakia", "Slovenia", "Solomon Islands", "Somalia", "South Africa", "South Korea", "South Sudan", "Spain", "Sri Lanka", "Sudan", "Suriname", "Sweden", "Switzerland", "Syria", "Taiwan", "Tajikistan", "Tanzania", "Thailand", "Timor-Leste", "Togo", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey", "Turkmenistan", "Tuvalu", "Uganda", "Ukraine", "United Arab Emirates", "UAE", "United Kingdom", "UK", "United States of America", "USA", "Uruguay", "Uzbekistan", "Vanuatu", "Vatican City", "Holy See", "Venezuela", "Vietnam", "Yemen", "Zambia", "Zimbabwe"];
    matchDescriptions["oHL-place-comma"] = ["Place name comma", "A comma should follow the last part of a geographic location. ([[MOS:GEOCOMMA]])"];
    const stateNamesRe = stateNames.join("|").replaceAll(".", "\\.");
    const countryNamesRe = countryNames.join("|").replaceAll(".", "\\.");
    const stateRe = new RegExp(", (" + stateNamesRe + ")([ <])", "g");
    const stateRe2 = new RegExp("(, (?:<a[^>\n]*?>))(" + stateNamesRe + ")<", "g"); // wikilinked
	const countryRe = new RegExp(", (" + countryNamesRe + ")([ <])", "g");
	const countryRe2 = new RegExp("(, (?:<a[^>\n]*?>))(" + countryNamesRe + ")<", "g");  // wikilinked
	html = html.replace(/([0-9]),/g, '$1,₭'); // guard
    html = html.replace(stateRe, ', $1€€$2');
    html = html.replace(stateRe2, '$1$2€€<');
    html = html.replace(countryRe, ', $1€€$2');
    html = html.replace(countryRe2, '$1$2€€<');
    html = html.replaceAll('€€</a>', '</a>€€'); // neater
    html = html.replaceAll('€€<', '<'); // clean
    html = html.replaceAll('€€ <a id="sectiontitlecopy', ' <a id="sectiontitlecopy'); // clean
    html = html.replace(/€€([,.:;)\]])/g, '$1'); // clean
    html = html.replaceAll('€€', '<span class="oHL oHL-place-comma oHL_added">[,]</span>');
    html = html.replaceAll(',₭', ','); // unguard
    filterList.push(".mw-references-wrap .oHL-place-comma");

    // Title italicization
    matchDescriptions["oHL-title-italics"] = ["Title italics", "Instances of the italicized page title should be consistently italicized in the body."];
    const pageTitle = mw.config.get("wgTitle").replace(/ \(.*\)$/, "");
    if (pageTitle == $("h1 i").first().text()) {
    	const pageTitleCleaned = pageTitle.replace(/:.*/, "");
    	const pageTitleEscaped = mw.util.escapeRegExp(pageTitleCleaned);
    	const titleRe = new RegExp("([\"' ])(" + pageTitleEscaped + ")", "gi");
    	html = html.replace(titleRe, '$1<span class="oHL oHL-title-italics">$2</span>');
    	filterList.push("i .oHL-title-italics", ".mw-references-wrap .oHL-title-italics",
    	                "a .oHL-title-italics, .shortdescription .oHL-title-italics",
    	                ".oHL_img_info .oHL-title-italics");
    }
    
    matchDescriptions["oHL-court-italics"] = ["Court case italics", "Titles of court cases are usually italicized. ([[MOS:TEXT#Names_and_titles]])"];
    // Court cases italicization
    html = html.replace(/ (v\.?) /g, ' <span class="oHL oHL-court-italics">$1</span> ');
    filterList.push("i .oHL-court-italics", ".mw-references-wrap .oHL-court-italics",
                    ".infobox-above .oHL-court-italics", ".hatnote .oHL-court-italics");
    
    // Full name in biographies
    matchDescriptions["oHL-fullname"] = ["Full name", "After the first mention, people should not be referred to by their full name. ([[MOS:SURNAME]])"];
    const categories = mw.config.get("wgCategories")?.join(" | ");
    if (categories.includes("births") ||  categories.includes("deaths")
        || categories.includes("Living people")) {
    	const fullNameTitle = $(".mw-page-title-main").text();
    	const fullNameLead = $("#bodyContent p b").first().text();
    	const subjectNames = [];
    	subjectNames.push(fullNameTitle);
    	if (fullNameLead != fullNameTitle) {
    		subjectNames.push(fullNameLead);
    	}
    	for (const name of subjectNames) {
    		if (name.includes(" ")) {
    		    html = html.replaceAll(name, '<span class="oHL oHL-fullname">' + name + '</span>');
    		}
    	}
    	filterList.push("#bodyContent p:first-of-type .oHL-fullname",
    	                "i .oHL-fullname", "a .oHL-fullname", "table .oHL-fullname",
    	                "figcaption .oHL-fullname", ".mw-references-wrap .oHL-fullname",
    	                "cite .oHL-fullname", ".side-box .oHL-fullname", ".hatnote .oHL-fullname");
    }

    // Pseudo-references
    matchDescriptions["oHL-pseudo-ref"] = ["Pseudo ref", "Numbered references should use <code>&lt;ref&gt;</code> tags."];
    html = html.replace(/(\[\d{1,2}\])/g, '<span class="oHL oHL-pseudo-ref">$1</span>');
    filterList.push(".reference .oHL-pseudo-ref", ".external .oHL-pseudo-ref", ".mw-references-wrap .oHL-pseudo-ref");

    // Degrees symbol
    matchDescriptions["oHL-bad-degree"] = ["Bad degree symbol", "Degrees should use the proper symbol. (<code>°</code>; [[MOS:NUM#Specific_units]])"];
    html = html.replaceAll('˚', '<span class="oHL oHL-bad-degree">˚</span>');
    matchDescriptions["oHL-missing-degree"] = ["Missing degrees symbol", "Temperatures should have the degrees symbol. (<code>°</code>; [[MOS:NUM#Specific_units]])"];
    html = html.replace(/([0-9]) (C|F)([ ,;:)])/g, '$1 <span class="oHL oHL-missing-degree oHL_added">[°]</span>$2$3');
    matchDescriptions["oHL-unspaced-degree"] = ["Unspaced degrees symbol", "The degrees symbol should be spaced. ([[MOS:NUM#Specific_units]])"];
    html = html.replace(/([0-9])°(C|F)/g, '$1<span class="oHL oHL-unspaced-degree oHL_added">[ ]</span>°$2');

    // Misc
    matchDescriptions["oHL-corporate"] = ["Corporate symbol", "Avoid using symbols like <code>™</code>, etc. ([[MOS:TMRULES]])"];
    html = html.replace(/([™©®]|\(TM\)|\(C\)|\(R\))/ig, '<span class="oHL oHL-corporate">$1</span>');
    filterList.push(".mw-references-wrap .oHL-corporate");

/*  html = html.replace(/([0-9]+°) ([0-9]+)′ ([0-9]+)″/g, '$1 $2€€ $3€€€'); // Guard
    matchDescriptions["oHL-prime"] = ["Prime symbol", "Outside of angles and coordinates, the prime symbols shouldn't be used. ([[MOS:UNITS]])"];
    html = html.replaceAll('€€€', '″'); // Unguard
    html = html.replaceAll('€€', '′'); // Unguard
    html = html.replace(/([′″])/g, '<span class="oHL oHL-prime">$1</span>');
*/

    matchDescriptions["oHL-unit-symbol"] = ["Inch & feet symbols", "\"in\" and \"ft\" should be used instead of quote marks. ([[MOS:NUM#Specific_units]])"];
    html = html.replace(/("\w.*?[0-9])"/g, '$1€€"'); // Guard
    html = html.replace(/([0-9])'s/g, "$1€€'s"); // Guard
    html = html.replace(/( [0-9]+)(['"])/g, '$1<span class="oHL oHL-unit-symbol">$2</span>');
    html = html.replaceAll("€€'", "'"); // Unguard
    html = html.replaceAll('€€"', '"'); // Unguard
    filterList.push(".mw-references-wrap .oHL-unit-symbol");

	matchDescriptions["oHL-bad-bullet"] = ["Bad bullet", "Lists on Wikipedia should use the proper list markup. ([[MOS:LISTBULLET]])"];
    html = html.replaceAll('•', '<span class="oHL oHL-bad-bullet">•</span>');
    filterList.push(".infobox-label .oHL-bad-bullet");

    matchDescriptions["oHL-spaced-amper"] = ["Ampersand", "Normal text should use \"and\" instead of the ampersand. ([[MOS:AMP]])"];
    html = html.replace(/ &amp; ([A-Z])/g, ' €amp€ $1'); // Guard
    html = html.replaceAll(' &amp; ', '<span class="oHL oHL-spaced-amper"> & </span>');
    html = html.replaceAll('€amp€', '&amp;'); // Unguard
    filterList.push(".mw-references-wrap .oHL-spaced-amper", ".oHL_wikilink .oHL-spaced-amper",
                    "a.new .oHL-spaced-amper", "i .oHL-spaced-amper", "table .oHL-spaced-amper",
                    ".cite .oHL-spaced-amper");

    // Never actually hit this one
    matchDescriptions["oHL-spaced-el"] = ["Spaced el", "An \"el\" (<code>l</code>) seems to have been typed instead of \"eye\" (<code>I</code>)."];
    html = html.replaceAll(' l ', '<span class="oHL oHL-spaced-el"> l </span>');

    matchDescriptions["oHL-unspaced-pgnum"] = ["Unspaced page number", "Page number abbreviations in citations should be spaced. (see examples at [[WP:CITE]])"];
    html = html.replace(/, p\.([0-9])/g, ', p.<span class="oHL oHL-unspaced-pgnum oHL_added">[ ]</span>$1');
    html = html.replace(/pp\.([0-9])/g, 'pp.<span class="oHL oHL-unspaced-pgnum oHL_added">[ ]</span>$1');

    matchDescriptions["oHL-unspaced-ordinal"] = ["Unspaced ordinal", "Ordinal abbreviations should be followed by a space."];
    html = html.replace(/([Nn]o\.)([0-9])/g, '$1<span class="oHL oHL-unspaced-ordinal oHL_added">[ ]</span>$2');
    filterList.push(".mw-references-wrap .oHL-unspaced-ordinal");

    matchDescriptions["oHL-arrow"] = ["Arrow symbols", "Arrows should use the proper Unicode characters. (<code>→</code>; <code>↔</code>)"];
    html = html.replace(/( |&lt;)-&gt;/g, '$1<span class="oHL oHL-arrow">-&gt;</span>');
    filterList.push(".mw-references-wrap .oHL-arrow");

    // Contractions
    matchDescriptions["oHL-contraction"] = ["Contraction", "Contractions should not be used outside of quoted text. ([[MOS:CONTRACTIONS]])"];
    // Try guarding up to four contractions
    // Guards won't work if HTML tags in between, e.g. `He said <span class="foo"> that's…``
    html = html.replace(/("\w[^"]*?)'([^'"]*)'([^'"]*)'([^'"]*)'/g, "$1'€$2'€$3'€$4'€"); // Guard
    html = html.replace(/("\w[^"]*?)'([^'"]*)'([^'"]*)'/g, "$1'€$2'€$3'€"); // Guard
    html = html.replace(/("\w[^"]*?)'([^'"]*)'/g, "$1'€$2'€"); // Guard
    html = html.replace(/("\w[^"]*?)([a-z])'([a-z])/g, "$1$2'€$3"); // Guard; this one also checks for surrounding letters

    html = html.replaceAll("n't", "<span class='oHL-opt oHL-contraction'>n't</span>");
    html = html.replaceAll("'ve", "<span class='oHL-opt oHL-contraction'>'ve</span>");
    html = html.replace(/(\w)'d/g, '$1<span class="oHL-opt oHL-contraction">\'d</span>');
    html = html.replaceAll("'ll", "<span class='oHL-opt oHL-contraction'>'ll</span>");

    html = html.replaceAll("they're", "<span class='oHL-opt oHL-contraction'>they're</span>");
    html = html.replaceAll("might've", "<span class='oHL-opt oHL-contraction'>might've</span>");
    
    html = html.replaceAll("that's", "<span class='oHL-opt oHL-contraction'>that's</span>");
    html = html.replaceAll(/ (t?here's)/g, " <span class='oHL-opt oHL-contraction'>$1</span>");
    html = html.replace(/ (s?he's)/g, " <span class='oHL-opt oHL-contraction'>$1</span>");
    html = html.replaceAll(" it's", " <span class='oHL-opt oHL-contraction'>it's</span>");
    html = html.replaceAll("who's", "<span class='oHL-opt oHL-contraction'>who's</span>");
    html = html.replaceAll("something's", "<span class='oHL-opt oHL-contraction'>something's</span>");
    html = html.replace(/'€+/g, "'"); // Unguard

    filterList.push(".mw-references-wrap .oHL-contraction", ".oHL_wikilink .oHL-contraction",
                    "a.new .oHL-contraction", "i .oHL-contraction", "blockquote .oHL-contraction",
                    ".poem .oHL-contraction");

    // Editorial issues
    /*html = html.replaceAll('and/or', '<span class="oHL oHL-and-or">$&</span>');

    html = html.replaceAll('fortun', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('sadly', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('ill-fated', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('fateful', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('tragedy', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('tragic', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('suffer', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('mirac', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('lucky', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('happily', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('interesting', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('curious', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('ironic', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('definitely', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('exclaimed', '<span class="oHL oHL-editorializing">$&</span>');
    // html = html.replaceAll('popular', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('famous', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll(' fame', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('infamy', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('prestigious', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('renowned', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('made headlines', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('iconic', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('acclaimed', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('visionary', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('outstanding', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll(' leading', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('celebrated', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('lauded', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('legendary', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('exceptional', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('spectacular', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('remarkable', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('amazing', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('extraordinar', '<span class="oHL oHL-editorializing">$&</span>')
    html = html.replaceAll('world-class', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('greatest', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('surprising', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('unexpect', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('a twist', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('bizzare', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('puzzling', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('incredibl', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('heroic', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('brave', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll(' courage', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('daring', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('beautiful', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('respected', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('forefront', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('tasty', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('disturbing', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('ingenious', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('brillian', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('a hit', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('extraordinary', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('phenomenal', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('innovative', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('pioneer', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('state of the art', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('state-of-the-art', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('cutting-edge', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('creatively', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('awesome', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('amusing', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('obvious', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('in fact', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('contrary', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('mere', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('so-called', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('of course', '<span class="oHL oHL-editorializing">$&</span>');
    // html = html.replaceAll(' even ', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('spite', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('begs', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('and yet', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('not to mention', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('should not', '<span class="oHL oHL-editorializing">$&</span>');
    html = html.replaceAll('should be noted', '<span class="oHL oHL-editorializing">$&</span>');
    filterList.push("blockquote .oHL-editorializing", "cite .oHL-editorializing",
                    ".oHL_wikilink .oHL-editorializing", "a.new .oHL-editorializing");

    html = html.replaceAll(/some say/gi, '<span class="oHL oHL-weasel">$&</span>');
    html = html.replaceAll(/it is said/gi, '<span class="oHL oHL-weasel">$&</span>');
    
    html = html.replaceAll('passed away', '<span class="oHL oHL-euphemism">$&</span>');
    html = html.replaceAll('survived by', '<span class="oHL oHL-euphemism">$&</span>');
    html = html.replaceAll(/gave (her|his|their) life/g, '<span class="oHL oHL-euphemism">$&</span>');
    html = html.replaceAll(/ma(d|k)e love/g, '<span class="oHL oHL-euphemism">$&</span>');
*/

    // Annotate non-visible or hard to distinguish elements
    html = html.replaceAll('×', '<ruby class="oHL_ruby"><rb>×</rb><rt>mult</rt></ruby>');
    html = html.replaceAll('&nbsp;', '<ruby class="oHL_ruby"><rb>&nbsp;</rb><rt>_nbsp_</rt></ruby>');
    html = html.replaceAll('&thinsp;', '<ruby class="oHL_ruby"><rb>&thinsp;</rb><rt>_thinsp_</rt></ruby>');
    html = html.replaceAll('&hairsp;', '<ruby class="oHL_ruby"><rb>&hairsp;</rb><rt>_hairsp_</rt></ruby>');
    html = html.replaceAll('&ZeroWidthSpace;', '<ruby class="oHL_ruby"><rb>&ZeroWidthSpace;</rb><rt>_ZeroWidthSpace_</rt></ruby>');
    html = html.replaceAll('&shy;', '<ruby class="oHL_ruby"><rb>&shy;</rb><rt>_shy_</rt></ruby>');
    html = html.replaceAll('–', '<ruby class="oHL_ruby"><rb>–</rb><rt>en</rt></ruby>');
    html = html.replaceAll('—', '<ruby class="oHL_ruby"><rb>—</rb><rt>em</rt></ruby>');
    html = html.replaceAll('−', '<ruby class="oHL_ruby"><rb>−</rb><rt>minus</rt></ruby>');
    html = html.replaceAll('‐', '<ruby class="oHL_ruby"><rb>‐</rb><rt>hyphen</rt></ruby>');
    html = html.replaceAll('ʼ', '<ruby class="oHL_ruby"><rb>ʼ</rb><rt>glottal</rt></ruby>');

    // Trailing spaces
    html = html.replace(/ +\n/g, '<span class="oHL_trailingSpace" title="Whitespace">_</span>\n');

    contentElement.innerHTML = html;
    
    // Make sure we didn't improperly unguard something
    const euroCountAfter = (html.match(/€/g) || []).length;
    if (euroCountBefore != euroCountAfter) {
    	$("#contentSub").after(`<div class='oHL_warning'>highlightStrings.js: Warning: Guard character count changed from ${euroCountBefore} to ${euroCountAfter}. Page text might be formatted incorrectly from original.</div>`);
        addReportButton();
    }
    
    const brokenHTML = html.match(/<span[^>]+<span.{5,60}/);
    if (brokenHTML != null && brokenHTML.length != 0) {
    	const brokenHTMLMessage = mw.html.escape(brokenHTML.toString());
    	$("#contentSub").after(`<div class='oHL_warning'>highlightStrings.js: Warning: Might have broken the page HTML: <code>${brokenHTMLMessage}</code></div>`);
        addReportButton();
    }
}

function postClean() {
    // Put back original attributes
    // We iterate backwards because we target the mangled ids and we don't want
    // them to change back until the end
    for (let i = mangled.length-1; i >= 0; i--) {
        unmangle(mangled[i]);
    }

    // Reattach the elements we removed at the start
    reattachTemp();

    whitelist();
    
    // Optionals
    $(".mw-references-wrap .oHL, .navbox .oHL").each(function markOptionalsSelector() {
        $(this).addClass("oHL-opt");
        $(this).removeClass("oHL");
    });
    $(refSectionsSelector).parent().nextUntil("h2").find(".oHL").each(function markOptionalsSection() {
        $(this).addClass("oHL-opt");
        $(this).removeClass("oHL");
    });
    // Handle elements tagged multiple times
    $(".oHL.oHL-opt").removeClass("oHL-opt");
    
    $(".infobox tr .oHL_ruby").children("rt").remove();
    $(".oHL_img_info_dimensions .oHL_ruby").children("rt").remove();
}

// Get wikitext of current page
function getWikitext() {
    // API docs: https://www.mediawiki.org/wiki/API:Revisions
    const apiUrl = location.origin + "/w/api.php";
    $.ajax({
        url: apiUrl,
        data: {
            action: "query",
            prop: "revisions",
            format: "json",
            revids: mw.config.get("wgRevisionId"),
            rvprop: "content",
            rvslots: "main"
        },
        success: searchWikitext
    });
}

function searchWikitext(response) {
    const pageId = mw.config.get("wgArticleId");
    const wikitext = response.query.pages[pageId].revisions[0].slots.main["*"];

    const searches = new Map();
    const results = new Map();

    // Note: need to escape backslashes here
    searches.set("nowiki", "<nowiki/?>");
    searches.set("include tags", "<(no|only)include/?>");
    searches.set("Infobox name param", "\\| +name +=");
    searches.set("Closable named refs", '<ref name=[^>]+></ref>');
    searches.set("Cite with author param", "\\| ?author1? ?=");
    searches.set("Reflist", "{{reflist\\|");
    searches.set("Anchors (span)", "<span id=[^>]+>");
    searches.set("Anchors (template)", "{{anchor ?\\|[^}]+}}");
    searches.set("Anchors (visible)", "{{(visible anchor|visanc|va|vanchor) ?\\|[^}]+}}");
    searches.set("Inline files", "(?<=.)\\[\\[File:[^\\|]+\\|");
    searches.set("Crosslanguage links", "\\[\\[[A-Za-z]{2}:[^\\]\\n]+\\]\\]");
    searches.set("Font size", "font-size:");
    searches.set("Comments", "<!--[\\s\\S]*?-->");
    searches.set("Math", "<math>");
    searches.set("HTML tags", "</(i|b|em|strong|ul|ol|dl|table|tr|td|abbr|col|"
                              + "body|figure|caption|hr|h1|h2|h3|h4|h5|h6|img|"
                              + "kbd|legend|pre|q|s|ruby|script|samp|small|big|"
                              + "span|u|video)>");
    searches.set("Redundant lang in link", ":en:");
    searches.set("Underscored wikilinks", "\\[\\[[^|\\]#]+_");
    searches.set("NOTOC", "__NOTOC__");
    searches.set("Sort keys", "\\[\\[Category:[^\\]\\n]+\\|[^\\]\\n]+\\]\\]");
    searches.set("Hardcoded thumbnail sizes", "\\[\\[(File|Image).*?[0-9]px\\|");
    searches.set("Redundant thumbnail sizes", "\\[\\[(File|Image).*?upright=1\\|");
    searches.set("Auto-named refs", '<ref name=":[0-9]+"');
    searches.set("Wiktionary links", "\\[\\[(wikt|wiktionary):[^\\]\\n]+\\]\\]");
    searches.set("Comma-less numbers", "(?<!((January|February|March|April|May|" // not preceded by a month
                                       + "June|July|August|September|October|"
                                       + "November|December|= *)( [0-9]{1,2},)?)"
                                       + "|File:[^|\\n]*)" // or an image
                                       + "(?<=[ (–])\\d{1,}\\d{3}"
                                       + "(?!'?s" // not followed by 's, e.g. 1990s
                                       + "|[^<]+<\\/ref|\"?/>" // not in a ref
                                       + "|[^|\\n]*\\.\\w{3,}\\|)"); // or an image
    searches.set("Piped italics", "[\\[|]''[^\\]\\n]+''\\]\\]");
    // TODO: expensive RegEx, can freeze the tab in rare cases
    searches.set("Punctuation in quotes", ".{40}(?<!{{[^}\\n]*)(?<=\\w+ \\w+)[.,;:][\"']");
    searches.set("Adjacent formatting", "''\\s+''");
    // TODO: expensive RegEx
    searches.set("Hyphenated names", "(?<![-/]|{{[^}]*|File:[^|\\n]*|Category:.*|<ref name=[^>]*)"
                                     + "[A-Z][a-z]+-[A-Z][a-z]+"
                                     + "(?![-/])");
    searches.set("Non-breaking hyphens", "‑");
    searches.set("Adjacent numbers", "(?<!File:[^|\\n]*)" // not preceded by an image
                                     + "(?<= )[0-9]+ [0-9]+"
                                     + "(?![^|\\n]*\\.\\w{3,}\\|)"); // or followed by an image
    searches.set("Days of the week", "(?<= )(Mon|Tue|Wed|Thur|Fri|Sat|Sun)(s|ur|nes)?(day)?(?=[ .,;:<])");
    searches.set("Redundant piped wikilinks", "\\[\\[([^|\\]\\n]+)\\|\\1\\]\\]");
    searches.set("Simplifiable wikilinks", "\\[\\[([^|\\n]+)\\|\\1[a-z]+\\]\\]")
    searches.set("Overly precise numbers", "(?<!<ref[^<]*)" // not in a DOI
                                           + "([0-9][,.][1-9]{3}|[0-9][,.]0[1-9]{2}|[0-9][,.][1-9]0[1-9]|[0-9][,.][1-9]{2}0|[0-9][,.]00[1-9])");
    searches.set("Long to short links", "\\[\\[[^|\\]\\n]+ [^|\\]\\n]+ [^|\\]\\n]+\\|[^ \\]\\n]+\\]\\]");
    searches.set("Headers with capital letters", "(?<===)[\\w ]+ [A-Z][^=\\n]+(?===)");
    searches.set("Table captions with capital letters", "(?<=\\|\\+)[\\w ]+ [A-Z][^|\\n]+");

    for (const [type, re] of searches) {
    	let flags = "gd";
    	if (type != "Hyphenated names" && type != "Headers with capital letters"
    	    && type != "Table captions with capital letters") {
        	flags += "i";
        }
        const searchRe = new RegExp(re, flags);
        const matches = wikitext.matchAll(searchRe);
        
        const matchesList = [];
        for (const m of matches) {
        	const start = m.indices[0][0];
        	const end = m.indices[0][1];
        	const context = 20;
        	
        	const leftContext = wikitext.substring(start-context, start);
        	const matchText = wikitext.substring(start, end);
        	const rightContext = wikitext.substring(end, end+context);
        	matchesList.push([leftContext, matchText, rightContext]);
        }
        if (matchesList.length > 0) {
        	results.set(type, matchesList);
        }
    }
    const nestedResults = getNestedQuotes(wikitext);
    if (nestedResults.length > 0) {
    	results.set("Nested quote marks", nestedResults);
    }

    showWikitextMatches(results);
    $("#oHL_comma_less_numbers summary").after("<div><label><input type='checkbox' id='oHL_commalessFilter'> Hide Years</label></div>");
    $("#oHL_commalessFilter").change(function filterCommalessResults() {
    	if ($("#oHL_commalessFilter").is(":checked")) {
    		$("#oHL_comma_less_numbers .oHL_wikitext-match").each(function hideYearResults() {
    			const yearText = $(this).children(".oHL_wikitext-match-text").text();
    			const year = parseInt(yearText);
    			if (year > 1300 && year < 2500) { // arbritrary date range
    				$(this).hide();
    			}
    		});
    	} else {
    		$("#oHL_comma_less_numbers .oHL_wikitext-match").show();
    	}
    });
    
    compareDefaultSort(wikitext);
    showLongQuotes(wikitext);
    showRedlinks();
    showFrequency();
    showRedirects();
    checkDisambigLink();
    checkOutlinkAnchors();
    tabulateReferences(wikitext);
}

function getNestedQuotes(wikitext) {
    wikitext = wikitext.replace(/(=[^>]+?)" /g, '$1₭ '); // guard quotes in <tags>
    wikitext = wikitext.replaceAll('>"<', '>₭<'); // guard single highlighted quotes
    wikitext = wikitext.replace(/([ >])"/g, '$1𐑱'); // 𐑱: left quote placeholder
    wikitext = wikitext.replace(/"([ .,;:\n]|<ref)/g, '𐑲$1'); // 𐑲: right quote placeholder
    wikitext = wikitext.replaceAll('₭', '"'); // unguard
    
    const matches = wikitext.matchAll(/𐑱[^𐑲\n]+𐑱[^𐑱\n]+𐑲[^𐑱\n]+𐑲/gd);
    const matchesList = [];
    for (const m of matches) {
        const start = m.indices[0][0];
        const end = m.indices[0][1];
        const context = 20;

        const leftContext = wikitext.substring(start - context, start);
        const matchText = wikitext.substring(start, end);
        const rightContext = wikitext.substring(end, end + context);
        const match = [leftContext, matchText, rightContext];
        const matchUnguarded =  match.map(m => m.replace(/[𐑱𐑲]/gu, '"'));
        matchesList.push(matchUnguarded);
    }
    
    return matchesList;
}


function compareDefaultSort(wikitext) {
    let title = mw.config.get("wgTitle");
    let defaultSort;
    const match = wikitext.match(/{DEFAULTSORT:([^}\n]+)}/i);
    if (match != null) {
        defaultSort = match[1];
    } else {
        defaultSort = title;
    }
    defaultSort = defaultSort.replace(/ \([^)]+\)$/, ""); // Remove " (disambiguation)"

    title = title.replace(/ \([^)]+\)$/, ""); // Remove " (disambiguation)"
    title = title.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); // https://stackoverflow.com/a/37511463/1995949
    title = title.replaceAll("–", "-");
    title = title.replaceAll("—", "-");
    title = title.replaceAll("×", "x");

    let titleCollated = title;
    const categories = $("#mw-normal-catlinks").text();
    if (title.startsWith("The ")) {
        titleCollated = title.substring(4) + ", The";
    } else if (title.startsWith("A ")) {
        titleCollated = title.substring(2) + ", A";
    } else if (categories.includes("births") || categories.includes("deaths")
               || categories.includes("Living people")) { // people
        // See: https://en.wikipedia.org/wiki/WP:NAMESORT
        title = title.replace("Saint ", "");
        title = title.replace("O'", "O"); // e.g. O'Neil
        let suffix = "";
        if (title.endsWith(" Jr.")) {
            suffix = " Jr.";
            title = title.substring(0, title.length - suffix.length);
        }

        // Assume the final part of the name is surname; not applicable to all cultures
        const splitName = title.split(" ");
        if (splitName.length > 1) {
            const lastPart = splitName.at(-1);
            const firstPart = splitName.slice(0, -1).join(" ");
            titleCollated = lastPart + ", " + firstPart + suffix;
        } else {
            titleCollated = title;
        }
    }

    if (titleCollated != defaultSort) {
        const mismatchText = `"${defaultSort}" ≠ "${titleCollated}"`;
        $("#oHL_results").append("<details id='oHL_defaultSort'><summary>DefaultSort mismatch <span class='summaryCount'>(1)</span></summary>"
                                 + "<ul><li>" + mismatchText + "</li></ul></details>");
    }
}

function showLongQuotes(wikitext) {
    const quoteRe = / "[A-Z.].*?"/g;
    const quotes = wikitext.match(quoteRe);
    if (quotes === null) { return; }

	const longQuotes = [];
    for (const quote of quotes) {
		const wordCount = quote.split(" ").length;
		if (wordCount > 40) {
			longQuotes.push([quote, wordCount]);
		}
    }
    if (longQuotes.length == 0) {
    	return;
    }
    
    let list = "<ul>";
    for (const [quote, wordCount] of longQuotes) {
		list += "<li>" + quote.substring(1) + " [~" + wordCount + " words]</li>";
	}
    list += "</ul>";
    $("#oHL_results").append("<details id='oHL_longQuotes'><summary>Long quotes <span class='summaryCount'>("
	                         + longQuotes.length + ")</span></summary>" + list + "</details>");
}

function checkOutlinkAnchors() {
	const anchorLinksArray = [];
	$(".oHL_wikilink").each(function getAnchoredWikilinks() {
		const anchor = decodeURIComponent(this.hash.slice(1));
		if (anchor != "" && !this.href.includes("/wiki/Help:")
		    && !this.href.includes("/wiki/Wikipedia:")
		    && !this.href.includes("/wiki/Talk:")) {
			const pageTitle = decodeURIComponent(this.pathname.split("/")[2]);
			const linkText = $(this).text().replace("|", " | ");
			anchorLinksArray.push({"link": pageTitle, "text": linkText, "anchor": anchor});
		}
	});
	
	if (anchorLinksArray.length == 0) {
		return;
	}
	
	// Deduplicate
	const anchorLinks = [...new Set(anchorLinksArray)];

    // Placeholder
	$("#oHL_results").append("<details id='oHL_brokenAnchors' style='display: none'><summary>Broken outgoing anchors <span class='summaryCount'>(0)</span></summary><ul></ul></details>");
	
	for (const anchorLink of anchorLinks) {
		// API docs: https://www.mediawiki.org/wiki/API:Parsing_wikitext
    	const apiUrl = location.origin + "/w/api.php";
        $.ajax({
            url: apiUrl,
            data: {
                action: "parse",
                page: anchorLink.link,
                prop: "text",
                format: "json",
                redirects: "true",
            },
            success: function processRevisions(response) {
            	checkAnchor(anchorLink, response);
            }
        });
	}

}

function checkAnchor(anchorLink, response) {
	const pageHtml = response.parse.text["*"];
	const pageParsed = $.parseHTML(pageHtml);
	const ids = $(pageParsed).find("span[id]").toArray().map(e => e.id);
	if (!ids.includes(anchorLink.anchor)) {
		const summaryElement = $("#oHL_brokenAnchors");
		const summaryCountElement = $(summaryElement).find(".summaryCount").first();
		const summaryCountString = summaryCountElement.text().replace("(", "").replace(")", "");
		const summaryCount = parseInt(summaryCountString) + 1;
		summaryCountElement.text("(" + summaryCount + ")");
		const linkHref = anchorLink.link + "#" + anchorLink.anchor;
		const linkText = anchorLink.link.replaceAll("_", " ") + " § "
		                 + anchorLink.anchor.replaceAll("_", " ");
		$(summaryElement).find("ul").append("<li>" + anchorLink.text + " → <a href='" + linkHref + "'>" + linkText + "</a></li>");
		$(summaryElement).show();
	}
}

function tabulateReferences(wikitext) {
    wikitext = wikitext.replace(/<!--.*?-->/gs, ''); // delete comments
	const templateRefRe = /<ref[^>]*>\s*{{(cite |citation)[^<]+<\/ref>/gi;
	const templateRefs = wikitext.match(templateRefRe) || [];
	const templateRefcount = templateRefs.length;
	const plainRefRe = /<ref[^>]*>[^{<]+<\/ref>/gi;
	const plainRefs = wikitext.match(plainRefRe) || [];
	const plainRefcount = plainRefs.length;
	const foundCount = templateRefcount + plainRefcount;
	if (foundCount == 0) { return; }

	let tableHeader = "<table class='wikitable oHL_refTable'><tr><th>#</th><th>Template</th>"
	                  + "<th>Author</th><th>Date</th><th>Access</th><th>Title</th>"
	                  + "<th>Work</th><th>Publisher</th><th><abbr title='Language'>Lang</abbr></th>"
	                  + "<th><abbr title='Protocol'>Proto</abbr></th></tr>";
	let tableContent = "";
	for (const ref of templateRefs) {
        const template = ref.match(/{cite ([^|}]+)/i)?.[1] || ref.match(/{(citation)/i)?.[1];
        const firstName = ref.match(/\|\s*(?:first|given)1?\s*=\s*([^|}]+)/i)?.[1] || "—";
        const lastName = ref.match(/\|\s*(?:last|surname)1?\s*=\s*([^|}]+)/i)?.[1] || "—";
        let author = ref.match(/\|\s*authors?\s*=\s*([^|}][^|}]+)/i)?.[1] || "—";
        if (author == "—" && firstName != "—") {
        	const firstName_ = firstName.trim();
        	const lastName_ = lastName.trim();
        	author = `${firstName_}&nbsp;/ ${lastName_}`;
        }
        let date = ref.match(/\|\s*(?:date|year)\s*=\s*(\w[^|}]+)/i)?.[1] || "—";
        if (date  == "—" && template.includes("tweet")) {
        	const tweetID = ref.match(/\/status\/([0-9]+)/)?.[1];
        	date = tweetURLtoDate(tweetID) || "—";
        }
        const accessdate = ref.match(/\|\s*access-?date\s*=\s*(\w[^|}]+)/i)?.[1] || "—";
        let title = ref.match(/\|\s*(?:script-)?title\s*=\s*([^|}][^|}]+)/i)?.[1] || "—";
        let url = ref.match(/\|\s*url\s*=\s*(http[^|}]+)/i)?.[1] || "—";
        if (template.includes("journal")) {
        	const doi = ref.match(/\|\s*doi\s*=\s*([^|}][^|}]+)/i)?.[1];
        	if (doi != null) {
        		url = "https://doi.org/" + doi.replace("/", "%2F");
        	}
        } else if (template.includes("tweet")) {
        	const user = ref.match(/\|\s*user\s*=\s*([^|}][^|}]+)/i)?.[1] || "—";
        	const number = ref.match(/\|\s*number\s*=\s*([^|}][^|}]+)/i)?.[1] || "—";
        	url = `https://twitter.com/${user}/status/${number}`;
        	url = url.replaceAll(" ", "");
        } else if (template == "Q") {
        	title = ref.match(/(Q[0-9]+)/i)?.[1];
        	url = `https://www.wikidata.org/wiki/${title}`;
        }
        url = url.trim();
        const ordinal = getRefOrdinalFromURL(url, ref, template);
        const archiveUrl = ref.match(/\|\s*archive-?url\s*=\s*(http[^|}]+)/i)?.[1] || "—";
        const urlStatus = ref.match(/\|\s*url-?status\s*=\s*(\w[^|}]+)/i)?.[1] || "—";
        const isLiveLink = /live/i.test(urlStatus);
        let isArchived = false;
        if (archiveUrl != "—" && !isLiveLink) { url = archiveUrl; isArchived = true; }
        const refEscaped = ref.replaceAll('"', '&quot;');
        if (title != "—" && url != "—") { title = `<a href="${url}" title="${refEscaped}">${title}</a>`; }
        if (title != "—") { title = `<span title="${refEscaped}">${title}</span>`; }
        let protocol = "—";
        if (url != "—") { protocol = url.startsWith("https:") ? "🔐" : "🔓"; }
        let work = ref.match(/\|\s*(?:work|website|journal|newspaper|magazine|periodical)\s*=\s*([^|}][^|}]+)/i)?.[1] || "—";
        if (template.includes("tweet")) { work = "Twitter"; }
        const publisher = ref.match(/\|\s*(?:publisher|agency)\s*=\s*([^|}][^|}]+)/i)?.[1] || "—";
        const language = ref.match(/\|\s*lang(?:uage)?\s*=\s*(\w[^|}]+)/i)?.[1] || "—";

		tableContent += `<tr><td>${ordinal}</td><td>${template}</td><td>${author}</td><td>${date}</td>
		                 <td>${accessdate}</td><td>${title}</td><td>${work}</td>
		                 <td>${publisher}</td><td>${language}</td><td>${protocol}</td></tr>`;
	}
	
	const refContentsRe = />([^<]*)<\/ref/i;
	for (const ref of plainRefs) {
		let url = "—";
		let title;
	    const formattedLinkMatch = ref.match(/\[(http[^ ]*) ([^\]]*)\]/i);
	    const plainURLMatch = ref.match(/(http[^ <]*)/i);
	    if (formattedLinkMatch) {
	    	url = formattedLinkMatch[1];
	    	title = formattedLinkMatch[2];
	    } else if (plainURLMatch) {
	    	url = plainURLMatch[1];
	    	title = ref.match(refContentsRe)[1];
	    } else { // ref contains no URLs
	    	title = ref.match(refContentsRe)[1];
	    }
	    
	    const refEscaped = ref.replaceAll('"', '&quot;');
        if (url != "—") {
        	title = `<a href="${url}" title="${refEscaped}">${title}</a>`;
        } else {
        	title = `<a class="oHL_unlinkedRef" title="${refEscaped}">${title}</a>`;
        }

	    const ordinal = getRefOrdinalFromURL(url, ref, "N/A");
	    let protocol = "—";
        if (url != "—") { protocol = url.startsWith("https:") ? "🔐" : "🔓"; }
	    tableContent += `<tr><td>${ordinal}</td><td>—</td><td>—</td><td>—</td>
		                 <td>—</td><td>${title}</td><td>—</td>
		                 <td>—</td><td>—</td><td>${protocol}</td></tr>`;
	}
	
	const table = tableHeader + tableContent + "</table>";
	
	let countString = foundCount;
	const totalCount = $(".reference-text").last().closest("li").index() + 1;
	if (foundCount != totalCount) { countString += "/" + totalCount; }
	
	$("#oHL_results").append("<details id='oHL_refTable'><summary>References <span class='summaryCount'>("
	                         + countString + ")</span></summary>" + table + "</details>");

	// tabulateMissingRefs();
    catchFinalOrdinal(foundCount, totalCount);

	mw.loader.using("jquery.tablesorter", function makeTableSortable() {
		$("#oHL_refTable table").tablesorter( { sortList: [ {0: "asc"} ] } );
	});
}

function getRefOrdinalFromURL(url, ref, template) {
    let ordinal = "—";
    let refParent;
    if (url != "—") {
    	// for some reason, MediaWiki upgrades some URLs to https even if they were http in the source
    	const urlStripped = url.replace(/https?:/, "");
    	const urlEscaped = CSS.escape(urlStripped);
    	refParent = $(".reference-text [href$='" + urlEscaped + "']").closest(".reference-text").closest("li");
    }
    
    if (typeof refParent == "undefined" || refParent.length == 0) {
        const isbn = ref.match(/\|\s*isbn\s*=\s*([^|}][^|}]+)/i)?.[1];
        if (isbn) {
        	const isbnTrimmed = isbn.trim();
        	refParent = $("cite [href*='" + isbnTrimmed + "']").closest("cite").closest("li");
        }
    }

    if (refParent) {
    	const ordinalNumber = $(refParent).index() + 1;
    	const refId = $(refParent).attr("id");
    	ordinal = `<a href='#${refId}'>${ordinalNumber}</a>`;
    }
    
    return ordinal;
}

function catchFinalOrdinal(foundCount, totalCount) {
	if (foundCount != totalCount) { return; }
	const ordinalColumn = $("#oHL_refTable tbody tr td:first-child");
	const ordinalString = $(ordinalColumn).text();
	const noOrdinalCount = (ordinalString.match(/—/g) || []).length;
	if (noOrdinalCount != 1) { return; }
	const ordinals = [];
	let blankOrdinal;
	ordinalColumn.each(function getOrdinals() {
		const ordinal = $(this).text();
		if (ordinal != "—") {
			ordinals.push(parseInt(ordinal));
		} else {
			blankOrdinal = this;
		}
	});
	ordinals.sort((a, b) => a - b);

	for (let counter = 1; counter <= totalCount; counter++) {
		if (!ordinals.includes(counter)) {
			const ordinalId = $(".references > li[id$='-" + counter + "']").attr("id");
			const markup = `<a href="#${ordinalId}">${counter}</a>`;
			$(blankOrdinal).html(markup);
			break;
		}
	}
}

/* function tabulateMissingRefs() {
	// Get all the refs we couldn't match
	const unmatched = [];
	const ordinals = [];
	$(".oHL_refTable tbody tr td:first-child").each(function iterateRefTableOrdinals() {
		const ordinal = $(this).text();
		if (ordinal == "—") {
			unmatched.push($(this));
		} else {
			ordinals.push(ordinal);
		}
	});
	
	for (const ref of unmatched) {
		;
	}
} */

// Reference: https://en.wikipedia.org/wiki/Snowflake_ID
function tweetURLtoDate(tweetID) {
	if (!tweetID) { return null; }
	const epoch = 1288834974657;
    // Example: https://twitter.com/wikipedia/status/1541815603606036480
    // e.g. 1541815603606036480
    const snowflake = parseInt(tweetID);
    // e.g. 0b 1 0101 0110 0101 1010 0001 0001 1111 0110 0010 00|01 0111 1010|0000 0000 0000
    const offsetBinary = snowflake.toString(2).substring(0, 39);
    // e.g. 367597485448
    const offset = parseInt(offsetBinary, 2);
    // e.g. 1288834974657 + 367597485448 = 1656432460_105
    const timestampMS = epoch + offset;
    // e.g. June 28, 2022
    const date = new Date(timestampMS).toLocaleDateString("en-us", { day:"numeric", year:"numeric", month:"long"});

    return date;
}

function showRedlinks() {
	const redLinks = $("a.new");
	if (redLinks.length == 0) { return; }

	const linkText = {};
	$(redLinks).each(function getRedlinks() {
		const link = $(this).attr("href");
		const text = $(this).text();
		linkText[link] = text;
	});
	
	const navLinks = $(".navbox a.new, .sidebar a.new");
	$(navLinks).each(function getNavlinks() {
		const link = $(this).attr("href");
		delete linkText[link];
	});
	
	const linkTextSize = Object.keys(linkText).length;
	if (linkTextSize == 0) { return; }

	let list = "<ul>";
	for (const [link, text] of Object.entries(linkText)) {
		list += "<li><a href='" + link + "' class='new'>" + text + "</a></li>";
	}
	
	list += "</ul>";
    $("#oHL_results").append("<details id='oHL_redlinks'><summary>Redlinks <span class='summaryCount'>("
	                         + linkTextSize + ")</span></summary>" + list + "</details>");
}

const wordListURL = "https://" + window.location.hostname + "/w/index.php?title=User:Opencooper/highlightStringsWordlist.js&action=raw&ctype=text/javascript";
function showFrequency() {
    $.ajax({
    	url: wordListURL,
        success: getFrequencies
    });
}

function containsVowel(s) {
    const vowels = ["a", "e", "i", "o", "u", "y"];
    return Array.from(s).filter(c => vowels.includes(c)).length > 0;
}

function getSingular(word) {
	const startLength = word.length;
    word = word.replace(/('s'|'s)$/, "");
    if (word.length != startLength) { return word; }
    word = word.replace(/'$/, "");
    
	if (word.endsWith("sses") || word.endsWith("xes")) {
		word = word.slice(0, -2);
	} else if (word.endsWith("us") || word.endsWith("ss") || word.endsWith("es")) {
		// do nothing
	} else if (word.endsWith("s")) {
		const precedingWordPart = word.slice(0, -2);
        if (containsVowel(precedingWordPart)) {
            word = word.slice(0, -1);
        }
	}
	
	return word;
}

// Simpler form of the Porter2 algorithm: http://snowball.tartarus.org/algorithms/english/stemmer.html
// Attempts to lemmatize better
function getStem(word) {
	function getRegions(s) {
        // Not implementing gener/commun/arsen exception
        const regionRe = /[aeiouy][^aeiouy](.*)/;
        const r1 = s.match(regionRe)?.[1] || "";
        const r2 = r1.match(regionRe)?.[1] || "";
        return [r1, r2];
    }

    function endsWithDouble(s) {
        return s.length >= 2 && s.slice(-1) == s.slice(-2, -1);
    }
    
    function isShort(s) {
        const [r1, r2] = getRegions(s);
        return r1 == "" && /[^aeiouy][aeiouy][^aeiouywxY]$/.test(s);
    }
	
	word = getSingular(word);
	
	if (word.length <= 2) {
        return word;
    }
    
    if (word.endsWith("ies")) {
		word = word.replace(/ies$/, "y");
    } else if (word.endsWith("es")) {
    	word = word.replace(/es$/, "");
    	const finalLetter = word.slice(-1);
    	if (/[bcdefgklmnopqrstuvz]/.test(finalLetter) || word.endsWith("ach")) {
    		word += "e";
    	}
    }

	// e.g. painting, rating
	if (word.endsWith("ting") || word.endsWith("ted")) {
		word = word.replace(/ing$/, "").replace(/ed$/, "");
		if (/[aeiouyt]$/.test(word)) {
			word += "e";
		}
	}
	
	// e.g. rising, sized, inviting
	if (word.endsWith("ising") || word.endsWith("izing") || word.endsWith("iting")) {
		word = word.replace(/(i[szt])ing$/, "$1e");
	} else if (word.endsWith("ised") || word.endsWith("ized") || word.endsWith("ised") || word.endsWith("ited")) {
		word = word.replace(/(i[szt])ed$/, "$1e");
	}
	
	// e.g. ensnaring, snored
	if (word.endsWith("naring") || word.endsWith("noring")) {
		word = word.replace(/(n[ao]r)ing$/, "$1e");
	} else if (word.endsWith("nared") || word.endsWith("nored")) {
		word = word.replace(/(n[ao]r)ed$/, "$1e");
	}
	
	if (word.endsWith("ing")) {
		word = word.replace(/ing$/, "");
    
    if (endsWithDouble(word)) {
                word = word.slice(0, -1);
    } else if (/[cpvn]$/.test(word)) {
        word += "e";
    }
	}
	
    if (word.endsWith("ied")) {
		word = word.replace(/ied$/, "y");
	} else if (word.endsWith("ed")) {
		const deletedWord = word.replace(/ed$/, "");
        if (containsVowel(deletedWord)) {
            word = deletedWord;
            if (word.endsWith("at") || word.endsWith("bl") || word.endsWith("iz") || word.endsWith("en") || word.endsWith("ok") || word.endsWith("v") || word.endsWith("in")) {
                word += "e";
            } else if (endsWithDouble(word)) {
                word = word.slice(0, -1);
            } else if (isShort(word)) {
                word += "e";
            }
        }
	}
  
  if (word.endsWith("er") || word.endsWith("est")) {
      word = word.replace(/er$/, "").replace(/est$/, "");
      if (word.endsWith("i")) {
          word = word.slice(0, -1) + "y";
      } else if (word.endsWith("m")) {
          word += "e";
      }
  }
	
	if (word.endsWith("tche")) {
		// e.g. blotches
		word = word.replace(/tche$/, "tch");
	} else if (word.endsWith("ttl") || word.endsWith("rul")) {
		// e.g. unsettled, overruling
		word += "e";
	}
	
	return word;
}

function getFrequencies(response) {
	const wordList = response.split("\n");
	const commentEnd = wordList.indexOf("//———") + 1;
	for (let i = 0; i <= commentEnd; i++) {
		wordList[i] = "";
	}

	const wordListDehyphenated = [];
	const wordListDeperioded = [];
	for (const word of wordList) {
		if (word.includes("-")) {
			const wordDehyphenated = word.replaceAll("-", "");
			wordListDehyphenated.push(wordDehyphenated);
		} else if (word.endsWith(".")) {
			const periodCount = word.match(/\./g).length;
			if (periodCount == 1) {
				const wordDeperioded = word.slice(0, -1);
				wordListDeperioded.push(wordDeperioded);
			}
		}
	}

	// Get article text and cleanup text we don't want
    const bodyContent = $("#mw-content-text .mw-content-ltr").first().clone();
    bodyContent.find("p, div, tr, .infobox-data, br").before("\n");
    bodyContent.find("li, td, sub, sup").before(" ");
    bodyContent.find("q").before('"'); bodyContent.find("q").after('"');
    bodyContent.find("sub, sup, math, style,"
                     + " [href='/wiki/Help:Pronunciation_respelling_key'],"
                     + " [href*='doi.org'], [href*='arxiv.org']," 
                     + " .ambox, .portalbox, .infobox-label, #toc, .texhtml,"
                     + " .printfooter, .sidebar, .IPA, .stub, .url,"
                     + " .sistersitebox, .mw-hidden-catlinks, .cs1-maint,"
                     + " .cs1-prop-foreign-lang-source, .cs1-visible-error,"
                     + " .harv-error, .cite-accessibility-label, .mw-editsection,"
                     + " .oHL_anchorLink, .oHL_piped, .oHL_added, .oHL_ruby rt,"
                     + " .oHL_trailingSpace, #oHL_wd_img, .oHL_shownAnchor,"
                     + " .oHL_img_info_dimensions, .oHL_clear,"
                     + " .navbox-title .hlist").remove();

    String.prototype.cleanText = function() {
    	return this.replaceAll("\n", " ")
                   .replaceAll("’", "'")
                   .replaceAll(/http[^ \n]*/g, "").replaceAll(/[\w.-]+\.[\w\/]{2,}/g, "")
                   .replaceAll(/([–—−+×·⋅÷√&\/\\<>{}~$@%_…\|\*=º°^′™©®†‡§←→↔~「」【】()・])/g, " ")
                   .replaceAll(/(\p{Emoji_Presentation})/ug, "")
                   .replaceAll(/([-‑‐])/g, " ")
                   .replaceAll(/[\s​]/g, " ")
                   .replaceAll("\u2060", "").replaceAll("\u200C", "").replaceAll("\u200D", "").replaceAll("\u200E", "").replaceAll("\u00AD", "") // invisible characters
                   .replaceAll(/(' '|(?<![A-Za-z])'|'(?![A-Za-z]))/g, " ")
                   .replaceAll(/[[\]]/g, "")
                   .replaceAll(/([.,:;"‘’“”„`´«»‹›!¡?¿#|&%。.,、:;?!()])/g, " ")
                   .replaceAll("æ", "ae").replaceAll("œ", "oe");
    }
    const bodyContentNavboxless = bodyContent.clone();
    bodyContentNavboxless.find(".navbox").remove();
    const bodyTextRawNavboxless = bodyContentNavboxless.text();
    
    let bodyTextRaw = bodyContent.text();
    const bodyText = bodyTextRaw.cleanText();
    // Create lists of words for filtering
    const refTextArray = bodyContent.find(refSectionsSelector + ", #Further_reading, #Additional_reading")
                                    .parent().nextUntil("h2, .navbox, .stub").text().cleanText().split(" ");
    const italicTextArray = bodyContent.find("i").append(" ").text().cleanText().split(" ");
    const wikilinkTextArray = bodyContent.find(".oHL_wikilink, a.new").append(" ").text().cleanText().split(" ");
    const externalTextArray = bodyContent.find(".external").append(" ").text().cleanText().split(" ");

    const blockTextArray = bodyContent.find("blockquote, .quotebox, .poem:not(blockquote .poem), .oHL_bad-indent").text().cleanText().split(" ");
    // TODO: use a different char for single quotes
    const quoteMarkTextArray = bodyTextRaw.replaceAll(/([ \(\n])['"]/g, "$1𐑱")  // 𐑱: left quote placeholder
                                          .replaceAll(/[‘“]/g, "𐑱")
                                          .replaceAll(/['’"]([ .,;:\)\n])/g, '𐑲$1') // 𐑲: right quote placeholder
                                          .replaceAll("”", "𐑲")
                                          .match(/(?<=𐑱)[^𐑲\n]+(?=𐑲)/g)
                                          ?.join(" ").replaceAll(/[𐑱𐑲]/g, "")
                                          .cleanText().split(" ");
    const quoteTextArray = blockTextArray.concat(quoteMarkTextArray);
    // Create lists of words for whitelisting
    const wikilinkPipedTextArray = $("#bodyContent .oHL_piped small").append(" ").text().cleanText().toLowerCase().split(" ");
    const hatnoteTextArray = bodyContent.find(".hatnote .oHL_wikilink").text().cleanText().toLowerCase().split(" ");
    const navboxTextArray = bodyContent.find(".navbox").text().cleanText().toLowerCase().split(" ");
    const foreignTextArray = bodyContent.find("[lang], .extiw").append(" ").text().cleanText().toLowerCase().split(" ");
    const categoryTextArray = $("#catlinks").clone().find("li").append("|").text().cleanText().toLowerCase().split(" ");
    const titleTextArray = bodyContent.find(".oHL_title").append(" ").text().cleanText().toLowerCase().split(" ");
    const codeTextArray = bodyContent.find("pre, code").append(" ").text().cleanText().toLowerCase().split(" ");
    const sicTextArray = bodyText.match(/\w+(?=\s+sic )/g)?.join(" ").toLowerCase().split(" ");
    const usernameArray = bodyTextRaw.match(/(?<=@)(\w+)/g)?.join(" ").replaceAll("_", "").toLowerCase().split(" ");
    const hashtagArray = bodyTextRaw.match(/(?<=#)(\w+)/g)?.join(" ").toLowerCase().split(" ");
    const gitHubArray = bodyContent.find("[href^='https://github.com/']").append(" ").text().split(" ").filter(s => s.includes("/")).join(" ").cleanText().toLowerCase().split(" ");

    bodyTextRaw = bodyTextRaw.replaceAll(/[ \n]*\n+[ \n]*/g, " ¶ ")
                             .replaceAll(/(¶ \^ ){2,}/g, "¶ ^ ");


    // Convert text into frequency list
    const tokens = bodyText.split(" ");
    const counts = {};
    for (const token of tokens) {
    	if (token.length <= 1 || /\d/.test(token) || /[A-Z]{2}|[a-z][A-Z]/.test(token)
    	    || !/\w/.test(token) || token.startsWith("d'") || token.startsWith("l'")) {
    		continue;
    	}
    	if (token in counts) {
    		counts[token] += 1;
    	} else {
    		counts[token] = 1;
    	}
    }

    // Merge together variants, e.g. "chopsticks" and "Chopsticks" for "chopstick"
    for (const [token, count] of Object.entries(counts)) {
    	const variants = [token];
        const tokenNormalized = token.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); // https://stackoverflow.com/a/37511463/1995949
		if (tokenNormalized != token) {
        	variants.push(tokenNormalized);
		}
		for (const variant of variants) {
			const variantSingular = getSingular(variant);
			if (variantSingular != variant) {
        	    variants.push(variantSingular);
		    }
		}
		for (const variant of variants) {
			const variantStemmed = getStem(variant);
			if (variantStemmed != variant) {
        	    variants.push(variantStemmed);
		    }
		}
    	for (const variant of variants) {
    		const tokenLowercased = variant.toLowerCase();
    		if (tokenLowercased != variant) {
    			variants.push(tokenLowercased);
    		}
    	}
    	
    	for (const variant of variants) {
    	    if (variant != token && variant in counts) {
            	counts[variant] += count;
        	    delete counts[token];
            }	
    	}
    }
    
    // Only keep words that don't repeat
    const singleCounts = [];
    for (const [token, count] of Object.entries(counts)) {
    	if (count == 1) {
    		singleCounts.push(token);
    	}
    }

    const wordWhiteListArray = wordList.concat(wordListDehyphenated, wordListDeperioded,
                                               navboxTextArray, foreignTextArray,
                                               categoryTextArray, titleTextArray,
                                               sicTextArray, usernameArray,
                                               hashtagArray, gitHubArray,
                                               codeTextArray, hatnoteTextArray,
                                               wikilinkPipedTextArray);
    const wordWhitelist = new Set(wordWhiteListArray);

    // Filter out common words
	const singleCountsUncommon = [];
    for (const token of singleCounts) {
    	const tokenLowercase = token.toLowerCase().replace("æ", "ae").replace("œ", "oe");
    	const tokenSingular = getSingular(tokenLowercase);
    	if (!wordWhitelist.has(tokenLowercase)
    	    && !wordWhitelist.has(tokenSingular)
    	    && !wordWhitelist.has(getStem(tokenSingular))) {
    		singleCountsUncommon.push(token);
    	}
    }

    if (singleCountsUncommon.length == 0) {
    	return;
    }
    
    // Alphabetize and build list
    const finalIndex = bodyTextRaw.length - 1;
    singleCountsUncommon.sort((a, b) => a.localeCompare(b, 'en', {'sensitivity': 'base'}));
    let listMarkup = "<ul>";
    for (const token of singleCountsUncommon) {
    	const searchRe = new RegExp("(?<=\\W)" + token + "(?=\\W)");
    	const contextIndex = bodyTextRaw.match(searchRe)?.index;
    	let context = "";
    	if (typeof contextIndex != "undefined") {
    	    const contextOffset = 35 - (token.length / 2);
    		const contextCenterIndex = contextIndex + (token.length / 2);
    		let contextStartIndex = contextCenterIndex - contextOffset;
    		if (contextStartIndex < 0) { contextStartIndex = 0; }
    		let contextEndIndex = contextCenterIndex + contextOffset;
    		if (contextEndIndex > finalIndex) { contextStartIndex = finalIndex; }
    		const contextText = bodyTextRaw.substring(contextStartIndex, contextEndIndex);
    		context = " – "
    		          + contextText.replace(searchRe, "<span class='oHL_wikitext-match-text'>"
    		                                       + token + "</span>");
    	}

    	let classList = [];
    	if (/[A-Z]/.test(token[0])) {
    		classList.push("oHL_uncommonWordUppercase");
    	}
    	if (/[a-z]/.test(token[0])) {
    		classList.push("oHL_uncommonWordLowercase");
    	}
    	if (!/^[A-Za-z']+$/.test(token)) {
    		classList.push("oHL_uncommonWordNonASCII");
    	}
    	if (refTextArray.includes(token)) {
    		classList.push("oHL_uncommonWordReference");
    	}
    	if (italicTextArray.includes(token)) {
    		classList.push("oHL_uncommonWordItalic");
    	}
    	if (quoteTextArray.includes(token)) {
    		classList.push("oHL_uncommonWordQuote");
    	}
    	if (wikilinkTextArray.includes(token)) {
    		classList.push("oHL_uncommonWordWikilink");
    	}
    	if (externalTextArray.includes(token)) {
    		classList.push("oHL_uncommonWordExternal");
    	}

    	listMarkup += "<li class='" + classList.join(" ")
    	              + "'><span class='oHL_uncommonWord'>" + token + "</span>"
    	              + context + "<span class='oHL_uncommonSymbols'></span></li>";
    }
    listMarkup += "</ul>"
    $("#oHL_results").append("<details id='oHL_uncommon'><summary>Uncommon words <span class='summaryCount'>("
	                         + singleCountsUncommon.length + ")</span></summary>" + listMarkup + "</details>");

	addFrequencyFilters();
	showUnbalanced(bodyTextRawNavboxless);
}

function addFrequencyFilters() {
	const filters = [
		{"id": "oHL_lowercaseFilter", "class": "oHL_uncommonWordLowercase", "label": "Lowercase", "symbol": "a"},
		{"id": "oHL_uppercaseFilter", "class": "oHL_uncommonWordUppercase", "label": "Uppercase", "symbol": "A"},
		{"id": "oHL_UnicodeFilter", "class": "oHL_uncommonWordNonASCII", "label": "Unicode", "symbol": "Ü"},
		{"id": "oHL_italicFilter", "class": "oHL_uncommonWordItalic", "label": "Italicized", "symbol": "𝐼"},
		{"id": "oHL_quoteFilter", "class": "oHL_uncommonWordQuote", "label": "Quoted", "symbol": "“"},
		{"id": "oHL_wikilinkFilter", "class": "oHL_uncommonWordWikilink", "label": "Wikilinked", "symbol": "∞"},
		{"id": "oHL_referenceFilter", "class": "oHL_uncommonWordReference", "label": "Reference", "symbol": "^"},
		{"id": "oHL_externalFilter", "class": "oHL_uncommonWordExternal", "label": "External", "symbol": "→"}
	]
	
	filters.forEach(f => {
		if (["a", "A", "Ü"].includes(f.symbol)) { return; }
		const symbolMarkup = "<span title='" + f.label + "'>" + f.symbol + "</span>";
		$("." + f.class + " .oHL_uncommonSymbols").append(symbolMarkup);
	});

	function updateFilterEnabledStatus() {
		if (this.checked) { return; }
    	const filterId = this.parentElement.parentElement.id;
    	const filterClass = filters.find(f => f.id == filterId).class;
    	const isFilterable = $("." + filterClass).not(".oHL_uncommonWordHidden").length > 0;
    	if (isFilterable) {
    		this.disabled = false;
    		$(this).parent().removeClass("oHL_filterDisabled");
    	} else {
    		this.disabled = true;
    		$(this).parent().addClass("oHL_filterDisabled");
    	}
    }

	$("#oHL_uncommon summary").after("<fieldset id='oHL_uncommonFilters'><legend>Hide:</legend></fieldset>");
    for (const filter of filters) {
        $("#oHL_uncommonFilters").append(` <span id='${filter.id}'><label><input type='checkbox'> <span class='oHL_filterLabel'>${filter.label}</span></label></span>`);
    }
    $("#oHL_uncommonFilters input").each(updateFilterEnabledStatus);

    const filterCheckboxSelector = filters.map(f => "#" + f.id + " input").join(", ");
    $(filterCheckboxSelector).change(function filterUncommonWords() {
		const hideList = [];
		const showList = [];
		for (const filter of filters) {
			if ($(`#${filter.id} input`).is(":checked")) {
    		    hideList.push("." + filter.class);
    	    } else {
    	    	showList.push("." + filter.class);
    	    }
		}
		
		const hideSelectors = hideList.join(", ");
		const showSelectors = showList.join(", ");
		$(hideSelectors).addClass("oHL_uncommonWordHidden");
		$(showSelectors).not(hideSelectors).removeClass("oHL_uncommonWordHidden");
		$("#oHL_uncommonFilters input").each(updateFilterEnabledStatus);
	});

	$(filterCheckboxSelector).hover(function showFilteredHighlights() {
		const filterId = this.parentElement.parentElement.id;
		const filterClass = filters.find(f => f.id == filterId).class;
		$("." + filterClass + " .oHL_uncommonWord").addClass("oHL_filterableWordHighlighted");
	}, function hideFilteredHighlights() {
		$(".oHL_filterableWordHighlighted").removeClass("oHL_filterableWordHighlighted");
	});
}

function showUnbalanced(bodyText) {
	const unbalanced = [];
	const brackets = {
		"\"\"": /"/g,
		"“”": [/“/g, /”/g],
		"()": [/\(/g, /\)/g],
		"[]": [/\[/g, /\]/g]
	};

	for (const line of bodyText.split("\n")) {
		for (const [bracket, bracketRe] of Object.entries(brackets)) {
			const bracketLeft = bracket[0];
			const bracketRight = bracket[1];
			
			let isUnbalanced = false;
			if (bracketLeft == '"') {
				const count = (line.match(bracketRe) || []).length;
			    if (count % 2 != 0) {
				    isUnbalanced = true;
			    }
			} else {
				const leftCount = (line.match(bracketRe[0]) || []).length;
				const rightCount = (line.match(bracketRe[1]) || []).length;
				if (leftCount != rightCount) {
					isUnbalanced = true;
				}
			}

			if (isUnbalanced) {
				let lineFormatted = line.replaceAll(bracketLeft, "<span class='oHL_wikitext-match-text'>"
				                                                 + bracketLeft + "</span>");
			    if (bracketRight != bracketLeft) {
			    	lineFormatted = lineFormatted.replaceAll(bracketRight, "<span class='oHL_wikitext-match-text'>"
				                                             + bracketRight + "</span>");
			    }
				unbalanced.push(lineFormatted);
			}
		}
	}

    if (unbalanced.length == 0) { return; }

	let list = "<ul>";
    for (const entry of unbalanced) {
		list += "<li>" + entry + "</li>";
	}
    list += "</ul>";

    $("#oHL_results").append("<details id='oHL_unbalanced'><summary>Unbalanced quotes and brackets <span class='summaryCount'>("
	                         + unbalanced.length + ")</span></summary>" + list + "</details>");
}

function showRedirects() {
	// Placeholder
	$("#oHL_results").append("<details id='oHL_redirects'><summary>Incoming redirects <span class='summaryCount'>(0)</span></summary></details>");
	
    // API docs: https://www.mediawiki.org/w/api.php?action=help&modules=query%2Bredirects
    const apiUrl = location.origin + "/w/api.php";
    $.ajax({
        url: apiUrl,
        data: {
            action: "query",
            prop: "redirects",
            rdprop: "title|fragment",
            rdnamespace: "0",
            rdlimit: "500",
            format: "json",
            titles: mw.config.get("wgPageName")
        },
        success: listRedirects
    });
}

function listRedirects(response) {
	const pageId = mw.config.get("wgArticleId");
	const redirects = response.query.pages[pageId].redirects;

	let redirectText = "No redirects.";
	let redirectCount = 0;

	const redirectCandidatesMarkup = $(".oHL_title").clone();
	redirectCandidatesMarkup.find(".oHL_ruby rt, .oHL_piped, .oHL_added").remove();
	let redirectCandidates = redirectCandidatesMarkup.toArray().map(e => $(e).text()
	    .replaceAll("\u2060", "").replaceAll("\u200C", "").replaceAll("\u200D", "").replaceAll("\u200E", "").replaceAll("\u00AD", ""));
	let pageTitle = mw.config.get("wgTitle");
	pageTitle = pageTitle.replace(/ \(.*\)/, "");
	redirectCandidates = redirectCandidates.filter(c => c.toLowerCase() != pageTitle.toLowerCase());
	
	if (typeof redirects != "undefined") {
		redirectText = "";
		redirectCount = redirects.length;
		redirects.sort((a, b) => a.title.localeCompare(b.title));

		redirects.forEach(r => {
            redirectText += "<li><a href='/w/index.php?title="
			                + encodeURIComponent(r.title).replaceAll("'", "%27")
			                + "&redirect=no'>" + r.title + "</a>";
			
			const candidateAlreadyInRedirects = redirectCandidates.some(c => c.toLowerCase() == r.title.toLowerCase());
			if (candidateAlreadyInRedirects) {
				redirectCandidates = redirectCandidates.filter(c => c.toLowerCase() != r.title.toLowerCase());
			}
			
			if (typeof r.fragment != "undefined") {
				const fragment = r.fragment.replaceAll(" ", "_");
				const fragmentEscaped = CSS.escape(fragment);
				if ($("#" + fragmentEscaped).length) {
					redirectText += " → <a";
				} else {
					redirectText += " → ❌ <a class='new'";
				}
				redirectText += " href='#" + fragment + "'>§" + fragment + "</a>";
			}            
			redirectText +=  "</li>";
        });
	}
	
	// TODO: just update the children instead of the whole element
	$("#oHL_redirects").html("<summary>Incoming redirects <span class='summaryCount'>("
	                         + redirectCount+")</span></summary><ul>"
	                         + redirectText + "</ul>");
	                         
	if (redirectCandidates.length > 0) {
		let redirectCandidatesText = "";
		for (const candidate of redirectCandidates) {
			redirectCandidatesText += "<li><a href='/wiki/" + encodeURIComponent(candidate).replaceAll("'", "%27")
			                          + "'>" + candidate + "</a></li>";
		}
		$("#oHL_redirects").after("<details id='oHL_redirect_candidates'><summary>New redirect candidates <span class='summaryCount'>("
		                          + redirectCandidates.length +")</span></summary><ul>"
		                          + redirectCandidatesText + "</ul></details>");
	}
}

// Check if page is linked to from disambig page
function checkDisambigLink() {
	const pageTitle = mw.config.get("wgTitle");
	if (pageTitle.slice(-1) != ")") {
		return;
	}
	
	// API docs: https://www.mediawiki.org/w/api.php?action=help&modules=query%2Blinkshere
    const apiUrl = location.origin + "/w/api.php";
    $.ajax({
        url: apiUrl,
        data: {
            action: "query",
            prop: "linkshere",
            lhprop: "title",
            lhnamespace: "0",
            lhlimit: "500",
            format: "json",
            titles: mw.config.get("wgPageName")
        },
        success: searchDisambigLink
    });
}

function searchDisambigLink(response) {
	const pageId = mw.config.get("wgArticleId");
	const incomingLinks = response.query.pages[pageId].linkshere;
	if (typeof incomingLinks == "undefined") { return; }
	const pageTitle = mw.config.get("wgTitle");
	const pageTitleWithoutParens = pageTitle.replace(/ \(.*\)$/, "");
	const pageTitleDab = pageTitleWithoutParens + " (disambiguation)";
	for (const link of incomingLinks) {
		if (link.title == pageTitleWithoutParens || link.title == pageTitleDab) {
			return;
		}
	}
	const dabMarkup = "<a href='/wiki/" + encodeURIComponent(pageTitleWithoutParens).replaceAll("'", "%27")
	                  + "'>" + pageTitleWithoutParens + "</a> or <a href='/wiki/"
	                  + encodeURIComponent(pageTitleDab).replaceAll("'", "%27")
	                  + "'>" + pageTitleDab + "</a>";
	$("#oHL_results").append("<details id='oHL_noDabLink'><summary>Incoming disambiguation link missing <span class='summaryCount'>(1)</span></summary>"
	                         + "<ul><li>Either " + dabMarkup + " need to link to this page.</i></ul></details>");
}

function showWikitextMatches(results) {
    if (results.size === 0) {
        return;
    }

    let resultsHTML = "";
    for (const [type, matches] of results) {
    	const cssId = "oHL_" + type.replaceAll(/[^\w]/g, "_").toLowerCase();
        resultsHTML += "<details id='" + cssId + "'><summary>" + type
                       + " <span class='summaryCount'>(" + matches.length
                       + ")</span></summary><ul>";
        matches.forEach(m => resultsHTML += "<li class='oHL_wikitext-match'>"
                                            + mw.html.escape(m[0])
                                            + "<span class='oHL_wikitext-match-text'>"
                                            + mw.html.escape(m[1]) + "</span>"
                                            + mw.html.escape(m[2]) + "</li>");
        resultsHTML += "</ul></details>";
    }
    $("#oHL_summary").after(resultsHTML);
}

// Check italicization of wikilinks
function getItalics() {
    const wikilinks = $(".oHL_wikilink").toArray();
    const whitelist = $(".mw-references-wrap .oHL_wikilink, .navbox .oHL_wikilink,"
                        + " .stub .oHL_wikilink, .hatnote .oHL_wikilink").toArray();
    const filteredWikilinks = wikilinks.filter(wl => !whitelist.includes(wl));
    
    const links = {};
    const crossNamespaceRe = /[a-z]:[A-Z]/;
    filteredWikilinks.forEach(l => {
        if (l.title === "" || crossNamespaceRe.test(l.title)) { return; }
        links[l.title] = l; // {title: selector}
    });
    
    $("#oHL_results").append("<details id='oHL_italicization' style='display: none;'><summary>Italicization  <span class='summaryCount'></span></summary><ul id='oHL_italicization_items'></ul></details>");
    const titles = Object.keys(links);
    console.log("highlightStrings.js: Getting DefaultSort for " + titles.length + " pages");
    // Need to chunk since API has a limit on number of titles
    for (var i = 0; i < titles.length; i+= 50) {
        const titleChunk = titles.slice(i, i+50);
        getDisplayTitles(links, titleChunk);
    }
}

function getDisplayTitles(links, titles) {
    // API docs: https://www.mediawiki.org/w/api.php?action=help&modules=query%2Bpageprops
    const apiUrl = location.origin + "/w/api.php";
    $.ajax({
        url: apiUrl,
        data: {
            action: "query",
            prop: "pageprops",
            ppprop: "displaytitle",
            format: "json",
            titles: titles.join("|"),
            redirects: "yes",
        },
        success: response => checkItalics(links, response)
    });
}

function checkItalics(links, response) {
    var redirects = {};
    response.query?.redirects?.forEach(r => {
        const newTitle = r.to;
        const oldTitle = r.from;
        redirects[newTitle] = oldTitle;
    });
    
    checkSelfRedirects(redirects);
    
    Object.values(response.query.pages).forEach(p => {
        let title = p.title;
        if (title in redirects) {
            title = redirects[title];
        }
        
        const element = links[title];
        const originalItalicized = $(element).parent("i").length;
        const displayItalicization = p?.pageprops?.displaytitle || "None";
        
        // Skip Foo (<i>Bar</i>)
        if (/\(<i>/.test(displayItalicization)) {
            return;
        }

        if (!originalItalicized && displayItalicization != "None"
            || originalItalicized && displayItalicization == "None") {
            const originalDisplay =  $(element).clone();
            $(originalDisplay).find("*").each(function cleanOriginalElement() {
                this.className = "";
                this.removeAttribute("lang");
            });
            $(originalDisplay).find("rt").remove();
            if (originalItalicized) { originalDisplay.wrapInner("<i></i>"); }
            const originalDisplayMarkup = $(originalDisplay).html();

            if (originalDisplayMarkup == displayItalicization) { return; }

            $("#oHL_italicization_items").append("<li>" + originalDisplayMarkup
                                                 + " <a href='#"+ element.id + "'>→</a> "
                                                 + displayItalicization + "</li>");
            $("#oHL_italicization").show();
            updateSummaryCount("#oHL_italicization");
        }
    });
}

function updateSummaryCount(selector) {
    const element = $(selector);
    const count = element.children("ul").children().length;
    const countElement = element.find(".summaryCount");
    countElement.text("(" + count + ")");
}

// Find any redirects that lead back to article we're on
function checkSelfRedirects(redirects) {
    const selfRedirects = [];
    const currentPage = mw.config.get("wgTitle");
    for (const [target, wikilink] of Object.entries(redirects)) {
        if (target == currentPage) {
            selfRedirects.push(wikilink);
        }
    }
    
    if (selfRedirects.length) {
        let redirectList = "";
        for (const r of selfRedirects) {
            redirectList += "<li>" + r + "</li>";
        }
        
        if ($("#oHL_selfRedirects").length == 0) {
            $("#oHL_results").append("<details id='oHL_selfRedirects'><summary>Self-redirects <span class='summaryCount'></span></summary><ul id='oHL_selfRedirects_items'></ul></details>");
        }
        $("#oHL_selfRedirects_items").append(redirectList);
        updateSummaryCount("#oHL_selfRedirects");
    }
}

// Find dead interwiki links
function getDeadInterwikis() {
	const links = {};
	$(".extiw").each(function getInterwikiLinks() {
		const url = new URL(this.href);
		const pageEncoded = url.pathname.replace("/wiki/", "");
		const page = decodeURIComponent(pageEncoded);
		
		if (!(url.host in links)) {
			links[url.host] = [page];
		} else {
			links[url.host].push(page);
		}
	})
	
	if (Object.keys(links).length == 0) {
		return;
	}
	
	$("#oHL_results").append("<details id='oHL_interwikiStatus' style='display: none;'><summary>Dead interwiki links  <span class='summaryCount'></span></summary><ul id='oHL_interwikiStatus_items'></ul></details>");
	
	for (const [hostname, pages] of Object.entries(links)) {
        // API docs: https://www.mediawiki.org/w/api.php?action=help&modules=query%2Binfo
        const apiUrl = "https://" + hostname + "/w/api.php";
        $.ajax({
            url: apiUrl,
            data: {
                action: "query",
                prop: "info",
                titles: pages.join("|"),
                format: "json",
                origin: "*"
            },
            success: function callLinkStatusFunction(response) {
            	getLinkStatus(response, hostname)
            }
        });
    }
}

function getLinkStatus(response, hostname) {
	const pages = response.query.pages;
	const site = hostname.replace(".wikimedia", "").replace(".org", "");
	for (const key in pages) {
		// Non-existent pages will have the key "missing"
		if (typeof pages[key].missing != "undefined") {
			const page = pages[key].title;
			const link = "https://" + hostname + "/wiki/" + page;
			$("#oHL_interwikiStatus_items").append("<li><a href='" + link +"'>" + site + ": " + page + "</a></li>");
            $("#oHL_interwikiStatus").show();
            updateSummaryCount("#oHL_interwikiStatus");
		}
	}
}

// Cosmetic changes
function tweakDisplay() {
    // Italics
    $("#mw-content-text i, #mw-content-text i a").addClass("oHL_i");
    $("h1 i, sup i, sup a, .stub i, .stub a, .ambox i, .ambox a").removeClass("oHL_i");

    // Clears
    $("div[style='clear:both;']").after("<span class='oHL_clear'>[clear]</span>");

    // Anchors
    $(".anchor").each(function showAnchors() {
    	const id = $(this).attr("id");
    	$(this).closest("h2, h3, h4, h5, h6").after("<a class='oHL_shownAnchor' style='font-size: 85%' href='#"
    	                                             + id + "'>#" + id + "</a>");
    });
    
    // Short descriptions
    $(".shortdescription").first().each(function showShortDescriptions() {
    	this.style.display = "";
    	$(this).prepend("<span class='oHL_added'>[Short description]: </span>");
    });

    // Ruby
    wrapRuby("em", "em");
    wrapRuby(".official-website", "official");
    wrapRuby("#mw-content-text big", "big");
    wrapRuby("#mw-content-text small", "small");
    wrapRuby("span[dir=rtl]", "RTL");
    wrapRuby("span.plainlinks", "plainlink");
    wrapRuby(".external[class*='mw-magiclink']", "magic");
    wrapRuby(".vanchor", "#vanchor");
    wrapRuby(".smallcaps", "smallcaps");

    $("#otherImages-pageImage").after("<p id='oHL_wd_img'>[Wikidata image]</p>");

    // Show piped link targets
    showPiped();
    
    // Show duplicate refs on hover
    $(".oHL-duplicated-ref.oHL, .oHL-duplicated-ref.oHL-opt").on("mouseover", function highlightDupeLinks() {
        const href = $(this).attr("href");
        const hrefCleaned = href.replace(/https?:\/\//, "");
        $("a[href*='" + hrefCleaned + "'].oHL_dupe_ref").addClass("oHL_dupe_ref_active");
    }).on("mouseout", _ => $(".oHL_dupe_ref_active").removeClass("oHL_dupe_ref_active"));

    
    // Re-enable ReferenceTooltips since we replaced the HTML
    mw.loader.load("//en.wikipedia.org/w/index.php?title=MediaWiki:Gadget-ReferenceTooltips.js&action=raw&ctype=text/javascript")
}

function wrapRuby(selector, label) {
    document.querySelectorAll(selector)?.forEach(e => {
        const rubyElement = document.createElement("ruby");
        rubyElement.className = "oHL_ruby";
        const innerRb = document.createElement("rb");
        rubyElement.appendChild(innerRb);
        
        const rtElement = document.createElement("rt");
        rtElement.textContent = label;
        rubyElement.appendChild(rtElement);
        
        e.parentElement.insertBefore(rubyElement, e);
        innerRb.appendChild(e);
    });
}

function displayMatches() {
    const matches = getMatchTotal(".oHL");
    const optionalMatches = getMatchTotal(".oHL-opt");
    const totalMatches = matches + optionalMatches;

    let alertMessage;
    if (totalMatches !== 0) {
        alertMessage = "<div id='oHL_matches'>Matches: " + matches
                       + "<br>Optional: " + optionalMatches + "</div>";
        window.addEventListener("keypress", keyListener, false);
        $("#mw-content-text").before("<div id='oHL_info'><div><sup id='oHL_info_counter'>0</sup>"
                                     + "&frasl;<sub id='oHL_info_total'>"
                                     + totalMatches + "</sub> <span id='oHL_info_arrows'>"
                                     + "<span id='oHL_info_left_arrow' title='Previous highlight [shift-n]' class='oHL_arrow_disabled'>←</span> "
                                     + "<span id='oHL_info_right_arrow' title='Next highlight [n]'>→</span>"
                                     + "</span></div><div id='oHL_info_class'></div></div>");
        $("#oHL_info_left_arrow").click(function previousHighlight() {
        	advanceHighlight(-1);
        });
        $("#oHL_info_right_arrow").click(function nextHighlight() {
        	advanceHighlight(1);
        });
    } else {
        alertMessage = "<div id='oHL_results'><div id='oHL_summary'>No matches.</div></div>";
    }

    $("#mw-content-text").before(alertMessage);

    $("#mw-content-text").before("<div id='oHL_results'></div><hr/>");
    getMatchesSummary(totalMatches);
    const gearIconURL = "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3c/OOjs_UI_icon_advanced_apex.svg/20px-OOjs_UI_icon_advanced_apex.svg.png";
    $("#oHL_summary").append("<div><button id='oHL_configureButton' class='cdx-button'><span class='cdx-icon'><img src='"
                             + gearIconURL + "'></span> Configure</button></div>");

    // Hover display
    $("#oHL_results").append("<span id='oHL_hover'></span>");
    $(".oHL, .oHL-opt").on("mouseenter", showHighlightName)
                       .on("mouseleave", _ => $("#oHL_hover").hide());
}

function getMatchTotal(selector) {
    let count = 0;
    $(selector).each(function countTotalMatches() {
        const hlClasses = getHLClasses([ ...this.classList ]);
        count += hlClasses.length;
    });
    return count;
}

function showHighlightName(e) {
    if ($(e.target).hasClass("oHL_disabled")) { return; }
    
    const hlClasses = getHLClasses([ ...e.target.classList ]);
    let hlTexts = [];
    for (const hlClass of hlClasses) {
    	let hlText = hlClass;
        if (hlClass in matchDescriptions) {
    	    hlText = matchDescriptions[hlClass][0];
        }
        hlTexts.push(hlText);
    }
    const hlTextsCombined = hlTexts.join(" · ");
    $("#oHL_hover").text(hlTextsCombined);
        
    $("#oHL_hover").css("top", "calc(" + e.clientY + "px - 2.4em)");
    $("#oHL_hover").css("left", e.clientX);
    $("#oHL_hover").show();
}

function getMatchesSummary(totalMatches) {
	// Wikify highlight descriptions
    for (const [hlName, hlProps] of Object.entries(matchDescriptions)) {
        let desc = hlProps[1];
        desc = desc.replace(/\[\[([^\[]+)\]\]/g, "<a href='/wiki/$1'>$1</a>");
        desc = desc.replace(/>([^<]*?)#([^<]*?)</g, ">$1 §&nbsp;$2<"); // section links
        desc = desc.replace(/{{([^{]+)}}/g, "<code><a href='/wiki/Template:$1'>{{$1}}</a></code>");
        matchDescriptions[hlName][1] = desc;
    }

    const counts = new Map();
    $(".oHL, .oHL-opt").each(function incrementCounts() {
        const hlClasses = getHLClasses([ ...this.classList ]);
        if (typeof hlClasses == "undefined") { return true; }
        for (const hlClass of hlClasses) {
            if (!counts.has(hlClass)) {
                counts.set(hlClass, 1);
             } else {
                const c = counts.get(hlClass);
                counts.set(hlClass, c+1);
             }
        }
    });
    if (counts.size === 0) { return; }
    
    const countsSorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
    
    let tableHTML = "<table class='wikitable'>";
    let countsTotal = 0;
    for (const entry of countsSorted) {
    	const hlClass = entry[0];
    	let hlText = hlClass;
    	let hlDesc = "";
    	if (hlClass in matchDescriptions) {
    		hlText = matchDescriptions[hlClass][0];
    		hlDesc = matchDescriptions[hlClass][1];
    	}
    	const count = entry[1];
    	
        tableHTML += "<tr><td><input oHLclass='" + hlClass
                     + "' type='checkbox' checked></td><td>"
                     + hlText + "</td><td>" + hlDesc + "</td><td>" + count
                     + "</td></tr>";
        countsTotal += count;
    }
    tableHTML += "</table>";
    $("#oHL_results").prepend("<details id='oHL_summary'><summary>Highlights"
                              + " <span class='summaryCount'>(" + totalMatches
                              + ")</span></summary>" + tableHTML+ "</details>");
    if (countsTotal != totalMatches) { console.warn("highlightStrings.js: Not all oHL matches have a class."); }

    // Allow toggling specific matches
    $("#oHL_results input").change(toggleResult);
    
    // Checkbox to de/select all
    $("#oHL_results tbody").before("<thead><tr><th><input title='(De)select all' id='oHL_checkAll' type='checkbox' checked></th>"
                                   + " <th>Name</th><th>Description</th><th>Count</th></tr></thead>");
    $("#oHL_checkAll").data("total", counts.size);
    $("#oHL_checkAll").data("checked", counts.size);
    $("#oHL_checkAll").change(e => {
    	if (!e.target.checked) {
    		$("#oHL_results input:checked").not("#oHL_checkAll").click();
    	} else {
    		$("#oHL_results input:not(:checked)").not("#oHL_checkAll").click();
    	}
    });
}

function toggleResult(e) {
    const oHLclass = $(e.target).attr("oHLclass");
    const element = $("." + oHLclass);
    if (!e.target.checked) {
    	// Disable
        $(element).addClass("oHL_disabled");

        if ($(element).hasClass("oHL_added")) {
            $(element).hide();
        }
        $("input[oHLclass='" + oHLclass + "']:checked").prop("checked", false);
        updateCheckAllBox(-1);
    } else {
    	// Enable
        $(element).removeClass("oHL_disabled");

        if ($(element).hasClass("oHL_added")) {
            $(element).show();
        }
        $("input[oHLclass='" + oHLclass + "']:not(checked)").prop("checked", true);
        updateCheckAllBox(1);
    }
}

function updateCheckAllBox(change) {
    const totalBoxes = $("#oHL_checkAll").data("total");
    let checkedBoxes = $("#oHL_checkAll").data("checked");
    checkedBoxes += change;
    $("#oHL_checkAll").data("checked", checkedBoxes);

    if (checkedBoxes == totalBoxes) {
        $("#oHL_checkAll").prop("checked", true);
        $("#oHL_checkAll").prop("indeterminate", false);
    } else if (checkedBoxes == 0) {
        $("#oHL_checkAll").prop("checked", false);
        $("#oHL_checkAll").prop("indeterminate", false);
    } else {
        $("#oHL_checkAll").prop("checked", true);
        $("#oHL_checkAll").prop("indeterminate", true);
    }
}

function getHLClasses(classArray) {
    return classArray.filter(c => c != "oHL-opt" && c.startsWith("oHL-"));
}

function keyListener(event) {
    let offset;
    event = event || window.event;
    const key = event.key || event.which;
    if (key === "n") {
        offset=1;
    } else if (key === "N") {
        offset=-1;
    } else {
        return;
    }
    
    advanceHighlight(offset);
}

function advanceHighlight(offset) {
    let index;
    const highlightList = $(".oHL, .oHL-opt").toArray();
    const currentHighlight = $(".oHL_keyed").first();
    if (currentHighlight.length == 0) {
        index = -1;
    } else {
        index = highlightList.findIndex(e => e == currentHighlight.get(0));
    }

    let nextIndex = index;
    let nextHighlight;
    let nextIsDisabled = true;
    // Search highlightList until we find an enabled highlight or reach the end
    while(nextIsDisabled) {
        nextIndex += offset;
        nextHighlight = highlightList[nextIndex];
        
        // No next higlight
        if (typeof nextHighlight == "undefined") {
        	// Pulse highlight
        	$(".oHL_keyed").animate({"border-width": "4px"}, 150);
        	$(".oHL_keyed").animate({"border-width": "2px"}, 100);
        	return;
        }
        nextIsDisabled = $(nextHighlight).hasClass("oHL_disabled");
    }

    if (!$(nextHighlight).is(":visible")) {
    	console.info("highlightStrings.js: Highlighted invisible element.");
    	
    	// Walk up DOM and make parents visible
    	let parent = nextHighlight.parentElement;
    	while (parent != null && parent.id != "bodyContent") {
    		$(parent).show();
    		parent = parent.parentElement;
    	}
    }

    $(".oHL_keyed").removeClass("oHL_keyed");
    const hlClasses = getHLClasses([ ...nextHighlight.classList ]);
    let hlTexts = [];
    for (const hlClass of hlClasses) {
    	let hlText = hlClass;
    	let hlDescription = "";
        if (hlClass in matchDescriptions) {
    	    hlText = matchDescriptions[hlClass][0];
    	    hlDescription = matchDescriptions[hlClass][1];
        }
        hlTexts.push("<div id='oHL_info_class_name'><input oHLclass='" + hlClass
                     + "' type='checkbox' checked> " + hlText
                     + "</div><div id='oHL_info_class_desc'>"
                     + hlDescription + "</div>");
    }
    const hlTextsCombined = hlTexts.join("<br>");
    $("#oHL_info_class").html(hlTextsCombined);
    $("#oHL_info_class input").change(toggleResult);

    // Adjust arrows being grayed out
    const finalIndex = parseInt($("#oHL_info_total").text()) - 1;
	if (offset == -1) { // left
		if (index == finalIndex) { // we were at the end
            $("#oHL_info_right_arrow").removeClass("oHL_arrow_disabled");
		}
        if (nextIndex == 0) { // we hit the beginning
        	$("#oHL_info_left_arrow").addClass("oHL_arrow_disabled");
        }
    } else { // right
        if (index == 0) { // we were at the beginning
            $("#oHL_info_left_arrow").removeClass("oHL_arrow_disabled");
        }
        if (nextIndex == finalIndex) { // we hit the end
        	$("#oHL_info_right_arrow").addClass("oHL_arrow_disabled");
        }
	}

    $("#oHL_info_counter").text(nextIndex+1);
    nextHighlight.classList.add("oHL_keyed");
    nextHighlight.scrollIntoView();
}

var mangleIndex = 0;
const mangled = [];
var mangleSkipCount = 0;
var mangleIdIndex = 0;
var mangleIdReuseCount = 0;
function mangle(element, attr) {
    const original = element.getAttribute(attr);

    // Empty attribute
    if (original === "") {
        return;
    }

    // Don't waste resources on simple attributes
    // But still do titles since wikilinks duplicate text in them
    const simpleAttributeRe = /^[\w]+$/;
    if (attr != "title" && simpleAttributeRe.test(original)) {
        mangleSkipCount++;
        return;
    }

    // We add a comma for the index number to avoid hitting a rule later
    const placeholder = "mangle" + mangleIndex.toLocaleString("en-US"); 
    mangleIndex++;
    if (attr == "id") {
        element.setAttribute("id", placeholder);
    } else {
        // We change the attribute name so imgs aren't reloaded as 404s
        element.setAttribute("hs-" + attr, placeholder);
        element.removeAttribute(attr);
    }

    /*
     * Id lookups are fast so let's reuse them or add our own
     * Ideally we could just cache element references, but we rewrite
     * the HTML, invalidating them
     */
    let targetId;
    if (element.id !== "") {
        targetId = element.id;
        mangleIdReuseCount++;
    } else {
        targetId = "mangleId" + mangleIdIndex++;
        element.id = targetId;
    }

    mangled.push({"selector": targetId, "attr": attr, "value": original});
}

function unmangle(original) {
    const element = document.getElementById(original.selector);
    if (element === null) {
    	console.warn("highlightStrings.js: " + original.selector + " doesn't exist!");
    	return;
    }
    element.setAttribute(original.attr, original.value);
}

var detachIndex = 0;
const detached = {};
function detachTemp() {
    const placeholder = "_hsdetach" + detachIndex++;

    const newElement = document.createElement("span");
    newElement.id = placeholder;
    this.parentNode.insertBefore(newElement, this);

    this.remove();
    detached[placeholder] = this;
}

function reattachTemp() {
	for (const [target, html] of Object.entries(detached)) {
		const element = document.getElementById(target);
		if (element == null) {
			console.warn("highlightStrings.js: Could not reattach " + target
			             + " (" + html
			             + "), either because element was broken or because it's"
			             + " a child of another detached element.");
			continue;
		}
		
        element.parentNode.insertBefore(html, element.nextSibling); // insertAfter
	}
}

function whitelist() {
    for (const selector of filterList) {
        const element = $(selector);
        if (element.hasClass("oHL_added")) {
            $(element).remove();
            continue;
        }
        
        $(element).removeClass("oHL oHL-opt");
    }

    // Bolded letter in Further reading and Sources sections
    $("#Further_reading, #Sources").parent().nextUntil("h2").find(".oHL-bolded-letter").removeClass("oHL");

    // Non-English text
    $("span[lang] .oHL, bdi[lang] .oHL").removeClass("oHL");

    $(".infobox center .oHL").removeClass("oHL"); // , .infobox th .oHL

    // Code and syntax highlighting
    $("pre .oHL, pre .oHL-opt").removeClass("oHL oHL-opt");

    $(".latitude .oHL, .longitude .oHL,"
      +" .mw-kartographer-attribution .oHL").removeClass("oHL");

    // Bibcode
    $("a[href*='adsabs.harvard.edu']").children(".oHL-two-dots, .oHL-unspaced-ellipsis").removeClass("oHL oHL-opt");

    // Infobox book LC Class
    $(".infobox a[href$='LCC_(identifier)']").parent().next().children(".oHL").removeClass("oHL");

    // Breaks before stub templates
    const breakElement = $(".stub").first().prev("p");
    if (breakElement.length && breakElement.find("br").length
        && breakElement.find(".oHL").length) {
        breakElement.remove();
    }
    
    // Handle cases where dates are followed by refs and then a closing parenthesis
    $(".oHL-datecomma + .reference").each(function filterDateCommas() {
    	let finalRefElement = this;
    	let sibling = finalRefElement.nextElementSibling;
    	if (sibling == null) { return true; }
        while (sibling != null && sibling.classList.contains("reference")) {
    	    finalRefElement = sibling;
    	    sibling = finalRefElement.nextElementSibling;
        }
    	
    	const textSibling = finalRefElement.nextSibling;
    	if (textSibling != null && textSibling.nodeType == 3 && textSibling.textContent.startsWith(")")) {
    		const oHLelement = this.previousElementSibling;
    		$(oHLelement).removeClass("oHL oHL-opt");
    	}
    });
    
    // Editorial terms in reception sections
    // $(".mw-headline").each(function filterReceptionEditorializing() {
    //     const header = $(this).text();
    //     if (/reception/i.test(header)) {
    //         $(this).parent().nextUntil("h2").find(".oHL-editorializing").removeClass("oHL");
    //         return false;
    //     }
    // });
}

function showImageInfo() {
    // Remove styling on multiple images so size shows
    $(".tmulti").find(".thumbimage").removeAttr("style");
    
    // Ignore templates
    $(".ambox img, .stub img, .dmbox img, .navbox img").addClass("noviewer");

    const extensions = ["jpg", "jpeg", "webp", "png", "gif", "tif", "tiff", "svg", "webm"];
    $("[typeof^='mw:File'] img").not(".noviewer").each(function getImageInfo() {
        const displayWidth = $(this).attr("width");
        const displayHeight = $(this).attr("height");
        const originalWidth = $(this).attr("data-file-width");
        const originalheight = $(this).attr("data-file-height");

        let imgAlt = $(this).attr("alt");
        // Don't include autogenerated alt text
        if (imgAlt?.includes(".")) {
            const imgAltlower = imgAlt.toLowerCase();
            for (const ext of extensions) {
                if (imgAltlower.endsWith(ext)) {
                    imgAlt = null;
                    break;
                }
            }
        }

        matchDescriptions["oHL-dupe-alt"] = ['Duplicate ALT text', "Alternative text for images should not repeat the caption. ([[MOS:ALT]])"];
        const imgCaption = $(this).closest(".mw-file-description").siblings("figcaption").text();
        let filenameMatch = $(this).parent(".mw-file-description").attr("href")?.match(/(Image|File):(.*)\.(jpe?g|webp|png|gif|tiff?|svg|webm)$/i);
        let filename;
        if (filenameMatch && filenameMatch.length >= 3) {
        	filename = filenameMatch[2].replaceAll("_", " ");
        }
        if ((imgAlt && imgCaption && imgAlt.toLowerCase() == imgCaption.toLowerCase())
            || (imgAlt && filename && imgAlt.toLowerCase() == filename.toLowerCase())) {
        	imgAlt = "<span class='oHL oHL-dupe-alt'>" + imgAlt + "</span>";
        }

        let displayMessage = "<span class='oHL_img_info_dimensions'>" + displayWidth + "×" + displayHeight + " ("
                             + originalWidth + "×" + originalheight + ")</span>";
        if (imgAlt) {
            displayMessage += " (Alt: `" + imgAlt + "`)";
        }
        $(this).parent().after("<p class='oHL_img_info'>" + displayMessage + "</p>");
    });
}

function showPiped() {
    // Ignore ISBN labels
    $(".oHL_wikilink[href^='/wiki/ISBN_(identifier)']").addClass("oHL_ISBN_pre");

    $(".navbox a, .sidebar a, .infobox th a, .infobox b a,  .oHL_ISBN,"
      + " .oHL_ISBN_pre, sup a, #disambigbox a, .stub a, .ambox a, .portalbox a,"
      + " .cs1-visible-error a, .cs1-maint a").addClass("oHL_no_pipe");

    // Note: doesn't handle redlinks
    $(".oHL_wikilink:not(.oHL_no_pipe)").each(function getPipeInfo() {
        const text = this.textContent;
        const target = this.getAttribute("title");

        if (target && this.textContent !== ""
            && text.toLowerCase() !== target.toLowerCase()) {
            const pipedName = document.createElement("span");
            pipedName.classList.add("oHL_piped");

            const smallText = document.createElement("small");
            smallText.textContent = target;
            pipedName.appendChild(smallText);

            const bigPipe = document.createElement("span");
            bigPipe.textContent = "|";
            bigPipe.classList.add("oHL_piped-pipe");
            pipedName.appendChild(bigPipe);

            this.insertAdjacentElement("afterbegin", pipedName);
        }
    });
}

function checkSectionOrder() {
    // First, build a list of all section ids
    const sections = [];
    $("h2 > .mw-headline").each(function getSectionIds() {
        sections.push($(this).attr("id"));
    });

    const len = sections.length;
    if (len < 2) { return; } // Too few sections

    // Make sure "External links" section is last
    matchDescriptions["oHL-nonfinal-ext"] = ["Non-final External links section", "The External links section should be the last subsection. ([[MOS:LAYOUT]])"];
    if (sections[len-1] !== "External_links") {
        $("#External_links").after("<div class='oHL oHL-nonfinal-ext oHL_added'>[Move section last↓]</div>");
    }

    // "See also" section last
    matchDescriptions["oHL-misplaced-seeAlso"] = ["Misplaced See also", "The See also section should be in the proper order of sections. ([[MOS:LAYOUT]])"];
    if (sections[len-1] === "See_also") {
        $("#See_also").after("<div class='oHL oHL-misplaced-seeAlso oHL_added'>[Move section up↑]</div>");
        return;
    }

    const endSections = ["References", "Sources", "Notes", "Explanatory_notes",
                         "Footnotes", "Bibliography", "Notes_and_references",
                         "Citations"];

    for (let i=0; i<len-1; i++) {
        if (sections[i] === "See_also") {
            if (!endSections.includes(sections[i+1])) {
                $("#See_also").after("<div class='oHL oHL-misplaced-seeAlso oHL_added'>[Move section↕]</div>");
            }

            break;
        }
    }

    // Further reading not after References
    matchDescriptions["oHL-misplaced-furtherReading"] = ["Misplaced Further reading", "The Further reading section should go after the References section. ([[MOS:LAYOUT]])"];
    for (let i=1; i<len-1; i++) {
        if (sections[i] === "Further_reading") {
            if (!endSections.includes(sections[i-1])) {
                $("#Further_reading").after("<div class='oHL oHL-misplaced-furtherReading oHL_added'>[Move section↕]</div>");
            }

            break;
        }
    }
}

function checkOverlinking() {
    const allWikilinks = $(".oHL_wikilink").toArray();
    const ignoreLinks = $(".infobox .oHL_wikilink, .navbox .oHL_wikilink,"
                          + " table .oHL_wikilink,"
                          + " .sidebar .oHL_wikilink,"
                          + " .quotebox .oHL_wikilink,"
                          + " .thumbcaption .oHL_wikilink,"
                          + " figcaption .oHL_wikilink,"
                          + " .gallery .oHL_wikilink,"
                          + " .quotebox .oHL_wikilink,"
                          + " .hatnote .oHL_wikilink,"
                          + " .succession-box .oHL_wikilink,"
                          + " .mw-references-wrap .oHL_wikilink,"
                          + " .listen .oHL_wikilink,"
                          + " .spoken-wikipedia .oHL_wikilink,"
                          + " .mw-ext-score .oHL_wikilink").toArray();
    const seeAlsoLinks = $("#See_also").parent().nextUntil("h2").filter("ul").find(".oHL_wikilink").toArray();

    // See also links already in body
    matchDescriptions["oHL-duplicate-seeAlso"] = ["Duplicate See also", "The See also section should not contain wikilinks already in the body. ([[MOS:NOTSEEALSO]])"];
    let whitelist = ignoreLinks.concat(seeAlsoLinks);
    let filteredLinks;
    if ($("#See_also").length) {
        filteredLinks = allWikilinks.filter(link => !whitelist.includes(link));
        
        const allHrefs = filteredLinks.map(l => l.getAttribute("href"));
        for (const link of seeAlsoLinks) {
            const href = link.getAttribute("href");
            if (allHrefs.includes(href)) {
                $(link).addClass("oHL oHL-duplicate-seeAlso");
            }
        }
    }
    
    // Any links that occur more than once besides the lead
    let leadMarker;
    if ($("#toc").length) {
        leadMarker = $("#toc");
    } else {
        leadMarker = $("#mw-content-text h2").first();
    }
    const leadLinks = leadMarker.prevAll().find(".oHL_wikilink").toArray();
    whitelist = whitelist.concat(leadLinks);
    const ignoreSections = refSectionsSelector + ", #Cast, #Filmography, #Discography,"
                           + " #Bibliography, #Further_reading, #External_links,"
                           + " #Works, #Selected_works";
    for (const section of ignoreSections.split(", ")) {
        const sectionLinks = $(section).parent().nextUntil("h2").find(".oHL_wikilink").toArray();
        whitelist = whitelist.concat(sectionLinks);
    }
    
    filteredLinks = allWikilinks.filter(link => !whitelist.includes(link));
    const ignoreHrefs = ["/wiki/ISBN_(identifier)", "/wiki/OCLC_(identifier)"];
    const linkCounts = new Map();
    for (const link of filteredLinks) {
        const href = link.getAttribute("href");
        if (ignoreHrefs.includes(href)) {
            continue;
        }

        const links = linkCounts.get(href);
        if (typeof links == "undefined") {
            linkCounts.set(href, [link]);
        } else {
            links.push(link);
        }
    }
    
    matchDescriptions["oHL-overlink"] = ["Overlink", "Aside from the lead, the text of an article should generally only contain a link once. ([[MOS:LINKONCE]])"];
    for (const [href, links] of linkCounts) {
        if (links.length < 2) { continue; }
        
        for (var i = 1; i < links.length; i++) {
            $(links[i]).addClass("oHL-opt oHL-overlink");
        }
    }
}

// TODO: Use a blacklist as well
function checkTitleItalicization() {
	matchDescriptions["oHL-category-italics"] = ["Category italicization", "Based on its categorization, the page's title might need to be italicized."];
    if ($("#firstHeading > i").length > 0) {
        return;
    }

    let categories = "";
    $("#catlinks li a").each(function getCategories() {
        categories += $(this).text() + " ";
    });

    const works = ["books", "novels", "films", "anime", "Manga series", " plays",
                   "television series", "albums", "paintings", "magazines",
                   "journals", "graphic novels", "sculptures", "cases",
                   "video games", "ships"];

    for (const w of works) {
        if (categories.includes(w)) {
            $("#mw-content-text").prepend("<div><span class='oHL-opt oHL-category-italics oHL_added'>[Italicize title] (" + w + " category)</span></div>");
            return;
        }
    }
}

function checkContrast() {
	matchDescriptions["oHL-color-contrast"] = ["Color contrast", "Text must have a minimum contrast (WCAG AA) for accessibility. ([[MOS:COLOR]])"];
    $(".infobox td[style], .infobox th[style], .navbox th[style],"
      + " .wikitable td[style], .wikitable th[style]").each(function checkTemplateContrast() {
    	const style = this.style.cssText;
    	if (/(background:|background-color:|color:|#\w)/.test(style)) {
    		checkElementContrast(this);
    	}
    });
    $(".quotebox").each(function checkQuoteboxContrast() {
    	checkElementContrast(this);
    });
}

function checkElementContrast(element) {
	// e.g. "rgb(6, 69, 173)" => [6, 69, 173]
	// can also have alpha, e.g. "rgba(0, 0, 0, 0)"
	const parseColorString = s => {
		const m = s.match(/rgba?\(([0-9]+), ([0-9]+), ([0-9]+)/);
		return [m[1], m[2], m[3]];
	};

	const textColorString = window.getComputedStyle(element)["color"];
    const bgColorString = window.getComputedStyle(element)["background-color"];
    const textColor = parseColorString(textColorString);
    const bgColor = parseColorString(bgColorString);
    
    // Want at least 4.5:1 ratio for WCAG AA
    // Reference: https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html
    const contrast = calculateContrastRatio(textColor, bgColor);
    const decToHex = d => Number(d).toString(16).padStart(2, '0');
    if (contrast < 4.5) {
    	const textColorHex = textColor.map(decToHex).join("");
    	const bgColorHex = bgColor.map(decToHex).join("");
    	$(element).addClass("oHL oHL-color-contrast");
    	$(element).append(" <a class='oHL_added external' href='https://webaim.org/resources/contrastchecker/?fcolor="
    	                  + textColorHex + "&bcolor=" + bgColorHex + "'>[AIM]</a>");
    }
}

// Reference: https://stackoverflow.com/questions/596216/formula-to-determine-perceived-brightness-of-rgb-color/
function calculateContrastRatio(c1RGB, c2RGB) {
    let c1Luminance = rgbToLuminance(c1RGB);
    let c2Luminance = rgbToLuminance(c2RGB);

    if (c1Luminance < c2Luminance) { // want lighter first
        [c1Luminance, c2Luminance] = [c2Luminance, c1Luminance];
    }
    const ratio = (c1Luminance + 0.05) / (c2Luminance + 0.05);
    return ratio;
}

function rgbToLuminance(RGB) {
    // Convert integers to decimal
    const RGBdec = RGB.map(i => i / 255);

    // Convert to linear value
    const RGBtoLinear = RGB => {
        if (RGB <= 0.04045) {
            return RGB / 12.92;
        } else {
            return Math.pow(((RGB + 0.055) / 1.055), 2.4);
        } 
    };
    const RGBlinear = RGBdec.map(RGBtoLinear);
    
    // Find luminance
    const luminance = 0.2126 * RGBlinear[0] + 0.7152 * RGBlinear[1] + 0.0722 * RGBlinear[2];
    
    // Convert to perceived lightness
    let perceivedLuminance;
    if (luminance <= (216/24389)) {
        perceivedLuminance = luminance * (24389/27);
    } else {
        perceivedLuminance = Math.pow(luminance, (1/3)) * 116 - 16;
    }

    return perceivedLuminance;
}

$(mw.util.addPortletLink("p-tb", "#", "Highlight strings", "hStrings",
                          "Highlight errors", "h"))
 .click(highlightStrings);

// </nowiki>