Skip to the content

Capsid menu

app/enhance/blog.ts

478 lines and 62 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of blog.ts
FunctionLinesComplexityCoveredCRAPMutation
onIdle6 to 163No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
readingProgress21 to 533No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
worthDrawing25 to 251No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
update34 to 412No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
schedule43 to 472No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
scrollSpy55 to 804No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:61>61 to 611No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:62>62 to 621No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:67>67 to 765No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:69>69 to 691No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
announce88 to 1002No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:97>97 to 992No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
decorateCodeBlock102 to 1404No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:120>120 to 1353No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:131>131 to 1341No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
codeBlocks146 to 1572No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
decorateAll148 to 1522No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
headingLinks159 to 1972No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:163>163 to 1954No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
land173 to 1844No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:181>181 to 1811No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:187>187 to 1911No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:189>189 to 1891No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:193>193 to 1931No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
footnotePreviews199 to 2833No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
cancelHide216 to 2192No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
hide221 to 2271No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
scheduleHide234 to 2371No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
show242 to 2695No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:277>277 to 2823No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
grow299 to 3244No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:308>308 to 3121No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:316>316 to 3193No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:321>321 to 3231No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
openOverlay326 to 3724No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
dismiss355 to 3552No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:355>355 to 3551No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:359>359 to 3612No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:363>363 to 3661No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:368>368 to 3711No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
lightbox374 to 4193No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:379>379 to 3914No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:387>387 to 3871No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
open404 to 4111No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:408>408 to 4081No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:413>413 to 4173No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
copyMarkdown421 to 4453No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:425>425 to 4445No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:440>440 to 4431No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
selectionLink454 to 5234No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
blockOf467 to 4736No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
update475 to 4918No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:494>494 to 4941No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:496>496 to 5101No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
shown497 to 5051No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:501>501 to 5041No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:507>507 to 5071No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:508>508 to 5081No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:513>513 to 5152No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:516>516 to 5181No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:519>519 to 5221No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:529>529 to 5341No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/lib/clipboard.ts, app/lib/text-fragment.mjs. No module of this repo imports it.

Source

import { copyText } from "../lib/clipboard";
import { textFragment } from "../lib/text-fragment.mjs";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");

function onIdle(fn: () => void) {
  // Not in this tsconfig's DOM lib, so it is typed as an optional extra; the `in` guard decides.
  const idle = window as typeof window & {
    requestIdleCallback?: (cb: () => void) => void;
  };
  if ("requestIdleCallback" in window && idle.requestIdleCallback) {
    idle.requestIdleCallback(fn);
  } else {
    setTimeout(fn, 1);
  }
}

// Two viewports, not one: at one the bar describes a few pixels of scroll and jumps from empty to full.
const PROGRESS_MIN_VIEWPORTS = 2;

function readingProgress() {
  const article = document.querySelector<HTMLElement>(".post .prose");
  if (!article) return;

  const worthDrawing = () => article.offsetHeight >= window.innerHeight * PROGRESS_MIN_VIEWPORTS;
  if (!worthDrawing()) return;

  const bar = document.createElement("div");
  bar.className = "reading-progress";
  bar.setAttribute("role", "presentation");
  document.body.insertBefore(bar, document.body.firstChild);

  let ticking = false;
  const update = () => {
    bar.hidden = !worthDrawing();
    const start = article.offsetTop;
    const height = article.offsetHeight - window.innerHeight;
    const scrolled = height > 0 ? (window.scrollY - start) / height : 0;
    bar.style.transform = `scaleX(${Math.min(1, Math.max(0, scrolled))})`;
    ticking = false;
  };

  const schedule = () => {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(update);
  };

  addEventListener("scroll", schedule, { passive: true });
  /* A rotation changes both the viewport and the article's height, so it re-asks the question. */
  addEventListener("resize", schedule, { passive: true });
  update();
}

