Proxified Links

Proxified hyperlinks to a proxy instance or Farside with no nonsense

// ==UserScript==
// @name        Proxified Links
// @author      proxi
// @homepageURL https://greasyfork.org/en/scripts/485274-proxified-links
// @copyright   2023 Schimon Jehudah (http://schimon.i2p)
// @license     AGPL-3.0-only; https://www.gnu.org/licenses/agpl-3.0.en.html
// @namespace   com.proxi.proxified
// @description Proxified hyperlinks to a proxy instance or Farside with no nonsense
//              Add or remove preferred services and instances yourself!
//
//              Forked from Proxify Links v23.10.17, by Schimon Jehudah, and modified with prejudice and style
//              - Proxy, proxy, proxy; add or configure non-proxying frontends yourself with discretion
//              - No probing HTTP requests to proxies, use a small hardcoded list of favorites, or
//                hold X key to use Farside redirection where possible
//              - Hold Z key to use the original link whenever needed
//              - Coexist with first-party frontends that can be used with discretion,
//                meaning don't mess with the official site by linking out to a proxy of itself!
//              - Ignore search engines and leave it to user browser and search configuration,
//                don't play around with nonexistent LMGTFY links or broken Google scrapers
//              - Clearnet by default following Farside, add personal lists of decentralized nodes
//              - Set noreferrer on supported links, optionally all links if `ENABLE_REFERER_HIDE_PAGEWIDE`,
//                preventing referer header while browsing without breaking common ajax functionality
// @run-at      document-end
// @version     0.2.3
// @match       *://*/*
// @icon        data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgOTAwMCA5MDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InllbGxvdyIvPjxwYXRoIGQ9Ik0uMjc5LS4xNDYuMTUgMGgtLjE3MmwuMjQtLjI2Ni0uMTMyLS4yNTNoLjE0OWwuMDc2LjE0OC4xMzEtLjE0OGguMTdsLS4yNC4yNjkuMTMuMjVILjM1NEwuMjc5LS4xNDZaIiBzdHlsZT0iZmlsbDp3aGl0ZTsiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDE2NzUgNzcyOCkgc2NhbGUoODAwMCkiLz48cGF0aCBkPSJNLjE4OCAwSC4wNDFMLjE5LS43MTZoLjI5MWEuMzQuMzQgMCAwIDEgLjEyMy4wMThjLjAzLjAxMy4wNTQuMDMzLjA3Mi4wNjFhLjE5Mi4xOTIgMCAwIDEgLjAyNi4xMDEuMjczLjI3MyAwIDAgMS0uMDIxLjEwNS4yNC4yNCAwIDAgMS0uMDUxLjA4Mi4yMzcuMjM3IDAgMCAxLS4xNjEuMDcxIDEuMTY2IDEuMTY2IDAgMCAxLS4xMy4wMDVILjI0NUwuMTg4IDBaTS4yNy0uMzkyaC4wNDVhLjUzLjUzIDAgMCAwIC4xNTYtLjAxNC4xMzEuMTMxIDAgMCAwIC4wNjEtLjA0Ny4xMjMuMTIzIDAgMCAwIC4wMjMtLjA3MS4wNy4wNyAwIDAgMC0uMDEyLS4wNDIuMDYyLjA2MiAwIDAgMC0uMDMyLS4wMjUuMzU4LjM1OCAwIDAgMC0uMDkzLS4wMDdILjMxM0wuMjctLjM5MloiIHRyYW5zZm9ybT0ibWF0cml4KDkzMjAgMCAwIDkzMTMgMTEyOCA3NzI4KSIvPjxwYXRoIGQ9Im0yNTEuNjg0IDY1NS45ODkuMTYyLjIzIDUyMi40ODYgMTczLjI5Mi0xMjEuMjk4LTE3My4zNTItLjUwMy0uMTdIMjUxLjY4NFptMTM0OS40NzYgMzE4LjYxLTI4My45OS05NS4wMTZjLTY0LjE5LS45NjItMTQ4LjMyLTEuNDQzLTI1Mi4zOS0xLjQ0M0g4MDguMzU5bC0xNy43MjMtMjUuMzI4LTIxMi4wNTQgMTIxLjc4N2gzNDYuMzg0bDEwMi4wODQtNTkuNzM3IDE3Ny4xNiA1OS43MzdoMzk2Ljk1WiIgc3R5bGU9ImZpbGw6eWVsbG93Z3JlZW4iIHRyYW5zZm9ybT0ibWF0cml4KDMuNDMgMCAwIC0xMS40NCA2NDIgMTUyMzUuNikiLz48cGF0aCBkPSJNMTUwMCA4MTIwaDU4MDB2MzYwSDE3NTB6Ii8+IDwvc3ZnPg==
// ==/UserScript==

/**
 * Basic configurations for functionality and performance
 */
const KEY_MODIFIED = 'x'; // X key to modify clicked link to Farside, if applicable
const KEY_BONAFIDE = 'z'; // Z key to revert clicked link to the original destination
const TOUCH_MODIFIED = 3; // Simple 3-finger tap gesture to modify tapped link to Farside, if applicable
const TOUCH_BONAFIDE = 5; // Simple 5-finger tap gesture to revert tapped link to the original destination
const TOUCH_PROXIFIED = 2; // Reserved 2-finger tap gesture to reset tapped link to static proxified

// Optional keys to limit unintentional triggering on non-links
// Set to empty '' string, proxification will happen automatically on selection
// TODO: improve touch support
const KEY_PROXIBITE = 'b'; // Optional B key to allow heavier elements to be proxified
const KEY_FRAMEBITE = 'f'; // Optional B+F key combination to trigger when iframes are updated
const TOUCH_PROXIBITE = 4; // Simple 3-finger tap gesture to allow all heavier elements to be proxified
const TOUCH_FRAMEBITE = 4;
const KEY_NAVIGATE = ''; // Optional filter for key nav used to proxify the selected element, e.g Tab

// Optional proxified iframes with supported src, proxified immediately when selected
// Best used if iframe content is already blocked by a content blocker (e.g. ublock click2load),
// so the original frame is never loaded automatically even if switching back from proxified (B+F+Z)
const ENABLE_IFRAME_PROXIFIED = true;

// Optional noreferer override on all proxified links
// If disabled, only upgrade undefined or noopener to noreferrer for proxified links
// Disable if proxified but bona fide (Z) links are breaking
const ENABLE_REFERER_HIDE = true;

// Optional noreferrer override on all links on the page
// Disable if non-proxified links or site authentication windows are breaking
const ENABLE_REFERER_HIDE_PAGEWIDE = true;

// Optional proxified links using query string if the main url does not match a proxy
// Useful for tracking links or old-fashioned HTML GET search, e.g. DuckDuckGo (uddg)
// Strongly recommend used with `ENABLE_REFERER_HIDE_PAGEWIDE` to avoid exposing search engine and keywords
// Disable if random links that shouldn't be proxified still are
const ENABLE_QUERY_PROXIFIED = true;
const ENABLE_QUERY_PROXIFIED_ON = []; // if empty, all query parameters will be included

// Optional stripping links of attributes that are necessary to proxify links (Google),
// and/or optional removal of link trackers on sites or search engines
const ENABLE_ATTRIBUTES_SMITE = true;
/** @type {Object<string, {attributes: string[], allowFuzzy?: boolean}>} */
const ENABLE_ATTRIBUTES_SMITE_ON_SITE = { 'google.com': { attributes: ['data-sb'] } };

/**
 * Configure site settings or add an instance of a proxy to the site's `redirect` list
 *
 * As is, use a shortlist of useful instances and mature proxy services
 * Out of box is an opinionated, not comprehensive or up-to-date, list of useful instances,
 * not ordered alphabetically but in the order of best proxied or most commonly linked
 *
 * @typedef {{
 *  redirect?: {
 *    replacements?: string[],
 *    suffix?: string, // optional suffix to a random replacement host from `replacements`
 *    routes?: ProxyRoute[], // optional route whitelist by matching url regex, defaults to replacing url host only if undefined
 *    inherit?: string // inherit any undefined properties in `redirect`
 *  },
 *  redirectToFarside?: {
 *    replacements?: string[],
 *    suffix?: string, // optional suffix to a random replacement host from `replacements`
 *    routes?: ProxyRoute[], // optional route whitelist by matching url regex, defaults to replacing url host only if undefined
 *    inherit?: string // inherit any undefined properties in `redirectToFarside`
 *  },
 *  allowFuzzy?: boolean, // allows fuzzy host matching, for subdomains
 *  allowIframe?: boolean, // allows matching on iframe src
 *  inherit?: string // inherit any undefined redirect rules
 * }} Proxy
 * @typedef {{
 *    regex: RegExp, // route regex match
 *    suffix?: string, // optional route suffix on host, appending to parent `suffix` if defined
 * }} ProxyRoute
 * @typedef {Object<string, Proxy>} ProxyList
 */

