site-check 1.0.0

references/check.js

3.7 KB · raw

// site-check: evaluate in the page (after scrolling to the bottom once so
// lazy images load). Returns a JSON string of findings.
(() => {
  const out = { url: location.href, viewport: [innerWidth, innerHeight] };

  // Horizontal overflow, ignoring elements hidden off-canvas on purpose.
  out.scrollWidth = document.documentElement.scrollWidth;
  out.overflowing = [...document.querySelectorAll("body *")]
    .filter((e) => {
      const r = e.getBoundingClientRect();
      const s = getComputedStyle(e);
      const hidden = s.visibility === "hidden" || s.display === "none" ||
        s.transform !== "none" && r.left >= innerWidth;
      return !hidden && r.width > 0 && r.right > innerWidth + 1;
    })
    .slice(0, 10)
    .map((e) => `${e.tagName.toLowerCase()}.${[...e.classList].join(".")} right=${Math.round(e.getBoundingClientRect().right)}`);

  // Images.
  const imgs = [...document.images];
  out.images = imgs.length;
  out.brokenImages = imgs
    .filter((i) => !(i.complete && i.naturalWidth > 0))
    .map((i) => i.currentSrc || i.src);
  out.missingAlt = imgs.filter((i) => !i.hasAttribute("alt")).map((i) => i.src);

  // Structure.
  out.title = document.title;
  out.h1Count = document.querySelectorAll("h1").length;

  // Small tap targets on touch-sized screens.
  if (innerWidth <= 768) {
    out.smallTargets = [...document.querySelectorAll("a, button, input, select, textarea, [role=button]")]
      .filter((e) => {
        const r = e.getBoundingClientRect();
        return r.width > 0 && (r.width < 44 || r.height < 44);
      })
      .slice(0, 15)
      .map((e) => `${e.tagName.toLowerCase()} "${(e.textContent || e.value || "").trim().slice(0, 30)}" ${Math.round(e.getBoundingClientRect().width)}x${Math.round(e.getBoundingClientRect().height)}`);
    out.smallInputs = [...document.querySelectorAll("input, select, textarea")]
      .filter((e) => e.getBoundingClientRect().width > 0 && parseFloat(getComputedStyle(e).fontSize) < 16)
      .map((e) => e.name || e.id || e.type);
  }

  // Contrast of common text against the nearest opaque background.
  const rgb = (c) => (c.match(/[\d.]+/g) || []).map(Number);
  const lum = ([r, g, b]) => {
    const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
    return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
  };
  const bgOf = (e) => {
    for (let n = e; n; n = n.parentElement) {
      const c = rgb(getComputedStyle(n).backgroundColor);
      if (c.length >= 3 && (c.length < 4 || c[3] > 0.9)) return c;
    }
    return [255, 255, 255];
  };
  out.lowContrast = [...document.querySelectorAll("p, li, a, button, label, h1, h2, h3, span")]
    .filter((e) => e.childElementCount === 0 && e.textContent.trim() && e.getBoundingClientRect().width > 0)
    .map((e) => {
      const s = getComputedStyle(e);
      const a = lum(rgb(s.color)), b = lum(bgOf(e));
      const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
      const large = parseFloat(s.fontSize) >= 24 || (parseFloat(s.fontSize) >= 19 && Number(s.fontWeight) >= 700);
      return { text: e.textContent.trim().slice(0, 40), ratio: Math.round(ratio * 100) / 100, need: large ? 3 : 4.5 };
    })
    .filter((x) => x.ratio < x.need)
    .slice(0, 15);

  // Links to try: same-origin hrefs and anchors.
  const links = [...document.querySelectorAll("a[href]")].map((a) => a.getAttribute("href"));
  out.anchorsMissing = links
    .filter((h) => h.startsWith("#") && h.length > 1)
    .filter((h) => !document.getElementById(decodeURIComponent(h.slice(1))));
  out.linksToFetch = [...new Set(links.filter((h) => !h.startsWith("#") && !h.startsWith("mailto:") && !h.startsWith("tel:")))].slice(0, 50);

  return JSON.stringify(out, null, 1);
})();