function scrollSpy() {
  const toc = document.querySelector<HTMLElement>(".post-toc");
  if (!toc) return;

  const links = Array.from(toc.querySelectorAll<HTMLAnchorElement>("a[href^='#']"));
  const targets = links
    .map((link) => document.getElementById(decodeURIComponent(link.hash.slice(1))))
    .filter((el): el is HTMLElement => el !== null);
  if (targets.length === 0) return;

  const seen = new Map<string, boolean>();
  const observer = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) seen.set(entry.target.id, entry.isIntersecting);
      const active = targets.find((t) => seen.get(t.id));
      for (const link of links) {
        const isActive = active !== undefined && link.hash === `#${active.id}`;
        // The state itself, not a styling hook, so a screen reader hears which section is current.
        if (isActive) link.setAttribute("aria-current", "location");
        else link.removeAttribute("aria-current");
      }
    },
    { rootMargin: "0px 0px -70% 0px" },
  );
  for (const target of targets) observer.observe(target);
}

/**
 * One shared live region for every copy control: a live region announces changes to its own
 * contents, so one per control would compete. `role="status"` so it never interrupts reading.
 */
let statusRegion: HTMLElement | null = null;

function announce(message: string) {
  if (!statusRegion) {
    statusRegion = document.createElement("p");
    statusRegion.className = "sr-only";
    statusRegion.setAttribute("role", "status");
    document.body.appendChild(statusRegion);
  }
  // Cleared first: a live region announces a change, so writing the same string twice would be silent.
  statusRegion.textContent = "";
  requestAnimationFrame(() => {
    if (statusRegion) statusRegion.textContent = message;
  });
}

function decorateCodeBlock(pre: HTMLElement) {
  // Tests for the button, not `data-enhanced`: the attribute can be lost while the button survives.
  if (pre.querySelector(":scope > .code-copy")) return;

  const lang = pre.dataset.lang;
  if (lang && lang !== "text") {
    const label = document.createElement("span");
    label.className = "code-lang";
    label.textContent = lang;
    label.setAttribute("aria-hidden", "true");
    pre.appendChild(label);
  }

  const button = document.createElement("button");
  button.type = "button";
  button.className = "code-copy";
  button.textContent = "Copy";
  button.setAttribute("aria-label", "Copy code to clipboard");
  button.addEventListener("click", async () => {
    const code = pre.querySelector("code")?.textContent ?? "";
    try {
      await copyText(code);
      button.textContent = "Copied";
      button.setAttribute("data-copied", "true");
      announce("Code copied");
    } catch {
      button.textContent = "Press Ctrl C";
      announce("Copy failed. Press Control C to copy.");
    }
    setTimeout(() => {
      button.textContent = "Copy";
      button.removeAttribute("data-copied");
    }, 2000);
  });
  pre.appendChild(button);

  // Last, so the padding never arrives before the thing it makes room for.
  pre.setAttribute("data-enhanced", "");
}

/**
 * Keeps observing so decoration does not depend on when this bundle runs relative to a subtree
 * rewrite. It terminates: `decorateCodeBlock` writes nothing to a `pre` already carrying `data-enhanced`.
 */
function codeBlocks() {
  const prose = document.querySelector<HTMLElement>(".prose");
  const decorateAll = () => {
    for (const pre of document.querySelectorAll<HTMLElement>(".prose pre[data-lang]")) {
      decorateCodeBlock(pre);
    }
  };

  decorateAll();
  if (!prose) return;
  new MutationObserver(decorateAll).observe(prose, { childList: true, subtree: true });
}

