« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js6
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js5
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js8
-rw-r--r--src/content/dependencies/generateContributionList.js1
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js2
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js5
-rw-r--r--src/content/dependencies/generateReleaseInfoContributionsLine.js1
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js5
-rw-r--r--src/content/dependencies/linkContribution.js92
-rw-r--r--src/content/dependencies/linkExternal.js148
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js71
-rw-r--r--src/content/dependencies/linkExternalFlash.js41
-rw-r--r--src/data/language.js14
-rw-r--r--src/data/things/language.js40
-rw-r--r--src/static/client3.js727
-rw-r--r--src/static/site5.css70
-rw-r--r--src/strings-default.yaml42
-rw-r--r--src/util/external-links.js679
18 files changed, 1630 insertions, 327 deletions
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index d6405283..dd5baab9 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -94,7 +94,11 @@ export default {
             links:
               language.formatDisjunctionList(
                 relations.externalLinks
-                  .map(link => link.slot('mode', 'album'))),
+                  .map(link =>
+                    link.slots({
+                      context: 'album',
+                      style: 'normal',
+                    }))),
           })),
     ]);
   },
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
index 331ddaba..f3705450 100644
--- a/src/content/dependencies/generateAlbumSidebarGroupBox.js
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -66,7 +66,10 @@ export default {
       !empty(relations.externalLinks) &&
         html.tag('p',
           language.$('releaseInfo.visitOn', {
-            links: language.formatDisjunctionList(relations.externalLinks),
+            links:
+              language.formatDisjunctionList(
+                relations.externalLinks
+                  .map(link => link.slot('context', 'group'))),
           })),
 
       slots.mode === 'album' &&
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index 03bc0af5..be9f9b86 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -161,7 +161,13 @@ export default {
           sec.visit &&
             html.tag('p',
               language.$('releaseInfo.visitOn', {
-                links: language.formatDisjunctionList(sec.visit.externalLinks),
+                links:
+                  language.formatDisjunctionList(
+                    sec.visit.externalLinks.map(link =>
+                      link.slots({
+                        context: 'artist',
+                        mode: 'platform',
+                      }))),
               })),
 
           sec.artworks?.artistGalleryLink &&
diff --git a/src/content/dependencies/generateContributionList.js b/src/content/dependencies/generateContributionList.js
index 731cfba5..6401e65e 100644
--- a/src/content/dependencies/generateContributionList.js
+++ b/src/content/dependencies/generateContributionList.js
@@ -16,5 +16,6 @@ export default {
               showIcons: true,
               showContribution: true,
               preventWrapping: false,
+              iconMode: 'tooltip',
             })))),
 };
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index 09c6b37c..c60f9696 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -133,7 +133,7 @@ export default {
               links:
                 language.formatDisjunctionList(
                   relations.externalLinks
-                    .map(link => link.slot('mode', 'flash'))),
+                    .map(link => link.slot('context', 'flash'))),
             })),
 
         sec.featuredTracks && [
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
index 0583755e..05df33fb 100644
--- a/src/content/dependencies/generateGroupInfoPage.js
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -107,7 +107,10 @@ export default {
           sec.info.visitLinks &&
             html.tag('p',
               language.$('releaseInfo.visitOn', {
-                links: language.formatDisjunctionList(sec.info.visitLinks),
+                links:
+                  language.formatDisjunctionList(
+                    sec.info.visitLinks
+                      .map(link => link.slot('context', 'group'))),
               })),
 
           html.tag('blockquote',
diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js
index 1fa8dcca..2e6c4709 100644
--- a/src/content/dependencies/generateReleaseInfoContributionsLine.js
+++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js
@@ -35,6 +35,7 @@ export default {
             link.slots({
               showContribution: slots.showContribution,
               showIcons: slots.showIcons,
+              iconMode: 'tooltip',
             }))),
     });
   },
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 9a7478ca..c347dbce 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -77,7 +77,10 @@ export default {
       html.tag('p',
         (relations.externalLinks
           ? language.$('releaseInfo.listenOn', {
-              links: language.formatDisjunctionList(relations.externalLinks),
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'track'))),
             })
           : language.$('releaseInfo.listenOn.noLinks', {
               name: html.tag('i', data.name),
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index 8e42f247..790afa4f 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -1,15 +1,8 @@
 import {empty} from '#sugar';
 
 export default {
-  contentDependencies: [
-    'linkArtist',
-    'linkExternalAsIcon',
-  ],
-
-  extraDependencies: [
-    'html',
-    'language',
-  ],
+  contentDependencies: ['linkArtist', 'linkExternalAsIcon'],
+  extraDependencies: ['html', 'language'],
 
   relations(relation, contribution) {
     const relations = {};
@@ -20,7 +13,6 @@ export default {
     if (!empty(contribution.who.urls)) {
       relations.artistIcons =
         contribution.who.urls
-          .slice(0, 4)
           .map(url => relation('linkExternalAsIcon', url));
     }
 
@@ -37,37 +29,81 @@ export default {
     showContribution: {type: 'boolean', default: false},
     showIcons: {type: 'boolean', default: false},
     preventWrapping: {type: 'boolean', default: true},
+
+    iconMode: {
+      validate: v => v.is('inline', 'tooltip'),
+      default: 'inline'
+    },
   },
 
   generate(data, relations, slots, {html, language}) {
-    const hasContributionPart = !!(slots.showContribution && data.what);
-    const hasExternalPart = !!(slots.showIcons && relations.artistIcons);
-
-    const externalLinks = hasExternalPart &&
-      html.tag('span',
-        {[html.noEdgeWhitespace]: true, class: 'icons'},
-        language.formatUnitList(relations.artistIcons));
+    const hasContribution = !!(slots.showContribution && data.what);
+    const hasExternalIcons = !!(slots.showIcons && relations.artistIcons);
 
     const parts = ['misc.artistLink'];
     const options = {artist: relations.artistLink};
 
-    if (hasContributionPart) {
+    if (hasContribution) {
       parts.push('withContribution');
       options.contrib = data.what;
     }
 
-    if (hasExternalPart) {
+    if (hasExternalIcons && slots.iconMode === 'inline') {
       parts.push('withExternalLinks');
-      options.links = externalLinks;
+      options.links =
+        html.tag('span',
+          {
+            [html.noEdgeWhitespace]: true,
+            class: ['icons', 'icons-inline'],
+          },
+          language.formatUnitList(
+            relations.artistIcons
+              .slice(0, 4)
+              .map(icon => icon.slot('context', 'artist'))));
     }
 
-    const content = language.formatString(parts.join('.'), options);
+    let content = language.formatString(parts.join('.'), options);
 
-    return (
-      (parts.length > 1 && slots.preventWrapping
-        ? html.tag('span',
-            {[html.noEdgeWhitespace]: true, class: 'nowrap'},
-            content)
-        : content));
-    },
+    if (hasExternalIcons && slots.iconMode === 'tooltip') {
+      content = [
+        content,
+        html.tag('span',
+          {
+            [html.noEdgeWhitespace]: true,
+            class: ['icons', 'icons-tooltip'],
+            inert: true,
+          },
+          html.tag('span',
+            {
+              [html.noEdgeWhitespace]: true,
+              [html.joinChildren]: '',
+              class: 'icons-tooltip-content',
+            },
+            relations.artistIcons
+              .map(icon => icon.slots({context: 'artist', withText: true})))),
+      ];
+    }
+
+    if (hasContribution || hasExternalIcons) {
+      content =
+        html.tag('span', {
+          [html.noEdgeWhitespace]: true,
+          [html.joinChildren]: '',
+
+          class: [
+            'contribution',
+
+            hasExternalIcons &&
+            slots.iconMode === 'tooltip' &&
+              'has-tooltip',
+
+            parts.length > 1 &&
+            slots.preventWrapping &&
+              'nowrap',
+          ],
+        }, content);
+    }
+
+    return content;
+  }
 };
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index 5de612e2..0a079614 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -1,140 +1,30 @@
-// TODO: Define these as extra dependencies and pass them somewhere
-const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
-const MASTODON_DOMAINS = ['types.pl'];
+import {isExternalLinkContext, isExternalLinkStyle} from '#external-links';
 
 export default {
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl: ({wikiInfo}) => ({wikiInfo}),
-
-  data(sprawl, url) {
-    const data = {url};
-
-    const {canonicalBase} = sprawl.wikiInfo;
-    if (canonicalBase) {
-      const {hostname: canonicalDomain} = new URL(canonicalBase);
-      Object.assign(data, {canonicalDomain});
-    }
-
-    return data;
-  },
+  data: (url) => ({url}),
 
   slots: {
-    mode: {
-      validate: v => v.is('generic', 'album', 'flash'),
+    style: {
+      // This awkward syntax is because the slot descriptor validator can't
+      // differentiate between a function that returns a validator (the usual
+      // syntax) and a function that is itself a validator.
+      validate: () => isExternalLinkStyle,
+      default: 'normal',
+    },
+
+    context: {
+      validate: () => isExternalLinkContext,
       default: 'generic',
     },
   },
 
-  generate(data, slots, {html, language}) {
-    let isLocal;
-    let domain;
-    let pathname;
-
-    try {
-      const url = new URL(data.url);
-      domain = url.hostname;
-      pathname = url.pathname;
-    } catch (error) {
-      // No support for relative local URLs yet, sorry! (I.e, local URLs must
-      // be absolute relative to the domain name in order to work.)
-      isLocal = true;
-      domain = null;
-      pathname = null;
-    }
-
-    // isLocal also applies for URLs which match the 'Canonical Base' under
-    // wiki-info.yaml, if present.
-    if (data.canonicalDomain && domain === data.canonicalDomain) {
-      isLocal = true;
-    }
-
-    const link = html.tag('a',
-      {
-        href: data.url,
-        class: 'nowrap',
-      },
-
-      // truly unhinged indentation here
-      isLocal
-        ? language.$('misc.external.local')
-
-    : domain.includes('bandcamp.com')
-        ? language.$('misc.external.bandcamp')
-
-    : BANDCAMP_DOMAINS.includes(domain)
-        ? language.$('misc.external.bandcamp.domain', {domain})
-
-    : MASTODON_DOMAINS.includes(domain)
-        ? language.$('misc.external.mastodon.domain', {domain})
-
-    : domain.includes('youtu')
-        ? slots.mode === 'album'
-          ? data.url.includes('list=')
-            ? language.$('misc.external.youtube.playlist')
-            : language.$('misc.external.youtube.fullAlbum')
-          : language.$('misc.external.youtube')
-
-    : domain.includes('soundcloud')
-        ? language.$('misc.external.soundcloud')
-
-    : domain.includes('tumblr.com')
-        ? language.$('misc.external.tumblr')
-
-    : domain.includes('twitter.com')
-        ? language.$('misc.external.twitter')
-
-    : domain.includes('deviantart.com')
-        ? language.$('misc.external.deviantart')
-
-    : domain.includes('wikipedia.org')
-        ? language.$('misc.external.wikipedia')
-
-    : domain.includes('poetryfoundation.org')
-        ? language.$('misc.external.poetryFoundation')
-
-    : domain.includes('instagram.com')
-        ? language.$('misc.external.instagram')
-
-    : domain.includes('patreon.com')
-        ? language.$('misc.external.patreon')
-
-    : domain.includes('spotify.com')
-        ? language.$('misc.external.spotify')
-
-    : domain.includes('newgrounds.com')
-        ? language.$('misc.external.newgrounds')
-
-        : domain);
-
-    switch (slots.mode) {
-      case 'flash': {
-        const wrap = content =>
-          html.tag('span', {class: 'nowrap'}, content);
-
-        if (domain.includes('homestuck.com')) {
-          const match = pathname.match(/\/story\/(.*)\/?/);
-          if (match) {
-            if (isNaN(Number(match[1]))) {
-              return wrap(language.$('misc.external.flash.homestuck.secret', {link}));
-            } else {
-              return wrap(language.$('misc.external.flash.homestuck.page', {
-                link,
-                page: match[1],
-              }));
-            }
-          }
-        } else if (domain.includes('bgreco.net')) {
-          return wrap(language.$('misc.external.flash.bgreco', {link}));
-        } else if (domain.includes('youtu')) {
-          return wrap(language.$('misc.external.flash.youtube', {link}));
-        }
-
-        return link;
-      }
-
-      default:
-        return link;
-    }
-  }
+  generate: (data, slots, {html, language}) =>
+    html.tag('a',
+      {href: data.url, class: 'nowrap'},
+      language.formatExternalLink(data.url, {
+        style: slots.style,
+        context: slots.context,
+      })),
 };
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
index cd168992..357c835c 100644
--- a/src/content/dependencies/linkExternalAsIcon.js
+++ b/src/content/dependencies/linkExternalAsIcon.js
@@ -1,46 +1,45 @@
-// TODO: Define these as extra dependencies and pass them somewhere
-const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
-const MASTODON_DOMAINS = ['types.pl'];
+import {isExternalLinkContext} from '#external-links';
 
 export default {
   extraDependencies: ['html', 'language', 'to'],
 
-  data(url) {
-    return {url};
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      // This awkward syntax is because the slot descriptor validator can't
+      // differentiate between a function that returns a validator (the usual
+      // syntax) and a function that is itself a validator.
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+
+    withText: {type: 'boolean'},
   },
 
-  generate(data, {html, language, to}) {
-    const domain = new URL(data.url).hostname;
-    const [id, msg] = (
-      domain.includes('bandcamp.com')
-        ? ['bandcamp', language.$('misc.external.bandcamp')]
-      : BANDCAMP_DOMAINS.includes(domain)
-        ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})]
-      : MASTODON_DOMAINS.includes(domain)
-        ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})]
-      : domain.includes('youtu')
-        ? ['youtube', language.$('misc.external.youtube')]
-      : domain.includes('soundcloud')
-        ? ['soundcloud', language.$('misc.external.soundcloud')]
-      : domain.includes('tumblr.com')
-        ? ['tumblr', language.$('misc.external.tumblr')]
-      : domain.includes('twitter.com')
-        ? ['twitter', language.$('misc.external.twitter')]
-      : domain.includes('deviantart.com')
-        ? ['deviantart', language.$('misc.external.deviantart')]
-      : domain.includes('instagram.com')
-        ? ['instagram', language.$('misc.external.bandcamp')]
-      : domain.includes('newgrounds.com')
-        ? ['newgrounds', language.$('misc.external.newgrounds')]
-        : ['globe', language.$('misc.external.domain', {domain})]);
+  generate(data, slots, {html, language, to}) {
+    const format = style =>
+      language.formatExternalLink(data.url, {style, context: slots.context});
+
+    const normalText = format('normal');
+    const compactText = format('compact');
+    const iconId = format('icon-id');
 
     return html.tag('a',
-      {href: data.url, class: 'icon'},
-      html.tag('svg', [
-        html.tag('title', msg),
-        html.tag('use', {
-          href: to('shared.staticIcon', id),
-        }),
-      ]));
+      {href: data.url, class: ['icon', slots.withText && 'has-text']},
+      [
+        html.tag('svg', [
+          !slots.withText &&
+            html.tag('title', normalText),
+
+          html.tag('use', {
+            href: to('shared.staticIcon', iconId),
+          }),
+        ]),
+
+        slots.withText &&
+          html.tag('span', {class: 'icon-text'},
+            compactText ?? normalText),
+      ]);
   },
 };
