« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/listing-spec.js2024
1 files changed, 1012 insertions, 1012 deletions
diff --git a/src/listing-spec.js b/src/listing-spec.js
index 22e93514..1345c478 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -14,1099 +14,1099 @@ import {
   sortChronologically,
 } from './util/wiki-data.js';
 
-const listingSpec = [
-  {
-    directory: 'albums/by-name',
-    stringsKey: 'listAlbums.byName',
+const listingSpec = [];
 
-    seeAlso: [
-      'tracks/by-album',
-    ],
+listingSpec.push({
+  directory: 'albums/by-name',
+  stringsKey: 'listAlbums.byName',
 
-    data: ({wikiData: {albumData}}) =>
-      sortAlphabetically(albumData.slice()),
+  seeAlso: [
+    'tracks/by-album',
+  ],
 
-    row: (album, {language, link}) =>
-      language.$('listingPage.listAlbums.byName.item', {
-        album: link.album(album),
-        tracks: language.countTracks(album.tracks.length, {unit: true}),
-      }),
-  },
-
-  {
-    directory: 'albums/by-tracks',
-    stringsKey: 'listAlbums.byTracks',
-
-    data: ({wikiData: {albumData}}) =>
-      albumData.slice()
-        .sort((a, b) => b.tracks.length - a.tracks.length),
-
-    row: (album, {language, link}) =>
-      language.$('listingPage.listAlbums.byTracks.item', {
-        album: link.album(album),
-        tracks: language.countTracks(album.tracks.length, {unit: true}),
-      }),
-  },
+  data: ({wikiData: {albumData}}) =>
+    sortAlphabetically(albumData.slice()),
 
-  {
-    directory: 'albums/by-duration',
-    stringsKey: 'listAlbums.byDuration',
-
-    data: ({wikiData: {albumData}}) =>
-      albumData
-        .map(album => ({
-          album,
-          duration: getTotalDuration(album.tracks),
-        }))
-        .filter(({duration}) => duration > 0)
-        .sort((a, b) => b.duration - a.duration),
-
-    row: ({album, duration}, {language, link}) =>
-      language.$('listingPage.listAlbums.byDuration.item', {
-        album: link.album(album),
-        duration: language.formatDuration(duration),
-      }),
-  },
-
-  {
-    directory: 'albums/by-date',
-    stringsKey: 'listAlbums.byDate',
-
-    seeAlso: [
-      'tracks/by-date',
-    ],
-
-    data: ({wikiData: {albumData}}) =>
-      sortChronologically(
-        albumData
-          .filter(album => album.date)),
-
-    row: (album, {language, link}) =>
-      language.$('listingPage.listAlbums.byDate.item', {
-        album: link.album(album),
-        date: language.formatDate(album.date),
-      }),
-  },
+  row: (album, {language, link}) =>
+    language.$('listingPage.listAlbums.byName.item', {
+      album: link.album(album),
+      tracks: language.countTracks(album.tracks.length, {unit: true}),
+    }),
+});
 
-  {
-    directory: 'albums/by-date-added',
-    stringsKey: 'listAlbums.byDateAdded',
-
-    data: ({wikiData: {albumData}}) =>
-      chunkByProperties(
-        sortAlphabetically(albumData.filter(a => a.dateAddedToWiki))
-          .sort((a, b) => {
-            if (a.dateAddedToWiki < b.dateAddedToWiki) return -1;
-            if (a.dateAddedToWiki > b.dateAddedToWiki) return 1;
-          }),
-        ['dateAddedToWiki']),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({dateAddedToWiki, chunk: albums}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listAlbums.byDateAdded.date', {
-              date: language.formatDate(dateAddedToWiki),
-            })),
+listingSpec.push({
+  directory: 'albums/by-tracks',
+  stringsKey: 'listAlbums.byTracks',
 
-          html.tag('dd',
-            html.tag('ul',
-              albums.map((album) =>
-                html.tag('li',
-                  language.$('listingPage.listAlbums.byDateAdded.album', {
-                    album: link.album(album),
-                  }))))),
-        ])),
-  },
+  data: ({wikiData: {albumData}}) =>
+    albumData.slice()
+      .sort((a, b) => b.tracks.length - a.tracks.length),
 
-  {
-    directory: 'artists/by-name',
-    stringsKey: 'listArtists.byName',
-
-    data: ({wikiData: {artistData}}) =>
-      sortAlphabetically(artistData.slice())
-        .map(artist => ({
-          artist,
-          contributions: getArtistNumContributions(artist),
-        })),
-
-    row: ({artist, contributions}, {language, link}) =>
-      language.$('listingPage.listArtists.byName.item', {
-        artist: link.artist(artist),
-        contributions: language.countContributions(contributions, {
-          unit: true,
-        }),
-      }),
-  },
+  row: (album, {language, link}) =>
+    language.$('listingPage.listAlbums.byTracks.item', {
+      album: link.album(album),
+      tracks: language.countTracks(album.tracks.length, {unit: true}),
+    }),
+});
 