function headingLinks() {
  for (const anchor of document.querySelectorAll<HTMLAnchorElement>(
    ".prose .heading-anchor",
  )) {
    anchor.addEventListener("click", (event) => {
      /*
       * `focus()` rather than `scrollIntoView`, so a screen reader announces the heading and the next Tab
       * continues from it. `tabindex="-1"` makes it focusable without adding it to the tab order.
       */
      if (!navigator.clipboard) return;
      event.preventDefault();
      const url = new URL(anchor.getAttribute("href") ?? "", location.href);
      const heading = anchor.closest("h2, h3, h4") ?? document.getElementById(url.hash.slice(1));

      const land = () => {
        history.replaceState(null, "", url.hash);
        if (!(heading instanceof HTMLElement)) return;
        const had = heading.hasAttribute("tabindex");
        if (!had) heading.setAttribute("tabindex", "-1");
        heading.focus();
        // Removed on blur rather than immediately: taking it away while the
        // element still holds focus drops focus to the body in some engines.
        if (!had) heading.addEventListener("blur", () => heading.removeAttribute("tabindex"), {
          once: true,
        });
      };

      void copyText(url.href)
        .then(() => {
          anchor.setAttribute("data-copied", "true");
          setTimeout(() => anchor.removeAttribute("data-copied"), 1500);
          announce("Link copied");
        })
        // A refused clipboard must not cost the reader the navigation, so landing happens either way.
        .catch(() => announce("Copy failed. The link is in the address bar."))
        .finally(land);
    });
  }
}

function footnotePreviews() {
  const notes = document.querySelector(".prose .footnotes");
  if (!notes) return;

  let bubble: HTMLElement | null = null;
  // The reference being previewed, and the aria-describedby it had before ("footnote-label").
  let owner: HTMLElement | null = null;
  let prior = "";
  let pending: ReturnType<typeof setTimeout> | null = null;
  /*
   * A manual popover, so the top layer lifts it over everything and CSS anchor positioning keeps it
   * under its reference (post-enhancements.css). Without anchor positioning it is placed once, in
   * document coordinates, as before. Every engine with anchor positioning shipped popover first, so
   * one probe answers both.
   */
  const anchored = CSS.supports("anchor-name: --a");

  const cancelHide = () => {
    if (pending !== null) clearTimeout(pending);
    pending = null;
  };

  const hide = () => {
    cancelHide();
    bubble?.remove();
    bubble = null;
    owner?.setAttribute("aria-describedby", prior);
    owner = null;
  };

  /*
   * WCAG 1.4.13 hoverable: leaving the reference schedules a hide and entering the bubble cancels it,
   * so the pointer can cross the gap to the bubble.
   */
  const HOVER_GRACE_MS = 220;
  const scheduleHide = () => {
    cancelHide();
    pending = setTimeout(hide, HOVER_GRACE_MS);
  };

  for (const ref of document.querySelectorAll<HTMLAnchorElement>(
    ".prose a[data-footnote-ref]",
  )) {
    const show = () => {
      const target = document.getElementById(decodeURIComponent(ref.hash.slice(1)));
      if (!target) return;
      hide();
      bubble = document.createElement("div");
      // One preview at a time, so its class can double as its id.
      bubble.id = bubble.className = "footnote-preview";
      bubble.setAttribute("role", "note");
      bubble.innerHTML = target.innerHTML;
      for (const back of bubble.querySelectorAll("[data-footnote-backref]")) back.remove();

      bubble.addEventListener("mouseenter", cancelHide);
      bubble.addEventListener("mouseleave", scheduleHide);

      // 1.3.1: the reference is described by the note it previews, so a screen reader hears it on focus.
      owner = ref;
      prior = ref.getAttribute("aria-describedby") ?? "";
      ref.setAttribute("aria-describedby", `${bubble.id} ${prior}`);
      document.body.appendChild(bubble);
      if (anchored) {
        bubble.popover = "manual";
        bubble.showPopover();
        return;
      }
      const box = ref.getBoundingClientRect();
      bubble.style.top = `${box.bottom + window.scrollY + 8}px`;
      bubble.style.left = `${Math.max(8, box.left + window.scrollX - 20)}px`;
    };
    ref.addEventListener("mouseenter", show);
    ref.addEventListener("focus", show);
    ref.addEventListener("mouseleave", scheduleHide);
    ref.addEventListener("blur", scheduleHide);
  }

  // Escape dismisses WITHOUT moving focus, as 1.4.13 asks.
  document.addEventListener("keydown", (event) => {
    if (event.key === "Escape" && bubble) {
      event.stopPropagation();
      hide();
    }
  });
}


