« 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/misc-templates.js312
-rw-r--r--src/page/album.js72
-rw-r--r--src/page/flash.js8
-rw-r--r--src/page/group.js14
-rw-r--r--src/page/news.js8
-rw-r--r--src/page/track.js5
-rw-r--r--src/strings-default.json5
-rwxr-xr-xsrc/upd8.js37
-rw-r--r--src/util/link.js54
-rw-r--r--src/util/sugar.js16
10 files changed, 307 insertions, 224 deletions
diff --git a/src/misc-templates.js b/src/misc-templates.js
index 53a05950..b5a635d1 100644
--- a/src/misc-templates.js
+++ b/src/misc-templates.js
@@ -30,10 +30,12 @@ export function generateAdditionalFilesShortcut(additionalFiles, {language}) {
   if (!additionalFiles?.length) return '';
 
   return language.$('releaseInfo.additionalFiles.shortcut', {
-    anchorLink: `<a href="#additional-files">${language.$(
-      'releaseInfo.additionalFiles.shortcut.anchorLink'
-    )}</a>`,
-    titles: language.formatUnitList(additionalFiles.map((g) => g.title)),
+    anchorLink:
+      html.tag('a',
+        {href: '#additional-files'},
+        language.$('releaseInfo.additionalFiles.shortcut.anchorLink')),
+    titles: language.formatUnitList(
+      additionalFiles.map(g => g.title)),
   });
 }
 
@@ -41,62 +43,45 @@ export function generateAdditionalFilesList(
   additionalFiles,
   {language, getFileSize, linkFile}
 ) {
-  if (!additionalFiles?.length) return '';
+  if (!additionalFiles?.length) return [];
 
   const fileCount = additionalFiles.flatMap((g) => g.files).length;
 
-  return fixWS`
-        <p id="additional-files">${language.$(
-          'releaseInfo.additionalFiles.heading',
-          {
-            additionalFiles: language.countAdditionalFiles(fileCount, {
-              unit: true,
-            }),
-          }
-        )}</p>
-        <dl>
-            ${additionalFiles
-              .map(
-                ({title, description, files}) => fixWS`
-                <dt>${
-                  description
-                    ? language.$(
-                        'releaseInfo.additionalFiles.entry.withDescription',
-                        {
-                          title,
-                          description,
-                        }
-                      )
-                    : language.$('releaseInfo.additionalFiles.entry', {title})
-                }</dt>
-                <dd><ul>
-                    ${files
-                      .map((file) => {
-                        const size = getFileSize(file);
-                        return size
-                          ? `<li>${language.$(
-                              'releaseInfo.additionalFiles.file.withSize',
-                              {
-                                file: linkFile(file),
-                                size: language.formatFileSize(
-                                  getFileSize(file)
-                                ),
-                              }
-                            )}</li>`
-                          : `<li>${language.$(
-                              'releaseInfo.additionalFiles.file',
-                              {
-                                file: linkFile(file),
-                              }
-                            )}</li>`;
-                      })
-                      .join('\n')}
-                </ul></dd>
-            `
-              )
-              .join('\n')}
-        </dl>
-    `;
+  return html.fragment([
+    html.tag('p',
+      {id: 'additional-files'},
+      language.$('releaseInfo.additionalFiles.heading', {
+        additionalFiles: language.countAdditionalFiles(fileCount, {
+          unit: true,
+        }),
+      })),
+
+    html.tag('dl',
+      additionalFiles.flatMap(({title, description, files}) => [
+        html.tag('dt',
+          (description
+            ? language.$('releaseInfo.additionalFiles.entry.withDescription', {
+                title,
+                description,
+              })
+            : language.$('releaseInfo.additionalFiles.entry', {title}))),
+
+        html.tag('dd',
+          html.tag('ul',
+            files.map((file) => {
+              const size = getFileSize(file);
+              return html.tag('li',
+                (size
+                  ? language.$('releaseInfo.additionalFiles.file.withSize', {
+                      file: linkFile(file),
+                      size: language.formatFileSize(size),
+                    })
+                  : language.$('releaseInfo.additionalFiles.file', {
+                      file: linkFile(file),
+                    })));
+            }))),
+      ])),
+  ]);
 }
 
 // Artist strings
