« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/listing-spec.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/listing-spec.js')
-rw-r--r--src/listing-spec.js1251
1 files changed, 284 insertions, 967 deletions
diff --git a/src/listing-spec.js b/src/listing-spec.js
index e01912c..73fbee6 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -1,995 +1,312 @@
-import {
-  empty,
-  accumulateSum,
-} from './util/sugar.js';
-
-import {
-  chunkByProperties,
-  getArtistNumContributions,
-  getTotalDuration,
-  sortAlphabetically,
-  sortByDate,
-  sortChronologically,
-} from './util/wiki-data.js';
-
-const listingSpec = [
-  {
-    directory: 'albums/by-name',
-    stringsKey: 'listAlbums.byName',
-
-    data: ({wikiData: {albumData}}) =>
-      sortAlphabetically(albumData.slice()),
-
-    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}),
-      }),
-  },
-
-  {
-    directory: 'albums/by-duration',
-    stringsKey: 'listAlbums.byDuration',
-
-    data: ({wikiData: {albumData}}) =>
-      albumData
-        .map(album => ({
-          album,
-          duration: getTotalDuration(album.tracks),
-        }))
-        .filter(album => album.duration)
-        .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',
-
-    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),
-      }),
-  },
-
-  {
-    directory: 'albums/by-date-added',
-    stringsKey: 'listAlbums.byDateAdded',
-
-    data: ({wikiData: {albumData}}) =>
-      chunkByProperties(
-        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',
-              albums.map((album) =>
-                html.tag('li',
-                  language.$('listingPage.listAlbums.byDateAdded.album', {
-                    album: link.album(album),
-                  }))))),
-        ])),
-  },
-
-  {
-    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,
-        }),
-      }),
-  },
-
-  {
-    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,
-                  }),
-                })))),
-        ]),
-
-        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}),
-                })))),
-        ]),
-    ]),
-  },
-
-  {
-    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}),
-      }),
-  },
-
-  {
-    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),
-      }),
-  },
-
-  {
-    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;
+import {showAggregate} from '#aggregate';
+import {empty} from '#sugar';
+
+const listingSpec = [];
+
+listingSpec.push({
+  directory: 'albums/by-name',
+  stringsKey: 'listAlbums.byName',
+  contentFunction: 'listAlbumsByName',
+
+  seeAlso: [
+    'tracks/by-album',
+  ],
+});
+
+listingSpec.push({
+  directory: 'albums/by-tracks',
+  stringsKey: 'listAlbums.byTracks',
+  contentFunction: 'listAlbumsByTracks',
+});
+
+listingSpec.push({
+  directory: 'albums/by-duration',
+  stringsKey: 'listAlbums.byDuration',
+  contentFunction: 'listAlbumsByDuration',
+});
+
+listingSpec.push({
+  directory: 'albums/by-date',
+  stringsKey: 'listAlbums.byDate',
+  contentFunction: 'listAlbumsByDate',
+
+  seeAlso: [
+    'tracks/by-date',
+  ],
+});
+
+listingSpec.push({
+  directory: 'albums/by-date-added',
+  stringsKey: 'listAlbums.byDateAdded',
+  contentFunction: 'listAlbumsByDateAdded',
+});
+
+listingSpec.push({
+  directory: 'artists/by-name',
+  stringsKey: 'listArtists.byName',
+  contentFunction: 'listArtistsByName',
+  seeAlso: ['artists/by-contribs', 'artists/by-group'],
+});
+
+listingSpec.push({
+  directory: 'artists/by-contribs',
+  stringsKey: 'listArtists.byContribs',
+  contentFunction: 'listArtistsByContributions',
+  seeAlso: ['artists/by-name', 'artists/by-group'],
+});
+
+listingSpec.push({
+  directory: 'artists/by-commentary',
+  stringsKey: 'listArtists.byCommentary',
+  contentFunction: 'listArtistsByCommentaryEntries',
+});
+
+listingSpec.push({
+  directory: 'artists/by-duration',
+  stringsKey: 'listArtists.byDuration',
+  contentFunction: 'listArtistsByDuration',
+});
+
+// TODO: hide if no groups...
+listingSpec.push({
+  directory: 'artists/by-group',
+  stringsKey: 'listArtists.byGroup',
+  contentFunction: 'listArtistsByGroup',
+  featureFlag: 'enableGroupUI',
+  seeAlso: ['artists/by-name', 'artists/by-contribs'],
+});
+
+listingSpec.push({
+  directory: 'artists/by-latest',
+  stringsKey: 'listArtists.byLatest',
+  contentFunction: 'listArtistsByLatestContribution',
+});
+
+listingSpec.push({
+  directory: 'groups/by-name',
+  stringsKey: 'listGroups.byName',
+  contentFunction: 'listGroupsByName',
+  featureFlag: 'enableGroupUI',
+});
+
+listingSpec.push({
+  directory: 'groups/by-category',
+  stringsKey: 'listGroups.byCategory',
+  contentFunction: 'listGroupsByCategory',
+  featureFlag: 'enableGroupUI',
+});
+
+listingSpec.push({
+  directory: 'groups/by-albums',
+  stringsKey: 'listGroups.byAlbums',
+  contentFunction: 'listGroupsByAlbums',
+  featureFlag: 'enableGroupUI',
+});
+
+listingSpec.push({
+  directory: 'groups/by-tracks',
+  stringsKey: 'listGroups.byTracks',
+  contentFunction: 'listGroupsByTracks',
+  featureFlag: 'enableGroupUI',
+});
+
+listingSpec.push({
+  directory: 'groups/by-duration',
+  stringsKey: 'listGroups.byDuration',
+  contentFunction: 'listGroupsByDuration',
+  featureFlag: 'enableGroupUI',
+});
+
+listingSpec.push({
+  directory: 'groups/by-latest-album',
+  stringsKey: 'listGroups.byLatest',
+  contentFunction: 'listGroupsByLatestAlbum',
+  featureFlag: 'enableGroupUI',
+});
+
+listingSpec.push({
+  directory: 'tracks/by-name',
+  stringsKey: 'listTracks.byName',
+  contentFunction: 'listTracksByName',
+});
+
+listingSpec.push({
+  directory: 'tracks/by-album',
+  stringsKey: 'listTracks.byAlbum',
+  contentFunction: 'listTracksByAlbum',
+});
+
+listingSpec.push({
+  directory: 'tracks/by-date',
+  stringsKey: 'listTracks.byDate',
+  contentFunction: 'listTracksByDate',
+});
+
+listingSpec.push({
+  directory: 'tracks/by-duration',
+  stringsKey: 'listTracks.byDuration',
+  contentFunction: 'listTracksByDuration',
+});
+
+listingSpec.push({
+  directory: 'tracks/by-duration-in-album',
+  stringsKey: 'listTracks.byDurationInAlbum',
+  contentFunction: 'listTracksByDurationInAlbum',
+});
+
+listingSpec.push({
+  directory: 'tracks/by-times-referenced',
+  stringsKey: 'listTracks.byTimesReferenced',
+  contentFunction: 'listTracksByTimesReferenced',
+});
+
+listingSpec.push({
+  directory: 'tracks/in-flashes/by-album',
+  stringsKey: 'listTracks.inFlashes.byAlbum',
+  contentFunction: 'listTracksInFlashesByAlbum',
+  featureFlag: 'enableFlashesAndGames',
+});
+
+listingSpec.push({
+  directory: 'tracks/in-flashes/by-flash',
+  stringsKey: 'listTracks.inFlashes.byFlash',
+  contentFunction: 'listTracksInFlashesByFlash',
+  featureFlag: 'enableFlashesAndGames',
+});
+
+listingSpec.push({
+  directory: 'tracks/with-lyrics',
+  stringsKey: 'listTracks.withLyrics',
+  contentFunction: 'listTracksWithLyrics',
+});
+
+listingSpec.push({
+  directory: 'tracks/with-sheet-music-files',
+  stringsKey: 'listTracks.withSheetMusicFiles',
+  contentFunction: 'listTracksWithSheetMusicFiles',
+  seeAlso: ['all-sheet-music-files'],
+});
+
+listingSpec.push({
+  directory: 'tracks/with-midi-project-files',
+  stringsKey: 'listTracks.withMidiProjectFiles',
+  contentFunction: 'listTracksWithMidiProjectFiles',
+  seeAlso: ['all-midi-project-files'],
+});
+
+listingSpec.push({
+  directory: 'tags/by-name',
+  stringsKey: 'listTags.byName',
+  contentFunction: 'listTagsByName',
+  featureFlag: 'enableArtTagUI',
+});
+
+listingSpec.push({
+  directory: 'tags/by-uses',
+  stringsKey: 'listTags.byUses',
+  contentFunction: 'listTagsByUses',
+  featureFlag: 'enableArtTagUI',
+});
+
+listingSpec.push({
+  directory: 'all-sheet-music-files',
+  stringsKey: 'other.allSheetMusic',
+  contentFunction: 'listAllSheetMusicFiles',
+  seeAlso: ['tracks/with-sheet-music-files'],
+  groupUnderOther: true,
+});
+
+listingSpec.push({
+  directory: 'all-midi-project-files',
+  stringsKey: 'other.allMidiProjectFiles',
+  contentFunction: 'listAllMidiProjectFiles',
+  seeAlso: ['tracks/with-midi-project-files'],
+  groupUnderOther: true,
+});
+
+listingSpec.push({
+  directory: 'all-additional-files',
+  stringsKey: 'other.allAdditionalFiles',
+  contentFunction: 'listAllAdditionalFiles',
+  groupUnderOther: true,
+});
+
+listingSpec.push({
+  directory: 'random',
+  stringsKey: 'other.randomPages',
+  contentFunction: 'listRandomPageLinks',
+  groupUnderOther: true,
+});
+
+{
+  const errors = [];
+
+  for (const listing of listingSpec) {
+    if (listing.seeAlso) {
+      const suberrors = [];
+
+      for (let i = 0; i < listing.seeAlso.length; i++) {
+        const directory = listing.seeAlso[i];
+        const match = listingSpec.find(listing => listing.directory === directory);
+
+        if (match) {
+          listing.seeAlso[i] = match;
+        } else {
+          listing.seeAlso[i] = null;
+          suberrors.push(new Error(`(index: ${i}) Didn't find a listing matching ${directory}`))
         }
+      }
 
-        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,
-        })),
-
-        ...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)
-          },
-        ]),
-      ]);
-
-      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')),
-
-          html.tag('ul',
-            toTracks.map(({artist, date}) =>
-              html.tag('li',
-                language.$('listingPage.listArtists.byLatest.item', {
-                  artist: link.artist(artist),
-                  date: language.formatDate(date),
-                })))),
-        ]),
-
-        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),
-                })))),
-        ]),
-      ]),
-  },
-
-  {
-    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,
-                  }),
-            })),
-
-          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'),
-                        }),
-                      }))))),
-        ])),
-  },
+      listing.seeAlso = listing.seeAlso.filter(Boolean);
 