-  {
-    directory: 'artists/by-contribs',
-    stringsKey: 'listArtists.byContribs',
-
-    data: ({wikiData: {artistData, wikiInfo}}) => ({
-      toTracks: artistData
-        .map(artist => ({
-          artist,
-          contributions:
-            artist.tracksAsContributor.length +
-            artist.tracksAsArtist.length,
-        }))
-        .sort((a, b) => b.contributions - a.contributions)
-        .filter(({contributions}) => contributions),
-
-      toArtAndFlashes: artistData
-        .map(artist => ({
-          artist,
-          contributions:
-            artist.tracksAsCoverArtist.length +
-            artist.albumsAsCoverArtist.length +
-            artist.albumsAsWallpaperArtist.length +
-            artist.albumsAsBannerArtist.length +
-            (wikiInfo.enableFlashesAndGames
-              ? artist.flashesAsContributor.length
-              : 0),
-        }))
-        .sort((a, b) => b.contributions - a.contributions)
-        .filter(({contributions}) => contributions),
+listingSpec.push({
+  directory: 'albums/by-duration',
+  stringsKey: 'listAlbums.byDuration',
 
-      // This is a kinda naughty hack, 8ut like, it's the only place
-      // we'd 8e passing wikiData to html() otherwise, so like....
-      // (Ok we do do this again once later.)
-      showAsFlashes: wikiInfo.enableFlashesAndGames,
+  data: ({wikiData: {albumData}}) =>
+    albumData
+      .map(album => ({
+        album,
+        duration: getTotalDuration(album.tracks),
+      }))
+      .filter(({duration}) => duration > 0)
+      .sort((a, b) => b.duration - a.duration),
+
+  row: ({album, duration}, {language, link}) =>
+    language.$('listingPage.listAlbums.byDuration.item', {
+      album: link.album(album),
+      duration: language.formatDuration(duration),
     }),
+});
 
-    html: (
-      {toTracks, toArtAndFlashes, showAsFlashes},
-      {html, language, link}
-    ) =>
-      html.tag('div', {class: 'content-columns'}, [
-        html.tag('div', {class: 'column'}, [
-          html.tag('h2',
-            language.$('listingPage.misc.trackContributors')),
+listingSpec.push({
+  directory: 'albums/by-date',
+  stringsKey: 'listAlbums.byDate',
 
-          html.tag('ul',
-            toTracks.map(({artist, contributions}) =>
-              html.tag('li',
-                language.$('listingPage.listArtists.byContribs.item', {
-                  artist: link.artist(artist),
-                  contributions: language.countContributions(contributions, {
-                    unit: true,
-                  }),
-                })))),
-        ]),
+  seeAlso: [
+    'tracks/by-date',
+  ],
 
-        html.tag('div', {class: 'column'}, [
-          html.tag('h2',
-            language.$(
-              'listingPage.misc' +
-                (showAsFlashes
-                  ? '.artAndFlashContributors'
-                  : '.artContributors'))),
+  data: ({wikiData: {albumData}}) =>
+    sortChronologically(
+      albumData
+        .filter(album => album.date)),
 
+  row: (album, {language, link}) =>
+    language.$('listingPage.listAlbums.byDate.item', {
+      album: link.album(album),
+      date: language.formatDate(album.date),
+    }),
+});
+
+listingSpec.push({
+  directory: 'albums/by-date-added',
+  stringsKey: 'listAlbums.byDateAdded',
+
+  data: ({wikiData: {albumData}}) =>
+    chunkByProperties(
+      sortAlphabetically(albumData.filter(a => a.dateAddedToWiki))
+        .sort((a, b) => {
+          if (a.dateAddedToWiki < b.dateAddedToWiki) return -1;
+          if (a.dateAddedToWiki > b.dateAddedToWiki) return 1;
+        }),
+      ['dateAddedToWiki']),
+
+  html: (data, {html, language, link}) =>
+    html.tag('dl',
+      data.flatMap(({dateAddedToWiki, chunk: albums}) => [
+        html.tag('dt',
+          {class: ['content-heading']},
+          language.$('listingPage.listAlbums.byDateAdded.date', {
+            date: language.formatDate(dateAddedToWiki),
+          })),
+
+        html.tag('dd',
           html.tag('ul',
-            toArtAndFlashes.map(({artist, contributions}) =>
+            albums.map((album) =>
               html.tag('li',
-                language.$('listingPage.listArtists.byContribs.item', {
-                  artist: link.artist(artist),
-                  contributions:
-                    language.countContributions(contributions, {unit: true}),
-                })))),
-        ]),
-    ]),
-  },
-
-  {
-    directory: 'artists/by-commentary',
-    stringsKey: 'listArtists.byCommentary',
-
-    data: ({wikiData: {artistData}}) =>
-      artistData
-        .map(artist => ({
-          artist,
-          entries:
-            artist.tracksAsCommentator.length +
-            artist.albumsAsCommentator.length,
-        }))
-        .filter(({entries}) => entries)
-        .sort((a, b) => b.entries - a.entries),
-
-    row: ({artist, entries}, {language, link}) =>
-      language.$('listingPage.listArtists.byCommentary.item', {
-        artist: link.artist(artist),
-        entries: language.countCommentaryEntries(entries, {unit: true}),
-      }),
-  },
+                language.$('listingPage.listAlbums.byDateAdded.album', {
+                  album: link.album(album),
+                }))))),
+      ])),
+});
+
+listingSpec.push({
+  directory: 'artists/by-name',
+  stringsKey: 'listArtists.byName',
+
+  data: ({wikiData: {artistData}}) =>
+    sortAlphabetically(artistData.slice())
+      .map(artist => ({
+        artist,
+        contributions: getArtistNumContributions(artist),
+      })),
 
-  {
-    directory: 'artists/by-duration',
-    stringsKey: 'listArtists.byDuration',
-
-    data: ({wikiData: {artistData}}) =>
-      artistData
-        .map((artist) => ({
-          artist,
-          duration: getTotalDuration([
-            ...(artist.tracksAsArtist ?? []),
-            ...(artist.tracksAsContributor ?? []),
-          ], {originalReleasesOnly: true}),
-        }))
-        .filter(({duration}) => duration > 0)
-        .sort((a, b) => b.duration - a.duration),
-
-    row: ({artist, duration}, {language, link}) =>
-      language.$('listingPage.listArtists.byDuration.item', {
-        artist: link.artist(artist),
-        duration: language.formatDuration(duration),
+  row: ({artist, contributions}, {language, link}) =>
+    language.$('listingPage.listArtists.byName.item', {
+      artist: link.artist(artist),
+      contributions: language.countContributions(contributions, {
+        unit: true,
       }),
-  },
-
-  {
-    directory: 'artists/by-latest',
-    stringsKey: 'listArtists.byLatest',
-
-    data({wikiData: {
-      albumData,
-      flashData,
-      trackData,
-      wikiInfo,
-    }}) {
-      const processContribs = values => {
-        const filteredValues = values
-          .filter(value => value.date && !empty(value.contribs));
-
-        const datedArtistLists = sortByDate(filteredValues)
-          .map(({
-            contribs,
-            date,
-          }) => ({
-            artists: contribs.map(({who}) => who),
-            date,
-          }));
-
-        const remainingArtists = new Set(datedArtistLists.flatMap(({artists}) => artists));
-        const artistEntries = [];
-
-        for (let i = datedArtistLists.length - 1; i >= 0; i--) {
-          const {artists, date} = datedArtistLists[i];
-          for (const artist of artists) {
-            if (!remainingArtists.has(artist))
-              continue;
-
-            remainingArtists.delete(artist);
-            artistEntries.push({
-              artist,
-              date,
-
-              // For sortChronologically!
-              directory: artist.directory,
-              name: artist.name,
-            });
-          }
-
-          // Early exit: If we've gotten every artist, there's no need to keep
-          // going.
-          if (remainingArtists.size === 0)
-            break;
-        }
-
-        return sortChronologically(artistEntries, {latestFirst: true});
-      };
-
-      // Tracks are super easy to sort because they only have one pertinent
-      // date: the date the track was released on.
+    }),
+});
+
+listingSpec.push({
+  directory: 'artists/by-contribs',
+  stringsKey: 'listArtists.byContribs',
+
+  data: ({wikiData: {artistData, wikiInfo}}) => ({
+    toTracks: artistData
+      .map(artist => ({
+        artist,
+        contributions:
+          artist.tracksAsContributor.length +
+          artist.tracksAsArtist.length,
+      }))
+      .sort((a, b) => b.contributions - a.contributions)
+      .filter(({contributions}) => contributions),
+
+    toArtAndFlashes: artistData
+      .map(artist => ({
+        artist,
+        contributions:
+          artist.tracksAsCoverArtist.length +
+          artist.albumsAsCoverArtist.length +
+          artist.albumsAsWallpaperArtist.length +
+          artist.albumsAsBannerArtist.length +
+          (wikiInfo.enableFlashesAndGames
+            ? artist.flashesAsContributor.length
+            : 0),
+      }))
+      .sort((a, b) => b.contributions - a.contributions)
+      .filter(({contributions}) => contributions),
+
+    // This is a kinda naughty hack, 8ut like, it's the only place
+    // we'd 8e passing wikiData to html() otherwise, so like....
+    // (Ok we do do this again once later.)
+    showAsFlashes: wikiInfo.enableFlashesAndGames,
+  }),
+
+  html: (
+    {toTracks, toArtAndFlashes, showAsFlashes},
+    {html, language, link}
+  ) =>
+    html.tag('div', {class: 'content-columns'}, [
+      html.tag('div', {class: 'column'}, [
+        html.tag('h2',
+          language.$('listingPage.misc.trackContributors')),
+
+        html.tag('ul',
+          toTracks.map(({artist, contributions}) =>
+            html.tag('li',
+              language.$('listingPage.listArtists.byContribs.item', {
+                artist: link.artist(artist),
+                contributions: language.countContributions(contributions, {
+                  unit: true,
+                }),
+              })))),
+      ]),
 
-      const toTracks = processContribs(
-        trackData.map(({
-          artistContribs,
+      html.tag('div', {class: 'column'}, [
+        html.tag('h2',
+          language.$(
+            'listingPage.misc' +
+              (showAsFlashes
+                ? '.artAndFlashContributors'
+                : '.artContributors'))),
+
+        html.tag('ul',
+          toArtAndFlashes.map(({artist, contributions}) =>
+            html.tag('li',
+              language.$('listingPage.listArtists.byContribs.item', {
+                artist: link.artist(artist),
+                contributions:
+                  language.countContributions(contributions, {unit: true}),
+              })))),
+      ]),
+  ]),
+});
+
+listingSpec.push({
+  directory: 'artists/by-commentary',
+  stringsKey: 'listArtists.byCommentary',
+
+  data: ({wikiData: {artistData}}) =>
+    artistData
+      .map(artist => ({
+        artist,
+        entries:
+          artist.tracksAsCommentator.length +
+          artist.albumsAsCommentator.length,
+      }))
+      .filter(({entries}) => entries)
+      .sort((a, b) => b.entries - a.entries),
+
+  row: ({artist, entries}, {language, link}) =>
+    language.$('listingPage.listArtists.byCommentary.item', {
+      artist: link.artist(artist),
+      entries: language.countCommentaryEntries(entries, {unit: true}),
+    }),
+});
+
+listingSpec.push({
+  directory: 'artists/by-duration',
+  stringsKey: 'listArtists.byDuration',
+
+  data: ({wikiData: {artistData}}) =>
+    artistData
+      .map((artist) => ({
+        artist,
+        duration: getTotalDuration([
+          ...(artist.tracksAsArtist ?? []),
+          ...(artist.tracksAsContributor ?? []),
+        ], {originalReleasesOnly: true}),
+      }))
+      .filter(({duration}) => duration > 0)
+      .sort((a, b) => b.duration - a.duration),
+
+  row: ({artist, duration}, {language, link}) =>
+    language.$('listingPage.listArtists.byDuration.item', {
+      artist: link.artist(artist),
+      duration: language.formatDuration(duration),
+    }),
+});
+
+listingSpec.push({
+  directory: 'artists/by-latest',
+  stringsKey: 'listArtists.byLatest',
+
+  data({wikiData: {
+    albumData,
+    flashData,
+    trackData,
+    wikiInfo,
+  }}) {
+    const processContribs = values => {
+      const filteredValues = values
+        .filter(value => value.date && !empty(value.contribs));
+
+      const datedArtistLists = sortByDate(filteredValues)
+        .map(({
+          contribs,
           date,
         }) => ({
-          contribs: artistContribs,
+          artists: contribs.map(({who}) => who),
           date,
-        })));
+        }));
 
-      // Artworks are a bit more involved because there are multiple dates
-      // involved - cover artists correspond to one date, wallpaper artists to
-      // another, etc.
+      const remainingArtists = new Set(datedArtistLists.flatMap(({artists}) => artists));
+      const artistEntries = [];
 
-      const toArtAndFlashes = processContribs([
-        ...trackData.map(({
-          coverArtistContribs,
-          coverArtDate,
-        }) => ({
-          contribs: coverArtistContribs,
-          date: coverArtDate,
-        })),
-
-        ...flashData
-          ? flashData.map(({
-              contributorContribs,
-              date,
-            }) => ({
-              contribs: contributorContribs,
-              date,
-            }))
-          : [],
-
-        ...albumData.flatMap(({
-          bannerArtistContribs,
-          coverArtistContribs,
-          coverArtDate,
-          date,
-          wallpaperArtistContribs,
-        }) => [
-          {
-            contribs: coverArtistContribs,
-            date: coverArtDate,
-          },
-          {
-            contribs: bannerArtistContribs,
-            date, // TODO: bannerArtDate (see issue #90)
-          },
-          {
-            contribs: wallpaperArtistContribs,
-            date, // TODO: wallpaperArtDate (see issue #90)
-          },
-        ]),
-      ]);
+      for (let i = datedArtistLists.length - 1; i >= 0; i--) {
+        const {artists, date} = datedArtistLists[i];
+        for (const artist of artists) {
+          if (!remainingArtists.has(artist))
+            continue;
 
-      return {
-        toArtAndFlashes,
-        toTracks,
-
-        // (Ok we did it again.)
-        // This is a kinda naughty hack, 8ut like, it's the only place
-        // we'd 8e passing wikiData to html() otherwise, so like....
-        showAsFlashes: wikiInfo.enableFlashesAndGames,
-      };
-    },
-
-    html: (
-      {toTracks, toArtAndFlashes, showAsFlashes},
-      {html, language, link}
-    ) =>
-      html.tag('div', {class: 'content-columns'}, [
-        html.tag('div', {class: 'column'}, [
-          html.tag('h2',
-            language.$('listingPage.misc.trackContributors')),
+          remainingArtists.delete(artist);
+          artistEntries.push({
+            artist,
+            date,
 
-          html.tag('ul',
-            toTracks.map(({artist, date}) =>
-              html.tag('li',
-                language.$('listingPage.listArtists.byLatest.item', {
-                  artist: link.artist(artist),
-                  date: language.formatDate(date),
-                })))),
-        ]),
+            // For sortChronologically!
+            directory: artist.directory,
+            name: artist.name,
+          });
+        }
 
-        html.tag('div', {class: 'column'}, [
-          html.tag('h2',
-            language.$(
-              'listingPage.misc' +
-                (showAsFlashes
-                  ? '.artAndFlashContributors'
-                  : '.artContributors'))),
+        // Early exit: If we've gotten every artist, there's no need to keep
+        // going.
+        if (remainingArtists.size === 0)
+          break;
+      }
+
+      return sortChronologically(artistEntries, {latestFirst: true});
+    };
+
+    // Tracks are super easy to sort because they only have one pertinent
+    // date: the date the track was released on.
+
+    const toTracks = processContribs(
+      trackData.map(({
+        artistContribs,
+        date,
+      }) => ({
+        contribs: artistContribs,
+        date,
+      })));
+
+    // Artworks are a bit more involved because there are multiple dates
+    // involved - cover artists correspond to one date, wallpaper artists to
+    // another, etc.
+
+    const toArtAndFlashes = processContribs([
+      ...trackData.map(({
+        coverArtistContribs,
+        coverArtDate,
+      }) => ({
+        contribs: coverArtistContribs,
+        date: coverArtDate,
+      })),
 
-          html.tag('ul',
-            toArtAndFlashes.map(({artist, date}) =>
-              html.tag('li',
-                language.$('listingPage.listArtists.byLatest.item', {
-                  artist: link.artist(artist),
-                  date: language.formatDate(date),
-                })))),
-        ]),
+      ...flashData
+        ? flashData.map(({
+            contributorContribs,
+            date,
+          }) => ({
+            contribs: contributorContribs,
+            date,
+          }))
+        : [],
+
+      ...albumData.flatMap(({
+        bannerArtistContribs,
+        coverArtistContribs,
+        coverArtDate,
+        date,
+        wallpaperArtistContribs,
+      }) => [
+        {
+          contribs: coverArtistContribs,
+          date: coverArtDate,
+        },
+        {
+          contribs: bannerArtistContribs,
+          date, // TODO: bannerArtDate (see issue #90)
+        },
+        {
+          contribs: wallpaperArtistContribs,
+          date, // TODO: wallpaperArtDate (see issue #90)
+        },
       ]),
-  },
-
-  {
-    directory: 'groups/by-name',
-    stringsKey: 'listGroups.byName',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableGroupUI,
-
-    data: ({wikiData: {groupData}}) =>
-      sortAlphabetically(groupData.slice()),
+    ]);
 
-    row: (group, {language, link}) =>
-      language.$('listingPage.listGroups.byCategory.group', {
-        group: link.groupInfo(group),
-        gallery: link.groupGallery(group, {
-          text: language.$('listingPage.listGroups.byCategory.group.gallery'),
-        }),
-      }),
-  },
-
-  {
-    directory: 'groups/by-category',
-    stringsKey: 'listGroups.byCategory',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableGroupUI,
-
-    data: ({wikiData: {groupCategoryData}}) =>
-      groupCategoryData
-        .map(category => ({
-          category,
-          groups: category.groups,
-        })),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({category, groups}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listGroups.byCategory.category', {
-              category: empty(groups)
-                ? category.name
-                : link.groupInfo(groups[0], {
-                    text: category.name,
-                  }),
-            })),
+    return {
+      toArtAndFlashes,
+      toTracks,
 
-          html.tag('dd',
-            empty(groups)
-              ? null // todo: #85
-              : html.tag('ul',
-                  category.groups.map(group =>
-                    html.tag('li',
-                      language.$('listingPage.listGroups.byCategory.group', {
-                        group: link.groupInfo(group),
-                        gallery: link.groupGallery(group, {
-                          text: language.$('listingPage.listGroups.byCategory.group.gallery'),
-                        }),
-                      }))))),
-        ])),
+      // (Ok we did it again.)
+      // This is a kinda naughty hack, 8ut like, it's the only place
+      // we'd 8e passing wikiData to html() otherwise, so like....
+      showAsFlashes: wikiInfo.enableFlashesAndGames,
+    };
   },
 
-  {
-    directory: 'groups/by-albums',
-    stringsKey: 'listGroups.byAlbums',
+  html: (
+    {toTracks, toArtAndFlashes, showAsFlashes},
+    {html, language, link}
+  ) =>
+    html.tag('div', {class: 'content-columns'}, [
+      html.tag('div', {class: 'column'}, [
+        html.tag('h2',
+          language.$('listingPage.misc.trackContributors')),
+
+        html.tag('ul',
+          toTracks.map(({artist, date}) =>
+            html.tag('li',
+              language.$('listingPage.listArtists.byLatest.item', {
+                artist: link.artist(artist),
+                date: language.formatDate(date),
+              })))),
+      ]),
 
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableGroupUI,
+      html.tag('div', {class: 'column'}, [
+        html.tag('h2',
+          language.$(
+            'listingPage.misc' +
+              (showAsFlashes
+                ? '.artAndFlashContributors'
+                : '.artContributors'))),
+
+        html.tag('ul',
+          toArtAndFlashes.map(({artist, date}) =>
+            html.tag('li',
+              language.$('listingPage.listArtists.byLatest.item', {
+                artist: link.artist(artist),
+                date: language.formatDate(date),
+              })))),
+      ]),
+    ]),
+});
 
-    data: ({wikiData: {groupData}}) =>
-      groupData
-        .map(group => ({
-          group,
-          albums: group.albums.length
-        }))
-        .sort((a, b) => b.albums - a.albums),
-
-    row: ({group, albums}, {language, link}) =>
-      language.$('listingPage.listGroups.byAlbums.item', {
-        group: link.groupInfo(group),
-        albums: language.countAlbums(albums, {unit: true}),
-      }),
-  },
+listingSpec.push({
+  directory: 'groups/by-name',
+  stringsKey: 'listGroups.byName',
 
-  {
-    directory: 'groups/by-tracks',
-    stringsKey: 'listGroups.byTracks',
+  condition: ({wikiData: {wikiInfo}}) =>
+    wikiInfo.enableGroupUI,
 
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableGroupUI,
+  data: ({wikiData: {groupData}}) =>
+    sortAlphabetically(groupData.slice()),
 
-    data: ({wikiData: {groupData}}) =>
-      groupData
-        .map((group) => ({
-          group,
-          tracks: accumulateSum(
-            group.albums,
-            ({tracks}) => tracks.length),
-        }))
-        .sort((a, b) => b.tracks - a.tracks),
-
-    row: ({group, tracks}, {language, link}) =>
-      language.$('listingPage.listGroups.byTracks.item', {
-        group: link.groupInfo(group),
-        tracks: language.countTracks(tracks, {unit: true}),
+  row: (group, {language, link}) =>
+    language.$('listingPage.listGroups.byCategory.group', {
+      group: link.groupInfo(group),
+      gallery: link.groupGallery(group, {
+        text: language.$('listingPage.listGroups.byCategory.group.gallery'),
       }),
-  },
+    }),
+});
 
-  {
-    directory: 'groups/by-duration',
-    stringsKey: 'listGroups.byDuration',
+listingSpec.push({
+  directory: 'groups/by-category',
+  stringsKey: 'listGroups.byCategory',
 
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableGroupUI,
+  condition: ({wikiData: {wikiInfo}}) =>
+    wikiInfo.enableGroupUI,
 
-    data: ({wikiData: {groupData}}) =>
-      groupData
-        .map(group => ({
-          group,
-          duration: getTotalDuration(
-            group.albums.flatMap(album => album.tracks),
-            {originalReleasesOnly: true}),
-        }))
-        .filter(({duration}) => duration > 0)
-        .sort((a, b) => b.duration - a.duration),
-
-    row: ({group, duration}, {language, link}) =>
-      language.$('listingPage.listGroups.byDuration.item', {
-        group: link.groupInfo(group),
-        duration: language.formatDuration(duration),
-      }),
-  },
+  data: ({wikiData: {groupCategoryData}}) =>
+    groupCategoryData
+      .map(category => ({
+        category,
+        groups: category.groups,
+      })),
 
-  {
-    directory: 'groups/by-latest-album',
-    stringsKey: 'listGroups.byLatest',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableGroupUI,
-
-    data: ({wikiData: {groupData}}) =>
-      sortChronologically(
-        groupData
-          .map(group => {
-            const albums = group.albums.filter(a => a.date);
-            return !empty(albums) && {
-              group,
-              directory: group.directory,
-              name: group.name,
-              date: albums[albums.length - 1].date,
-            };
-          })
-          .filter(Boolean),
-        {latestFirst: true}),
-
-    row: ({group, date}, {language, link}) =>
-      language.$('listingPage.listGroups.byLatest.item', {
-        group: link.groupInfo(group),
-        date: language.formatDate(date),
-      }),
-  },
+  html: (data, {html, language, link}) =>
+    html.tag('dl',
+      data.flatMap(({category, groups}) => [
+        html.tag('dt',
+          {class: ['content-heading']},
+          language.$('listingPage.listGroups.byCategory.category', {
+            category: empty(groups)
+              ? category.name
+              : link.groupInfo(groups[0], {
+                  text: category.name,
+                }),
+          })),
+
+        html.tag('dd',
+          empty(groups)
+            ? null // todo: #85
+            : html.tag('ul',
+                category.groups.map(group =>
+                  html.tag('li',
+                    language.$('listingPage.listGroups.byCategory.group', {
+                      group: link.groupInfo(group),
+                      gallery: link.groupGallery(group, {
+                        text: language.$('listingPage.listGroups.byCategory.group.gallery'),
+                      }),
+                    }))))),
+      ])),
+});
+
+listingSpec.push({
+  directory: 'groups/by-albums',
+  stringsKey: 'listGroups.byAlbums',
+
+  condition: ({wikiData: {wikiInfo}}) =>
+    wikiInfo.enableGroupUI,
+
+  data: ({wikiData: {groupData}}) =>
+    groupData
+      .map(group => ({
+        group,
+        albums: group.albums.length
+      }))
+      .sort((a, b) => b.albums - a.albums),
+
+  row: ({group, albums}, {language, link}) =>
+    language.$('listingPage.listGroups.byAlbums.item', {
+      group: link.groupInfo(group),
+      albums: language.countAlbums(albums, {unit: true}),
+    }),
+});
+
+listingSpec.push({
+  directory: 'groups/by-tracks',
+  stringsKey: 'listGroups.byTracks',
+
+  condition: ({wikiData: {wikiInfo}}) =>
+    wikiInfo.enableGroupUI,
+
+  data: ({wikiData: {groupData}}) =>
+    groupData
+      .map((group) => ({
+        group,
+        tracks: accumulateSum(
+          group.albums,
+          ({tracks}) => tracks.length),
+      }))
+      .sort((a, b) => b.tracks - a.tracks),
+
+  row: ({group, tracks}, {language, link}) =>
+    language.$('listingPage.listGroups.byTracks.item', {
+      group: link.groupInfo(group),
+      tracks: language.countTracks(tracks, {unit: true}),
+    }),
+});
+
+listingSpec.push({
+  directory: 'groups/by-duration',
+  stringsKey: 'listGroups.byDuration',
+
+  condition: ({wikiData: {wikiInfo}}) =>
+    wikiInfo.enableGroupUI,
+
+  data: ({wikiData: {groupData}}) =>
+    groupData
+      .map(group => ({
+        group,
+        duration: getTotalDuration(
+          group.albums.flatMap(album => album.tracks),
+          {originalReleasesOnly: true}),
+      }))
+      .filter(({duration}) => duration > 0)
+      .sort((a, b) => b.duration - a.duration),
+
+  row: ({group, duration}, {language, link}) =>
+    language.$('listingPage.listGroups.byDuration.item', {
+      group: link.groupInfo(group),
+      duration: language.formatDuration(duration),
+    }),
+});
 
-  {
-    directory: 'tracks/by-name',
-    stringsKey: 'listTracks.byName',
+listingSpec.push({
+  directory: 'groups/by-latest-album',
+  stringsKey: 'listGroups.byLatest',
 
-    data: ({wikiData: {trackData}}) =>
-      sortAlphabetically(trackData.slice()),
+  condition: ({wikiData: {wikiInfo}}) =>
+    wikiInfo.enableGroupUI,
 
-    row: (track, {language, link}) =>
-      language.$('listingPage.listTracks.byName.item', {
-        track: link.track(track),
-      }),
-  },
+  data: ({wikiData: {groupData}}) =>
+    sortChronologically(
+      groupData
+        .map(group => {
+          const albums = group.albums.filter(a => a.date);
+          return !empty(albums) && {
+            group,
+            directory: group.directory,
+            name: group.name,
+            date: albums[albums.length - 1].date,
+          };
+        })
+        .filter(Boolean),
+      {latestFirst: true}),
+
+  row: ({group, date}, {language, link}) =>
+    language.$('listingPage.listGroups.byLatest.item', {
+      group: link.groupInfo(group),
+      date: language.formatDate(date),
+    }),
+});
 
-  {
-    directory: 'tracks/by-album',
-    stringsKey: 'listTracks.byAlbum',
+listingSpec.push({
+  directory: 'tracks/by-name',
+  stringsKey: 'listTracks.byName',
 
-    data: ({wikiData: {albumData}}) =>
-      albumData.map(album => ({
-        album,
-        tracks: album.tracks,
-      })),
+  data: ({wikiData: {trackData}}) =>
+    sortAlphabetically(trackData.slice()),
 
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({album, tracks}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listTracks.byAlbum.album', {
-              album: link.album(album),
-            })),
+  row: (track, {language, link}) =>
+    language.$('listingPage.listTracks.byName.item', {
+      track: link.track(track),
+    }),
+});
 
-          html.tag('dd',
-            html.tag('ol',
-              tracks.map(track =>
-                html.tag('li',
-                  language.$('listingPage.listTracks.byAlbum.track', {
-                    track: link.track(track),
-                  }))))),
-        ])),
-  },
+listingSpec.push({
+  directory: 'tracks/by-album',
+  stringsKey: 'listTracks.byAlbum',
 
-  {
-    directory: 'tracks/by-date',
-    stringsKey: 'listTracks.byDate',
-
-    data: ({wikiData: {albumData}}) =>
-      chunkByProperties(
-        sortByDate(
-          sortChronologically(albumData)
-            .flatMap(album => album.tracks)
-            .filter(track => track.date)),
-        ['album', 'date']),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({album, date, chunk: tracks}) => [
-          html.tag('dt',
-            language.$('listingPage.listTracks.byDate.album', {
-              album: link.album(album),
-              date: language.formatDate(date),
-            })),
+  data: ({wikiData: {albumData}}) =>
+    albumData.map(album => ({
+      album,
+      tracks: album.tracks,
+    })),
 
-          html.tag('dd',
-            html.tag('ul',
-              tracks.map(track =>
-                track.originalReleaseTrack
-                  ? html.tag('li',
-                      {class: 'rerelease'},
-                      language.$('listingPage.listTracks.byDate.track.rerelease', {
-                        track: link.track(track),
-                      }))
-                  : html.tag('li',
-                      language.$('listingPage.listTracks.byDate.track', {
-                        track: link.track(track),
-                      }))))),
-        ])),
-  },
+  html: (data, {html, language, link}) =>
+    html.tag('dl',
+      data.flatMap(({album, tracks}) => [
+        html.tag('dt',
+          {class: ['content-heading']},
+          language.$('listingPage.listTracks.byAlbum.album', {
+            album: link.album(album),
+          })),
+
+        html.tag('dd',
+          html.tag('ol',
+            tracks.map(track =>
+              html.tag('li',
+                language.$('listingPage.listTracks.byAlbum.track', {
+                  track: link.track(track),
+                }))))),
+      ])),
+});
+
+listingSpec.push({
+  directory: 'tracks/by-date',
+  stringsKey: 'listTracks.byDate',
+
+  data: ({wikiData: {albumData}}) =>
+    chunkByProperties(
+      sortByDate(
+        sortChronologically(albumData)
+          .flatMap(album => album.tracks)
+          .filter(track => track.date)),
+      ['album', 'date']),
+
+  html: (data, {html, language, link}) =>
+    html.tag('dl',
+      data.flatMap(({album, date, chunk: tracks}) => [
+        html.tag('dt',
+          language.$('listingPage.listTracks.byDate.album', {
+            album: link.album(album),
+            date: language.formatDate(date),
+          })),
+
+        html.tag('dd',
+          html.tag('ul',
+            tracks.map(track =>
+              track.originalReleaseTrack
+                ? html.tag('li',
+                    {class: 'rerelease'},
+                    language.$('listingPage.listTracks.byDate.track.rerelease', {
+                      track: link.track(track),
+                    }))
+                : html.tag('li',
+                    language.$('listingPage.listTracks.byDate.track', {
+                      track: link.track(track),
+                    }))))),
+      ])),
+});
+
+listingSpec.push({
+  directory: 'tracks/by-duration',
+  stringsKey: 'listTracks.byDuration',
+
+  data: ({wikiData: {trackData}}) =>
+    trackData
+      .map(track => ({
+        track,
+        duration: track.duration
+      }))
+      .filter(({duration}) => duration > 0)
+      .sort((a, b) => b.duration - a.duration),
+
+  row: ({track, duration}, {language, link}) =>
+    language.$('listingPage.listTracks.byDuration.item', {
+      track: link.track(track),
+      duration: language.formatDuration(duration),
+    }),
+});
+
+listingSpec.push({
+  directory: 'tracks/by-duration-in-album',
+  stringsKey: 'listTracks.byDurationInAlbum',
+
+  data: ({wikiData: {albumData}}) =>
+    albumData.map(album => ({
+      album,
+      tracks: album.tracks
+        .slice()
+        .sort((a, b) => (b.duration ?? 0) - (a.duration ?? 0)),
+    })),
+
+  html: (data, {html, language, link}) =>
+    html.tag('dl',
+      data.flatMap(({album, tracks}) => [
+        html.tag('dt',
+          {class: ['content-heading']},
+          language.$('listingPage.listTracks.byDurationInAlbum.album', {
+            album: link.album(album),
+          })),
 
-  {
-    directory: 'tracks/by-duration',
-    stringsKey: 'listTracks.byDuration',
-
-    data: ({wikiData: {trackData}}) =>
-      trackData
-        .map(track => ({
-          track,
-          duration: track.duration
-        }))
-        .filter(({duration}) => duration > 0)
-        .sort((a, b) => b.duration - a.duration),
-
-    row: ({track, duration}, {language, link}) =>
-      language.$('listingPage.listTracks.byDuration.item', {
-        track: link.track(track),
-        duration: language.formatDuration(duration),
+        html.tag('dd',
+          html.tag('ul',
+            tracks.map(track =>
+              html.tag('li',
+                language.$('listingPage.listTracks.byDurationInAlbum.track', {
+                  track: link.track(track),
+                  duration: language.formatDuration(track.duration ?? 0),
+                }))))),
+      ])),
+});
+
+listingSpec.push({
+  directory: 'tracks/by-times-referenced',
+  stringsKey: 'listTracks.byTimesReferenced',
+
+  data: ({wikiData: {trackData}}) =>
+    trackData
+      .map(track => ({
+        track,
+        timesReferenced: track.referencedByTracks.length,
+      }))
+      .filter(({timesReferenced}) => timesReferenced)
+      .sort((a, b) => b.timesReferenced - a.timesReferenced),
+
+  row: ({track, timesReferenced}, {language, link}) =>
+    language.$('listingPage.listTracks.byTimesReferenced.item', {
+      track: link.track(track),
+      timesReferenced: language.countTimesReferenced(timesReferenced, {
+        unit: true,
       }),
-  },
-
-  {
-    directory: 'tracks/by-duration-in-album',
-    stringsKey: 'listTracks.byDurationInAlbum',
-
-    data: ({wikiData: {albumData}}) =>
-      albumData.map(album => ({
-        album,
-        tracks: album.tracks
-          .slice()
-          .sort((a, b) => (b.duration ?? 0) - (a.duration ?? 0)),
+    }),
+});
+
+listingSpec.push({
+  directory: 'tracks/in-flashes/by-album',
+  stringsKey: 'listTracks.inFlashes.byAlbum',
+
+  condition: ({wikiData: {wikiInfo}}) =>
+    wikiInfo.enableFlashesAndGames,
+
+  data: ({wikiData: {trackData}}) =>
+    chunkByProperties(
+      trackData.filter(t => !empty(t.featuredInFlashes)),
+      ['album']),
+
+  html: (data, {html, language, link}) =>
+    html.tag('dl',
+      data.flatMap(({album, chunk: tracks}) => [
+        html.tag('dt',
+          {class: ['content-heading']},
+          language.$('listingPage.listTracks.inFlashes.byAlbum.album', {
+            album: link.album(album),
+            date: language.formatDate(album.date),
+          })),
+
+        html.tag('dd',
+          html.tag('ul',
+            tracks.map(track =>
+              html.tag('li',
+                language.$('listingPage.listTracks.inFlashes.byAlbum.track', {
+                  track: link.track(track),
+                  flashes: language.formatConjunctionList(
+                    track.featuredInFlashes.map(link.flash)),
+                }))))),
+      ])),
+});
+
+listingSpec.push({
+  directory: 'tracks/in-flashes/by-flash',
+  stringsKey: 'listTracks.inFlashes.byFlash',
+
+  condition: ({wikiData: {wikiInfo}}) =>
+    wikiInfo.enableFlashesAndGames,
+
+  data: ({wikiData: {flashData}}) =>
+    sortChronologically(flashData.slice())
+      .map(flash => ({
+        flash,
+        tracks: flash.featuredTracks,
       })),
 
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({album, tracks}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listTracks.byDurationInAlbum.album', {
-              album: link.album(album),
-            })),
-
-          html.tag('dd',
-            html.tag('ul',
-              tracks.map(track =>
-                html.tag('li',
-                  language.$('listingPage.listTracks.byDurationInAlbum.track', {
-                    track: link.track(track),
-                    duration: language.formatDuration(track.duration ?? 0),
-                  }))))),
-        ])),
-  },
-
-  {
-    directory: 'tracks/by-times-referenced',
-    stringsKey: 'listTracks.byTimesReferenced',
-
-    data: ({wikiData: {trackData}}) =>
-      trackData
-        .map(track => ({
-          track,
-          timesReferenced: track.referencedByTracks.length,
-        }))
-        .filter(({timesReferenced}) => timesReferenced)
-        .sort((a, b) => b.timesReferenced - a.timesReferenced),
-
-    row: ({track, timesReferenced}, {language, link}) =>
-      language.$('listingPage.listTracks.byTimesReferenced.item', {
-        track: link.track(track),
-        timesReferenced: language.countTimesReferenced(timesReferenced, {
-          unit: true,
-        }),
-      }),
-  },
-
-  {
-    directory: 'tracks/in-flashes/by-album',
-    stringsKey: 'listTracks.inFlashes.byAlbum',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableFlashesAndGames,
-
-    data: ({wikiData: {trackData}}) =>
-      chunkByProperties(
-        trackData.filter(t => !empty(t.featuredInFlashes)),
-        ['album']),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({album, chunk: tracks}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listTracks.inFlashes.byAlbum.album', {
-              album: link.album(album),
-              date: language.formatDate(album.date),
-            })),
-
-          html.tag('dd',
-            html.tag('ul',
-              tracks.map(track =>
-                html.tag('li',
-                  language.$('listingPage.listTracks.inFlashes.byAlbum.track', {
-                    track: link.track(track),
-                    flashes: language.formatConjunctionList(
-                      track.featuredInFlashes.map(link.flash)),
-                  }))))),
-        ])),
-  },
-
-  {
-    directory: 'tracks/in-flashes/by-flash',
-    stringsKey: 'listTracks.inFlashes.byFlash',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableFlashesAndGames,
-
-    data: ({wikiData: {flashData}}) =>
-      sortChronologically(flashData.slice())
-        .map(flash => ({
-          flash,
-          tracks: flash.featuredTracks,
-        })),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({flash, tracks}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listTracks.inFlashes.byFlash.flash', {
-              flash: link.flash(flash),
-              date: language.formatDate(flash.date),
-            })),
-
-          html.tag('dd',
-            html.tag('ul',
-              tracks.map(track =>
-                html.tag('li',
-                  language.$('listingPage.listTracks.inFlashes.byFlash.track', {
-                    track: link.track(track),
-                    album: link.album(track.album),
-                  }))))),
-        ])),
-  },
-
-  {
-    directory: 'tracks/with-lyrics',
-    stringsKey: 'listTracks.withLyrics',
-
-    data: ({wikiData: {albumData}}) =>
-      albumData
-        .map(album => ({
-          album,
-          tracks: album.tracks.filter(t => t.lyrics),
-        }))
-        .filter(({tracks}) => !empty(tracks)),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({album, tracks}) => [
-          html.tag('dt',
-            {class: ['content-heading']},
-            language.$('listingPage.listTracks.withLyrics.album', {
-              album: link.album(album),
-              date: language.formatDate(album.date),
-            })),
-
-          html.tag('dd',
-            html.tag('ul',
-              tracks.map(track =>
-                html.tag('li',
-                  language.$('listingPage.listTracks.withLyrics.track', {
-                    track: link.track(track),
-                  }))))),
-        ])),
-  },
-
-  {
-    directory: 'tracks/with-sheet-music-files',
-    stringsKey: 'listTracks.withSheetMusicFiles',
-
-    seeAlso: [
-      'all-sheet-music',
-    ],
-
-    data: ({wikiData: {albumData}}) =>
-      albumData
-        .map(album => ({
-          album,
-          tracks: album.tracks.filter(t => !empty(t.sheetMusicFiles)),
-        }))
-        .filter(({tracks}) => !empty(tracks)),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({album, tracks}) => [
-          html.tag('dt',
-            {class: 'content-heading'},
-            language.$('listingPage.listTracks.withSheetMusicFiles.album', {
-              album: link.album(album),
-              date: language.formatDate(album.date),
-            })),
+  html: (data, {html, language, link}) =>
+    html.tag('dl',
+      data.flatMap(({flash, tracks}) => [
+        html.tag('dt',
+          {class: ['content-heading']},
+          language.$('listingPage.listTracks.inFlashes.byFlash.flash', {
+            flash: link.flash(flash),
+            date: language.formatDate(flash.date),
+          })),
+
+        html.tag('dd',
+          html.tag('ul',
+            tracks.map(track =>
+              html.tag('li',
+                language.$('listingPage.listTracks.inFlashes.byFlash.track', {
+                  track: link.track(track),
+                  album: link.album(track.album),
+                }))))),
+      ])),
+});
+
+listingSpec.push({
+  directory: 'tracks/with-lyrics',
+  stringsKey: 'listTracks.withLyrics',
+
+  data: ({wikiData: {albumData}}) =>
+    albumData
+      .map(album => ({
+        album,
+        tracks: album.tracks.filter(t => t.lyrics),
+      }))
+      .filter(({tracks}) => !empty(tracks)),
 
-          html.tag('dd',
-            html.tag('ul',
-              tracks.map(track =>
-                html.tag('li',
-                  language.$('listingPage.listTracks.withSheetMusicFiles.track', {
-                    track: link.track(track, {
-                      hash: 'sheet-music-files',
-                    }),
-                  }))))),
-        ])),
-  },
+  html: (data, {html, language, link}) =>
+    html.tag('dl',
+      data.flatMap(({album, tracks}) => [
+        html.tag('dt',
+          {class: ['content-heading']},
+          language.$('listingPage.listTracks.withLyrics.album', {
+            album: link.album(album),
+            date: language.formatDate(album.date),
+          })),
+
+        html.tag('dd',
+          html.tag('ul',
+            tracks.map(track =>
+              html.tag('li',
+                language.$('listingPage.listTracks.withLyrics.track', {
+                  track: link.track(track),
+                }))))),
+      ])),
+});
+
+listingSpec.push({
+  directory: 'tracks/with-sheet-music-files',
+  stringsKey: 'listTracks.withSheetMusicFiles',
+
+  seeAlso: [
+    'all-sheet-music',
+  ],
+
+  data: ({wikiData: {albumData}}) =>
+    albumData
+      .map(album => ({
+        album,
+        tracks: album.tracks.filter(t => !empty(t.sheetMusicFiles)),
+      }))
+      .filter(({tracks}) => !empty(tracks)),
 
-  {
-    directory: 'tracks/with-midi-project-files',
-    stringsKey: 'listTracks.withMidiProjectFiles',
+  html: (data, {html, language, link}) =>
+    html.tag('dl',
+      data.flatMap(({album, tracks}) => [
+        html.tag('dt',
+          {class: 'content-heading'},
+          language.$('listingPage.listTracks.withSheetMusicFiles.album', {
+            album: link.album(album),
+            date: language.formatDate(album.date),
+          })),
+
+        html.tag('dd',
+          html.tag('ul',
+            tracks.map(track =>
+              html.tag('li',
+                language.$('listingPage.listTracks.withSheetMusicFiles.track', {
+                  track: link.track(track, {
+                    hash: 'sheet-music-files',
+                  }),
+                }))))),
+      ])),
+});
 
-    data: ({wikiData: {albumData}}) =>
-      albumData
-        .map(album => ({
-          album,
-          tracks: album.tracks.filter(t => !empty(t.midiProjectFiles)),
-        }))
-        .filter(({tracks}) => !empty(tracks)),
-
-    html: (data, {html, language, link}) =>
-      html.tag('dl',
-        data.flatMap(({album, tracks}) => [
-          html.tag('dt',
-            {class: 'content-heading'},
-            language.$('listingPage.listTracks.withMidiProjectFiles.album', {
-              album: link.album(album),
-              date: language.formatDate(album.date),
-            })),
+listingSpec.push({
+  directory: 'tracks/with-midi-project-files',
+  stringsKey: 'listTracks.withMidiProjectFiles',
 
-          html.tag('dd',
-            html.tag('ul',
-              tracks.map(track =>
-                html.tag('li',
-                  language.$('listingPage.listTracks.withMidiProjectFiles.track', {
-                    track: link.track(track, {
-                      hash: 'midi-project-files',
-                    }),
-                  }))))),
-        ])),
-  },
+  data: ({wikiData: {albumData}}) =>
+    albumData
+      .map(album => ({
+        album,
+        tracks: album.tracks.filter(t => !empty(t.midiProjectFiles)),
+      }))
+      .filter(({tracks}) => !empty(tracks)),
 
-  {
-    directory: 'tags/by-name',
-    stringsKey: 'listTags.byName',
-
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableArtTagUI,
-
-    data: ({wikiData: {artTagData}}) =>
-      sortAlphabetically(
-        artTagData
-          .filter(tag => !tag.isContentWarning)
-          .map(tag => ({
-            tag,
-            timesUsed: tag.taggedInThings.length,
-
-            // For sortAlphabetically!
-            directory: tag.directory,
-            name: tag.name,
-          }))),
-
-    row: ({tag, timesUsed}, {language, link}) =>
-      language.$('listingPage.listTags.byName.item', {
-        tag: link.tag(tag),
-        timesUsed: language.countTimesUsed(timesUsed, {unit: true}),
-      }),
-  },
+  html: (data, {html, language, link}) =>
+    html.tag('dl',
+      data.flatMap(({album, tracks}) => [
+        html.tag('dt',
+          {class: 'content-heading'},
+          language.$('listingPage.listTracks.withMidiProjectFiles.album', {
+            album: link.album(album),
+            date: language.formatDate(album.date),
+          })),
+
+        html.tag('dd',
+          html.tag('ul',
+            tracks.map(track =>
+              html.tag('li',
+                language.$('listingPage.listTracks.withMidiProjectFiles.track', {
+                  track: link.track(track, {
+                    hash: 'midi-project-files',
+                  }),
+                }))))),
+      ])),
+});
 