diff --git a/src/content/dependencies/linkExternalFlash.js b/src/content/dependencies/linkExternalFlash.js
deleted file mode 100644
index 65158ff8..00000000
--- a/src/content/dependencies/linkExternalFlash.js
+++ /dev/null
@@ -1,41 +0,0 @@
-// Note: This function is seriously hard-coded for HSMusic, with custom
-// presentation of links to Homestuck flashes hosted various places.
-
-export default {
-  contentDependencies: ['linkExternal'],
-  extraDependencies: ['html', 'language'],
-
-  relations(relation, url) {
-    return {
-      link: relation('linkExternal', url),
-    };
-  },
-
-  data(url, flash) {
-    return {
-      url,
-      page: flash.page,
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    const {link} = relations;
-    const {url, page} = data;
-
-    return html.tag('span',
-      {class: 'nowrap'},
-
-      url.includes('homestuck.com')
-        ? isNaN(Number(page))
-          ? language.$('misc.external.flash.homestuck.secret', {link})
-          : language.$('misc.external.flash.homestuck.page', {link, page})
-
-    : url.includes('bgreco.net')
-        ? language.$('misc.external.flash.bgreco', {link})
-
-    : url.includes('youtu')
-        ? language.$('misc.external.flash.youtube', {link})
-
-        : link);
-  },
-};
diff --git a/src/data/language.js b/src/data/language.js
index 3fc14da7..6f774f27 100644
--- a/src/data/language.js
+++ b/src/data/language.js
@@ -7,15 +7,11 @@ import chokidar from 'chokidar';
 import he from 'he'; // It stands for "HTML Entities", apparently. Cursed.
 import yaml from 'js-yaml';
 
-import T from '#things';
+import {externalLinkSpec} from '#external-links';
 import {colors, logWarn} from '#cli';
-
-import {
-  annotateError,
-  annotateErrorWithFile,
-  showAggregate,
-  withAggregate,
-} from '#sugar';
+import {annotateError, annotateErrorWithFile, showAggregate, withAggregate}
+  from '#sugar';
+import T from '#things';
 
 const {Language} = T;
 
@@ -114,6 +110,8 @@ export function initializeLanguageObject() {
   language.escapeHTML = string =>
     he.encode(string, {useNamedReferences: true});
 
+  language.externalLinkSpec = externalLinkSpec;
+
   return language;
 }
 
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 646eb6d1..70481299 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,5 +1,13 @@
-import {Tag} from '#html';
 import {isLanguageCode} from '#validators';
+import {Tag} from '#html';
+
+import {
+  getExternalLinkStringOfStyleFromDescriptors,
+  getExternalLinkStringsFromDescriptors,
+  isExternalLinkContext,
+  isExternalLinkSpec,
+  isExternalLinkStyle,
+} from '#external-links';
 
 import {
   externalFunction,
@@ -72,6 +80,13 @@ export class Language extends Thing {
       update: {validate: (t) => typeof t === 'object'},
     },
 
+    // List of descriptors for providing to external link utilities when using
+    // language.formatExternalLink - refer to util/external-links.js for info.
+    externalLinkSpec: {
+      flags: {update: true, expose: true},
+      update: {validate: isExternalLinkSpec},
+    },
+
     // Update only
 
     escapeHTML: externalFunction(),
@@ -299,6 +314,29 @@ export class Language extends Thing {
       : duration;
   }
 
+  formatExternalLink(url, {
+    style = 'normal',
+    context = 'generic',
+  } = {}) {
+    if (!this.externalLinkSpec) {
+      throw new TypeError(`externalLinkSpec unavailable`);
+    }
+
+    isExternalLinkContext(context);
+
+    if (style === 'all') {
+      return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, {
+        language: this,
+        context,
+      });
+    }
+
+    return getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
+      language: this,
+      context,
+    });
+  }
+
   formatIndex(value) {
     this.assertIntlAvailable('intl_pluralOrdinal');
     return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
diff --git a/src/static/client3.js b/src/static/client3.js
index 8372a268..9db9fc6c 100644
--- a/src/static/client3.js
+++ b/src/static/client3.js
@@ -81,6 +81,31 @@ function fetchData(type, directory) {
   );
 }
 