@@ -108,35 +93,53 @@ export function getArtistString(
   return language.formatConjunctionList(
     artists.map(({who, what}) => {
       const {urls} = who;
-      return [
-        link.artist(who),
-        showContrib && what && `(${what})`,
-        showIcons &&
-          urls?.length &&
-          `<span class="icons">(${language.formatUnitList(
-            urls.map((url) => iconifyURL(url, {language}))
-          )})</span>`,
-      ]
-        .filter(Boolean)
-        .join(' ');
-    })
-  );
+
+      const hasContribPart = !!(showContrib && what);
+      const hasExternalPart = !!(showIcons && urls?.length);
+
+      const artistLink = link.artist(who);
+
+      const externalLinks = hasExternalPart &&
+        html.tag('span',
+          {class: 'icons'},
+          language.formatUnitList(
+            urls.map(url => iconifyURL(url, {language}))));
+
+      return (
+        (hasContribPart
+          ? (hasExternalPart
+              ? language.$('misc.artistLink.withContribution.withExternalLinks', {
+                  artist: artistLink,
+                  contrib: what,
+                  links: externalLinks,
+                })
+              : language.$('misc.artistLink.withContribution', {
+                  artist: artistLink,
+                  contrib: what,
+                }))
+          : (hasExternalPart
+              ? language.$('misc.artistLink.withExternalLinks', {
+                  artist: artistLink,
+                  links: externalLinks,
+                })
+              : language.$('misc.artistLink', {
+                  artist: artistLink,
+                })))
+      );
+    }));
 }
 
 // Chronology links
 
-export function generateChronologyLinks(
-  currentThing,
-  {
-    dateKey = 'date',
-    contribKey,
-    getThings,
-    headingString,
-    link,
-    linkAnythingMan,
-    language,
-  }
-) {
+export function generateChronologyLinks(currentThing, {
+  dateKey = 'date',
+  contribKey,
+  getThings,
+  generateNavigationLinks,
+  headingString,
+  link,
+  language,
+}) {
   const contributions = currentThing[contribKey];
   if (!contributions) {
     return '';
@@ -157,55 +160,45 @@ export function generateChronologyLinks(
       // Kinda a hack, but we automatically detect which is (probably) the
       // right function to use here.
       const args = [thingsUnsorted, {getDate: (t) => t[dateKey]}];
-      const things = thingsUnsorted.every(
-        (t) => t instanceof Album || t instanceof Track
-      )
-        ? sortAlbumsTracksChronologically(...args)
-        : sortChronologically(...args);
+      const things = (
+        thingsUnsorted.every(t => t instanceof Album || t instanceof Track)
+          ? sortAlbumsTracksChronologically(...args)
+          : sortChronologically(...args));
+
+      if (things.length === 0) return '';
 
       const index = things.indexOf(currentThing);
 
       if (index === -1) return '';
 
-      // TODO: This can pro8a8ly 8e made to use generatePreviousNextLinks?
-      // We'd need to make generatePreviousNextLinks use toAnythingMan tho.
-      const previous = things[index - 1];
-      const next = things[index + 1];
-      const parts = [
-        previous &&
-          linkAnythingMan(previous, {
-            color: false,
-            text: language.$('misc.nav.previous'),
-          }),
-        next &&
-          linkAnythingMan(next, {
-            color: false,
-            text: language.$('misc.nav.next'),
-          }),
-      ].filter(Boolean);
-
-      if (!parts.length) {
-        return '';
-      }
-
-      const stringOpts = {
-        index: language.formatIndex(index + 1, {language}),
-        artist: link.artist(artist),
-      };
-
-      return fixWS`
-            <div class="chronology">
-                <span class="heading">${language.$(
-                  headingString,
-                  stringOpts
-                )}</span>
-                ${
-                  parts.length &&
-                  `<span class="buttons">(${parts.join(', ')})</span>`
-                }
-            </div>
-        `;
+      const heading = (
+        html.tag('span', {class: 'heading'},
+          language.$(headingString, {
+            index: language.formatIndex(index + 1, {language}),
+            artist: link.artist(artist),
+          })));
+
+      const navigation = things.length > 1 &&
+        html.tag('span',
+          {
+            [html.onlyIfContent]: true,
+            class: 'buttons',
+          },
+          generateNavigationLinks(currentThing, {
+            data: things,
+            isMain: false,
+          }));
+
+      return (
+        html.tag('div', {class: 'chronology'},
+          (navigation
+            ? language.$('misc.chronology.withNavigation', {
+                heading,
+                navigation,
+              })
+            : heading)));
     })
+    // TODO: use html.fragment when calling and get rid of these lines
     .filter(Boolean)
     .join('\n');
 }
@@ -570,38 +563,63 @@ export function generateInfoGalleryLinks(
   ].join(', ');
 }
 
