Replace stat names with icons

Replaces stat titles and user navigation with icons from https://boxicons.com

// ==UserScript==
// @name        Replace stat names with icons
// @match       https://archiveofourown.org/*
// @grant       none
// @author      genusslicht
// @description Replaces stat titles and user navigation with icons from https://boxicons.com
// @license     MIT
// @namespace   ao3-boxicons
// @version     1.0.4
// @icon        https://archiveofourown.org/favicon.ico
// @supportURL  https://gist.github.com/genusslicht/2ba4be62a30f936e7cc9d8f2c33409f5
// ==/UserScript==

// AO3 css selectors
const WordsTotal = "dl.statistics dd.words";
const WordsWork = "dl.stats dd.words";
const WordsSeries = ".series.meta.group dl.stats>dd:nth-of-type(1)";
const ChaptersWork = "dl.stats dd.chapters";
const CollectionsWork = "dl.stats dd.collections";
const CommentsWork = "dl.stats dd.comments";
const KudosTotal = "dl.statistics dd.kudos";
const KudosWork = "dl.stats dd.kudos";
const BookmarksTotal = "dl.statistics dd.bookmarks";
const BookmarksWork = "dl.stats dd.bookmarks";
const BookmarksSeries = ".series.meta.group dl.stats>dd:nth-of-type(4)";
const BookmarksCollection = "li.collection dl.stats dd a[href$=bookmarks]";
const HitsTotal = "dl.statistics dd.hits";
const HitsWork = "dl.stats dd.hits";
const SubscribersTotal = "dl.statistics dd[class=subscriptions]";
const SubscribersWork = "dl.stats dd.subscriptions";
const FandomsCollection = "li.collection dl.stats dd a[href$=fandoms]";
const AuthorSubscribers = "dl.statistics dd.user.subscriptions";
const CommentThreads = "dl.statistics dd.comment.thread";
const WorksCollection = "li.collection dl.stats dd a[href$=works]";
const WorksSeries = ".series.meta.group dl.stats>dd:nth-of-type(2)";
const SeriesComplete = ".series.meta.group dl.stats>dd:nth-of-type(3)";
const Kudos2HitsWork = "dl.stats dd.kudos-hits-ratio";
const ReadingTimeWork = "dl.stats dd.reading-time";
const DatePublishedWork = "dl.work dl.stats dd.published";
const DateStatusTitle = "dl.work dl.stats dt.status";
const DateStatusWork = "dl.work dl.stats dd.status";

const AccountUserNav = "#header a.dropdown-toggle[href*='/users/']";
const PostUserNav = "#header a.dropdown-toggle[href*='/works/new']";
const LogoutUserNav = "#header a[href*='/users/logout']";

/**
 * Initialises boxicons.com css and adds a small css to add some space between icon and stats count.
 */
function initBoxicons() {
  // load boxicon style
  const boxicons = document.createElement("link");
  boxicons.setAttribute("href", "https://unpkg.com/boxicons@2.1.4/css/boxicons.min.css");
  boxicons.setAttribute("rel", "stylesheet");
  document.head.appendChild(boxicons);

  // css that adds margin for icons
  const boxiconsCSS = document.createElement("style");
  boxiconsCSS.setAttribute("type", "text/css");
  boxiconsCSS.innerHTML = `
    i.bx {
      margin-right: .3em;
    }`;
  document.head.appendChild(boxiconsCSS);
}

/**
 * Creates a new element with the icon class added to the classList.
 *
 * @param {String} iconClass        Name of the boxicons class to use. (The "bx(s)" prefix can be omitted)
 * @param {Object} options
 * @param {Object} options.tooltip  adds a tooltip to the element
 * @param {Object} options.solid    Indicates if the icon should be of the "solid" variant.
 *                                  Will be ignored if iconClass has "bx(s)" prefix.
 * @returns <i> Element with the neccessary classes for a boxicons icon.
 */
function getNewIconElement(iconClass, options = {}) {
  const i = document.createElement("i");
  i.classList.add("bx");
  if (options?.addTooltip && options?.tooltip) i.setAttribute("title", options.tooltip);

  if (/^bxs?-/i.test(iconClass)) {
    i.classList.add(iconClass);
  } else {
    i.classList.add(options?.solid ? "bxs-" + iconClass : "bx-" + iconClass);
  }
  return i;
}

/**
 * Prepends the given boxicons class to the given element.
 * Note: If the element is an <i> tag, nothing will happen, as we assume that the <i> is already an icon.
 *
 * @param {HTMLElement} element     parent element that the icon class should be prepended to.
 * @param {String} iconClass        name of the boxicons class to use. (The "bx(s)" prefix can be omitted)
 * @param {Object} options
 * @param {Object} options.tooltip  adds a tooltip to the element
 * @param {Object} options.solid    Indicates if the icon should be of the "solid" variant.
 *                                  Will be ignored if iconClass has "bx(s)" prefix.
 */
function setIcon(element, iconClass, options = {}) {
  if (element.tagName !== "I") element.prepend(getNewIconElement(iconClass, options));
  if (options?.tooltip) element.setAttribute("title", options.tooltip);
}