-  {
-    directory: 'tags/by-uses',
-    stringsKey: 'listTags.byUses',
+listingSpec.push({
+  directory: 'tags/by-name',
+  stringsKey: 'listTags.byName',
 
-    condition: ({wikiData: {wikiInfo}}) =>
-      wikiInfo.enableArtTagUI,
+  condition: ({wikiData: {wikiInfo}}) =>
+    wikiInfo.enableArtTagUI,
 
-    data: ({wikiData: {artTagData}}) =>
+  data: ({wikiData: {artTagData}}) =>
+    sortAlphabetically(
       artTagData
         .filter(tag => !tag.isContentWarning)
         .map(tag => ({
           tag,
-          timesUsed: tag.taggedInThings.length
-        }))
-        .sort((a, b) => b.timesUsed - a.timesUsed),
-
-    row: ({tag, timesUsed}, {language, link}) =>
-      language.$('listingPage.listTags.byUses.item', {
-        tag: link.tag(tag),
-        timesUsed: language.countTimesUsed(timesUsed, {unit: true}),
-      }),
-  },
-
-  {
-    directory: 'all-sheet-music',
-    stringsKey: 'other.allSheetMusic',
-    groupUnderOther: true,
+          timesUsed: tag.taggedInThings.length,
 
-    seeAlso: [
-      'tracks/with-sheet-music-files',
-    ],
+          // For sortAlphabetically!
+          directory: tag.directory,
+          name: tag.name,
+        }))),
 