-export function generatePreviousNextLinks(
-  current,
-  {data, link, linkKey, language}
-) {
-  const linkFn = link[linkKey];
+// Generate "previous" and "next" links relative to a given current thing and a
+// data set (array of things) which includes it, optionally including additional
+// provided links like "random". This is for use in navigation bars and other
+// inline areas.
+//
+// By default, generated links include ID attributes which enable client-side
+// keyboard shortcuts. Provide isMain: false to disable this (if the generated
+// links aren't the for the page's primary navigation).
+export function generateNavigationLinks(current, {
+  additionalLinks = [],
+  data,
+  isMain = true,
+  language,
+  link,
+  linkKey = 'anything',
+}) {
+  let previousLink, nextLink;
 
-  const index = data.indexOf(current);
-  const previous = data[index - 1];
-  const next = data[index + 1];
+  if (current) {
+    const linkFn = link[linkKey].bind(link);
 
-  return [
-    previous &&
-      linkFn(previous, {
+    const index = data.indexOf(current);
+    const previousThing = data[index - 1];
+    const nextThing = data[index + 1];
+
+    previousLink = previousThing &&
+      linkFn(previousThing, {
         attributes: {
-          id: 'previous-button',
-          title: previous.name,
+          id: isMain && 'previous-button',
+          title: previousThing.name,
         },
         text: language.$('misc.nav.previous'),
         color: false,
-      }),
-    next &&
-      linkFn(next, {
+      });
+
+    nextLink = nextThing &&
+      linkFn(nextThing, {
         attributes: {
-          id: 'next-button',
-          title: next.name,
+          id: isMain && 'next-button',
+          title: nextThing.name,
         },
         text: language.$('misc.nav.next'),
         color: false,
-      }),
-  ]
-    .filter(Boolean)
-    .join(', ');
+      });
+  }
+
+  const links = [
+    previousLink,
+    nextLink,
+    ...additionalLinks,
+  ].filter(Boolean);
+
+  if (!links.length) {
+    return '';
+  }
+
+  return language.formatUnitList(links);
 }
 
 // Footer stuff
diff --git a/src/page/album.js b/src/page/album.js
index 11c6da29..1cceead9 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -102,6 +102,7 @@ export function write(album, {wikiData}) {
       generateAdditionalFilesList,
       generateChronologyLinks,
       generateCoverLink,
+      generateNavigationLinks,
       getAlbumCover,
       getAlbumStylesheet,
       getArtistString,
@@ -277,18 +278,19 @@ export function write(album, {wikiData}) {
                   })
               ]),
 