+function dispatchInternalEvent(event, eventName, ...args) {
+  const [infoName] =
+    Object.entries(clientInfo)
+      .find(pair => pair[1].event === event);
+
+  if (!infoName) {
+    throw new Error(`Expected event to be stored on clientInfo`);
+  }
+
+  const {[eventName]: listeners} = event;
+
+  if (!listeners) {
+    throw new Error(`Event name "${eventName}" isn't stored on ${infoName}.event`);
+  }
+
+  for (const listener of listeners) {
+    try {
+      listener(...args);
+    } catch (error) {
+      console.warn(`Uncaught error in listener for ${infoName}.${eventName}`);
+      console.debug(error);
+    }
+  }
+}
+
 // JS-based links -----------------------------------------
 
 const scriptedLinkInfo = clientInfo.scriptedLinkInfo = {
@@ -329,17 +354,496 @@ if (
     });
 }
 
-// Data & info card ---------------------------------------
+// Tooltip-style hover (infrastructure) -------------------
+
+const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = {
+  settings: {
+    // Hovering has two speed settings. The normal setting is used by default,
+    // and once a tooltip is displayed as a result of hover, the entire tooltip
+    // system will enter a "fast hover mode" - hovering will activate tooltips
+    // sooner. "Fast hover mode" is disabled after a sustained duration of not
+    // hovering over any hoverables; it's meant only to accelerate switching
+    // tooltips while still deciding, or getting a quick overview across more
+    // than one tooltip.
+    normalHoverInfoDelay: 400,
+    fastHoveringInfoDelay: 150,
+    endFastHoveringDelay: 500,
+
+    // Focusing has a single speed setting, which is how long it will take to
+    // enter a functional "focus mode" (though it's not actually implemented
+    // in terms of this state). As soon as "focus mode" is entered, the tooltip
+    // for the current hoverable is displayed, and focusing another hoverable
+    // will cause the current tooltip to be swapped for that one immediately.
+    // "Focus mode" ends as soon as anything apart from a tooltip or hoverable
+    // is focused, and it will be necessary to wait on this delay again.
+    focusInfoDelay: 750,
+
+    hideTooltipDelay: 500,
+  },
 
-/*
-const NORMAL_HOVER_INFO_DELAY = 750;
-const FAST_HOVER_INFO_DELAY = 250;
-const END_FAST_HOVER_DELAY = 500;
-const HIDE_HOVER_DELAY = 250;
+  state: {
+    // These maps store a record for each registered element and related state
+    // and registration info, if applicable.
+    registeredTooltips: new Map(),
+    registeredHoverables: new Map(),
+
+    // These are common across all tooltips, rather than stored individually,
+    // based on the principles that 1) only a single tooltip can be displayed
+    // at once, and 2) likewise, only a single hoverable can be hovered,
+    // focused, or otherwise active at once.
+    hoverTimeout: null,
+    focusTimeout: null,
+    touchTimeout: null,
+    hideTimeout: null,
+    currentlyShownTooltip: null,
+    currentlyActiveHoverable: null,
+    tooltipWasJustHidden: false,
+    hoverableWasRecentlyTouched: false,
+
+    // Fast hovering is a global mode which is activated as soon as any tooltip
+    // is displayed and turns off after a delay of no hoverables being hovered.
+    // Note that fast hovering may be turned off while hovering a tooltip, but
+    // it will never be turned off while idling over a hoverable.
+    fastHovering: false,
+    endFastHoveringTimeout: false,
+
+    // These track the identifiers of current touches and a record of current
+    // identifiers that are "banished" by scrolling - that is, touches which
+    // existed while the page scrolled and were probably responsible for that
+    // scrolling. This is a bit loose (we can't actually tell which touches
+    // caused the page to scroll) but it's intended to keep scrolling the page
+    // from causing the current tooltip to be hidden.
+    currentTouchIdentifiers: new Set(),
+    touchIdentifiersBanishedByScrolling: new Set(),
+  },
+
+  event: {
+    whenTooltipShouldBeShown: [],
+    whenTooltipShouldBeHidden: [],
+  },
+};
+
+// Adds DOM event listeners, so must be called during addPageListeners step.
+function registerTooltipElement(tooltip) {
+  const {state} = hoverableTooltipInfo;
+
+  if (!tooltip)
+    throw new Error(`Expected tooltip`);
+
+  if (state.registeredTooltips.has(tooltip))
+    throw new Error(`This tooltip is already registered`);
+
+  // No state or registration info here.
+  state.registeredTooltips.set(tooltip, {});
+
+  tooltip.addEventListener('mouseenter', () => {
+    handleTooltipMouseEntered(tooltip);
+  });
+
+  tooltip.addEventListener('mouseleave', () => {
+    handleTooltipMouseLeft(tooltip);
+  });
+
+  tooltip.addEventListener('focusin', event => {
+    handleTooltipReceivedFocus(tooltip, event.relatedTarget);
+  });
+
+  tooltip.addEventListener('focusout', event => {
+    // This event gets activated for tabbing *between* links inside the
+    // tooltip, which is no good and certainly doesn't represent the focus
+    // leaving the tooltip.
+    if (currentlyShownTooltipHasFocus(event.relatedTarget)) return;
+
+    handleTooltipLostFocus(tooltip, event.relatedTarget);
+  });
+}
+
+// Adds DOM event listeners, so must be called during addPageListeners step.
+function registerTooltipHoverableElement(hoverable, tooltip) {
+  const {state} = hoverableTooltipInfo;
+
+  if (!hoverable || !tooltip)
+    if (hoverable)
+      throw new Error(`Expected hoverable and tooltip, got only hoverable`);
+    else
+      throw new Error(`Expected hoverable and tooltip, got neither`);
+
+  if (!state.registeredTooltips.has(tooltip))
+    throw new Error(`Register tooltip before registering hoverable`);
+
+  if (state.registeredHoverables.has(hoverable))
+    throw new Error(`This hoverable is already registered`);
+
+  state.registeredHoverables.set(hoverable, {tooltip});
+
+  hoverable.addEventListener('mouseenter', () => {
+    handleTooltipHoverableMouseEntered(hoverable);
+  });
+
+  hoverable.addEventListener('mouseleave', () => {
+    handleTooltipHoverableMouseLeft(hoverable);
+  });
+
+  hoverable.addEventListener('focusin', event => {
+    handleTooltipHoverableReceivedFocus(hoverable, event);
+  });
+
+  hoverable.addEventListener('focusout', event => {
+    handleTooltipHoverableLostFocus(hoverable, event);
+  });
+
+  hoverable.addEventListener('touchend', event => {
+    handleTooltipHoverableTouchEnded(hoverable, event);
+  });
+
+  hoverable.addEventListener('click', event => {
+    handleTooltipHoverableClicked(hoverable, event);
+  });
+}
+
+function handleTooltipMouseEntered(tooltip) {
+  const {state} = hoverableTooltipInfo;
+
+  if (state.currentlyShownTooltip !== tooltip) return;
+
+  // Don't time out the current tooltip while hovering it.
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipMouseLeft(tooltip) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  if (state.currentlyShownTooltip !== tooltip) return;
+
+  // Start timing out the current tooltip when it's left. This could be
+  // canceled by mousing over a hoverable, or back over the tooltip again.
+  if (!state.hideTimeout) {
+    state.hideTimeout =
+      setTimeout(() => {
+        state.hideTimeout = null;
+        hideCurrentlyShownTooltip();
+      }, settings.hideTooltipDelay);
+  }
+}
+
+function handleTooltipReceivedFocus(tooltip) {
+  const {state} = hoverableTooltipInfo;
+
+  // Cancel the tooltip-hiding timeout if it exists. The tooltip will never
+  // be hidden while it contains the focus anyway, but this ensures the timeout
+  // will be suitably reset when the tooltip loses focus.
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipLostFocus(tooltip) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  // Hide the current tooltip right away when it loses focus.
+  hideCurrentlyShownTooltip();
+}
+
+function handleTooltipHoverableMouseEntered(hoverable) {
+  const {event, settings, state} = hoverableTooltipInfo;
+
+  const hoverTimeoutDelay =
+    (state.fastHovering
+      ? settings.fastHoveringInfoDelay
+      : settings.normalHoverInfoDelay);
+
+  // Start a timer to show the corresponding tooltip, with the delay depending
+  // on whether fast hovering or not. This could be canceled by mousing out of
+  // the hoverable.
+  state.hoverTimeout =
+    setTimeout(() => {
+      state.hoverTimeout = null;
+      state.fastHovering = true;
+      showTooltipFromHoverable(hoverable);
+    }, hoverTimeoutDelay);
+
+  // Don't stop fast hovering while over any hoverable.
+  if (state.endFastHoveringTimeout) {
+    clearTimeout(state.endFastHoveringTimeout);
+    state.endFastHoveringTimeout = null;
+  }
+
+  // Don't time out the current tooltip while over any hoverable.
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipHoverableMouseLeft(hoverable) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  // Don't show a tooltip when not over a hoverable!
+  if (state.hoverTimeout) {
+    clearTimeout(state.hoverTimeout);
+    state.hoverTimeout = null;
+  }
+
+  // Start timing out fast hovering (if active) when not over a hoverable.
+  // This will only be canceled by mousing over another hoverable.
+  if (state.fastHovering && !state.endFastHoveringTimeout) {
+    state.endFastHoveringTimeout =
+      setTimeout(() => {
+        state.endFastHoveringTimeout = null;
+        state.fastHovering = false;
+      }, settings.endFastHoveringDelay);
+  }
+
+  // Start timing out the current tooltip when mousing not over a hoverable.
+  // This could be canceled by mousing over another hoverable, or over the
+  // currently shown tooltip.
+  if (state.currentlyShownTooltip && !state.hideTimeout) {
+    state.hideTimeout =
+      setTimeout(() => {
+        state.hideTimeout = null;
+        hideCurrentlyShownTooltip();
+      }, settings.hideTooltipDelay);
+  }
+}
+
+function handleTooltipHoverableReceivedFocus(hoverable) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  // By default, display the corresponding tooltip after a delay.
+
+  state.focusTimeout =
+    setTimeout(() => {
+      state.focusTimeout = null;
+      showTooltipFromHoverable(hoverable);
+    }, settings.focusInfoDelay);
+
+  // If a tooltip was just hidden - which is almost certainly a result of the
+  // focus changing - then display this tooltip immediately, canceling the
+  // above timeout.
+
+  if (state.tooltipWasJustHidden) {
+    clearTimeout(state.focusTimeout);
+    state.focusTimeout = null;
+
+    showTooltipFromHoverable(hoverable);
+  }
+}
+
+function handleTooltipHoverableLostFocus(hoverable, domEvent) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  // Don't show a tooltip from focusing a hoverable if it isn't focused
+  // anymore! If another hoverable is receiving focus, that will be evaluated
+  // and set its own focus timeout after we clear the previous one here.
+  if (state.focusTimeout) {
+    clearTimeout(state.focusTimeout);
+    state.focusTimeout = null;
+  }
+
+  // Unless focus is entering the tooltip itself, hide the tooltip immediately.
+  // This will set the tooltipWasJustHidden flag, which is detected by a newly
+  // focused hoverable, if applicable.
+  if (!currentlyShownTooltipHasFocus(domEvent.relatedTarget)) {
+    hideCurrentlyShownTooltip();
+  }
+}
+
+function handleTooltipHoverableTouchEnded(hoverable, domEvent) {
+  const {settings, state} = hoverableTooltipInfo;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  // Don't proceed if this hoverable's tooltip is already visible - in that
+  // case touching the hoverable again should behave just like a normal click.
+  if (state.currentlyShownTooltip === tooltip) return;
+
+  const touches = Array.from(domEvent.changedTouches);
+  const identifiers = touches.map(touch => touch.identifier);
+
+  // Don't process touch events that were "banished" because the page was
+  // scrolled while those touches were active, and most likely as a result of
+  // them.
+  filterMultipleArrays(touches, identifiers,
+    (_touch, identifier) =>
+      !state.touchIdentifiersBanishedByScrolling.has(identifier));
+
+  if (empty(touches)) return;
+
+  // Don't proceed if none of the (just-ended) touches ended over the
+  // hoverable.
+  const anyTouchEndedOverHoverable =
+    touches.some(touch =>
+      hoverable.contains(
+        document.elementFromPoint(touch.clientX, touch.clientY)));
+
+  if (!anyTouchEndedOverHoverable) {
+    return;
+  }
+
+  if (state.touchTimeout) {
+    clearTimeout(state.touchTimeout);
+    state.touchTimeout = null;
+  }
+
+  // Show the tooltip right away.
+  showTooltipFromHoverable(hoverable);
+
+  // Set a state, for a brief but not instantaneous period, indicating that a
+  // hoverable was recently touched. The touchend event may precede the click
+  // event by some time, and we don't want to navigate away from the page as
+  // a result of the click event which this touch precipitated.
+  state.hoverableWasRecentlyTouched = true;
+  state.touchTimeout =
+    setTimeout(() => {
+      state.hoverableWasRecentlyTouched = false;
+    }, 250);
+}
+
+function handleTooltipHoverableClicked(hoverable, domEvent) {
+  const {state} = hoverableTooltipInfo;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  // Don't navigate away from the page if the this hoverable was recently
+  // touched (and had its tooltip activated). That flag won't be set if its
+  // tooltip was already open before the touch.
+  if (
+    state.currentlyActiveHoverable === hoverable &&
+    state.hoverableWasRecentlyTouched
+  ) {
+    event.preventDefault();
+  }
+}
+
+function currentlyShownTooltipHasFocus(focusElement = document.activeElement) {
+  const {state} = hoverableTooltipInfo;
+
+  const {
+    currentlyShownTooltip: tooltip,
+    currentlyActiveHoverable: hoverable,
+  } = state;
+
+  // If there's no tooltip, it can't possibly have focus.
+  if (!tooltip) return false;
+
+  // If the tooltip literally contains (or is) the focused element, then that's
+  // the principle condition we're looking for.
+  if (tooltip.contains(focusElement)) return true;
+
+  // If the hoverable *which opened the tooltip* is focused, then that also
+  // represents the tooltip being focused (in its currently shown state).
+  if (hoverable.contains(focusElement)) return true;
+
+  return false;
+}
+
+function hideCurrentlyShownTooltip() {
+  const {event, state} = hoverableTooltipInfo;
+  const {currentlyShownTooltip: tooltip} = state;
+
+  // If there was no tooltip to begin with, we're functionally in the desired
+  // state already, so return true.
+  if (!tooltip) return true;
+
+  // Never hide the tooltip if it's focused.
+  if (currentlyShownTooltipHasFocus()) return false;
+
+  state.currentlyShownTooltip = null;
+  state.currentlyActiveHoverable = null;
 
-let fastHover = false;
-let endFastHoverTimeout = null;
+  // Set this for one tick of the event cycle.
+  state.tooltipWasJustHidden = true;
+  setTimeout(() => {
+    state.tooltipWasJustHidden = false;
+  });
+
+  dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip});
+
+  return true;
+}
+
+function showTooltipFromHoverable(hoverable) {
+  const {event, state} = hoverableTooltipInfo;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  if (!hideCurrentlyShownTooltip()) return false;
+
+  state.currentlyShownTooltip = tooltip;
+  state.currentlyActiveHoverable = hoverable;
+
+  state.tooltipWasJustHidden = false;
+
+  dispatchInternalEvent(event, 'whenTooltipShouldBeShown', {hoverable, tooltip});
+
+  return true;
+}
+
+function addHoverableTooltipPageListeners() {
+  const {state} = hoverableTooltipInfo;
+
+  const getTouchIdentifiers = domEvent =>
+    Array.from(domEvent.changedTouches)
+      .map(touch => touch.identifier)
+      .filter(identifier => typeof identifier !== 'undefined');
+
+  document.body.addEventListener('touchstart', domEvent => {
+    for (const identifier of getTouchIdentifiers(domEvent)) {
+      state.currentTouchIdentifiers.add(identifier);
+    }
+  });
 
+  window.addEventListener('scroll', () => {
+    for (const identifier of state.currentTouchIdentifiers) {
+      state.touchIdentifiersBanishedByScrolling.add(identifier);
+    }
+  });
+
+  document.body.addEventListener('touchend', domEvent => {
+    setTimeout(() => {
+      for (const identifier of getTouchIdentifiers(domEvent)) {
+        state.currentTouchIdentifiers.delete(identifier);
+        state.touchIdentifiersBanishedByScrolling.delete(identifier);
+      }
+    });
+  });
+
+  document.body.addEventListener('touchend', domEvent => {
+    const hoverables = Array.from(state.registeredHoverables.keys());
+    const tooltips = Array.from(state.registeredTooltips.keys());
+
+    const touches = Array.from(domEvent.changedTouches);
+    const identifiers = touches.map(touch => touch.identifier);
+
+    // Don't process touch events that were "banished" because the page was
+    // scrolled while those touches were active, and most likely as a result of
+    // them.
+    filterMultipleArrays(touches, identifiers,
+      (_touch, identifier) =>
+        !state.touchIdentifiersBanishedByScrolling.has(identifier));
+
+    if (empty(touches)) return;
+
+    const anyTouchOverAnyHoverableOrTooltip =
+      touches.some(({clientX, clientY}) => {
+        const element = document.elementFromPoint(clientX, clientY);
+        if (hoverables.some(el => el.contains(element))) return true;
+        if (tooltips.some(el => el.contains(element))) return true;
+        return false;
+      });
+
+    if (!anyTouchOverAnyHoverableOrTooltip) {
+      hideCurrentlyShownTooltip();
+    }
+  });
+}
+
+clientSteps.addPageListeners.push(addHoverableTooltipPageListeners);
+
+// Data & info card ---------------------------------------
+
+/*
 function colorLink(a, color) {
   console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet');
   return;
@@ -505,53 +1009,6 @@ const infoCard = (() => {
   };
 })();
 
-function makeInfoCardLinkHandlers(type) {
-  let hoverTimeout = null;
-
-  return {
-    mouseenter(evt) {
-      hoverTimeout = setTimeout(
-        () => {
-          fastHover = true;
-          infoCard.show(type, evt.target);
-        },
-        fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY);
-
-      clearTimeout(endFastHoverTimeout);
-      endFastHoverTimeout = null;
-
-      infoCard.cancelHide();
-    },
-
-    mouseleave() {
-      clearTimeout(hoverTimeout);
-
-      if (fastHover && !endFastHoverTimeout) {
-        endFastHoverTimeout = setTimeout(() => {
-          endFastHoverTimeout = null;
-          fastHover = false;
-        }, END_FAST_HOVER_DELAY);
-      }
-
-      infoCard.readyHide();
-    },
-  };
-}
-
-const infoCardLinkHandlers = {
-  track: makeInfoCardLinkHandlers('track'),
-};
-
-function addInfoCardLinkHandlers(type) {
-  for (const a of document.querySelectorAll(`a[data-${type}]`)) {
-    for (const [eventName, handler] of Object.entries(
-      infoCardLinkHandlers[type]
-    )) {
-      a.addEventListener(eventName, handler);
-    }
-  }
-}
-
 // Info cards are disa8led for now since they aren't quite ready for release,
 // 8ut you can try 'em out 8y setting this localStorage flag!
 //
@@ -672,6 +1129,8 @@ function addHashLinkListeners() {
 
       processScrollingAfterHashLinkClicked();
 
+      dispatchInternalEvent(event, 'whenHashLinkClicked', {link: hashLink});
+
       for (const handler of event.whenHashLinkClicked) {
         handler({
           link: hashLink,
@@ -958,6 +1417,8 @@ clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings);
 
 // Image overlay ------------------------------------------
 
+// TODO: Update to clientSteps style.
+
 function addImageOverlayClickHandlers() {
   const container = document.getElementById('image-overlay-container');
 
@@ -1245,6 +1706,8 @@ function loadImage(imageUrl, onprogress) {
 
 // Group contributions table ------------------------------
 
+// TODO: Update to clientSteps style.
+
 const groupContributionsTableInfo =
   Array.from(document.querySelectorAll('#content dl'))
     .filter(dl => dl.querySelector('a.group-contributions-sort-button'))
@@ -1277,6 +1740,160 @@ for (const info of groupContributionsTableInfo) {
   });
 }
 
+// Artist link icon tooltips ------------------------------
+
+const externalIconTooltipInfo = clientInfo.externalIconTooltipInfo = {
+  hoverableLinks: null,
+  iconContainers: null,
+};
+
+function getExternalIconTooltipReferences() {
+  const info = externalIconTooltipInfo;
+
+  const spans =
+    Array.from(document.querySelectorAll('span.contribution.has-tooltip'));
+
+  info.hoverableLinks =
+    spans
+      .map(span => span.querySelector('a'));
+
+  info.iconContainers =
+    spans
+      .map(span => span.querySelector('span.icons-tooltip'));
+}
+
+function addExternalIconTooltipInternalListeners() {
+  const info = externalIconTooltipInfo;
+
+  hoverableTooltipInfo.event.whenTooltipShouldBeShown.push(({tooltip}) => {
+    if (!info.iconContainers.includes(tooltip)) return;
+    showExternalIconTooltip(tooltip);
+  });
+
+  hoverableTooltipInfo.event.whenTooltipShouldBeHidden.push(({tooltip}) => {
+    if (!info.iconContainers.includes(tooltip)) return;
+    hideExternalIconTooltip(tooltip);
+  });
+}
+
+function showExternalIconTooltip(iconContainer) {
+  iconContainer.classList.add('visible');
+  iconContainer.inert = false;
+}
+
+function hideExternalIconTooltip(iconContainer) {
+  iconContainer.classList.remove('visible');
+  iconContainer.inert = true;
+}
+
+function addExternalIconTooltipPageListeners() {
+  const info = externalIconTooltipInfo;
+
+  for (const {hoverable, tooltip} of stitchArrays({
+    hoverable: info.hoverableLinks,
+    tooltip: info.iconContainers,
+  })) {
+    registerTooltipElement(tooltip);
+    registerTooltipHoverableElement(hoverable, tooltip);
+  }
+}
+
+clientSteps.getPageReferences.push(getExternalIconTooltipReferences);
+clientSteps.addInternalListeners.push(addExternalIconTooltipInternalListeners);
+clientSteps.addPageListeners.push(addExternalIconTooltipPageListeners);
+
+/*
+const linkIconTooltipInfo =
+  Array.from(document.querySelectorAll('span.contribution.has-tooltip'))
+    .map(span => ({
+      mainLink: span.querySelector('a'),
+      iconsContainer: span.querySelector('span.icons-tooltip'),
+      iconLinks: span.querySelectorAll('span.icons-tooltip a'),
+    }));
+
+for (const info of linkIconTooltipInfo) {
+  const focusElements =
+    [info.mainLink, ...info.iconLinks];
+
+  const hoverElements =
+    [info.mainLink, info.iconsContainer];
+
+  let hidden = true;
+
+  const show = () => {
+    info.iconsContainer.classList.add('visible');
+    info.iconsContainer.inert = false;
+    hidden = false;
+  };
+
+  const hide = () => {
+    info.iconsContainer.classList.remove('visible');
+    info.iconsContainer.inert = true;
+    hidden = true;
+  };
+
+  const considerHiding = () => {
+    if (hoverElements.some(el => el.matches(':hover'))) {
+      return;
+    }
+
+    if (<document.activeElement is inside tooltip>) {
+      return;
+    }
+
+    if (justTouched) {
+      return;
+    }
+
+    hide();
+  };
+
+  // Hover (pointer)
+
+  let hoverTimeout;
+
+  info.mainLink.addEventListener('mouseenter', () => {
+    if (hidden) {
+      hoverTimeout = setTimeout(show, 250);
+    }
+  });
+
+  info.mainLink.addEventListener('mouseout', () => {
+    if (hidden) {
+      clearTimeout(hoverTimeout);
+    } else {
+      considerHiding();
+    }
+  });
+
+  info.iconsContainer.addEventListener('mouseout', () => {
+    if (!hidden) {
+      considerHiding();
+    }
+  });
+
+  // Focus (keyboard)
+
+  let focusTimeout;
+
+  info.mainLink.addEventListener('focus', () => {
+    focusTimeout = setTimeout(show, 750);
+  });
+
+  info.mainLink.addEventListener('blur', () => {
+    clearTimeout(focusTimeout);
+  });
+
+  info.iconsContainer.addEventListener('focusout', () => {
+    requestAnimationFrame(considerHiding);
+  });
+
+  info.mainLink.addEventListener('blur', () => {
+    requestAnimationFrame(considerHiding);
+  });
+}
+*/
+
 // Sticky commentary sidebar ------------------------------
 
 const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = {
diff --git a/src/static/site5.css b/src/static/site5.css
index ea27e35e..5a769545 100644
--- a/src/static/site5.css
+++ b/src/static/site5.css
@@ -427,6 +427,7 @@ a {
 
 a:hover {
   text-decoration: underline;
+  text-decoration-style: solid !important;
 }
 
 a.current {
@@ -472,11 +473,63 @@ a:not([href]):hover {
   white-space: nowrap;
 }
 
+.contribution {
+  position: relative;
+}
+
+.contribution.has-tooltip > a {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
 .icons {
   font-style: normal;
   white-space: nowrap;
 }
 
+.icons-tooltip {
+  position: absolute;
+  z-index: 3;
+  left: -36px;
+  top: calc(1em - 2px);
+  padding: 4px 12px 6px 8px;
+}
+
+.icons-tooltip:not(.visible) {
+  display: none;
+}
+
+.icons-tooltip-content {
+  display: block;
+  padding: 6px 2px 2px 2px;
+  background: var(--bg-black-color);
+  border: 1px dotted var(--primary-color);
+  border-radius: 6px;
+
+  -webkit-backdrop-filter:
+    brightness(1.5) saturate(1.4) blur(4px);
+
+          backdrop-filter:
+    brightness(1.5) saturate(1.4) blur(4px);
+
+  -webkit-user-select: none;
+          user-select: none;
+
+  box-shadow:
+    0 3px 4px 4px #000000aa,
+    0 -2px 4px -2px var(--primary-color) inset;
+
+  cursor: default;
+}
+
+.icons a:hover {
+  filter: brightness(1.4);
+}
+
+.icons a {
+  padding: 0 3px;
+}
+
 .icon {
   display: inline-block;
   width: 24px;
@@ -492,6 +545,23 @@ a:not([href]):hover {
   fill: var(--primary-color);
 }
 
+.icon.has-text {
+  display: block;
+  width: unset;
+  height: 1.4em;
+}
+
+.icon.has-text > svg {
+  width: 18px;
+  height: 18px;
+  top: -0.1em;
+}
+
+.icon.has-text > .icon-text {
+  margin-left: 24px;
+  padding-right: 8px;
+}
+
 .rerelease,
 .other-group-accent {
   opacity: 0.7;
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index e6b8d6db..d0d46998 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -404,21 +404,31 @@ misc:
   #   wiki - sorry!
 
   external:
+    external: "External"
 
-    # domain:
-    #   General domain when one the URL doesn't match one of the
-    #   sites below.
+    withDomain:
+      "{PLATFORM} ({DOMAIN})"
 
-    domain: "External ({DOMAIN})"
-
-    # local:
-    #   Files which are locally available on the wiki (under its media
-    #   directory).
+    withHandle:
+      "{PLATFORM} ({HANDLE})"
 
     local: "Wiki Archive (local upload)"
 
+    bandcamp: "Bandcamp"
+
+    bgreco:
+      _: "bgreco.net"
+      flash: "bgreco.net (high quality audio)"
+
     deviantart: "DeviantArt"
+
+    homestuck:
+      _: "Homestuck"
+      page: "Homestuck (page {PAGE})"
+      secretPage: "Homestuck (secret page)"
+
     instagram: "Instagram"
+    mastodon: "Mastodon"
     newgrounds: "Newgrounds"
     patreon: "Patreon"
     poetryFoundation: "Poetry Foundation"
@@ -428,26 +438,12 @@ misc:
     twitter: "Twitter"
     wikipedia: "Wikipedia"
 
-    bandcamp:
-      _: "Bandcamp"
-      domain: "Bandcamp ({DOMAIN})"
-
-    mastodon:
-      _: "Mastodon"
-      domain: "Mastodon ({DOMAIN})"
-
     youtube:
       _: "YouTube"
+      flash: "YouTube (on any device)"
       playlist: "YouTube (playlist)"
       fullAlbum: "YouTube (full album)"
 
-    flash:
-      bgreco: "{LINK} (HQ Audio)"
-      youtube: "{LINK} (on any device)"
-      homestuck:
-        page: "{LINK} (page {PAGE})"
-        secret: "{LINK} (secret page)"
-
   # missingImage:
   #   Fallback text displayed in an image when it's sourced to a file
   #   that isn't available under the wiki's media directory. While it
diff --git a/src/util/external-links.js b/src/util/external-links.js
new file mode 100644
index 00000000..0a4a77cf
--- /dev/null
+++ b/src/util/external-links.js
@@ -0,0 +1,679 @@
+import {empty, stitchArrays} from '#sugar';
+
+import {
+  is,
+  isObject,
+  isStringNonEmpty,
+  oneOf,
+  optional,
+  validateArrayItems,
+  validateInstanceOf,
+  validateProperties,
+} from '#validators';
+
+export const externalLinkStyles = [
+  'normal',
+  'compact',
+  'platform',
+  'icon-id',
+];
+
+export const isExternalLinkStyle = is(...externalLinkStyles);
+
+export const externalLinkContexts = [
+  'album',
+  'artist',
+  'flash',
+  'generic',
+  'group',
+  'track',
+];
+
+export const isExternalLinkContext = is(...externalLinkContexts);
+
+// This might need to be adjusted for YAML importing...
+const isRegExp =
+  validateInstanceOf(RegExp);
+
+export const isExternalLinkExtractSpec =
+  validateProperties({
+    prefix: optional(isStringNonEmpty),
+
+    url: optional(isRegExp),
+    domain: optional(isRegExp),
+    pathname: optional(isRegExp),
+    query: optional(isRegExp),
+  });
+
+export const isExternalLinkSpec =
+  validateArrayItems(
+    validateProperties({
+      match: validateProperties({
+        // TODO: Don't allow providing both of these, and require providing one
+        domain: optional(isStringNonEmpty),
+        domains: optional(validateArrayItems(isStringNonEmpty)),
+
+        // TODO: Don't allow providing both of these
+        pathname: optional(isRegExp),
+        pathnames: optional(validateArrayItems(isRegExp)),
+
+        // TODO: Don't allow providing both of these
+        query: optional(isRegExp),
+        queries: optional(validateArrayItems(isRegExp)),
+
+        context:
+          optional(oneOf(
+            isExternalLinkContext,
+            validateArrayItems(isExternalLinkContext))),
+      }),
+
+      platform: isStringNonEmpty,
+      substring: optional(isStringNonEmpty),
+
+      // TODO: Don't allow 'handle' or 'custom' options if the corresponding
+      // properties aren't provided
+      normal: optional(is('domain', 'handle', 'custom')),
+      compact: optional(is('domain', 'handle', 'custom')),
+      icon: optional(isStringNonEmpty),
+
+      handle: optional(isExternalLinkExtractSpec),
+
+      // TODO: This should validate each value with isExternalLinkExtractSpec.
+      custom: optional(isObject),
+    }));
+
+export const fallbackDescriptor = {
+  platform: 'external',
+
+  normal: 'domain',
+  compact: 'domain',
+  icon: 'globe',
+};
+
+// TODO: Define all this stuff in data as YAML!
+export const externalLinkSpec = [
+  // Special handling for album links
+
+  {
+    match: {
+      context: 'album',
+      domain: 'youtube.com',
+      pathname: /^playlist/,
+    },
+
+    platform: 'youtube',
+    substring: 'playlist',
+
+    icon: 'youtube',
+  },
+
+  {
+    match: {
+      context: 'album',
+      domain: 'youtube.com',
+      pathname: /^watch/,
+    },
+
+    platform: 'youtube',
+    substring: 'fullAlbum',
+
+    icon: 'youtube',
+  },
+
+  {
+    match: {
+      context: 'album',
+      domain: 'youtu.be',
+    },
+
+    platform: 'youtube',
+    substring: 'fullAlbum',
+
+    icon: 'youtube',
+  },
+
+  // Special handling for artist links
+
+  {
+    match: {
+      domain: 'patreon.com',
+      context: 'artist',
+    },
+
+    platform: 'patreon',
+
+    normal: 'handle',
+    compact: 'handle',
+    icon: 'globe',
+
+    handle: /([^/]*)\/?$/,
+  },
+
+  {
+    match: {
+      context: 'artist',
+      domain: 'youtube.com',
+    },
+
+    platform: 'youtube',
+
+    normal: 'handle',
+    compact: 'handle',
+    icon: 'youtube',
+
+    handle: {
+      pathname: /^(@.*?)\/?$/,
+    },
+  },
+
+  // Special handling for flash links
+
+  {
+    match: {
+      context: 'flash',
+      domain: 'bgreco.net',
+    },
+
+    platform: 'bgreco',
+    substring: 'flash',
+
+    icon: 'globe',
+  },
+
+  // This takes precedence over the secretPage match below.
+  {
+    match: {
+      context: 'flash',
+      domain: 'homestuck.com',
+      pathname: /^story\/[0-9]+\/?$/,
+    },
+
+    platform: 'homestuck',
+    substring: 'page',
+
+    normal: 'custom',
+    icon: 'globe',
+
+    custom: {
+      page: {
+        pathname: /[0-9]+/,
+      },
+    },
+  },
+
+  {
+    match: {
+      context: 'flash',
+      domain: 'homestuck.com',
+      pathname: /^story\/.+\/?$/,
+    },
+
+    platform: 'homestuck',
+    substring: 'secretPage',
+
+    icon: 'globe',
+  },
+
+  {
+    match: {
+      context: 'flash',
+      domains: ['youtube.com', 'youtu.be'],
+    },
+
+    platform: 'youtube',
+    substring: 'flash',
+
+    icon: 'youtube',
+  },
+
+  // Generic domains, sorted alphabetically (by string)
+
+  {
+    match: {domains: ['bc.s3m.us', 'music.solatrus.com']},
+
+    platform: 'bandcamp',
+
+    normal: 'domain',
+    compact: 'domain',
+    icon: 'bandcamp',
+  },
+
+  {
+    match: {domain: '.bandcamp.com'},
+
+    platform: 'bandcamp',
+
+    compact: 'handle',
+    icon: 'bandcamp',
+
+    handle: {domain: /^[^.]*/},
+  },
+
+  {
+    match: {domain: 'deviantart.com'},
+    platform: 'deviantart',
+    icon: 'deviantart',
+  },
+
+  {
+    match: {domain: 'homestuck.com'},
+    platform: 'homestuck',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'hsmusic.wiki'},
+    platform: 'local',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'instagram.com'},
+    platform: 'instagram',
+    icon: 'instagram',
+  },
+
+  {
+    match: {domains: ['types.pl']},
+
+    platform: 'mastodon',
+
+    normal: 'domain',
+    compact: 'domain',
+    icon: 'mastodon',
+  },
+
+  {
+    match: {domain: 'newgrounds.com'},
+    platform: 'newgrounds',
+    icon: 'newgrounds',
+  },
+
+  {
+    match: {domain: 'patreon.com'},
+    platform: 'patreon',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'poetryfoundation.org'},
+    platform: 'poetryFoundation',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'soundcloud.com'},
+
+    platform: 'soundcloud',
+
+    compact: 'handle',
+    icon: 'soundcloud',
+
+    handle: /([^/]*)\/?$/,
+  },
+
+  {
+    match: {domain: 'spotify.com'},
+    platform: 'spotify',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: '.tumblr.com'},
+
+    platform: 'tumblr',
+
+    compact: 'handle',
+    icon: 'tumblr',
+
+    handle: {domain: /^[^.]*/},
+  },
+
+  {
+    match: {domain: 'twitter.com'},
+
+    platform: 'twitter',
+
+    compact: 'handle',
+    icon: 'twitter',
+
+    handle: {
+      prefix: '@',
+      pathname: /^@?([a-zA-Z0-9_]*)\/?$/,
+    },
+  },
+
+  {
+    match: {domain: 'wikipedia.org'},
+    platform: 'wikipedia',
+    icon: 'misc',
+  },
+
+  {
+    match: {domains: ['youtube.com', 'youtu.be']},
+    platform: 'youtube',
+    icon: 'youtube',
+  },
+];
+
+function urlParts(url) {
+  const {
+    hostname: domain,
+    pathname,
+    search: query,
+  } = new URL(url);
+
+  return {domain, pathname, query};
+}
+
+function createEmptyResults() {
+  return Object.fromEntries(externalLinkStyles.map(style => [style, null]));
+}
+
+export function getMatchingDescriptorsForExternalLink(url, descriptors, {
+  context = 'generic',
+} = {}) {
+  const {domain, pathname, query} = urlParts(url);
+
+  const compareDomain = string => domain.includes(string);
+  const comparePathname = regex => regex.test(pathname.slice(1));
+  const compareQuery = regex => regex.test(query.slice(1));
+
+  const matchingDescriptors =
+    descriptors
+      .filter(({match}) => {
+        if (match.domain) return compareDomain(match.domain);
+        if (match.domains) return match.domains.some(compareDomain);
+        return false;
+      })
+      .filter(({match}) => {
+        if (Array.isArray(match.context)) return match.context.includes(context);
+        if (match.context) return context === match.context;
+        return true;
+      })
+      .filter(({match}) => {
+        if (match.pathname) return comparePathname(match.pathname);
+        if (match.pathnames) return match.pathnames.some(comparePathname);
+        return true;
+      })
+      .filter(({match}) => {
+        if (match.query) return compareQuery(match.query);
+        if (match.queries) return match.quieries.some(compareQuery);
+        return true;
+      });
+
+  return [...matchingDescriptors, fallbackDescriptor];
+}
+
+export function extractPartFromExternalLink(url, extract) {
+  const {domain, pathname, query} = urlParts(url);
+
+  let regexen = [];
+  let tests = [];
+  let prefix = '';
+
+  if (extract instanceof RegExp) {
+    regexen.push(extract);
+    tests.push(url);
+  } else {
+    for (const [key, value] of Object.entries(extract)) {
+      switch (key) {
+        case 'prefix':
+          prefix = value;
+          continue;
+
+        case 'url':
+          tests.push(url);
+          break;
+
+        case 'domain':
+          tests.push(domain);
+          break;
+
+        case 'pathname':
+          tests.push(pathname.slice(1));
+          break;
+
+        case 'query':
+          tests.push(query.slice(1));
+          break;
+
+        default:
+          tests.push('');
+          break;
+      }
+
+      regexen.push(value);
+    }
+  }
+
+  for (const {regex, test} of stitchArrays({
+    regex: regexen,
+    test: tests,
+  })) {
+    const match = test.match(regex);
+    if (match) {
+      return prefix + (match[1] ?? match[0]);
+    }
+  }
+
+  return null;
+}
+
+export function extractAllCustomPartsFromExternalLink(url, custom) {
+  const customParts = {};
+
+  // All or nothing: if one part doesn't match, all results are scrapped.
+  for (const [key, value] of Object.entries(custom)) {
+    customParts[key] = extractPartFromExternalLink(url, value);
+    if (!customParts[key]) return null;
+  }
+
+  return customParts;
+}
+
+export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) {
+  const prefix = 'misc.external';
+
+  function getPlatform() {
+    return language.$(prefix, descriptor.platform);
+  }
+
+  function getDomain() {
+    return urlParts(url).domain;
+  }
+
+  function getCustom() {
+    if (!descriptor.custom) {
+      return null;
+    }
+
+    const customParts =
+      extractAllCustomPartsFromExternalLink(url, descriptor.custom);
+
+    if (!customParts) {
+      return null;
+    }
+
+    return language.$(prefix, descriptor.platform, descriptor.substring, customParts);
+  }
+
+  function getHandle() {
+    if (!descriptor.handle) {
+      return null;
+    }
+
+    return extractPartFromExternalLink(url, descriptor.handle);
+  }
+
+  function getNormal() {
+    if (descriptor.custom) {
+      if (descriptor.normal === 'custom') {
+        return getCustom();
+      } else {
+        return null;
+      }
+    }
+
+    if (descriptor.normal === 'domain') {
+      const platform = getPlatform();
+      const domain = getDomain();
+
+      if (!platform || !domain) {
+        return null;
+      }
+
+      return language.$(prefix, 'withDomain', {platform, domain});
+    }
+
+    if (descriptor.normal === 'handle') {
+      const platform = getPlatform();
+      const handle = getHandle();
+
+      if (!platform || !handle) {
+        return null;
+      }
+
+      return language.$(prefix, 'withHandle', {platform, handle});
+    }
+
+    return language.$(prefix, descriptor.platform, descriptor.substring);
+  }
+
+  function getCompact() {
+    if (descriptor.custom) {
+      if (descriptor.compact === 'custom') {
+        return getCustom();
+      } else {
+        return null;
+      }
+    }
+
+    if (descriptor.compact === 'domain') {
+      const domain = getDomain();
+
+      if (!domain) {
+        return null;
+      }
+
+      return language.sanitize(domain.replace(/^www\./, ''));
+    }
+
+    if (descriptor.compact === 'handle') {
+      const handle = getHandle();
+
+      if (!handle) {
+        return null;
+      }
+
+      return language.sanitize(handle);
+    }
+  }
+
+  function getIconId() {
+    return descriptor.icon ?? null;
+  }
+
+  switch (style) {
+    case 'normal': return getNormal();
+    case 'compact': return getCompact();
+    case 'platform': return getPlatform();
+    case 'icon-id': return getIconId();
+  }
+}
+
+export function couldDescriptorSupportStyle(descriptor, style) {
+  if (style === 'normal') {
+    if (descriptor.custom) {
+      return descriptor.normal === 'custom';
+    } else {
+      return true;
+    }
+  }
+
+  if (style === 'compact') {
+    if (descriptor.custom) {
+      return descriptor.compact === 'custom';
+    } else {
+      return !!descriptor.compact;
+    }
+  }
+
+  if (style === 'platform') {
+    return true;
+  }
+
+  if (style === 'icon-id') {
+    return !!descriptor.icon;
+  }
+}
+
+export function getExternalLinkStringOfStyleFromDescriptors(url, style, descriptors, {
+  language,
+  context = 'generic',
+}) {
+  const matchingDescriptors =
+    getMatchingDescriptorsForExternalLink(url, descriptors, {context});
+
+  const styleFilteredDescriptors =
+    matchingDescriptors.filter(descriptor =>
+      couldDescriptorSupportStyle(descriptor, style));
+
+  for (const descriptor of styleFilteredDescriptors) {
+    const descriptorResult =
+      getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language});
+
+    if (descriptorResult) {
+      return descriptorResult;
+    }
+  }
+
+  return null;
+}
+
+export function getExternalLinkStringsFromDescriptor(url, descriptor, {language}) {
+  const getStyle = style =>
+    getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language});
+
+  return {
+    'normal': getStyle('normal'),
+    'compact': getStyle('compact'),
+    'platform': getStyle('platform'),
+    'icon-id': getStyle('icon-id'),
+  };
+}
+
+export function getExternalLinkStringsFromDescriptors(url, descriptors, {
+  language,
+  context = 'generic',
+}) {
+  const results = createEmptyResults();
+  const remainingKeys = new Set(Object.keys(results));
+
+  const matchingDescriptors =
+    getMatchingDescriptorsForExternalLink(url, descriptors, {context});
+
+  for (const descriptor of matchingDescriptors) {
+    const descriptorResults =
+      getExternalLinkStringsFromDescriptor(url, descriptor, {language});
+
+    const descriptorKeys =
+      new Set(
+        Object.entries(descriptorResults)
+          .filter(entry => entry[1])
+          .map(entry => entry[0]));
+
+    for (const key of remainingKeys) {
+      if (descriptorKeys.has(key)) {
+        results[key] = descriptorResults[key];
+        remainingKeys.delete(key);
+      }
+    }
+
+    if (empty(remainingKeys)) {
+      return results;
+    }
+  }
+
+  return results;
+}