/**
 * `src` is passed in rather than read from `currentSrc`, which is whichever `srcset` rung the browser
 * downloaded: a resized copy shown as full size.
 */
/** One name, moved from the figure to the full image and back, so the browser morphs one into the other. */
const GROW_NAME = "lightbox-figure";

/**
 * The figure grows into the viewer and shrinks back, as a same-document View Transition. It carries
 * no type, so the cross-document page fade (motion-print.css, type site) does not restyle it.
 * Skipped under reduced motion, which the stylesheet's rule cannot reach from script, and where the
 * API is missing: then the change simply happens, as it did before.
 */
function grow(from: HTMLElement | undefined, to: HTMLElement, change: () => void) {
  const start = (document as Document & {
    startViewTransition?: (update: () => void) => { ready: Promise<void>; finished: Promise<void> };
  }).startViewTransition;
  if (!from || !start || reduceMotion.matches) {
    change();
    return;
  }
  from.style.viewTransitionName = GROW_NAME;
  const transition = start.call(document, () => {
    from.style.viewTransitionName = "";
    to.style.viewTransitionName = GROW_NAME;
    change();
  });
  // A skipped transition rejects `ready` with an InvalidStateError: the tab went hidden, or another
  // transition started first. The update callback still ran, so the change happened and only the
  // animation was dropped. Anything else is a real failure and is rethrown to where it can be seen.
  void transition.ready.catch((error: unknown) => {
    if (error instanceof DOMException && error.name === "InvalidStateError") return;
    throw error;
  });
  // The name is cleared once the morph ends, so no later transition picks up a stale pair.
  void transition.finished.finally(() => {
    to.style.viewTransitionName = "";
  });
}

function openOverlay(
  src: string,
  alt: string,
  restoreFocus: () => void,
  label?: string,
  source?: HTMLElement,
) {
  // `restoreFocus` is kept because the opener is not always the element focus should land on.
  const dialog = document.createElement("dialog");
  dialog.className = "lightbox";
  /*
   * Empty alt means decorative, so the dialog gets a generic label rather than an empty one. A
   * diagram passes its own short label: its alt describes every node and is too long for a name.
   */
  dialog.setAttribute("aria-label", label ?? (alt || "Full size image"));
  if (reduceMotion.matches) dialog.dataset.reduced = "true";

  const full = document.createElement("img");
  full.src = src;
  full.alt = alt;
  dialog.appendChild(full);

  const close = document.createElement("button");
  close.type = "button";
  close.className = "lightbox-close";
  close.textContent = "Close";
  dialog.appendChild(close);

  // The full image shrinks back into the figure it came from. Escape closes natively, without it.
  const dismiss = () => grow(full, source ?? full, () => dialog.close());
  close.addEventListener("click", dismiss);

  // On a `<dialog>` the element itself is the click target for its backdrop.
  dialog.addEventListener("click", (event) => {
    if (event.target === dialog) dismiss();
  });

  dialog.addEventListener("close", () => {
    dialog.remove();
    restoreFocus();
  });

  grow(source, full, () => {
    document.body.appendChild(dialog);
    dialog.showModal();
  });
}

function lightbox() {
  const links = Array.from(
    document.querySelectorAll<HTMLAnchorElement>(".prose a.image-link"),
  );
  for (const link of links) {
    link.addEventListener("click", (event) => {
      const href = link.getAttribute("href");
      if (!href) return;
      event.preventDefault();
      const image = link.querySelector("img");
      openOverlay(
        href,
        image?.alt ?? "",
        () => link.focus({ preventScroll: true }),
        undefined,
        image ?? undefined,
      );
    });
  }

  // Diagram images are not wrapped in an anchor, and carry no `srcset`, so their `src` is the original.
  const diagrams = Array.from(
    document.querySelectorAll<HTMLImageElement>(".prose img.diagram-image"),
  );
  for (const image of diagrams) {
    image.classList.add("zoomable");
    /* A button to assistive technology and the keyboard too, not only to a pointer. */
    image.tabIndex = 0;
    image.setAttribute("role", "button");
    image.setAttribute("aria-label", `Enlarge diagram: ${image.alt}`);
    const open = () =>
      openOverlay(
        image.src,
        image.alt,
        () => image.focus({ preventScroll: true }),
        "Diagram, full size",
        image,
      );
    image.addEventListener("click", open);
    image.addEventListener("keydown", (event) => {
      if (event.key !== "Enter" && event.key !== " ") return;
      event.preventDefault();
      open();
    });
  }
}