-  {
-    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}),
-      }),
-  },
+      if (!empty(suberrors)) {
+        errors.push(new AggregateError(suberrors, `Errors matching "see also" listings for ${listing.directory}`));
+      }
+    } else {
+      listing.seeAlso = null;
+    }
+  }
 
-  {
-    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}),
-      }),
-  },
-
-  {
-    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}),
-        }))
-        .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: '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),
-      }),
-  },
-
-  {
-    directory: 'tracks/by-name',
-    stringsKey: 'listTracks.byName',
-
-    data: ({wikiData: {trackData}}) =>
-      sortAlphabetically(trackData.slice()),
-
-    row: (track, {language, link}) =>
-      language.$('listingPage.listTracks.byName.item', {
-        track: link.track(track),
-      }),
-  },
-
-  {
-    directory: 'tracks/by-album',
-    stringsKey: 'listTracks.byAlbum',
-
-    data: ({wikiData: {albumData}}) =>
-      albumData.map(album => ({
-        album,
-        tracks: album.tracks,
-      })),
-
-    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),
-                  }))))),
-        ])),
-  },
-
-  {
-    directory: 'tracks/by-date',
-    stringsKey: 'listTracks.byDate',
-
-    data: ({wikiData: {trackData}}) =>
-      chunkByProperties(
-        sortChronologically(trackData.filter(t => t.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.aka
-                  ? 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),
-                      }))))),
-        ])),
-  },
-
-  {
-    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),
-      }),
-  },
-
-  {
-    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),
-            })),
-
-          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: '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}),
-      }),
-  },
-
-  {
-    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}),
-      }),
-  },
-
-  {
-    // Holy beans the spaghetti LOL
-
-    directory: 'random',
-    stringsKey: 'other.randomPages',
-
-    data: ({wikiData: {fandomAlbumData, officialAlbumData}}) => [
-      {
-        albums: officialAlbumData,
-        name: 'Official',
-        randomCode: 'official',
-      },
-      {
-        albums: fandomAlbumData,
-        name: 'Fandom',
-        randomCode: 'fandom',
-      },
-    ],
-
-    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`) +
-                  ')',
-              ]),
-
-              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))))),
-          ]),
-        ]),
-      ]),
-  },
-];
+  if (!empty(errors)) {
+    const aggregate = new AggregateError(errors, `Errors validating listings`);
+    showAggregate(aggregate, {showTraces: false});
+  }
+}
 
 const filterListings = (directoryPrefix) =>
-  listingSpec.filter((l) => l.directory.startsWith(directoryPrefix));
+  listingSpec.filter(l => l.directory.startsWith(directoryPrefix));
 
 const listingTargetSpec = [
   {
-    title: ({language}) => language.$('listingPage.target.album'),
+    stringsKey: 'album',
     listings: filterListings('album'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.artist'),
+    stringsKey: 'artist',
     listings: filterListings('artist'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.group'),
+    stringsKey: 'group',
     listings: filterListings('group'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.track'),
+    stringsKey: 'track',
     listings: filterListings('track'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.tag'),
+    stringsKey: 'tag',
     listings: filterListings('tag'),
   },
   {
-    title: ({language}) => language.$('listingPage.target.other'),
-    listings: [listingSpec.find((l) => l.directory === 'random')],
+    stringsKey: 'other',
+    listings: listingSpec.filter(l => l.groupUnderOther),
   },
 ];
 
+for (const target of listingTargetSpec) {
+  for (const listing of target.listings) {
+    listing.target = target;
+  }
+}
+
 export {listingSpec, listingTargetSpec};