/**
 * @type {ProxyList}
 */
const PROXIES = {
  // youtube.com, youtu.be, m.youtube.com, youtube-nocookie.com
  // /watch, /trending, /@, /channel/
  // Allows iframe matching too, which is useful when content blocking embeds
  //  e.g. ublock ||youtube.com^$3p,frame,redirect=click2load.html
  'youtube.com': {
    allowIframe: true,
    redirect: {
      replacements: [
        'https://piped.video',
        'https://piped.smnz.de',
        'https://piped.projectsegfau.lt',
        'https://piped.privacydev.net',
        //'https://piped.lunar.icu', // embedded frame blocked by x-frame-options
        'https://piped.adminforge.de',
        'https://pd.vern.cc',
        // The following Invidious instances not only allow video proxy but proxy by default
        'https://iv.datura.network',
        'https://invidious.projectsegfau.lt',
        'https://invidious.fdn.fr',
      ],
    },
    redirectToFarside: {
      // Only use Farside's Piped redirect since most Invidious instances do not proxy videos by default
      // Aside from proxy by default, Invidious is preferred for nojs, configuration, and download functionality
      // Specific Invidious instances that proxy by default are included in the instance `redirect` list
      replacements: ['https://farside.link/piped'],
    },
  },
  'youtu.be': {
    redirect: {
      inherit: 'youtube.com',
      routes: [
        {
          regex: /^https?:\/\/(www\.)?youtu\.be\/([A-Za-z0-9_-]+)\??(.*)$/,
          suffix: '/watch?v=$2&$3', // manually add in path and params to support invidious https://github.com/iv-org/invidious/issues/3933
        },
      ],
    },
    redirectToFarside: {
      inherit: 'youtube.com',
      routes: [
        {
          regex: /^https?:\/\/(www\.)?youtu\.be\/([A-Za-z0-9_-]+)\??(.*)$/,
          suffix: '/watch?v=$2&$3', // manually add in path and params to support invidious https://github.com/iv-org/invidious/issues/3933
        },
      ],
    },
  },
  'm.youtube.com': { inherit: 'youtube.com' },
  'youtube-nocookie.com': { inherit: 'youtube.com' },

  // reddit.com
  'reddit.com': {
    redirect: {
      replacements: [
        //'https://libreddit.projectsegfau.lt', // low availability
        'https://libreddit.privacydev.net',
        'https://l.opnxng.com',
        //'https://reddit.invak.id', // down
        'https://libreddit.kavin.rocks',
      ],
    },
    redirectToFarside: {
      // teddit no longer actively maintained: https://codeberg.org/teddit/teddit
      replacements: ['https://farside.link/libreddit'],
    },
  },
  // redd.it image shortlinks
  'i.redd.it': {
    redirect: {
      inherit: 'reddit.com',
      suffix: '/img',
    },
    redirectToFarside: {
      inherit: 'reddit.com',
      suffix: '/img',
    },
  },
  'preview.redd.it': {
    redirect: {
      inherit: 'reddit.com',
      suffix: '/preview/pre',
    },
    redirectToFarside: {
      inherit: 'reddit.com',
      suffix: '/preview/pre',
    },
  },
  'external-preview.redd.it': {
    redirect: {
      inherit: 'reddit.com',
      suffix: '/preview/external-pre',
    },
    redirectToFarside: {
      inherit: 'reddit.com',
      suffix: '/preview/external-pre',
    },
  },

  /**
   *
   * Below are less mature or partially featured services
   *
   */

  // stackoverflow.com, {subdomain}.stackexchange.com
  // TBD: superuser.com and other stack sites pending AnonymousOverflow support
  'stackoverflow.com': {
    redirect: {
      replacements: [
        'https://ao.vern.cc',
        'https://overflow.smnz.de',
        'https://overflow.lunar.icu',
        'https://overflow.adminforge.de',
        //'https://overflow.hostux.net', // low stability
        // 'https://overflow.projectsegfau.lt', // low availability
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/anonymousoverflow'],
    },
  },
  '.stackexchange.com': {
    allowFuzzy: true, // enable matching loosely with arbitrary subdomain
    redirect: {
      inherit: 'stackoverflow.com',
      routes: [
        {
          regex: /^https?:\/\/(www\.)?([a-z]+)\..*?\//g,
          suffix: '/exchange/$2/', // suffix /exchange/{subdomain} on hostname
        },
      ],
    },
    redirectToFarside: {
      inherit: 'stackoverflow.com',
      routes: [
        {
          regex: /^https?:\/\/(www\.)?([a-z]+)\..*?\//g,
          suffix: '/exchange/$2/',
        },
      ],
    },
  },

  // quora.com
  'quora.com': {
    redirect: {
      replacements: ['https://quetre.iket.me', 'https://quetre.pussthecat.org', 'https://quetre.privacydev.net'],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/quetre'],
    },
  },

  // {artist}.bandcamp.com
  // Note: bandcamp.com/search route not supported, add above for 'bandcamp.com' if this rare link is needed is the wild
  // Note: {cdn}.bcbits.com routes not supported, add below for '.bcbits.com' if this rare link is needed in the wild
  '.bandcamp.com': {
    allowFuzzy: true,
    redirect: {
      replacements: ['https://tent.sny.sh', 'https://tn.vern.cc'],
      routes: [
        {
          // {artist}.bandcamp.com with no additional path except optional /music
          // exclude daily.bandcamp.com
          regex: /^https?:\/\/(www\.)?((?!daily\.)[a-z0-9\-]+)\.bandcamp\.com\/?(music)?$/g,
          suffix: '/artist.php?name=$2',
        },
        {
          // {artist}.bandcamp.com/{release}/{name}
          // exclude daily.bandcamp.com, e.g. daily.bandcamp.com/features/{article}
          regex: /^https?:\/\/(www\.)?((?!daily\.)[a-z0-9\-]+)\.bandcamp\.com\/([a-z]+)\/([a-z0-9\-]+)/g,
          suffix: '/release.php?artist=$2&type=$3&name=$4',
        },
      ],
    },
  },

  // instagram.com
  // Low feature parity
  'instagram.com': {
    redirect: {
      replacements: ['https://ig.opnxng.com', 'https://proxigram.lunar.icu'],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/proxigram'],
    },
  },

  // tiktok
  // Low feature parity
  'tiktok.com': {
    redirect: {
      replacements: [
        'https://proxitok.pussthecat.org',
        'https://tok.artemislena.eu',
        'https://tok.adminforge.de',
        'https://tik.hostux.net',
        'https://proxitok.lunar.icu',
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/proxitok'],
    },
  },

  // imgur.com, i.imgur.com, i.stack.imgur.com
  'imgur.com': {
    redirect: {
      replacements: [
        'https://rimgo.pussthecat.org',
        'https://imgur.artemislena.eu',
        'https://rimgo.vern.cc',
        'https://rimgo.hostux.net',
        'https://rimgo.lunar.icu',
        //'https://rimgo.projectsegfau.lt', // low availability
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/rimgo'],
    },
  },
  'i.imgur.com': { inherit: 'imgur.com' },
  'i.stack.imgur.com': {
    inherit: 'imgur.com',
    redirect: { inherit: 'imgur.com', suffix: '/stack' },
    redirectToFarside: { inherit: 'imgur.com', suffix: '/stack' },
  },

  // github.com, gists.github.com
  // /explore, /{group}/{repo}, /{group}/{repo}/archive, gists.github.com -> /gists/
  // Low feature parity
  // Use only for repo landing page, downloads, and gists
  'github.com': {
    redirect: {
      replacements: [
        'https://gothub.lunar.icu',
        'https://g.opnxng.com',
        //'https://gothub.projectsegfau.lt', // low availability
        'https://gothub.dev.projectsegfau.lt',
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/gothub'],
    },
  },
  // gist.github.com
  'gist.github.com': {
    redirect: {
      inherit: 'github.com',
      suffix: '/gist/',
      routes: [{ regex: /https?:\/\/(.*?)\//g }], // replace entire domain
    },
    redirectToFarside: {
      inherit: 'github.com',
      suffix: '/gist/',
      routes: [{ regex: /https?:\/\/(.*?)\//g }],
    },
  },

  // imdb.com, m.imdb.com
  'imdb.com': {
    redirect: {
      replacements: [
        'https://libremdb.pussthecat.org',
        'https://libremdb.iket.me',
        'https://ld.vern.cc',
        'https://libremdb.lunar.icu',
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/libremdb'],
    },
  },
  'm.imdb.com': { inherit: 'imdb.com' },

  // genius.com
  // Low feature parity
  'genius.com': {
    redirect: {
      replacements: ['https://dumb.privacydev.net', 'https://dm.vern.cc', 'https://dumb.lunar.icu'],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/dumb'],
    },
  },

  // medium.com - Uncomment to use
  // Low feature parity by design
  // Not a proxy by design, alternative frontend still requests from the official servers
  //
  // Recommend setting Medium to noscript and/or loading through more standard proxies such as TOR
  // Medium with JS disabled works as of now, but other proxy sites such as archive.org can be used if needed
  /* --- Remove this line to use --- //
  'medium.com': {
    redirect: {
      replacements: ['https://scribe.rip', 'https://sc.vern.cc', 'https://m.opnxng.com'],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/scribe'],
    },
  },
  // ------------------------------- */

  // fandom.com - Uncomment to use
  // Not a full proxy, alternative frontend still requests from the official servers
  //
  // Recommend simply using a content blocker to block ads and other annoyances
  /* --- Remove this line to use --- //
  '.fandom.com': {
    allowFuzzy: true,
    redirect: {
      replacements: [
        'https://breezewiki.com',
        'https://antifandom.com',
        'https://breezewiki.pussthecat.org',
        'https://bw.projectsegfau.lt',
        'https://breeze.hostux.net',
        'https://bw.artemislena.eu',
        'https://breeze.nohost.network',
        'https://z.opnxng.com',
      ],
      routes: [
        {
          regex: /^https?:\/\/(www\.)?([a-z\-]+)\..*?\//g,
          suffix: '/$2/', // suffix /{subdomain} on hostname
        },
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/breezewiki'],
      routes: [
        {
          regex: /^https?:\/\/(www\.)?([a-z\-]+)\..*?\//g,
          suffix: '/$2/',
        },
      ],
    },
  },
  // ------------------------------- */

  // wikipedia.org - Uncomment to use
  //
  // Recommend setting Wikipedia to noscript and/or loading through more standard proxies such as TOR
  // If absolutely needed, recommend rolling your own Wikiless instance routed through a proxy or VPN
  // Wikipedia trustworthiness and scriptless tracking is more or less equivalent to wikiless instances
  /* --- Remove this line to use --- //
  'wikipedia.org': {
    redirect: {
      replacements: [
        'https://wiki.adminforge.de',
        'https://wikiless.lunar.icu',
        'https://wikiless.org',
        'https://wl.vern.cc',
      ],
    },
    redirectToFarside: {
      replacements: ['https://farside.link/wikiless'],
    },
  },
  // ------------------------------- */
};

/**
 * Configure site link exclusions
 *
 * The most common exclusion will be on the first-party site itself, as many proxies are not complete replacements.
 * Common unsuppoorted features and paths are excluded, though this is not intended to exhaustively track the list of
 * proxied frontends and availability or configuration of individual instances.
 *
 * excludeLinks can exclude links that match any provided rule, namely matchingPath
 * excludeOn can exclude links when host matches `self` or any provided, e.g. by matchingPath (all paths if empty)
 * @typedef {{
 *  excludeLinks?: {
 *    matchingPath?: (string|RegExp)[],
 *    matchingHost?: (string|RegExp)[],
 *    matchingUrl?: (string|RegExp)[],
 *    matchingText?: (string|RegExp)[],
 *    inherit?: string // inherit any undefined properties in `excludeLinks`
 *  },
 *  excludeOn?: {
 *    matchingPath?: (string|RegExp)[],
 *    matchingHost?: (string|RegExp)[],
 *    matchingUrl?: (string|RegExp)[],
 *    matchingBody?: (string|RegExp)[],
 *    matchingHead?: (string|RegExp)[],
 *    inherit?: string // inherit any undefined properties in `excludeOn`
 *  },
 *  allowFuzzy?: boolean, // allows fuzzy host matching, for subdomains
 *  inherit?: string // inherits any undefined exclusion rules
 * }} Exclusion
 * @typedef {Object<string, Exclusion>} ExclusionList
 */

/**
 * Configure per-site exclusions
 * @type {ExclusionList}
 */
const EXCLUSIONS = {
  // youtube.com, m.youtube.com, youtube-nocookie.com
  'youtube.com': {
    excludeLinks: {
      matchingPath: ['/users/'],
    },
    excludeOn: {
      // exclude linking out from the official site
      matchingHost: ['youtube.com', 'youtube-nocookie.com'],
      matchingHead: [
        '<meta property="og:title" content="Piped">',
        /<meta property="og:site_name" content=".*Invidious">/,
      ],
    },
  },
  'm.youtube.com': { inherit: 'youtube.com' },
  'youtube-nocookie.com': { inherit: 'youtube.com' },

  // reddit.com
  'reddit.com': {
    excludeLinks: {
      // old.reddit.com can be used almost entirely with noscript
      matchingHost: ['old.reddit.com'],
    },
    excludeOn: {
      // exclude on reddit.com but still proxy links while browsing old.reddit.com
      matchingUrl: [/^https?:\/\/(www\.)?(?!old\.)reddit.com/],
    },
  },

  // stackoverflow.com, {subdomain}.stackexchange.com
  'stackoverflow.com': {
    excludeLinks: {
      matchingPath: ['/questions/tagged/', '/users/'],
    },
    excludeOn: {
      matchingHost: ['stackoverflow.com', 'stackexchange.com'],
    },
  },
  '.stackexchange.com': {
    allowFuzzy: true,
    inherit: 'stackoverflow.com',
  },

  // quora.com
  'quora.com': { excludeOn: { matchingHost: ['quora.com'] } },

  // {artist}.bandcamp.com
  '.bandcamp.com': { allowFuzzy: true, excludeOn: { matchingHost: ['bandcamp.com'] } },

  // instagram.com
  'instagram.com': {
    excludeOn: {
      matchingHost: ['instagram.com'],
      matchingHead: [
        /<meta property="og:title" content="[a-zA-Z0-9 _\-+=.,:;'?\/\\`!@#$%^&*()-_\[\]{}|]+? • Proxigram">/,
      ],
    },
  },

  // tiktok.com
  'tiktok.com': {
    excludeOn: {
      matchingHost: ['tiktok.com'],
      // exclude on ProxiTok itself, linking out to original
      matchingHead: ['<meta property="og:site_name" content="ProxiTok">'],
    },
  },

  // imgur.com
  'imgur.com': { excludeOn: { matchingHost: ['imgur.com'] } },
  'i.imgur.com': { inherit: 'imgur.com' },
  'i.stack.imgur.com': { inherit: 'imgur.com' },

  // github.com
  // a lot to blacklist, can also whitelist limited functionality instead
  'github.com': {
    excludeLinks: {
      matchingText: [],
      // prettier-ignore
      matchingPath: ['/actions','/blame/','/codespaces/','/collections/','/commit/','/commits/','/compare/','/customer-stories','/delete/','/discussions/','/enterprise/','/events/','/features/','/graphs/','/issues','/marketplace/','/notifications/','/orgs/','/projects/','/pulls', '/pull/','/pulse','/releases','/security','/sessions/','/sponsors/','/tags','/tree/','/wiki/'],
    },
    excludeOn: {
      matchingHost: ['github.com'],
      // exclude on Gothub page itself, linking out
      matchingBody: ['<a href="https://codeberg.org/gothub/gothub">Source code</a>'],
    },
  },
  'gist.github.com': {
    excludeLinks: {
      // gist single directory paths, e.g. users, /discover, /starred
      matchingPath: [/^\/[A-Za-z0-9_.-]+\/?$/],
    },
    inherit: 'github.com',
  },

  // imdb.com
  'imdb.com': {
    excludeOn: {
      matchingHost: ['imdb.com'],
      // exclude on libremdb itself, linking out
      matchingHead: ['<meta property="og:site_name" content="libremdb">'],
    },
  },

  // genius.com
  // /artist and other pages may not work, but not blacklisting any paths for now
  'genius.com': { excludeOn: { matchingHost: ['genius.com'] } },

  // medium.com
  'medium.com': { excludeOn: { matchingHost: ['medium.com'] } },

  // fandom.com
  'fandom.com': { excludeOn: { matchingHost: ['fandom.com'] } },

  // wikipedia.org
  'wikipedia.org': { excludeOn: { matchingHost: ['wikipedia.org'] } },
};

/**
 * Begin script logic
 *
 * Do not modify below if adding or making changes to available proxies
 */
const DEBUG = false; // Console logging enabled when true

// Tag names of hovered link elements that can be proxified
const PROXIFY_ON = ['A', 'IFRAME'];
// Tag names of hovered elements that should also lookup to the parent node for links
// TODO: per-tag and per-site configuration
const PARENT_LOOKUP_ON = [
  'BUTTON',
  'IMG',
  'SVG',
  'H1',
  'H2',
  'H3',
  'H4',
  'H5',
  'H6',
  'B',
  'I',
  'EM',
  'STRONG',
  'SMALL',
  'SUP',
  'SUB',
  'S',
  'U',
  'LI',
];
// Default depth limit to ancestor lookup recursion
const PARENT_LOOKUP_STEPS = 1;
/**
 * Additional per-site parent lookup config, to fix site-specific proxification
 * TODO: further segment sites on matchesPath or other rules and per-nodeName configs
 * @type { Object<string, {nodeNames: string[], steps: number} > }
 */
const PARENT_LOOKUP_ON_SITE = {
  // google.com mobile search results div
  // TODO: improve mobile search inline media touch events swallowed
  'google.com': { nodeNames: ['DIV'], steps: 4 },
  // duckduckgo.com search results span, hero summary SVGs and title container div
  'duckduckgo.com': { nodeNames: ['SPAN', 'DIV', 'PATH', 'RECT'], steps: 4 },
  // startpage.com search results inline images/videos
  'startpage.com': { steps: 2 },
};

// Tag names of hovered elements that should also globally search all hovered elements for links
const HOVER_LOOKUP_ON = ['P', 'SPAN', 'LI', 'LABEL', /*'DIV',*/ 'BUTTON', 'IMG', 'SVG'];
// Additional tag names to search per-site, to fix site-specific proxificaton
const HOVER_LOOKUP_ON_HOST = {
  'duckduckgo.com': ['DIV'],
};

const CLASS_PROXIFIED = 'proxi-fied';
const CLASS_PROXIFIED_LIVE = 'proxi-live';
const CLASS_BODY_FARSIDE = 'proxi-side';
const CLASS_BODY_BONAFIDE = 'proxi-fide';
const CLASS_BODY_BITE = 'proxi-bite';
const CLASS_BODY_FRAMEBITE = 'proxi-framebite';
const STYLE_HIGHLIGHT_ID = 'proxi-highlite';
const ATTR_PROXIFIED_SITE = 'proxi-site';
const ATTR_PROXIFIED_FARSIDE = 'proxi-site-side';
const ATTR_PROXIFIED_DENIED = 'proxi-nied';
const ATTR_BONAFIDE_SITE = 'proxi-site-bonafide';
const ATTR_IS_FARSIDE = 'proxi-side';

// https://uibakery.io/regex-library/url
const REGEX_URL =
  /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/;

// Key event modifiers for selecting destination
document.body.addEventListener('keydown', e => handleModifierKey(e));
document.body.addEventListener('keyup', e => handleModifierKey(e));
document.body.addEventListener('touchstart', e => handleModifierGesture(e));

/**
 * Lazy load link processing when user interacts per-element
 *
 * Handle hovering over links and completed keyboard navigation over link
 * Does not handle other basic redirects such as form action or onclick attributes
 */
document.body.addEventListener('mouseover', handleElement); // link hover
document.body.addEventListener('touchstart', handleElement); // link touch
document.body.addEventListener('keyup', handleElement); // keyboard navigation

/**
 * Hover event handler to find an anchor hyperlink to check
 * @param {Event} e
 */
function handleElement(e) {
  // Currently support specific events, and optional key nav filter
  if (
    e.type !== 'mouseover' &&
    e.type !== 'touchstart' &&
    (e.type !== 'keyup' || (!!KEY_NAVIGATE && e.key !== KEY_NAVIGATE))
  ) {
    return;
  }

  // Ignore target on multi-touch touch event
  if (e.type === 'touchstart' && e.touches.length !== 1) {
    return;
  }

  // Look for any element or elements that this event could be trying to proxify
  const proxifiableEls = getProxifiableElements(e);
  if (!proxifiableEls || !proxifiableEls.length) return;

  // Prioritize anchor links
  // Take the first anchor, even if it has already been processed
  const targetAnchor = proxifiableEls.find(el => el.nodeName === 'A');
  if (!!targetAnchor) {
    // Hovered anchor found, process it unless it was already processed before
    if (!isElementProxified(targetAnchor)) {
      handleAnchorEl(targetAnchor);
    } else {
      // Otherwise, trigger an update on the link element to ensure it is current
      setTimeout(() => {
        updateProxifiedElement(targetAnchor, 'href');
      }, 0);
    }

    // Only handle one element per event for now to avoid overeager proxification
    return;
  }

  // Handle iframe, if enabled
  // If both or either bite key is empty, handle on any interaction
  if (ENABLE_IFRAME_PROXIFIED) {
    const isProxibite = document.body.classList.contains(CLASS_BODY_BITE);
    const isFramebite = document.body.classList.contains(CLASS_BODY_FRAMEBITE);
    const canBite =
      (!KEY_PROXIBITE || isProxibite || e.key === KEY_PROXIBITE || e.touches?.length === TOUCH_PROXIBITE) &&
      (!KEY_FRAMEBITE || isFramebite || e.key === KEY_FRAMEBITE || e.touches?.length === TOUCH_FRAMEBITE);
    const targetIframe = canBite && proxifiableEls.find(el => el.nodeName === 'IFRAME');
    if (!!targetIframe) {
      if (!isElementProxified(targetIframe)) {
        handleIframe(targetIframe);
      } else {
        setTimeout(() => {
          updateProxifiedElement(targetIframe, 'src');
        }, 0);
      }
    }

    return;
  }
}

/**
 * Handle an anchor element to be proxified
 * Allows re-proxifying the anchor
 *
 * @param {HTMLAnchorElement} el Anchor element
 * @returns {void}
 */
function handleAnchorEl(el) {
  // Perform element and url validation and returns a valid proxy
  const { url, proxy } = preproxifyElement(el, 'href') || {};

  // Perform any global link modifications
  if (ENABLE_REFERER_HIDE_PAGEWIDE) el.rel = 'noreferrer';
  let optSmite;
  if (
    ENABLE_ATTRIBUTES_SMITE &&
    !!el.href &&
    !!(optSmite = getByHost(ENABLE_ATTRIBUTES_SMITE_ON_SITE, window.location.host, true))
  ) {
    // If enabled on the current site, strip any click event attributes from this anchor element
    // Resulting hyperlink is intended to be a primitive link with no events intercepting navigation
    // TODO: Support reverting events on bonafide
    for (const attr of optSmite.attributes) {
      el.removeAttribute(attr);
    }
  }

  // Proxify the link
  // Error handling will have been done on preproxification
  if (!!proxy) {
    proxifyElement(
      el,
      url,
      'href',
      proxy,
      /** @param {HTMLAnchorElement} el */ el => {
        // Trigger an update on the link now that it has been proxified
        updateProxifiedElement(el, 'href');

        // Upgrade undefined or noopener relationship to norefererer
        // If `ENABLE_REFERER_HIDE`, override on all proxified links
        // TODO: Support modifier key to revert change?
        if (!el.rel || el.rel === 'noopener' || ENABLE_REFERER_HIDE) el.rel = 'noreferrer';
      }
    );
  }
}

/**
 * Handle an iframe to be proxified
 * Allows re-proxifying the frame
 *
 * @param {HTMLIFrameElement} el
 * @returns {void}
 */
function handleIframe(el) {
  const { url, proxy } = preproxifyElement(el, 'src') || {};
  if (!!proxy)
    proxifyElement(
      el,
      url,
      'src',
      proxy,
      /** @param {HTMLIframeElement} el */ el => {
        // Trigger an update on the link now that it has been proxified
        updateProxifiedElement(el, 'src');
      }
    );
}

/**
 * Get the element or elements that can be proxified, including those already proxified,
 * from an event where the user is hovering or making a selection
 *
 * @param {MouseEvent | KeyboardEvent | TouchEvent} e
 * @returns {Element[]?} If none, null
 */
function getProxifiableElements(e) {
  if (!(e instanceof MouseEvent || e instanceof KeyboardEvent || e instanceof TouchEvent)) {
    error('Invalid event');
    return null;
  }

  if (!e.target) return null;
  const nodeName = e.target.nodeName.toUpperCase();

  // Anchor links get top priority if directly targeted
  if (nodeName === 'A') return [e.target];

  // Otherwise, build a list of candidates in the order of probable priority
  // Add in other directly targeted non-anchor links to the top
  const proxifiableEls = [];
  if (PROXIFY_ON.includes(nodeName)) proxifiableEls.push(e.target);

  // Parents may not receive propagated events, so use this event now to check if nested under a link
  let optLookup = getByHost(PARENT_LOOKUP_ON_SITE, window.location.host, true);
  if (PARENT_LOOKUP_ON.includes(nodeName) || optLookup?.nodeNames?.includes(nodeName)) {
    // Step up through ancestors
    // Or use target.closest() to search all ancestors
    const lookupSteps = Math.max(optLookup?.steps ? optLookup.steps : 1, PARENT_LOOKUP_STEPS);
    let currentNode = e.target;
    for (let step = 0; step < lookupSteps; step++) {
      if (!!currentNode.parentNode) {
        currentNode = currentNode.parentNode;

        // take processed or unprocessed proxifiable elements
        if (PROXIFY_ON.includes(currentNode.nodeName.toUpperCase())) {
          proxifiableEls.push(currentNode);
        }
      } else {
        break;
      }
    }
  }

  // A link may be a sibling or other relative, or not even a relative, that does not receive propagated
  // For mouse hover or bite event, find any link element that is currently being hovered
  // TODO: Skip previously lookup elements and handle updateProxifiedElement rerender separately from proxifying

  const isHoverLookup =
    e instanceof MouseEvent &&
    (HOVER_LOOKUP_ON.includes(nodeName) || HOVER_LOOKUP_ON_HOST[window.location.host]?.includes(nodeName));
  const isProxiBite = !!KEY_PROXIBITE && document.body.classList.contains(CLASS_BODY_BITE);
  if (isHoverLookup || isProxiBite) {
    // Select all hovered elements and take the processed or unprocessed proxifiable elements
    const hoveredEls = document.querySelectorAll(':hover');
    for (const el of hoveredEls) {
      if (PROXIFY_ON.includes(el.nodeName.toUpperCase())) {
        proxifiableEls.push(el);
      }
    }
  }

  return !!proxifiableEls.length ? proxifiableEls : null;
}

/**
 * Perform element and destination url validation and return a valid proxy
 *
 * @param {HTMLElement} el Target element to be validated for proxification
 * @param {string} destinationAttr Element link destination attribute name
 * @returns {{url, Proxy} | null} Returns a random proxy for the element, null if none
 */
function preproxifyElement(el, destinationAttr) {
  const destination = el[destinationAttr];
  const text = el.outerText;

  // Validate anchor is intended to be a hyperlink
  if (!destination.length) {
    el.setAttribute(ATTR_PROXIFIED_DENIED, 'Invalid, empty, or undefined destination attribute');
    return null;
  }
  if (!REGEX_URL.test(destination)) {
    el.setAttribute(ATTR_PROXIFIED_DENIED, 'Hyperlink destination is not a valid absolute URL');
    return null;
  }

  // Parse the destination as a URL
  let url;
  try {
    url = new URL(destination);
  } catch (ex) {
    // Exit on malformed URL
    error(`[${text}](${destination}) error parsing URL: ${ex}`);
    el.setAttribute(ATTR_PROXIFIED_DENIED, 'Error parsing URL');
    return null;
  }

  // Exit if the original link is excluded for any reason
  // Page-wide exclusions to each destination host are memoized so may save unnecessary checking
  let excludedReason;
  if ((excludedReason = getExclusionReasonForLink(url, text))) {
    el.setAttribute(ATTR_PROXIFIED_DENIED, excludedReason);
    return null;
  }

  // Get a flattened proxy rule that can be applied for this link
  let proxy = getProxyForLink(url);

  // If no proxy can be found for the link url, optionally search its query string
  // Find a proxy match for any query param that is whitelisted, if used, and is a valid url
  if (!proxy && ENABLE_QUERY_PROXIFIED) {
    for (const [paramKey, paramVal] of url.searchParams) {
      // if query param whitelist is empty, allow all
      // TODO: blacklist or other prevention on undesirable effects like callback urls
      if (!ENABLE_QUERY_PROXIFIED_ON?.length || ENABLE_QUERY_PROXIFIED_ON.includes(paramKey)) {
        let queryStringLinkCandidate = paramVal;
        let queryStringURLCandidate, queryStringProxyCandidate;
        try {
          queryStringLinkCandidate = decodeURI(paramVal); // attempt to decode a full URI encoded to the query string
        } catch (ex) {
          warn(`Failed to decode query string param: ${ex}`);
          continue;
        }

        // skip param if it doesn't look like an absolute url to proxify
        if (!REGEX_URL.test(queryStringLinkCandidate)) {
          continue;
        }

        try {
          queryStringURLCandidate = new URL(queryStringLinkCandidate);
        } catch (ex) {
          error(`Failed to parse validated url for query param "${paramKey}"`);
          continue;
        }

        if (getExclusionReasonForLink(queryStringURLCandidate, text)) {
          continue;
        }

        queryStringProxyCandidate = getProxyForLink(queryStringURLCandidate);

        // if successful, break on query string and take first match
        if (!!queryStringProxyCandidate) {
          url = queryStringURLCandidate;
          proxy = queryStringProxyCandidate;
          break;
        }
      }
    }
  }

  // Automatic exclusion if no proxy found on this pass
  if (!proxy) {
    el.setAttribute(ATTR_PROXIFIED_DENIED, 'Not found in proxies list');
    return null;
  }

  return { url, proxy };
}

/**
 * Proxify the link element with the specified proxy settings
 * Supports anchor and iframe elements for now
 *
 * @param {HTMLAnchorElement | HTMLIFrameElement} el
 * @param {URL} url
 * @param {string} destinationAttr Element link destination attribute name
 * @param {Proxy} proxy
 * @param {Function<HTMLAnchorElement | HTMLIFrameElement>?} onProxification Optional success callback
 * @returns {void}
 */
function proxifyElement(el, url, destinationAttr, proxy, onProxification) {
  if (!(el instanceof HTMLAnchorElement) && !(el instanceof HTMLIFrameElement)) {
    error(`${el} is not a supported element type`);
    return;
  }

  if (!isProxyValid(proxy)) {
    error(`Invalid proxy settings for proxifying ${url}`);
    return;
  }

  let proxifyDenied;

  // Build a url to a random instance
  if (!!proxy.redirect) {
    const instanceList = proxy.redirect.replacements;
    const instanceSuffix = proxy.redirect.suffix;
    const instanceRoutes = proxy.redirect.routes;
    const urlInstanceProxified = getProxifiedUrl(url, instanceList, instanceSuffix, instanceRoutes);

    if (urlInstanceProxified instanceof URL) {
      el.setAttribute(ATTR_PROXIFIED_SITE, urlInstanceProxified);
    } else if (typeof urlInstanceProxified === 'string') {
      proxifyDenied ||= urlInstanceProxified;
    }
  }

  // Build a url to a random Farside service redirect
  if (!!proxy.redirectToFarside) {
    const farsideList = proxy.redirectToFarside.replacements;
    const farsideSuffix = proxy.redirectToFarside.suffix;
    const farsideRoutes = proxy.redirectToFarside.routes;
    const urlFarsideProxified = getProxifiedUrl(url, farsideList, farsideSuffix, farsideRoutes);

    if (urlFarsideProxified instanceof URL) {
      el.setAttribute(ATTR_PROXIFIED_FARSIDE, urlFarsideProxified);
    } else if (typeof urlFarsideProxified === 'string') {
      proxifyDenied ||= urlFarsideProxified;
    }
  }

  // Complete the proxification
  if (el.hasAttribute(ATTR_PROXIFIED_SITE) || el.hasAttribute(ATTR_PROXIFIED_FARSIDE)) {
    // Mark the link as successfully proxified
    el.classList.add(CLASS_PROXIFIED);

    // Store the original anchor href
    if (!el.hasAttribute(ATTR_BONAFIDE_SITE)) {
      el.setAttribute(ATTR_BONAFIDE_SITE, el[destinationAttr]);
    }

    // Success callback
    if (!!onProxification) {
      onProxification(el);
    }
  } else if (!!proxifyDenied) {
    // No effect proxifying, but a denial reason was given
    el.setAttribute(ATTR_PROXIFIED_DENIED, proxifyDenied);
  } else {
    // Log unexpected failure past validated inputs
    error(`Failure proxifying ${url}`);
  }
}

/**
 * Return a proxified URL given a list of replacement hosts and optional routes
 *
 * @param {URL} url
 * @param {string[]} replacements
 * @param {string?} suffix Optional static suffix on the replacement host
 * @param {ProxyRoute[]?} routes Optional route list specifying a regex match with its corresponding options
 * @returns {URL | string | null} Proxified URL, string reason if denied, null if failed
 */
function getProxifiedUrl(url, replacements, suffix, routes) {
  if (!url) {
    error('URL null or undefined');
    return null;
  }

  if (!replacements) {
    error('Replacements list null or undefined');
    return null;
  }

  // Get a cleaned list of replacement strings
  const replacementList = replacements.filter(
    replacement => typeof replacement === 'string' && REGEX_URL.test(replacement)
  );

  // Get a random replacement string from the list and replace
  if (replacementList.length > 0) {
    const replacementRandom = replacementList[Math.floor(Math.random() * replacementList.length)];
    const replacementSuffix = suffix || '';
    const replacementRoute = routes?.find(route => route?.regex instanceof RegExp && route.regex.test(url.href));

    const failedWhitelist = !!routes && !replacementRoute; // proxy routes whitelist defined but none matched
    let urlProxified = new URL(url);
    if (!!replacementRoute) {
      // Regex replacement
      // TODO: Support replacement function as an alternative to the replacement string route suffix
      try {
        const routeRegex = replacementRoute.regex;
        const routeSuffix = replacementRoute.suffix || '';
        const hrefProxified = url.href.replace(routeRegex, replacementRandom + replacementSuffix + routeSuffix);
        urlProxified = new URL(hrefProxified);
      } catch (ex) {
        warn(`Invalid regex replaced URL for ${url}`);
        return null;
      }
    } else if (!failedWhitelist) {
      // Default regex replacement on url host and scheme
      // Skip if whitelisted routes were defined but not matched
      try {
        const hrefProxified = url.href.replace(
          /(https?:\/\/)(.*?)(\/.*)/,
          replacementRandom + replacementSuffix + '$3'
        );
        urlProxified = new URL(hrefProxified);
      } catch (ex) {
        error(`Invalid default replaced URL for ${url}`);
        return null;
      }
    }

    if (url.href !== urlProxified.href) {
      return urlProxified;
    } else if (failedWhitelist) {
      return 'Failed routes whitelist';
    } else {
      error(`No effect proxifying ${url}`);
      return null;
    }
  }

  warn(`Missing or invalid replacement URLs for ${url}`);
  return null;
}

/**
 * Returns matching proxy for the specified link url
 *
 * @param {URL} url
 * @returns {Proxy?} Proxy settings by url, null if not found
 */
function getProxyForLink(url) {
  // Get the redirect rules from the proxy matching url host
  /** @type {Proxy?} */
  const proxy = getByHost(PROXIES, url.host, true);

  // Inherit any redirect rules that were left completely undefined, then inherit
  // any rule properties that were left undefined
  /** @type {Proxy?} */
  let flattenedProxyRule;
  try {
    flattenedProxyRule = flattenInheritance(proxy, PROXIES);
  } catch (ex) {
    error(`Error inheriting proxy rules for ${url}: ${ex}`);
    return false;
  }

  if (!!flattenedProxyRule) {
    return flattenedProxyRule;
  }

  return null;
}

/**
 * Returns whether link URL is supported by a listed proxy and not explicitly excluded
 *
 * @param {URL} url
 * @param {string} text
 * @returns {string|null} Reason if link is invalid, null if valid
 */
function getExclusionReasonForLink(url, text) {
  // Exit if this URL is excluded by path or innertext, or by the current page location
  // Look for exclusion rules on both this host and inherited, if applicable
  /** @type {Exclusion?} */
  const exclusion = getByHost(EXCLUSIONS, url.host, true);

  /** @type {Exclusion?} */
  let flattenedExclusionRule;
  try {
    flattenedExclusionRule = flattenInheritance(exclusion, EXCLUSIONS);
  } catch (ex) {
    // Likely inheriting rule that is unexpectedly undefined, probably due to incorrect or nested inheritance
    warn(`Error inheriting exclusion rules for ${url}: ${ex}`);
    return 'Error inheriting exclusion rule';
  }

  // With the final exclusion rule set, check if the link url is excluded
  if (!!flattenedExclusionRule) {
    const excludedReason = isLinkExcludedByRule(url, text, flattenedExclusionRule);
    if (!!excludedReason) {
      return excludedReason;
    }
  }

  return null;
}

/**
 * Flatten generic object upwards with inherited data
 *
 * Only goes one level deep, both for inheritance and nested inheritors
 * @typedef {{
 *  [keys: string]: Inheritor,
 *  inherit?: string
 * }} Inheritor
 *
 * @param {Inheritor} source
 * @param {Object<string, Inheritor>} dictionary
 * @returns {Inheritor?}
 * @throws {Error} Exception on copying with Object.assign()
 */
function flattenInheritance(source, dictionary) {
  // Avoid destructive shallow copies on `source` or other objects
  let root = source;
  if (!root || !(typeof root === 'object')) return null;

  const rootInheritance = dictionary[root.inherit];
  if (!!rootInheritance && typeof rootInheritance === 'object') {
    // Merge two rules into one
    root = Object.assign({}, rootInheritance, root);
  }

  // For the final state of inherited nested objects that also inherit,
  // flatten their inheritance as well (non-recursive)
  for (const key of Object.keys(root)) {
    const branch = root[key];
    if (branch?.inherit && dictionary[branch.inherit]) {
      // if inherited object also includes corresponding nested data, merge to
      const branchInheritance = dictionary[branch.inherit]?.[key];
      if (!!branchInheritance && typeof branchInheritance === 'object') {
        root[key] = Object.assign({}, branchInheritance, branch);
      }
    }
  }

  // Warn if it appears that recursive inheritance is configured
  // Inheritance beyond the first level of root and property data is not supported
  if (!!rootInheritance?.inherit) {
    warn(`Multi depth recursion hit ${rootInheritance.inherit} but is not supported`);
  }

  return root;
}

/**
 * Validate proxy rules have enough instance or Farside rules to proxify a link
 *
 * @param {Proxy} proxy
 * @returns {boolean} true if valid, false otherwise
 */
function isProxyValid(proxy) {
  if (!proxy) return false;

  const isInstanceRedirectValid =
    !!proxy.redirect && proxy.redirect.replacements && proxy.redirect.replacements.length > 0;
  const isFarsideRedirectValid =
    !!proxy.redirectToFarside &&
    proxy.redirectToFarside.replacements &&
    proxy.redirectToFarside.replacements.length > 0;
  if (!isInstanceRedirectValid && !isFarsideRedirectValid) {
    // neither instance or Farside rules fully defined
    return false;
  }

  return true;
}

/**
 * Handle keydown and keyup event to set modifiers on proxified links
 * @param {KeyboardEvent} e
 * @returns {void}
 */
function handleModifierKey(e) {
  if (e.type !== 'keyup' && e.type !== 'keydown') {
    error('Invalid modifier key event');
    return;
  }

  let isDomChanged = false;
  toggleModifierKeyState(e, KEY_MODIFIED, CLASS_BODY_FARSIDE) && (isDomChanged = true);
  toggleModifierKeyState(e, KEY_BONAFIDE, CLASS_BODY_BONAFIDE) && (isDomChanged = true);
  toggleModifierKeyState(e, KEY_PROXIBITE, CLASS_BODY_BITE) && (isDomChanged = true);
  toggleModifierKeyState(e, KEY_FRAMEBITE, CLASS_BODY_FRAMEBITE) && (isDomChanged = true);

  // If DOM state changed, trigger an render update on proxified elements
  if (isDomChanged) {
    setTimeout(() => {
      updateAllProxifiedAnchors(true);
    }, 0);
  }
}

/**
 * Handle multi-touch gesture event to set modifiers on proxified links, for touch-only devices
 * @param {TouchEvent} e
 * @returns {void}
 */
function handleModifierGesture(e) {
  if (!(e instanceof TouchEvent)) {
    error('Invalid modifier touch gesture event');
    return;
  }

  // only support multi-touch to trigger/reset modifiers
  if (e.touches.length <= 1) {
    return;
  }

  let isDomChanged = false;
  toggleModifierGestureState(e, TOUCH_MODIFIED, CLASS_BODY_FARSIDE) && (isDomChanged = true);
  toggleModifierGestureState(e, TOUCH_BONAFIDE, CLASS_BODY_BONAFIDE) && (isDomChanged = true);
  toggleModifierGestureState(e, TOUCH_PROXIBITE, CLASS_BODY_BITE, true) && (isDomChanged = true);
  toggleModifierGestureState(e, TOUCH_FRAMEBITE, CLASS_BODY_FRAMEBITE, true) && (isDomChanged = true);

  // If DOM state changed, trigger an render update on proxified elements
  if (isDomChanged) {
    setTimeout(() => {
      updateAllProxifiedAnchors(true);
    }, 0);
  }
}

/**
 * Toggle a page-wide class by checking KeyboardEvent matches the specified toggleKey
 *
 * @param {KeyboardEvent} e
 * @param {string} toggleKey
 * @param {string} toggleClass Classname to toggle in DOM
 * @returns {boolean} True if changed, false if no change to DOM
 */
function toggleModifierKeyState(e, toggleKey, toggleClass) {
  if (!(e instanceof KeyboardEvent)) {
    error('Event is not KeyboardEvent');
    return;
  }

  if (e.type !== 'keyup' && e.type !== 'keydown') {
    error('Invalid modifier key event');
    return false;
  }

  if (e.key === toggleKey) {
    const setModifierOn = e.type === 'keydown';
    const originalState = document.body.classList.contains(toggleClass);

    if (setModifierOn) {
      if (!originalState) {
        document.body.classList.add(toggleClass);
        return true;
      }
    } else {
      if (originalState) {
        document.body.classList.remove(toggleClass);
        return true;
      }
    }
  }

  return false;
}

/**
 * Toggle a page-wide class by checking TouchEvent matches the specified touch gesture
 *
 * @param {ToggleEvent} e
 * @param {number} toggleGesture Simple gesture based on number of fingers
 * @param {string} toggleClass Classname to toggle in DOM
 * @param {boolean} isManualOff Optional setting to require manually repeating gesture to toggle off
 * @returns {boolean} True if changed, false if no change to DOM
 */
function toggleModifierGestureState(e, toggleGesture, toggleClass, isManualOff = false) {
  if (!(e instanceof TouchEvent)) {
    error('Event is not TouchEvent');
    return;
  }

  // detect gesture
  // only simple touch count gesture is supported currently
  const gestureTouchCount = toggleGesture;
  const isGestured = gestureTouchCount !== TOUCH_PROXIFIED && e.touches.length === gestureTouchCount;

  // handle gesture to toggle modifier
  const originalState = document.body.classList.contains(toggleClass);
  if (isGestured) {
    if (!originalState) {
      document.body.classList.add(toggleClass);
      return true;
    } else if (isManualOff) {
      // gesture off, if supported by this gesture
      document.body.classList.remove(toggleClass);
      return true;
    }
  } else if (!isManualOff) {
    // automatic off, if supported by this gesture
    if (originalState) {
      document.body.classList.remove(toggleClass);
      return true;
    }
  }

  return false;
}

/**
 * Rerender proxified changes to the specified link element
 *
 * @param {HTMLElement} el
 * @param {string} destinationAttr Element link destination attribute name
 * @returns {void}
 */
function updateProxifiedElement(el, destinationAttr) {
  if (!(el instanceof HTMLElement)) {
    error(`${el} is not an HTMLElement`);
    return;
  }

  if (el.hasAttribute(ATTR_PROXIFIED_DENIED)) {
    return; // element has been proxified but denied
  }

  if (!el[destinationAttr]) {
    error(`Invalid attribute ${destinationAttr}`);
    return;
  }

  if (!isElementProxified(el)) {
    error(`Anchor [${destinationAttr}=${el[destinationAttr]}] is not proxified`);
    return;
  }

  // Check DOM modifier state
  // Instead of using internal state, follow what the rendered DOM has
  const isBonafide = document.body.classList.contains(CLASS_BODY_BONAFIDE);
  // attempt to use Farside automatically if direct site is not found
  const isFarside = document.body.classList.contains(CLASS_BODY_FARSIDE) || !el.hasAttribute(ATTR_PROXIFIED_SITE);

  // Reset active states before re-applying as necessary
  el.removeAttribute(ATTR_IS_FARSIDE);
  el.classList.remove(CLASS_PROXIFIED_LIVE);

  // Update link destination
  // The original, bonafide link takes precedence
  if (isBonafide && el.hasAttribute(ATTR_BONAFIDE_SITE)) {
    if (el[destinationAttr] !== el.getAttribute(ATTR_BONAFIDE_SITE))
      el[destinationAttr] = el.getAttribute(ATTR_BONAFIDE_SITE);
  } else if (isFarside && el.hasAttribute(ATTR_PROXIFIED_FARSIDE)) {
    if (el[destinationAttr] !== el.getAttribute(ATTR_PROXIFIED_FARSIDE))
      el[destinationAttr] = el.getAttribute(ATTR_PROXIFIED_FARSIDE);
    el.setAttribute(ATTR_IS_FARSIDE, true);
    el.classList.add(CLASS_PROXIFIED_LIVE);
  } else if (el.hasAttribute(ATTR_PROXIFIED_SITE)) {
    if (el[destinationAttr] !== el.getAttribute(ATTR_PROXIFIED_SITE))
      el[destinationAttr] = el.getAttribute(ATTR_PROXIFIED_SITE);
    el.classList.add(CLASS_PROXIFIED_LIVE);
  }
}

/**
 * Rerender state to all proxified anchor elements in the document
 *
 * @param {boolean} doHoveredOnly Only render proxified elements that are being hovered
 * @returns {void}
 */
function updateAllProxifiedAnchors(doHoveredOnly) {
  const proxifiedEls = document.querySelectorAll(
    `a.${CLASS_PROXIFIED}${doHoveredOnly ? ':is(:hover, :focus-within)' : ''}`
  );
  for (const proxifiedEl of proxifiedEls) {
    updateProxifiedElement(proxifiedEl, 'href');
  }
}

/**
 * Add proxified link styles to indicate functionality and readiness
 *
 * Highlight proxied links yellow
 * Highlight Farside-redirected links green
 */
// TODO: Manually apply inline element styles to avoid style-src CSP
const isHoverOnly = true; // toggle styling on when hovering on link
const _important = true ? '!important' : ''; // toggle overriding page styles as much as possible
// Style anchor element and children under anchor element
const selectorAnchor = isHoverOnly
  ? `a.${CLASS_PROXIFIED_LIVE}:is(:hover, :focus-within)`
  : `a.${CLASS_PROXIFIED_LIVE}`;
// add additional styles at higher specificity to child containers
const selectorChildren = ':is(p, span, h1, h2, h3, h4, h5, h6, label, div, button)';
addPageStyle(
  `${selectorAnchor} { \
    color: black ${_important}; \
    background-color: yellow ${_important}; \
    text-shadow: none ${_important}; \
    \
    font-style: oblique ${_important}; \
    font-weight: bold ${_important}; \
    \
    ${selectorChildren} { \
      color: yellow ${_important}; \
      background-color: black ${_important}; \
      text-shadow: none ${_important}; \
      \
      font-style: oblique ${_important}; \
      font-weight: bold ${_important}; \
    } \
    :is(img) { \
      filter: sepia(1) hue-rotate(20deg) contrast(1.25) brightness(1.25); ${_important}; \
    }\
  }`
);
// Add optional highlighter padding at lowest specificity :where()
addPageStyle(
  `:where(${selectorAnchor}) { \
    // padding: 0 0.3em; \
    \
    ${selectorChildren} { \
      padding: 0 0.3em; \
    } \
  }`
);
// Change colors when links redirect through Farside
addPageStyle(
  `${selectorAnchor}[${ATTR_IS_FARSIDE}="true"] { \
    color: yellowgreen ${_important}; \
    background-color: black ${_important}; \
    \
    ${selectorChildren} { \
      color: black ${_important}; \
      background-color: yellowgreen ${_important}; \
    } \
    :is(img) { \
      filter: invert(1) sepia(1) hue-rotate(45deg) contrast(1.25) brightness(1.25) ${_important}; \
    } \
  }`
);
// Highlight all iframes when frame bite is enabled
addPageStyle(
  `body.proxi-bite.proxi-framebite { \
    iframe { \
      border: yellow solid 0.3em ${_important}; \
    } \
    iframe[${ATTR_IS_FARSIDE}="true"]  { \
      border-color: yellowgreen ${_important}; \
    } \
  }`
);

/**
 * Insert a page-level style
 * Adds a document stylesheet to write to when needed
 *
 * @param {string} css
 * @returns {void}
 */
function addPageStyle(css) {
  const style =
    document.getElementById(STYLE_HIGHLIGHT_ID) ||
    (function () {
      const style = document.createElement('style');
      style.id = STYLE_HIGHLIGHT_ID;
      document.head.appendChild(style);
      return style;
    })();

  const sheet = style.sheet;
  try {
    sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length);
  } catch (ex) {
    // Likely stylesheet is null from failing to add to DOM
    // Continue on, even without styling
    warn(`Failed to apply style: ${ex}`);
  }
}

/**
 * Check given link url and text against provided exclusion rule
 *
 * @param {URL} url
 * @param {string} text
 * @param {Exclusion} rule
 * @returns {string?} First found exclusion reason, null if false
 */
function isLinkExcludedByRule(url, text, rule) {
  if (!rule) {
    error(`Invalid flattened exclusion rule for ${url}`);
    return 'Invalid flatted exclusion rule';
  }

  // Return reason if excluding the current page that this link is on
  // Check first to short circuit link processing on pages proxifying is excluded
  // Results are memoized by page after being processed once, manually invalidate `_excludedOn` if needed
  const currentDirection = `${window.location.href}=>${url.host}`;
  if (!!_excludedOn[currentDirection]) {
    return _excludedOn[currentDirection];
  }
  if (!!rule.excludeOn && _excludedOn[currentDirection] === undefined) {
    /** @type {URL | null} */
    let currentUrl = null;
    try {
      currentUrl = new URL(currentDirection);
    } catch (ex) {
      // unexpected failure on parsing current URL, error and continue
      error(`Error parsing page URL: ${ex}`);
    }

    if (!!rule.excludeOn.matchingPath) {
      for (const m of rule.excludeOn.matchingPath) {
        if (!!currentUrl.pathname.match(m)) {
          _excludedOn[currentDirection] = `excludeOn.matchingPath[${m}]`;
        }
      }
    }

    if (!!rule.excludeOn.matchingHost) {
      for (const m of rule.excludeOn.matchingHost) {
        if (!!currentUrl.host.match(m)) {
          _excludedOn[currentDirection] = `excludeOn.matchingHost[${m}]`;
        }
      }
    }

    if (!!rule.excludeOn.matchingUrl) {
      for (const m of rule.excludeOn.matchingUrl) {
        if (!!currentUrl.href.match(m)) {
          _excludedOn[currentDirection] = `excludeOn.matchingUrl[${m}]`;
        }
      }
    }

    if (!!rule.excludeOn.matchingBody) {
      for (const m of rule.excludeOn.matchingBody) {
        if (document.getElementsByTagName('body')[0].innerHTML.match(m)) {
          _excludedOn[currentDirection] = `excludeOn.matchingBody[${m}]`;
        }
      }
    }

    if (!!rule.excludeOn.matchingHead) {
      // clean before searching
      // if the userscript extension mounts to head, may false positive on the userscript itself
      if (_documentHeadCleaned === undefined) {
        const documentHeadTemp = document.createElement('head');
        documentHeadTemp.innerHTML = document.getElementsByTagName('head')[0]?.innerHTML;
        let s;
        for (const scriptNodes = documentHeadTemp.getElementsByTagName('script'); (s = scriptNodes[0]); ) {
          // cut down scriptNodes queue
          s.parentNode.removeChild(s);
        }
        _documentHeadCleaned = documentHeadTemp.innerHTML;
      }

      for (const m of rule.excludeOn.matchingHead) {
        if (_documentHeadCleaned.match(m)) {
          _excludedOn[currentDirection] = `excludeOn.matchingHead[${m}]`;
        }
      }
    }

    // process this page url once even if no matches found for this page
    // return any non-nullish match found, or continue to find other link exclusions
    if (_excludedOn[currentDirection] === undefined) {
      _excludedOn[currentDirection] = null;
    } else if (!!_excludedOn[currentDirection]) {
      return _excludedOn[currentDirection];
    }
  }

  // Return reason if excluding this link
  if (!!rule.excludeLinks) {
    if (!!rule.excludeLinks.matchingPath) {
      for (const m of rule.excludeLinks.matchingPath) {
        if (!!url.pathname.match(m)) return `excludeLinks.matchingPath[${m}]`;
      }
    }

    if (!!rule.excludeLinks.matchingHost) {
      for (const m of rule.excludeLinks.matchingHost) {
        if (!!url.host.match(m)) return `excludeLinks.matchingHost[${m}]`;
      }
    }

    if (!!rule.excludeLinks.matchingUrl) {
      for (const m of rule.excludeLinks.matchingUrl) {
        if (!!url.href.match(m)) return `excludeLinks.matchingUrl[${m}]`;
      }
    }

    if (!!rule.excludeLinks.matchingText) {
      for (const m of rule.excludeLinks.matchingText) {
        if (!!text.match(m)) return `excludeLinks.matchingText[${m}]`;
      }
    }
  }
}
const _excludedOn = {}; // memoized excludeOn results
let _documentHeadCleaned; // memoized cleaned <head>

/**
 * Check if element has been fully processed already
 * @param {HTMLElement} el
 * @returns {boolean}
 */
function isElementProxified(el) {
  return (
    el?.hasAttribute(ATTR_PROXIFIED_SITE) ||
    el?.hasAttribute(ATTR_PROXIFIED_FARSIDE) ||
    el?.hasAttribute(ATTR_PROXIFIED_DENIED)
  );
}

/**
 * Returns the dictionary entry for the specified host, agnostic to WWW
 * Take explicit host match if found, but optionally try searching for SLD+TLD only (strip subdomains)
 *
 * @param {Object<string, T?>} dictionary
 * @param {string} host
 * @param {boolean} allowFuzzy
 * @returns {T?}
 */
function getByHost(dictionary, host, allowFuzzy) {
  if (typeof dictionary !== 'object') {
    error('Invalid dictionary used to look up host');
    return null;
  }

  // Find the explicit entry for the host key
  const explicit = dictionary[host];
  if (!!explicit) {
    return explicit;
  }

  // Strip WWW and try again
  const agnosticWWW = dictionary[host.replace(/^www\./, '')];
  if (!!agnosticWWW) {
    return agnosticWWW;
  }

  // Non-WWW subdomains in hostname may be causing misses
  // Too many TLDs to handle easily, so allow fuzzy matching on SLD+TLD if enabled
  // Search for dictionary keys matching to the end of host and try the first hit
  if (allowFuzzy) {
    // try any dictionary key that has opted into fuzzy matching
    const candidateHostKey = Object.keys(dictionary)
      .filter(hostKey => !!dictionary[hostKey].allowFuzzy)
      .find(hostKey => {
        // try to fit the key to the end of the matching host
        const SLDTLDRegex = new RegExp(escapeRegExp(hostKey) + '$');
        return SLDTLDRegex.test(host);
      });

    const agnosticSubdomain = dictionary[candidateHostKey];
    if (!!agnosticSubdomain) {
      return agnosticSubdomain;
    }
  }

  return null;
}

/**
 * Escape regex special characters in a string
 * TC39 X-standard
 *
 * @param {string} string
 * @returns {string}
 */
function escapeRegExp(string) {
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
 * Print to console as error
 *
 * @param  {...any} data
 */
function error(...data) {
  if (DEBUG) {
    console.error(...data);
  }
}

/**
 * Print to console as warning
 *
 * @param  {...any} data
 */
function warn(...data) {
  if (DEBUG) {
    console.warn(...data);
  }
}