function copyMarkdown() {
  const trigger = document.querySelector<HTMLAnchorElement>("[data-copy-markdown]");
  if (!trigger || !navigator.clipboard) return;

  trigger.addEventListener("click", async (event) => {
    event.preventDefault();
    const original = trigger.textContent;
    try {
      const response = await fetch(trigger.getAttribute("href") ?? "");
      // An error page is not the markdown: fail over to opening the link, which shows what went wrong.
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      await copyText(await response.text());
      trigger.textContent = "Copied";
      trigger.setAttribute("data-copied", "true");
      announce("Markdown copied");
    } catch {
      location.href = trigger.getAttribute("href") ?? "";
      return;
    }
    setTimeout(() => {
      trigger.textContent = original;
      trigger.removeAttribute("data-copied");
    }, 2000);
  });
}

const SELECTION_LABEL = "Copy link to selection";

/**
 * Script-only with no fallback: there is no scriptless way to read a selection, and the permalink
 * is the equivalent path. Placed in the flow after the selected block, it is the next tab stop.
 * The origin comes from the permalink, never `location`.
 */
function selectionLink() {
  const article = document.querySelector<HTMLElement>(".post .prose");
  const permalink = document.querySelector<HTMLAnchorElement>(".post-share .u-url");
  if (!article || !permalink || !navigator.clipboard) return;

  const button = document.createElement("button");
  button.type = "button";
  button.className = "selection-link";
  button.textContent = SELECTION_LABEL;

  let url = "";
  let dragging = false;

  const blockOf = (node: Node) => {
    let element = node instanceof Element ? node : node.parentElement;
    while (element && element !== article && element.parentElement !== article) {
      element = element.parentElement;
    }
    return element === article ? null : element;
  };

  const update = () => {
    const selection = getSelection();
    const fragment = selection?.isCollapsed ? null : textFragment(selection?.toString() ?? "");
    if (!selection || !fragment || !article.contains(selection.getRangeAt(0).commonAncestorContainer)) {
      button.remove();
      return;
    }
    url = `${permalink.href}#:~:text=${fragment}`;
    button.textContent = SELECTION_LABEL;
    const block = blockOf(selection.getRangeAt(0).endContainer);
    /*
     * Only when not already there: `insertBefore` detaches a node that has a parent, and a target
     * detached between pointerup and click never gets the click. Not `.after()`: the global Element
     * here is HTMLRewriter's.
     */
    if (block && block.nextSibling !== button) article.insertBefore(button, block.nextSibling);
  };

  // A mousedown on the button would collapse the selection and take the button away with it.
  button.addEventListener("mousedown", (event) => event.preventDefault());

  button.addEventListener("click", () => {
    const shown = (label: string) => {
      button.textContent = label;
      button.setAttribute("data-copied", "true");
      announce(label);
      setTimeout(() => {
        button.textContent = SELECTION_LABEL;
        button.removeAttribute("data-copied");
      }, 2000);
    };
    copyText(url).then(
      () => shown("Link copied"),
      () => shown("Copy failed"),
    );
  });

  // `selectionchange` fires on every character of a drag, so pointer drags update only on release.
  document.addEventListener("selectionchange", () => {
    if (!dragging) update();
  });
  document.addEventListener("pointerdown", () => {
    dragging = true;
  });
  document.addEventListener("pointerup", () => {
    dragging = false;
    update();
  });
}

readingProgress();
scrollSpy();
codeBlocks();
headingLinks();
onIdle(() => {
  footnotePreviews();
  lightbox();
  copyMarkdown();
  selectionLink();
});