-            hasAdditionalFiles &&
-              generateAdditionalFilesList(album.additionalFiles, {
-                // TODO: Kinda near the metal here...
-                getFileSize: (file) =>
-                  getSizeOfAdditionalFile(
-                    urls.from('media.root').to(
-                      'media.albumAdditionalFile',
-                      album.directory,
-                      file)),
-                linkFile: (file) =>
-                  link.albumAdditionalFile({album, file}),
-              }),
+            ...html.fragment(
+              hasAdditionalFiles &&
+                generateAdditionalFilesList(album.additionalFiles, {
+                  // TODO: Kinda near the metal here...
+                  getFileSize: (file) =>
+                    getSizeOfAdditionalFile(
+                      urls.from('media.root').to(
+                        'media.albumAdditionalFile',
+                        album.directory,
+                        file)),
+                  linkFile: (file) =>
+                    link.albumAdditionalFile({album, file}),
+                })),
 
             ...album.commentary ? [
               html.tag('p', language.$('releaseInfo.artistCommentary')),
@@ -317,7 +319,11 @@ export function write(album, {wikiData}) {
               }),
             },
           ],
-          bottomRowContent: generateAlbumNavLinks(album, null, {language}),
+          bottomRowContent: generateAlbumNavLinks(album, null, {
+            generateNavigationLinks,
+            html,
+            language,
+          }),
           content: generateAlbumChronologyLinks(album, null, {
             generateChronologyLinks,
             html,
@@ -526,33 +532,35 @@ export function generateAlbumSecondaryNav(album, currentTrack, {
   };
 }
 
-export function generateAlbumNavLinks(
-  album,
-  currentTrack,
-  {generatePreviousNextLinks, language}
-) {
+export function generateAlbumNavLinks(album, currentTrack, {
+  generateNavigationLinks,
+  html,
+  language,
+}) {
   const isTrackPage = !!currentTrack;
 
   if (album.tracks.length <= 1) {
     return '';
   }
 
-  const previousNextLinks =
-    isTrackPage &&
-      generatePreviousNextLinks(currentTrack, {
-        data: album.tracks,
-        linkKey: 'track',
-      });
-
-  const randomLink = `<a href="#" data-random="track-in-album" id="random-button">${
-    isTrackPage
+  const randomLink = html.tag('a',
+    {
+      href: '#',
+      dataRandom: 'track-in-album',
+      id: 'random-button'
+    },
+    (isTrackPage
       ? language.$('trackPage.nav.random')
-      : language.$('albumPage.nav.randomTrack')
-  }</a>`;
+      : language.$('albumPage.nav.randomTrack')));
+
+  const navigationLinks =
+    generateNavigationLinks(currentTrack, {
+      additionalLinks: [randomLink],
+      data: album.tracks,
+      linkKey: 'track',
+    });
 
-  return previousNextLinks
-    ? `(${previousNextLinks}<span class="js-hide-until-data">, ${randomLink}</span>)`
-    : `<span class="js-hide-until-data">(${randomLink})</span>`;
+  return `(${navigationLinks})`;
 }
 
 export function generateAlbumChronologyLinks(album, currentTrack, {
diff --git a/src/page/flash.js b/src/page/flash.js
index 3ce7646c..c123c1be 100644
--- a/src/page/flash.js
+++ b/src/page/flash.js
@@ -20,7 +20,7 @@ export function write(flash, {wikiData}) {
       fancifyFlashURL,
       generateChronologyLinks,
       generateCoverLink,
-      generatePreviousNextLinks,
+      generateNavigationLinks,
       getArtistString,
       getFlashCover,
       getThemeString,
@@ -106,7 +106,7 @@ export function write(flash, {wikiData}) {
 
       nav: generateNavForFlash(flash, {
         generateChronologyLinks,
-        generatePreviousNextLinks,
+        generateNavigationLinks,
         html,
         link,
         language,
@@ -195,7 +195,7 @@ export function writeTargetless({
 
 function generateNavForFlash(flash, {
   generateChronologyLinks,
-  generatePreviousNextLinks,
+  generateNavigationLinks,
   html,
   language,
   link,
@@ -203,7 +203,7 @@ function generateNavForFlash(flash, {
 }) {
   const {flashData} = wikiData;
 
-  const previousNextLinks = generatePreviousNextLinks(flash, {
+  const previousNextLinks = generateNavigationLinks(flash, {
     data: flashData,
     linkKey: 'flash',
   });
diff --git a/src/page/group.js b/src/page/group.js
index 9959ec29..ef3813f5 100644
--- a/src/page/group.js
+++ b/src/page/group.js
@@ -26,7 +26,7 @@ export function write(group, {wikiData}) {
     page: ({
       fancifyURL,
       generateInfoGalleryLinks,
-      generatePreviousNextLinks,
+      generateNavigationLinks,
       getLinkThemeString,
       getThemeString,
       html,
@@ -104,7 +104,7 @@ export function write(group, {wikiData}) {
 
       nav: generateGroupNav(group, false, {
         generateInfoGalleryLinks,
-        generatePreviousNextLinks,
+        generateNavigationLinks,
         language,
         link,
         wikiData,
@@ -117,7 +117,7 @@ export function write(group, {wikiData}) {
     path: ['groupGallery', group.directory],
     page: ({
       generateInfoGalleryLinks,
-      generatePreviousNextLinks,
+      generateNavigationLinks,
       getAlbumGridHTML,
       getLinkThemeString,
       getThemeString,
@@ -151,7 +151,7 @@ export function write(group, {wikiData}) {
                 language.formatDuration(totalDuration, {
                   unit: true,
                 })),
-              })),
+            })),
 
           wikiInfo.enableGroupUI &&
           wikiInfo.enableListings &&
@@ -191,7 +191,7 @@ export function write(group, {wikiData}) {
 
       nav: generateGroupNav(group, true, {
         generateInfoGalleryLinks,
-        generatePreviousNextLinks,
+        generateNavigationLinks,
         language,
         link,
         wikiData,
@@ -253,7 +253,7 @@ function generateGroupSidebar(currentGroup, isGallery, {
 
 function generateGroupNav(currentGroup, isGallery, {
   generateInfoGalleryLinks,
-  generatePreviousNextLinks,
+  generateNavigationLinks,
   link,
   language,
   wikiData,
@@ -271,7 +271,7 @@ function generateGroupNav(currentGroup, isGallery, {
     linkKeyInfo: 'groupInfo',
   });
 
-  const previousNextLinks = generatePreviousNextLinks(currentGroup, {
+  const previousNextLinks = generateNavigationLinks(currentGroup, {
     data: groupData,
     linkKey,
   });
diff --git a/src/page/news.js b/src/page/news.js
index 49cee7b9..62f94fb9 100644
--- a/src/page/news.js
+++ b/src/page/news.js
@@ -13,7 +13,7 @@ export function write(entry, {wikiData}) {
     type: 'page',
     path: ['newsEntry', entry.directory],
     page: ({
-      generatePreviousNextLinks,
+      generateNavigationLinks,
       html,
       language,
       link,
@@ -39,7 +39,7 @@ export function write(entry, {wikiData}) {
       },
 
       nav: generateNewsEntryNav(entry, {
-        generatePreviousNextLinks,
+        generateNavigationLinks,
         html,
         language,
         link,
@@ -104,7 +104,7 @@ export function writeTargetless({wikiData}) {
 }
 
 function generateNewsEntryNav(entry, {
-  generatePreviousNextLinks,
+  generateNavigationLinks,
   html,
   language,
   link,
@@ -112,7 +112,7 @@ function generateNewsEntryNav(entry, {
 }) {
   // The newsData list is sorted reverse chronologically (newest ones first),
   // so the way we find next/previous entries is flipped from normal.
-  const previousNextLinks = generatePreviousNextLinks(entry, {
+  const previousNextLinks = generateNavigationLinks(entry, {
     data: newsData.slice().reverse(),
     linkKey: 'newsEntry',
 
diff --git a/src/page/track.js b/src/page/track.js
index 258e11d3..514f2cb0 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -151,7 +151,7 @@ export function write(track, {wikiData}) {
       fancifyURL,
       generateChronologyLinks,
       generateCoverLink,
-      generatePreviousNextLinks,
+      generateNavigationLinks,
       generateTrackListDividedByGroups,
       getAlbumStylesheet,
       getArtistString,
@@ -377,7 +377,8 @@ export function write(track, {wikiData}) {
           bottomRowContent:
             album.tracks.length > 1 &&
               generateAlbumNavLinks(album, track, {
-                generatePreviousNextLinks,
+                generateNavigationLinks,
+                html,
                 language,
               }),
         },
diff --git a/src/strings-default.json b/src/strings-default.json
index d94f6deb..15f9a332 100644
--- a/src/strings-default.json
+++ b/src/strings-default.json
@@ -131,10 +131,15 @@
   "misc.alt.trackCover": "track cover",
   "misc.alt.artistAvatar": "artist avatar",
   "misc.alt.flashArt": "flash art",
+  "misc.artistLink": "{ARTIST}",
+  "misc.artistLink.withContribution": "{ARTIST} ({CONTRIB})",
+  "misc.artistLink.withExternalLinks": "{ARTIST} ({LINKS})",
+  "misc.artistLink.withContribution.withExternalLinks": "{ARTIST} ({CONTRIB}) ({LINKS})",
   "misc.chronology.seeArtistPages": "(See artist pages for chronology info!)",
   "misc.chronology.heading.coverArt": "{INDEX} cover art by {ARTIST}",
   "misc.chronology.heading.flash": "{INDEX} flash/game by {ARTIST}",
   "misc.chronology.heading.track": "{INDEX} track by {ARTIST}",
+  "misc.chronology.withNavigation": "{HEADING} ({NAVIGATION})",
   "misc.external.domain": "External ({DOMAIN})",
   "misc.external.local": "Wiki Archive (local upload)",
   "misc.external.bandcamp": "Bandcamp",
diff --git a/src/upd8.js b/src/upd8.js
index df5380ba..86b2f526 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -89,7 +89,7 @@ import {
   generateChronologyLinks,
   generateCoverLink,
   generateInfoGalleryLinks,
-  generatePreviousNextLinks,
+  generateNavigationLinks,
   generateTrackListDividedByGroups,
   getAlbumGridHTML,
   getAlbumStylesheet,
@@ -1562,18 +1562,6 @@ function generateRedirectPage(title, target, {language}) {
     `;
 }
 
-// RIP toAnythingMan (previously getHrefOfAnythingMan), 2020-05-25<>2021-05-14.
-// ........Yet the function 8reathes life anew as linkAnythingMan! ::::)
-function linkAnythingMan(anythingMan, {link, wikiData, ...opts}) {
-  return wikiData.albumData.includes(anythingMan)
-    ? link.album(anythingMan, opts)
-    : wikiData.trackData.includes(anythingMan)
-    ? link.track(anythingMan, opts)
-    : wikiData.flashData?.includes(anythingMan)
-    ? link.flash(anythingMan, opts)
-    : 'idk bud';
-}
-
 async function processLanguageFile(file) {
   const contents = await readFile(file, 'utf-8');
   const json = JSON.parse(contents);
@@ -2353,11 +2341,6 @@ async function main() {
               entries.map(([key, fn]) => [key, bindOpts(fn, {to})])
             );
 
-            bound.linkAnythingMan = bindOpts(linkAnythingMan, {
-              link: bound.link,
-              wikiData,
-            });
-
             bound.parseAttributes = bindOpts(parseAttributes, {
               to,
             });
@@ -2437,9 +2420,17 @@ async function main() {
               }
             );
 
+            bound.generateNavigationLinks = bindOpts(
+              generateNavigationLinks,
+              {
+                link: bound.link,
+                language,
+              }
+            );
+
             bound.generateChronologyLinks = bindOpts(generateChronologyLinks, {
+              generateNavigationLinks: bound.generateNavigationLinks,
               link: bound.link,
-              linkAnythingMan: bound.linkAnythingMan,
               language,
               wikiData,
             });
@@ -2462,14 +2453,6 @@ async function main() {
               }
             );
 
-            bound.generatePreviousNextLinks = bindOpts(
-              generatePreviousNextLinks,
-              {
-                link: bound.link,
-                language,
-              }
-            );
-
             bound.generateTrackListDividedByGroups = bindOpts(
               generateTrackListDividedByGroups,
               {
diff --git a/src/util/link.js b/src/util/link.js
index ee3579d5..8fe3c2f4 100644
--- a/src/util/link.js
+++ b/src/util/link.js
@@ -14,6 +14,17 @@
 import * as html from './html.js';
 import {getColors} from './colors.js';
 
+import {
+  Album,
+  Artist,
+  ArtTag,
+  Flash,
+  Group,
+  NewsEntry,
+  StaticPage,
+  Track,
+} from '../data/things.js';
+
 export function getLinkThemeString(color) {
   if (!color) return '';
 
@@ -80,6 +91,21 @@ const linkPathname = (key, conf) =>
 const linkIndex = (key, conf) =>
   linkHelper((_, {to}) => to('localized.' + key), conf);
 
+// Mapping of Thing constructor classes to the key for a link.x() function.
+// These represent a sensible "default" link, i.e. to the primary page for
+// the given thing based on what it's an instance of. This is used for the
+// link.anything() function.
+const linkAnythingMapping = [
+  [Album, 'album'],
+  [Artist, 'artist'],
+  [ArtTag, 'tag'],
+  [Flash, 'flash'],
+  [Group, 'groupInfo'],
+  [NewsEntry, 'newsEntry'],
+  [StaticPage, 'staticPage'],
+  [Track, 'track'],
+];
+
 const link = {
   globalOptions: {
     // This should usually only 8e used during development! It'll take any
@@ -134,6 +160,34 @@ const link = {
   root: linkPathname('shared.path', {color: false}),
   data: linkPathname('data.path', {color: false}),
   site: linkPathname('localized.path', {color: false}),
+
+  // This is NOT an arrow functions because it should be callable for other
+  // "this" objects - i.e, if we bind arguments in other functions on the same
+  // link object, link.anything() should use those bound functions, not the
+  // original ones we're exporting here.
+  //
+  // This function has been through a lot of names:
+  //   - getHrefOfAnythingMan (2020-05-25)
+  //   - toAnythingMan (2021-03-02)
+  //   - linkAnythingMan (2021-05-14)
+  //   - link.anything (2022-09-15)
+  // ...And it'll probably end up being renamed yet again one day!
+  //
+  anything(...args) {
+    if (!this) {
+      throw new Error(`Missing value for \`this\` - investigate JS call stack`);
+    }
+
+    const [thing] = args;
+
+    for (const [constructor, fnKey] of linkAnythingMapping) {
+      if (thing instanceof constructor) {
+        return Reflect.apply(this[fnKey], this, args);
+      }
+    }
+
+    throw new Error(`Unrecognized type of thing for linking: ${thing}`);
+  },
 };
 
 export default link;
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 754f1991..8b59deef 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -107,12 +107,26 @@ export function escapeRegex(string) {
   return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
 }
 
+// Binds default values for arguments in a {key: value} type function argument
+// (typically the second argument, but may be overridden by providing a
+// [bindOpts.bindIndex] argument). Typically useful for preparing a function for
+// reuse within one or multiple other contexts, which may not be aware of
+// required or relevant values provided in the initial context.
+//
+// This function also passes the identity of `this` through (the returned value
+// is not an arrow function), though note it's not a true bound function either
+// (since Function.prototype.bind only supports positional arguments, not
+// "options" specified via key/value).
+//
 export function bindOpts(fn, bind) {
   const bindIndex = bind[bindOpts.bindIndex] ?? 1;
 
   const bound = function (...args) {
     const opts = args[bindIndex] ?? {};
-    return fn(...args.slice(0, bindIndex), {...bind, ...opts});
+    return Reflect.apply(fn, this, [
+      ...args.slice(0, bindIndex),
+      {...bind, ...opts}
+    ]);
   };
 
   Object.defineProperty(bound, 'name', {