/**
 * Iterates through all elements that apply to the given querySelector and adds an element with the given icon class to it.
 *
 * @param {String} querySelector    CSS selector for the elements to find and iconify.
 * @param {String} iconClass        name of the boxicons class to use. (The "bx(s)" prefix can be omitted)
 * @param {Object} options
 * @param {Object} options.tooltip  adds a tooltip to the element
 * @param {Object} options.solid    Indicates if the icon should be of the "solid" variant.
 *                                  Will be ignored if iconClass has "bx(s)" prefix.
 */
function findElementsAndSetIcon(querySelector, iconClass, options = {}) {
  const els = document.querySelectorAll(querySelector);
  els.forEach((el) => (el.firstChild.nodeType === Node.ELEMENT_NODE ? setIcon(el.firstChild, iconClass, options) : setIcon(el, iconClass, options)));
}

/**
 * Adds an CSS that will hide the stats titles and prepends an icon to all stats.
 */
function iconifyStats() {
  // css to hide stats titles
  const statsCSS = document.createElement("style");
  statsCSS.setAttribute("type", "text/css");
  statsCSS.innerHTML = `
    dl.stats dt {
      display: none !important;
    }`;
  document.head.appendChild(statsCSS);

  findElementsAndSetIcon(`${WordsTotal}, ${WordsWork}, ${WordsSeries}`, "pen", { tooltip: "Word Count", solid: true });
  findElementsAndSetIcon(ChaptersWork, "food-menu", { tooltip: "Chapters" });
  findElementsAndSetIcon(CollectionsWork, "collection", { tooltip: "Collections", solid: true });
  findElementsAndSetIcon(CommentsWork, "chat", { tooltip: "Comments", solid: true });
  findElementsAndSetIcon(`${KudosTotal}, ${KudosWork}`, "heart", { tooltip: "Kudos", solid: true });
  findElementsAndSetIcon(`${BookmarksTotal}, ${BookmarksWork}, ${BookmarksCollection}, ${BookmarksSeries}`, "bookmarks", { tooltip: "Bookmarks", solid: true });
  findElementsAndSetIcon(`${HitsTotal}, ${HitsWork}`, "show-alt", { tooltip: "Hits" });
  findElementsAndSetIcon(`${SubscribersTotal}, ${SubscribersWork}`, "bell", { tooltip: "Subscriptions", solid: true });
  findElementsAndSetIcon(AuthorSubscribers, "bell-ring", { tooltip: "User Subscriptions", solid: true });
  findElementsAndSetIcon(CommentThreads, "conversation", { tooltip: "Comment Threads", solid: true });
  findElementsAndSetIcon(FandomsCollection, "crown", { tooltip: "Fandoms", solid: true });
  findElementsAndSetIcon(`${WorksCollection}, ${WorksSeries}`, "library", { tooltip: "Work Count" });
  findElementsAndSetIcon(SeriesComplete, "flag-checkered", { tooltip: "Series Complete", solid: true });

  // AO3E elements
  findElementsAndSetIcon(Kudos2HitsWork, "hot", { tooltip: "Kudos to Hits", solid: true });
  findElementsAndSetIcon(ReadingTimeWork, "hourglass", { tooltip: "Time to Read", solid: true });

  // calendar icons at works page
  findElementsAndSetIcon(DatePublishedWork, "calendar-plus", { tooltip: "Published" });
  const workStatus = document.querySelector(DateStatusTitle);
  if (workStatus && workStatus.innerHTML.startsWith("Updated")) {
    setIcon(document.querySelector(DateStatusWork), "calendar-edit", { tooltip: "Updated" });
  } else if (workStatus && workStatus.innerHTML.startsWith("Completed")) {
    setIcon(document.querySelector(DateStatusWork), "calendar-check", { tooltip: "Completed" });
  }
}

/**
 * Replaces the "Hi, {user}!", "Post" and "Log out" text at the top of the page with icons.
 */
function iconifyUserNav() {
  // add css for user navigation icons
  const userNavCss = document.createElement("style");
  userNavCss.setAttribute("type", "text/css");
  userNavCss.innerHTML = `
  ${LogoutUserNav},
  ${AccountUserNav},
  ${PostUserNav} {
    /* font size needs to be higher to make icons the right size */
    font-size: 1.25rem;
    /* left and right padding for a slightly bigger hover hitbox */
    padding: 0 .3rem;
  }

  ${LogoutUserNav} i.bx {
    /* overwrite the right margin for logout icon */
    margin-right: 0;
    /* add left margin instead to add more space to user actions */
    margin-left: .3em;
  }`;
  document.head.appendChild(userNavCss);

  // replace text with icons
  document.querySelector(AccountUserNav).replaceChildren(getNewIconElement("user-circle", { tooltip: "User Area", addTooltip: true, solid: true }));
  document.querySelector(PostUserNav).replaceChildren(getNewIconElement("book-add", { tooltip: "New Work", addTooltip: true, solid: true }));
  document.querySelector(LogoutUserNav).replaceChildren(getNewIconElement("log-out", { tooltip: "Logout", addTooltip: true }));
}

(function () {
  initBoxicons();
  iconifyStats();
  iconifyUserNav();
})();