-    data: ({wikiData: {albumData}}) =>
-      albumData
-        .map(album => ({
-          album,
-          tracks: album.tracks.filter(t => !empty(t.sheetMusicFiles)),
-        }))
-        .filter(({tracks}) => !empty(tracks)),
-
-    html: (data, {
-      html,
-      language,
-      link,
-    }) =>
-      data.flatMap(({album, tracks}) => [
-        html.tag('h3', link.album(album)),
-
-        html.tag('dl', tracks.flatMap(track => [
-          // No hash here since the full list of sheet music is already visible below.
-          // The track link serves more as a quick way to recall which track it is or
-          // visit listening links, all of which is positioned at the top of the page.
-          html.tag('dt', link.track(track)),
-          html.tag('dd',
-            // This page doesn't really look better with color-coded sheet music links.
-            // Track links are still colored.
-            /* {style: getLinkThemeString(track.color)}, */
-            html.tag('ul', track.sheetMusicFiles.map(({title, files}) =>
-              html.tag('li',
-                {class: [files.length > 1 && 'has-details']},
-                (files.length === 1
-                  ? link.albumAdditionalFile(
-                      {album, file: files[0]},
-                      {
-                        text: language.$('listingPage.other.allSheetMusic.sheetMusicLink', {title}),
-                      })
-                  : html.tag('details', [
-                      html.tag('summary',
-                        html.tag('span',
-                          language.$('listingPage.other.allSheetMusic.sheetMusicLink.withMultipleFiles', {
-                            title: html.tag('span', {class: 'group-name'}, title),
-                            files: language.countAdditionalFiles(files.length, {unit: true}),
-                          }))),
-                      html.tag('ul', files.map(file =>
-                        html.tag('li',
-                          link.albumAdditionalFile({album, file})))),
-                    ])))))),
-        ])),
-      ]),
-  },
-
-  {
-    directory: 'random',
-    stringsKey: 'other.randomPages',
-    groupUnderOther: true,
-
-    data: ({wikiData: {albumData}}) => [
-      {
-        name: 'Official',
-        randomCode: 'official',
-        albums: albumData
-          .filter((album) => album.groups
-            .some((group) => group.directory === OFFICIAL_GROUP_DIRECTORY)),
-      },
-      {
-        name: 'Fandom',
-        randomCode: 'fandom',
-        albums: albumData
-          .filter((album) => album.groups
-            .every((group) => group.directory !== OFFICIAL_GROUP_DIRECTORY)),
-      },
-    ],
-
-    html: (data, {getLinkThemeString, html}) =>
-      html.fragment([
-        html.tag('p',
-          `Choose a link to go to a random page in that category or album! If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry.`),
-
-        html.tag('p',
-          {class: 'js-hide-once-data'},
-          `(Data files are downloading in the background! Please wait for data to load.)`),
-
-        html.tag('p',
-          {class: 'js-show-once-data'},
-          `(Data files have finished being downloaded. The links should work!)`),
-
-        html.tag('dl', [
-          html.tag('dt',
-            `Miscellaneous:`),
-
-          html.tag('dd',
-            html.tag('ul', [
-              html.tag('li', [
-                html.tag('a',
-                  {href: '#', 'data-random': 'artist'},
-                  `Random Artist`),
-                '(' +
-                  html.tag('a',
-                    {href: '#', 'data-random': 'artist-more-than-one-contrib'},
-                    `&gt;1 contribution`) +
-                  ')',
-              ]),
+  row: ({tag, timesUsed}, {language, link}) =>
+    language.$('listingPage.listTags.byName.item', {
+      tag: link.tag(tag),
+      timesUsed: language.countTimesUsed(timesUsed, {unit: true}),
+    }),
+});
+
+listingSpec.push({
+  directory: 'tags/by-uses',
+  stringsKey: 'listTags.byUses',
+
+  condition: ({wikiData: {wikiInfo}}) =>
+    wikiInfo.enableArtTagUI,
+
+  data: ({wikiData: {artTagData}}) =>
+    artTagData
+      .filter(tag => !tag.isContentWarning)
+      .map(tag => ({
+        tag,
+        timesUsed: tag.taggedInThings.length
+      }))
+      .sort((a, b) => b.timesUsed - a.timesUsed),
+
+  row: ({tag, timesUsed}, {language, link}) =>
+    language.$('listingPage.listTags.byUses.item', {
+      tag: link.tag(tag),
+      timesUsed: language.countTimesUsed(timesUsed, {unit: true}),
+    }),
+});
 
-              html.tag('li',
-                html.tag('a',
-                  {href: '#', 'data-random': 'album'},
-                  `Random Album (whole site)`)),
+listingSpec.push({
+  directory: 'all-sheet-music',
+  stringsKey: 'other.allSheetMusic',
+  groupUnderOther: true,
 
-              html.tag('li',
-                html.tag('a',
-                  {href: '#', 'data-random': 'track'},
-                  `Random Track (whole site)`)),
-            ])),
+  seeAlso: [
+    'tracks/with-sheet-music-files',
+  ],
 
-          ...data.flatMap(({albums, name, randomCode}) => [
-            html.tag('dt', [
-              name + ':',
+  data: ({wikiData: {albumData}}) =>
+    albumData
+      .map(album => ({
+        album,
+        tracks: album.tracks.filter(t => !empty(t.sheetMusicFiles)),
+      }))
+      .filter(({tracks}) => !empty(tracks)),
+
+  html: (data, {
+    html,
+    language,
+    link,
+  }) =>
+    data.flatMap(({album, tracks}) => [
+      html.tag('h3', link.album(album)),
+
+      html.tag('dl', tracks.flatMap(track => [
+        // No hash here since the full list of sheet music is already visible below.
+        // The track link serves more as a quick way to recall which track it is or
+        // visit listening links, all of which is positioned at the top of the page.
+        html.tag('dt', link.track(track)),
+        html.tag('dd',
+          // This page doesn't really look better with color-coded sheet music links.
+          // Track links are still colored.
+          /* {style: getLinkThemeString(track.color)}, */
+          html.tag('ul', track.sheetMusicFiles.map(({title, files}) =>
+            html.tag('li',
+              {class: [files.length > 1 && 'has-details']},
+              (files.length === 1
+                ? link.albumAdditionalFile(
+                    {album, file: files[0]},
+                    {
+                      text: language.$('listingPage.other.allSheetMusic.sheetMusicLink', {title}),
+                    })
+                : html.tag('details', [
+                    html.tag('summary',
+                      html.tag('span',
+                        language.$('listingPage.other.allSheetMusic.sheetMusicLink.withMultipleFiles', {
+                          title: html.tag('span', {class: 'group-name'}, title),
+                          files: language.countAdditionalFiles(files.length, {unit: true}),
+                        }))),
+                    html.tag('ul', files.map(file =>
+                      html.tag('li',
+                        link.albumAdditionalFile({album, file})))),
+                  ])))))),
+      ])),
+    ]),
+});
+
+listingSpec.push({
+  directory: 'random',
+  stringsKey: 'other.randomPages',
+  groupUnderOther: true,
+
+  data: ({wikiData: {albumData}}) => [
+    {
+      name: 'Official',
+      randomCode: 'official',
+      albums: albumData
+        .filter((album) => album.groups
+          .some((group) => group.directory === OFFICIAL_GROUP_DIRECTORY)),
+    },
+    {
+      name: 'Fandom',
+      randomCode: 'fandom',
+      albums: albumData
+        .filter((album) => album.groups
+          .every((group) => group.directory !== OFFICIAL_GROUP_DIRECTORY)),
+    },
+  ],
+
+  html: (data, {getLinkThemeString, html}) =>
+    html.fragment([
+      html.tag('p',
+        `Choose a link to go to a random page in that category or album! If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry.`),
+
+      html.tag('p',
+        {class: 'js-hide-once-data'},
+        `(Data files are downloading in the background! Please wait for data to load.)`),
+
+      html.tag('p',
+        {class: 'js-show-once-data'},
+        `(Data files have finished being downloaded. The links should work!)`),
+
+      html.tag('dl', [
+        html.tag('dt',
+          `Miscellaneous:`),
+
+        html.tag('dd',
+          html.tag('ul', [
+            html.tag('li', [
+              html.tag('a',
+                {href: '#', 'data-random': 'artist'},
+                `Random Artist`),
               '(' +
                 html.tag('a',
-                  {href: '#', 'data-random': 'album-in-' + randomCode},
-                  `Random Album`) +
-                ', ' +
-                html.tag('a',
-                  {href: '#', 'data-random': 'track-in' + randomCode},
-                  'Random Track') +
+                  {href: '#', 'data-random': 'artist-more-than-one-contrib'},
+                  `&gt;1 contribution`) +
                 ')',
             ]),
 
-            html.tag('dd',
-              html.tag('ul',
-                albums.map(album =>
-                  html.tag('li',
-                    html.tag('a',
-                      {
-                        href: '#',
-                        'data-random': 'track-in-album',
-                        style: getLinkThemeString(album.color) +
-                          `; --album-directory: ${album.directory}`,
-                      },
-                      album.name))))),
+            html.tag('li',
+              html.tag('a',
+                {href: '#', 'data-random': 'album'},
+                `Random Album (whole site)`)),
+
+            html.tag('li',
+              html.tag('a',
+                {href: '#', 'data-random': 'track'},
+                `Random Track (whole site)`)),
+          ])),
+
+        ...data.flatMap(({albums, name, randomCode}) => [
+          html.tag('dt', [
+            name + ':',
+            '(' +
+              html.tag('a',
+                {href: '#', 'data-random': 'album-in-' + randomCode},
+                `Random Album`) +
+              ', ' +
+              html.tag('a',
+                {href: '#', 'data-random': 'track-in' + randomCode},
+                'Random Track') +
+              ')',
           ]),
+
+          html.tag('dd',
+            html.tag('ul',
+              albums.map(album =>
+                html.tag('li',
+                  html.tag('a',
+                    {
+                      href: '#',
+                      'data-random': 'track-in-album',
+                      style: getLinkThemeString(album.color) +
+                        `; --album-directory: ${album.directory}`,
+                    },
+                    album.name))))),
         ]),
       ]),
-  },
-];
+    ]),
+});
 
 const filterListings = (directoryPrefix) =>
   listingSpec.filter(l => l.directory.startsWith(directoryPrefix));