« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/content/dependencies/generateListRandomPageLinksAlbumLink.js18
-rw-r--r--src/content/dependencies/generateListRandomPageLinksAllAlbumsSection.js35
-rw-r--r--src/content/dependencies/generateListRandomPageLinksGroupSection.js54
-rw-r--r--src/content/dependencies/generateListingPage.js52
-rw-r--r--src/content/dependencies/linkTemplate.js8
-rw-r--r--src/content/dependencies/listArtistsByContributions.js116
-rw-r--r--src/content/dependencies/listArtistsByGroup.js133
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js592
-rw-r--r--src/content/dependencies/listArtistsByName.js45
-rw-r--r--src/content/dependencies/listRandomPageLinks.js62
-rw-r--r--src/data/things/artist.js17
-rw-r--r--src/listing-spec.js11
-rw-r--r--src/static/client2.js45
-rw-r--r--src/strings-default.yaml102
-rw-r--r--src/util/html.js4
15 files changed, 773 insertions, 521 deletions
diff --git a/src/content/dependencies/generateListRandomPageLinksAlbumLink.js b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js
new file mode 100644
index 00000000..b3560aca
--- /dev/null
+++ b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js
@@ -0,0 +1,18 @@
+export default {
+  contentDependencies: ['linkAlbum'],
+
+  data: (album) =>
+    ({directory: album.directory}),
+
+  relations: (relation, album) =>
+    ({albumLink: relation('linkAlbum', album)}),
+
+  generate: (data, relations) =>
+    relations.albumLink.slots({
+      anchor: true,
+      attributes: {
+        'data-random': 'track-in-album',
+        'style': `--album-directory: ${data.directory}`,
+      },
+    }),
+};
diff --git a/src/content/dependencies/generateListRandomPageLinksAllAlbumsSection.js b/src/content/dependencies/generateListRandomPageLinksAllAlbumsSection.js
new file mode 100644
index 00000000..e03252c9
--- /dev/null
+++ b/src/content/dependencies/generateListRandomPageLinksAllAlbumsSection.js
@@ -0,0 +1,35 @@
+import {sortChronologically} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateListRandomPageLinksAlbumLink', 'linkGroup'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({albumData}) => ({albumData}),
+
+  query: (sprawl) => ({
+    albums:
+      sortChronologically(sprawl.albumData.slice())
+        .filter(album => album.tracks.length > 1),
+  }),
+
+  relations: (relation, query) => ({
+    albumLinks:
+      query.albums
+        .map(album => relation('generateListRandomPageLinksAlbumLink', album)),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tags([
+      html.tag('dt',
+        language.$('listingPage.other.randomPages.fromAlbum')),
+
+      html.tag('dd',
+        html.tag('ul',
+          relations.albumLinks
+            .map(albumLink =>
+              html.tag('li',
+                language.$('listingPage.other.randomPages.album', {
+                  album: albumLink,
+                }))))),
+    ]),
+};
diff --git a/src/content/dependencies/generateListRandomPageLinksGroupSection.js b/src/content/dependencies/generateListRandomPageLinksGroupSection.js
index 2a684b19..d05be8f7 100644
--- a/src/content/dependencies/generateListRandomPageLinksGroupSection.js
+++ b/src/content/dependencies/generateListRandomPageLinksGroupSection.js
@@ -1,8 +1,7 @@
-import {stitchArrays} from '#sugar';
 import {sortChronologically} from '#wiki-data';
 
 export default {
-  contentDependencies: ['generateColorStyleVariables', 'linkGroup'],
+  contentDependencies: ['generateListRandomPageLinksAlbumLink', 'linkGroup'],
   extraDependencies: ['html', 'language', 'wikiData'],
 
   sprawl: ({albumData}) => ({albumData}),
@@ -18,64 +17,35 @@ export default {
     groupLink:
       relation('linkGroup', group),
 
-    albumColorVariables:
+    albumLinks:
       query.albums
-        .map(() => relation('generateColorStyleVariables')),
+        .map(album => relation('generateListRandomPageLinksAlbumLink', album)),
   }),
 
-  data: (query, sprawl, group) => ({
-    groupDirectory:
-      group.directory,
-
-    albumColors:
-      query.albums
-        .map(album => album.color),
-
-    albumDirectories:
-      query.albums
-        .map(album => album.directory),
-
-    albumNames:
-      query.albums
-        .map(album => album.name),
-  }),
-
-  generate: (data, relations, {html, language}) =>
+  generate: (relations, {html, language}) =>
     html.tags([
       html.tag('dt',
-        language.$('listingPage.other.randomPages.group', {
+        language.$('listingPage.other.randomPages.fromGroup', {
           group: relations.groupLink,
 
           randomAlbum:
             html.tag('a',
-              {href: '#', 'data-random': 'album-in-' + data.groupDirectory},
-              language.$('listingPage.other.randomPages.group.randomAlbum')),
+              {href: '#', 'data-random': 'album-in-group-dl'},
+              language.$('listingPage.other.randomPages.fromGroup.randomAlbum')),
 
           randomTrack:
             html.tag('a',
-              {href: '#', 'data-random': 'track-in-' + data.groupDirectory},
-              language.$('listingPage.other.randomPages.group.randomTrack')),
+              {href: '#', 'data-random': 'track-in-group-dl'},
+              language.$('listingPage.other.randomPages.fromGroup.randomTrack')),
         })),
 
       html.tag('dd',
         html.tag('ul',
-          stitchArrays({
-            colorVariables: relations.albumColorVariables,
-            color: data.albumColors,
-            directory: data.albumDirectories,
-            name: data.albumNames,
-          }).map(({colorVariables, color, directory, name}) =>
+          relations.albumLinks
+            .map(albumLink =>
               html.tag('li',
                 language.$('listingPage.other.randomPages.album', {
-                  album:
-                    html.tag('a', {
-                      href: '#',
-                      'data-random': 'track-in-album',
-                      style:
-                        colorVariables.slot('color', color).content +
-                        '; ' +
-                        `--album-directory: ${directory}`,
-                    }, name),
+                  album: albumLink,
                 }))))),
     ]),
 };
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
index 08eb40c6..45b7dc1b 100644
--- a/src/content/dependencies/generateListingPage.js
+++ b/src/content/dependencies/generateListingPage.js
@@ -7,6 +7,7 @@ export default {
     'generatePageLayout',
     'linkListing',
     'linkListingIndex',
+    'linkTemplate',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
@@ -26,6 +27,9 @@ export default {
     relations.chunkHeading =
       relation('generateContentHeading');
 
+    relations.showSkipToSectionLinkTemplate =
+      relation('linkTemplate');
+
     if (listing.target.listings.length > 1) {
       relations.sameTargetListingLinks =
         listing.target.listings
@@ -65,6 +69,9 @@ export default {
     chunkTitles: {validate: v => v.strictArrayOf(v.isObject)},
     chunkRows: {validate: v => v.strictArrayOf(v.isObject)},
 
+    showSkipToSection: {type: 'boolean', default: false},
+    chunkIDs: {validate: v => v.strictArrayOf(v.isString)},
+
     listStyle: {
       validate: v => v.is('ordered', 'unordered'),
       default: 'unordered',
@@ -84,12 +91,13 @@ export default {
 
       const parts = [baseStringsKey, contextStringsKey];
 
-      if (options.stringsKey) {
+      const {stringsKey, ...passOptions} = options;
+
+      if (stringsKey) {
         parts.push(options.stringsKey);
-        delete options.stringsKey;
       }
 
-      return language.formatString(parts.join('.'), options);
+      return language.formatString(parts.join('.'), passOptions);
     };
 
     return relations.layout.slots({
@@ -121,6 +129,8 @@ export default {
               listings: language.formatUnitList(relations.seeAlsoLinks),
             })),
 
+        slots.content,
+
         slots.type === 'rows' &&
           html.tag(listTag,
             slots.rows.map(row =>
@@ -128,16 +138,42 @@ export default {
                 formatListingString('item', row)))),
 
         slots.type === 'chunks' &&
-          html.tag('dl',
+          html.tag('dl', [
+            slots.showSkipToSection && [
+              html.tag('dt',
+                language.$('listingPage.skipToSection')),
+
+              html.tag('dd',
+                html.tag('ul',
+                  stitchArrays({
+                    title: slots.chunkTitles,
+                    id: slots.chunkIDs,
+                  }).filter(({id}) => id)
+                    .map(({title, id}) =>
+                      html.tag('li',
+                        relations.showSkipToSectionLinkTemplate
+                          .clone()
+                          .slots({
+                            hash: id,
+                            content:
+                              html.normalize(
+                                formatListingString('chunk.title', title)
+                                  .toString()
+                                  .replace(/:$/, '')),
+                          }))))),
+            ],
+
             stitchArrays({
               title: slots.chunkTitles,
               rows: slots.chunkRows,
-            }).map(({title, rows}) => [
+              id: slots.chunkIDs,
+            }).map(({title, rows, id}) => [
                 relations.chunkHeading
                   .clone()
                   .slots({
                     tag: 'dt',
                     title: formatListingString('chunk.title', title),
+                    id,
                   }),
 
                 html.tag('dd',
@@ -146,10 +182,8 @@ export default {
                       html.tag('li',
                         {class: row.stringsKey === 'rerelease' && 'rerelease'},
                         formatListingString('chunk.item', row))))),
-              ])),
-
-        slots.type === 'custom' &&
-          slots.content,
+              ]),
+          ]),
       ],
 
       navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js
index d9af726c..a361a4e7 100644
--- a/src/content/dependencies/linkTemplate.js
+++ b/src/content/dependencies/linkTemplate.js
@@ -64,6 +64,14 @@ export default {
       style = `--primary-color: ${primary}; --dim-color: ${dim}`;
     }
 
+    if (slots.attributes?.style) {
+      if (style) {
+        style += '; ' + slots.attributes.style;
+      } else {
+        style = slots.attributes.style;
+      }
+    }
+
     if (slots.tooltip) {
       title = slots.tooltip;
     }
diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js
index 86c8cfa2..58c51a40 100644
--- a/src/content/dependencies/listArtistsByContributions.js
+++ b/src/content/dependencies/listArtistsByContributions.js
@@ -1,5 +1,11 @@
-import {stitchArrays, unique} from '#sugar';
-import {filterByCount, sortAlphabetically, sortByCount} from '#wiki-data';
+import {empty, stitchArrays, unique} from '#sugar';
+
+import {
+  filterByCount,
+  filterMultipleArrays,
+  sortAlphabetically,
+  sortByCount,
+} from '#wiki-data';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist'],
@@ -96,68 +102,54 @@ export default {
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    const lists = Object.fromEntries(
-      ([
-        ['tracks', [
-          relations.artistLinksByTrackContributions,
-          data.countsByTrackContributions,
-          'countTracks',
-        ]],
-
-        ['artworks', [
-          relations.artistLinksByArtworkContributions,
-          data.countsByArtworkContributions,
-          'countArtworks',
-        ]],
-
-        data.enableFlashesAndGames &&
-          ['flashes', [
-            relations.artistLinksByFlashContributions,
-            data.countsByFlashContributions,
-            'countFlashes',
-          ]],
-      ]).filter(Boolean)
-        .map(([key, [artistLinks, counts, countFunction]]) => [
-          key,
-          html.tag('ul',
-            stitchArrays({
-              artistLink: artistLinks,
-              count: counts,
-            }).map(({artistLink, count}) =>
-                html.tag('li',
-                  language.$('listingPage.listArtists.byContribs.item', {
-                    artist: artistLink,
-                    contributions: language[countFunction](count, {unit: true}),
-                  })))),
-        ]));
+  generate(data, relations, {language}) {
+    const listChunkIDs = ['tracks', 'artworks', 'flashes'];
+    const listTitleStringsKeys = ['trackContributors', 'artContributors', 'flashContributors'];
+    const listCountFunctions = ['countTracks', 'countArtworks', 'countFlashes'];
+
+    const listArtistLinks = [
+      relations.artistLinksByTrackContributions,
+      relations.artistLinksByArtworkContributions,
+      relations.artistLinksByFlashContributions,
+    ];
+
+    const listArtistCounts = [
+      data.countsByTrackContributions,
+      data.countsByArtworkContributions,
+      data.countsByFlashContributions,
+    ];
+
+    filterMultipleArrays(
+      listChunkIDs,
+      listTitleStringsKeys,
+      listCountFunctions,
+      listArtistLinks,
+      listArtistCounts,
+      (_chunkID, _titleStringsKey, _countFunction, artistLinks, _artistCounts) =>
+        !empty(artistLinks));
 
     return relations.page.slots({
-      type: 'custom',
-      content:
-        html.tag('div', {class: 'content-columns'}, [
-          html.tag('div', {class: 'column'}, [
-            html.tag('h2',
-              language.$('listingPage.misc.trackContributors')),
-
-            lists.tracks,
-          ]),
-
-          html.tag('div', {class: 'column'}, [
-            html.tag('h2',
-              language.$(
-                'listingPage.misc.artContributors')),
-
-            lists.artworks,
-
-            lists.flashes && [
-              html.tag('h2',
-                language.$('listingPage.misc.flashContributors')),
-
-              lists.flashes,
-            ],
-          ]),
-        ]),
+      type: 'chunks',
+
+      showSkipToSection: true,
+      chunkIDs: listChunkIDs,
+
+      chunkTitles:
+        listTitleStringsKeys.map(stringsKey => ({stringsKey})),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: listArtistLinks,
+          artistCounts: listArtistCounts,
+          countFunction: listCountFunctions,
+        }).map(({artistLinks, artistCounts, countFunction}) =>
+            stitchArrays({
+              artistLink: artistLinks,
+              artistCount: artistCounts,
+            }).map(({artistLink, artistCount}) => ({
+                artist: artistLink,
+                contributions: language[countFunction](artistCount, {unit: true}),
+              }))),
     });
   },
 };
diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js
new file mode 100644
index 00000000..3778b9e3
--- /dev/null
+++ b/src/content/dependencies/listArtistsByGroup.js
@@ -0,0 +1,133 @@
+import {empty, stitchArrays, unique} from '#sugar';
+
+import {
+  filterMultipleArrays,
+  getArtistNumContributions,
+  sortAlphabetically,
+} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artistData, wikiInfo}) {
+    return {artistData, wikiInfo};
+  },
+
+  query(sprawl, spec) {
+    const artists = sortAlphabetically(sprawl.artistData.slice());
+    const groups = sprawl.wikiInfo.divideTrackListsByGroups;
+
+    if (empty(groups)) {
+      return {spec, artists};
+    }
+
+    const artistGroups =
+      artists.map(artist =>
+        unique(
+          unique([
+            ...artist.albumsAsAny,
+            ...artist.tracksAsAny.map(track => track.album),
+          ]).flatMap(album => album.groups)))
+
+    const artistsByGroup =
+      groups.map(group =>
+        artists.filter((artist, index) => artistGroups[index].includes(group)));
+
+    filterMultipleArrays(groups, artistsByGroup,
+      (group, artists) => !empty(artists));
+
+    return {spec, groups, artistsByGroup};
+  },
+
+  relations(relation, query) {
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    if (query.artists) {
+      relations.artistLinks =
+        query.artists
+          .map(artist => relation('linkArtist', artist));
+    }
+
+    if (query.artistsByGroup) {
+      relations.groupLinks =
+        query.groups
+          .map(group => relation('linkGroup', group));
+
+      relations.artistLinksByGroup =
+        query.artistsByGroup
+          .map(artists => artists
+            .map(artist => relation('linkArtist', artist)));
+    }
+
+    return relations;
+  },
+
+  data(query) {
+    const data = {};
+
+    if (query.artists) {
+      data.counts =
+        query.artists
+          .map(artist => getArtistNumContributions(artist));
+    }
+
+    if (query.artistsByGroup) {
+      data.groupDirectories =
+        query.groups
+          .map(group => group.directory);
+
+      data.countsByGroup =
+        query.artistsByGroup
+          .map(artists => artists
+            .map(artist => getArtistNumContributions(artist)));
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {language}) {
+    return (
+      (relations.artistLinksByGroup
+        ? relations.page.slots({
+            type: 'chunks',
+
+            showSkipToSection: true,
+            chunkIDs:
+              data.groupDirectories
+                .map(directory => `contributed-to-${directory}`),
+
+            chunkTitles:
+              relations.groupLinks.map(groupLink => ({
+                group: groupLink,
+              })),
+
+            chunkRows:
+              stitchArrays({
+                artistLinks: relations.artistLinksByGroup,
+                counts: data.countsByGroup,
+              }).map(({artistLinks, counts}) =>
+                  stitchArrays({
+                    link: artistLinks,
+                    count: counts,
+                  }).map(({link, count}) => ({
+                      artist: link,
+                      contributions: language.countContributions(count, {unit: true}),
+                    }))),
+          })
+        : relations.page.slots({
+            type: 'rows',
+            rows:
+              stitchArrays({
+                link: relations.artistLinks,
+                count: data.counts,
+              }).map(({link, count}) => ({
+                  artist: link,
+                  contributions: language.countContributions(count, {unit: true}),
+                })),
+          })));
+  },
+};
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
index 3870afde..45f8390f 100644
--- a/src/content/dependencies/listArtistsByLatestContribution.js
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -1,15 +1,16 @@
-import {transposeArrays, empty, stitchArrays} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
+import T from '#things';
 
 import {
   chunkMultipleArrays,
-  compareCaseLessSensitive,
-  compareDates,
-  filterMultipleArrays,
-  reduceMultipleArrays,
   sortAlphabetically,
+  sortAlbumsTracksChronologically,
+  sortFlashesChronologically,
   sortMultipleArrays,
 } from '#wiki-data';
 
+const {Album, Flash} = T;
+
 export default {
   contentDependencies: [
     'generateListingPage',
@@ -20,348 +21,299 @@ export default {
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({artistData, wikiInfo}) {
-    return {
-      artistData,
-      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
-    };
-  },
+  sprawl: ({albumData, artistData, flashData, trackData, wikiInfo}) =>
+    ({albumData, artistData, flashData, trackData,
+      enableFlashesAndGames: wikiInfo.enableFlashesAndGames}),
 
   query(sprawl, spec) {
-    const query = {
-      spec,
-      enableFlashesAndGames: sprawl.enableFlashesAndGames,
-    };
-
-    const queryContributionInfo = (
-      artistsKey,
-      chunkThingsKey,
-      datesKey,
-      datelessArtistsKey,
-      fn,
-    ) => {
-      const artists = sortAlphabetically(sprawl.artistData.slice());
-
-      // Each value stored in dateLists, corresponding to each artist,
-      // is going to be a list of dates and nulls. Any nulls represent
-      // a contribution which isn't associated with a particular date.
-      const [chunkThingLists, dateLists] =
-        transposeArrays(artists.map(artist => fn(artist)));
-
-      // Scrap artists who don't even have any relevant contributions.
-      // These artists may still have other contributions across the wiki, but
-      // they weren't returned by the callback and so aren't relevant to this
-      // list.
-      filterMultipleArrays(
-        artists,
-        chunkThingLists,
-        dateLists,
-        (artists, chunkThings, dates) => !empty(dates));
-
-      // Also exclude artists whose remaining contributions are all dateless.
-      // But keep track of the artists removed here, since they'll be displayed
-      // in an additional list in the final listing page.
-      const {removed: [datelessArtists]} =
-        filterMultipleArrays(
-          artists,
-          chunkThingLists,
-          dateLists,
-          (artist, chunkThings, dates) => !empty(dates.filter(Boolean)));
-
-      // Cut out dateless contributions. They're not relevant to finding the
-      // latest date.
-      for (const [chunkThings, dates] of transposeArrays([chunkThingLists, dateLists])) {
-        filterMultipleArrays(chunkThings, dates, (chunkThing, date) => date);
+    //
+    // First main step is to get the latest thing each artist has contributed
+    // to, and the date associated with that contribution! Some notes:
+    //
+    // * Album and track contributions are considered before flashes, so
+    //   they'll take priority if an artist happens to have multiple contribs
+    //   landing on the same date to both an album and a flash.
+    //
+    // * The final (album) contribution list is chunked by album, but also by
+    //   date, because an individual album can cover a variety of dates.
+    //
+    // * If an artist has contributed both artworks and tracks to the album
+    //   containing their latest contribution, then that will be indicated
+    //   in an annotation, but *only if* those contributions were also on
+    //   the same date.
+    //
+    // * If an artist made contributions to multiple albums on the same date,
+    //   then the first of the *albums* sorted chronologically (latest first)
+    //   is the one that will count.
+    //
+    // * Same for artists who've contributed to multiple flashes which were
+    //   released on the same date.
+    //
+    // * The map may exclude artists none of whose contributions were dated.
+    //
+
+    const artistLatestContribMap = new Map();
+
+    const considerDate = (artist, date, thing, contribution) => {
+      if (!date) {
+        return;
       }
 
-      const [chunkThings, dates] =
-        transposeArrays(
-          transposeArrays([chunkThingLists, dateLists])
-            .map(([chunkThings, dates]) =>
-              reduceMultipleArrays(
-                chunkThings, dates,
-                (accChunkThing, accDate, chunkThing, date) =>
-                  (date && date > accDate
-                    ? [chunkThing, date]
-                    : [accChunkThing, accDate]))));
-
-      sortMultipleArrays(artists, dates, chunkThings,
-        (artistA, artistB, dateA, dateB, chunkThingA, chunkThingB) => {
-          const dateComparison = compareDates(dateA, dateB, {latestFirst: true});
-          if (dateComparison !== 0) {
-            return dateComparison;
-          }
-
-          // TODO: Compare alphabetically, not just by directory.
-          return compareCaseLessSensitive(chunkThingA.directory, chunkThingB.directory);
-        });
-
-      const chunks =
-        chunkMultipleArrays(artists, dates, chunkThings,
-          (artist, lastArtist, date, lastDate, chunkThing, lastChunkThing) =>
-            +date !== +lastDate || chunkThing !== lastChunkThing);
+      if (artistLatestContribMap.has(artist)) {
+        const latest = artistLatestContribMap.get(artist);
+        if (latest.date > date) {
+          return;
+        }
 
-      query[chunkThingsKey] =
-        chunks.map(([artists, dates, chunkThings]) => chunkThings[0]);
-
-      query[datesKey] =
-        chunks.map(([artists, dates, chunkThings]) => dates[0]);
+        if (latest.date === date) {
+          if (latest.thing === thing) {
+            // May combine differnt contributions to the same thing and date.
+            latest.contribution.add(contribution);
+          }
 
-      query[artistsKey] =
-        chunks.map(([artists, dates, chunkThings]) => artists);
+          // Earlier-processed things of same date take priority.
+          return;
+        }
+      }
 
-      query[datelessArtistsKey] = datelessArtists;
+      // First entry for artist or more recent contribution than latest date.
+      artistLatestContribMap.set(artist, {
+        date,
+        thing,
+        contribution: new Set([contribution]),
+      });
     };
 
-    queryContributionInfo(
-      'artistsByTrackContributions',
-      'albumsByTrackContributions',
-      'datesByTrackContributions',
-      'datelessArtistsByTrackContributions',
-      artist => {
-        const tracks =
-          [...artist.tracksAsArtist, ...artist.tracksAsContributor]
-            .filter(track => !track.originalReleaseTrack);
-
-        const albums = tracks.map(track => track.album);
-        const dates = tracks.map(track => track.date);
+    const getArtists = (thing, key) => thing[key].map(({who}) => who);
 
-        return [albums, dates];
-      });
+    const albumsLatestFirst = sortAlbumsTracksChronologically(sprawl.albumData.slice());
+    const tracksLatestFirst = sortAlbumsTracksChronologically(sprawl.trackData.slice());
+    const flashesLatestFirst = sortFlashesChronologically(sprawl.flashData.slice());
 
-    queryContributionInfo(
-      'artistsByArtworkContributions',
-      'albumsByArtworkContributions',
-      'datesByArtworkContributions',
-      'datelessArtistsByArtworkContributions',
-      artist => [
-        [
-          ...artist.tracksAsCoverArtist.map(track => track.album),
-          ...artist.albumsAsCoverArtist,
-          ...artist.albumsAsWallpaperArtist,
-          ...artist.albumsAsBannerArtist,
-        ],
-        [
-          // TODO: Per-artwork dates, see #90.
-          ...artist.tracksAsCoverArtist.map(track => track.coverArtDate ?? track.date),
-          ...artist.albumsAsCoverArtist.map(album => album.coverArtDate ?? album.date),
-          ...artist.albumsAsWallpaperArtist.map(album => album.coverArtDate ?? album.date),
-          ...artist.albumsAsBannerArtist.map(album => album.coverArtDate ?? album.date),
-        ],
-      ]);
-
-    if (sprawl.enableFlashesAndGames) {
-      queryContributionInfo(
-        'artistsByFlashContributions',
-        'flashesByFlashContributions',
-        'datesByFlashContributions',
-        'datelessArtistsByFlashContributions',
-        artist => [
-          [
-            ...artist.flashesAsContributor,
-          ],
-          [
-            ...artist.flashesAsContributor.map(flash => flash.date),
-          ],
-        ]);
+    for (const album of albumsLatestFirst) {
+      for (const artist of new Set([
+        ...getArtists(album, 'coverArtistContribs'),
+        ...getArtists(album, 'wallpaperArtistContribs'),
+        ...getArtists(album, 'bannerArtistContribs'),
+      ])) {
+        // Might combine later with 'track' of the same album and date.
+        considerDate(artist, album.coverArtDate ?? album.date, album, 'artwork');
+      }
     }
 
-    return query;
-  },
-
-  relations(relation, query) {
-    const relations = {};
-
-    relations.page =
-      relation('generateListingPage', query.spec);
-
-    // Track contributors
-
-    relations.albumLinksByTrackContributions =
-      query.albumsByTrackContributions
-        .map(album => relation('linkAlbum', album));
-
-    relations.artistLinksByTrackContributions =
-      query.artistsByTrackContributions
-        .map(artists =>
-          artists.map(artist => relation('linkArtist', artist)));
-
-    relations.datelessArtistLinksByTrackContributions =
-      query.datelessArtistsByTrackContributions
-        .map(artist => relation('linkArtist', artist));
-
-    // Artwork contributors
-
-    relations.albumLinksByArtworkContributions =
-      query.albumsByArtworkContributions
-        .map(album => relation('linkAlbum', album));
-
-    relations.artistLinksByArtworkContributions =
-      query.artistsByArtworkContributions
-        .map(artists =>
-          artists.map(artist => relation('linkArtist', artist)));
+    for (const track of tracksLatestFirst) {
+      for (const artist of getArtists(track, 'coverArtistContribs')) {
+        // No special effect if artist already has 'artwork' for the same album and date.
+        considerDate(artist, track.coverArtDate ?? track.date, track.album, 'artwork');
+      }
 
-    relations.datelessArtistLinksByArtworkContributions =
-      query.datelessArtistsByArtworkContributions
-        .map(artist => relation('linkArtist', artist));
+      for (const artist of new Set([
+        ...getArtists(track, 'artistContribs'),
+        ...getArtists(track, 'contributorContribs'),
+      ])) {
+        // Might be combining with 'artwork' of the same album and date.
+        considerDate(artist, track.date, track.album, 'track');
+      }
+    }
 
-    // Flash contributors
+    for (const flash of flashesLatestFirst) {
+      for (const artist of getArtists(flash, 'contributorContribs')) {
+        // Won't take priority above album contributions of the same date.
+        considerDate(artist, flash.date, flash, 'flash');
+      }
+    }
 
-    if (query.enableFlashesAndGames) {
-      relations.flashLinksByFlashContributions =
-        query.flashesByFlashContributions
-          .map(flash => relation('linkFlash', flash));
+    //
+    // Next up is to sort all the processed artist information!
+    //
+    // Entries with the same album/flash and the same date go together first,
+    // with the following rules for sorting artists therein:
+    //
+    // * If the contributions are different, which can only happen for albums,
+    //   then it's tracks-only first, tracks + artworks next, and artworks-only
+    //   last.
+    //
+    // * If the contributions are the same, then sort alphabetically.
+    //
+    // Entries with different albums/flashes follow another set of rules:
+    //
+    // * Later dates come before earlier dates.
+    //
+    // * On the same date, albums come before flashes.
+    //
+    // * Things of the same type *and* date are sorted alphabetically.
+    //
+
+    const artistsAlphabetically =
+      sortAlphabetically(sprawl.artistData.slice());
+
+    const artists =
+      Array.from(artistLatestContribMap.keys());
+
+    const artistContribEntries =
+      Array.from(artistLatestContribMap.values());
+
+    const artistThings =
+      artistContribEntries.map(({thing}) => thing);
+
+    const artistDates =
+      artistContribEntries.map(({date}) => date);
+
+    const artistContributions =
+      artistContribEntries.map(({contribution}) => contribution);
+
+    sortMultipleArrays(artistThings, artistDates, artistContributions, artists,
+      (thing1, thing2, date1, date2, contrib1, contrib2, artist1, artist2) => {
+        if (date1 === date2 && thing1 === thing2) {
+          // Move artwork-only contribs after contribs with tracks.
+          if (!contrib1.has('track') && contrib2.has('track')) return 1;
+          if (!contrib2.has('track') && contrib1.has('track')) return -1;
+
+          // Move track-only contribs before tracks with tracks and artwork.
+          if (!contrib1.has('artwork') && contrib2.has('artwork')) return -1;
+          if (!contrib2.has('artwork') && contrib1.has('artwork')) return 1;
+
+          // Sort artists of the same type of contribution alphabetically,
+          // referring to a previous sort.
+          const index1 = artistsAlphabetically.indexOf(artist1);
+          const index2 = artistsAlphabetically.indexOf(artist2);
+          return index1 - index2;
+        } else {
+          // Move later dates before earlier ones.
+          if (date1 !== date2) return date2 - date1;
+
+          // Move albums before flashes.
+          if (thing1 instanceof Album && thing2 instanceof Flash) return -1;
+          if (thing1 instanceof Flash && thing2 instanceof Album) return 1;
+
+          // Sort two albums or two flashes alphabetically, referring to a
+          // previous sort (which was chronological but includes the correct
+          // ordering for things released on the same date).
+          const thingsLatestFirst =
+            (thing1 instanceof Album
+              ? albumsLatestFirst
+              : flashesLatestFirst);
+          const index1 = thingsLatestFirst.indexOf(thing1);
+          const index2 = thingsLatestFirst.indexOf(thing2);
+          return index2 - index1;
+        }
+      });
 
-      relations.artistLinksByFlashContributions =
-        query.artistsByFlashContributions
-          .map(artists =>
-            artists.map(artist => relation('linkArtist', artist)));
+    const chunks =
+      chunkMultipleArrays(artistThings, artistDates, artistContributions, artists,
+        (thing, lastThing, date, lastDate) =>
+          thing !== lastThing ||
+          +date !== +lastDate);
 
-      relations.datelessArtistLinksByFlashContributions =
-        query.datelessArtistsByFlashContributions
-          .map(artist => relation('linkArtist', artist));
-    }
+    const chunkThings =
+      chunks.map(([artistThings, , , ]) => artistThings[0]);
 
-    return relations;
-  },
+    const chunkDates =
+      chunks.map(([, artistDates, , ]) => artistDates[0]);
 
-  data(query) {
-    const data = {};
+    const chunkArtistContributions =
+      chunks.map(([, , artistContributions, ]) => artistContributions);
 
-    data.enableFlashesAndGames = query.enableFlashesAndGames;
+    const chunkArtists =
+      chunks.map(([, , , artists]) => artists);
 
-    data.datesByTrackContributions = query.datesByTrackContributions;
-    data.datesByArtworkContributions = query.datesByArtworkContributions;
+    // And one bonus step - keep track of all the artists whose contributions
+    // were all without date.
 
-    if (query.enableFlashesAndGames) {
-      data.datesByFlashContributions = query.datesByFlashContributions;
-    }
+    const datelessArtists =
+      artistsAlphabetically
+        .filter(artist => !artists.includes(artist));
 
-    return data;
+    return {
+      spec,
+      chunkThings,
+      chunkDates,
+      chunkArtistContributions,
+      chunkArtists,
+      datelessArtists,
+    };
   },
 
-  generate(data, relations, {html, language}) {
-    const chunkTitles = Object.fromEntries(
-      ([
-        ['tracks', [
-          'album',
-          relations.albumLinksByTrackContributions,
-          data.datesByTrackContributions,
-        ]],
-
-        ['artworks', [
-          'album',
-          relations.albumLinksByArtworkContributions,
-          data.datesByArtworkContributions,
-        ]],
-
-        data.enableFlashesAndGames &&
-          ['flashes', [
-            'flash',
-            relations.flashLinksByFlashContributions,
-            data.datesByFlashContributions,
-          ]],
-      ]).filter(Boolean)
-        .map(([key, [stringsKey, links, dates]]) => [
-          key,
-          stitchArrays({link: links, date: dates})
-            .map(({link, date}) =>
-              html.tag('dt',
-                language.$(`listingPage.listArtists.byLatest.chunk.title.${stringsKey}`, {
-                  [stringsKey]: link,
-                  date: language.formatDate(date),
-                }))),
-        ]));
-
-    const chunkItems = Object.fromEntries(
-      ([
-        ['tracks', relations.artistLinksByTrackContributions],
-        ['artworks', relations.artistLinksByArtworkContributions],
-        data.enableFlashesAndGames &&
-          ['flashes', relations.artistLinksByFlashContributions],
-      ]).filter(Boolean)
-        .map(([key, artistLinkLists]) => [
-          key,
-          artistLinkLists.map(artistLinks =>
-            html.tag('dd',
-              html.tag('ul',
-                artistLinks.map(artistLink =>
-                  html.tag('li',
-                    language.$('listingPage.listArtists.byLatest.chunk.item', {
-                      artist: artistLink,
-                    })))))),
-        ]));
-
-    const lists = Object.fromEntries(
-      ([
-        ['tracks', [
-          chunkTitles.tracks,
-          chunkItems.tracks,
-          relations.datelessArtistLinksByTrackContributions,
-        ]],
-
-        ['artworks', [
-          chunkTitles.artworks,
-          chunkItems.artworks,
-          relations.datelessArtistLinksByArtworkContributions,
-        ]],
-
-        data.enableFlashesAndGames &&
-          ['flashes', [
-            chunkTitles.flashes,
-            chunkItems.flashes,
-            relations.datelessArtistLinksByFlashContributions,
-          ]],
-      ]).filter(Boolean)
-        .map(([key, [titles, items, datelessArtistLinks]]) => [
-          key,
-          html.tags([
-            html.tag('dl',
-              stitchArrays({
-                title: titles,
-                items: items,
-              }).map(({title, items}) => [title, items])),
-
-            !empty(datelessArtistLinks) && [
-              html.tag('p',
-                language.$('listingPage.listArtists.byLatest.dateless.title')),
-
-              html.tag('ul',
-                datelessArtistLinks.map(artistLink =>
-                  html.tag('li',
-                    language.$('listingPage.listArtists.byLatest.dateless.item', {
-                      artist: artistLink,
-                    })))),
-            ],
-          ]),
-        ]));
-
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    chunkAlbumLinks:
+      query.chunkThings
+        .map(thing =>
+          (thing instanceof Album
+            ? relation('linkAlbum', thing)
+            : null)),
+
+    chunkFlashLinks:
+      query.chunkThings
+        .map(thing =>
+          (thing instanceof Flash
+            ? relation('linkFlash', thing)
+            : null)),
+
+    chunkArtistLinks:
+      query.chunkArtists
+        .map(artists => artists
+          .map(artist => relation('linkArtist', artist))),
+
+    datelessArtistLinks:
+      query.datelessArtists
+        .map(artist => relation('linkArtist', artist)),
+  }),
+
+  data: (query) => ({
+    chunkDates: query.chunkDates,
+    chunkArtistContributions: query.chunkArtistContributions,
+  }),
+
+  generate(data, relations, {language}) {
     return relations.page.slots({
-      type: 'custom',
-      content:
-        html.tag('div', {class: 'content-columns'}, [
-          html.tag('div', {class: 'column'}, [
-            html.tag('h2',
-              language.$('listingPage.misc.trackContributors')),
-
-            lists.tracks,
-          ]),
-
-          html.tag('div', {class: 'column'}, [
-            html.tag('h2',
-              language.$(
-                'listingPage.misc.artContributors')),
-
-            lists.artworks,
-
-            lists.flashes && [
-              html.tag('h2',
-                language.$('listingPage.misc.flashContributors')),
-
-              lists.flashes,
-            ],
-          ]),
-        ]),
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          albumLink: relations.chunkAlbumLinks,
+          flashLink: relations.chunkFlashLinks,
+          date: data.chunkDates,
+        }).map(({albumLink, flashLink, date}) => ({
+            date: language.formatDate(date),
+            ...(albumLink
+              ? {stringsKey: 'album', album: albumLink}
+              : {stringsKey: 'flash', flash: flashLink}),
+          }))
+          .concat(
+            (empty(relations.datelessArtistLinks)
+              ? []
+              : [{stringsKey: 'dateless'}])),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: relations.chunkArtistLinks,
+          contributions: data.chunkArtistContributions,
+        }).map(({artistLinks, contributions}) =>
+            stitchArrays({
+              artistLink: artistLinks,
+              contribution: contributions,
+            }).map(({artistLink, contribution}) => ({
+                artist: artistLink,
+                stringsKey:
+                  (contribution.has('track') && contribution.has('artwork')
+                    ? 'tracksAndArt'
+                 : contribution.has('track')
+                    ? 'tracks'
+                 : contribution.has('artwork')
+                    ? 'art'
+                    : null),
+              })))
+          .concat(
+            (empty(relations.datelessArtistLinks)
+              ? []
+              : [
+                  relations.datelessArtistLinks.map(artistLink => ({
+                    artist: artistLink,
+                  })),
+                ])),
     });
   },
 };
diff --git a/src/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js
index 6c0ad836..554b4587 100644
--- a/src/content/dependencies/listArtistsByName.js
+++ b/src/content/dependencies/listArtistsByName.js
@@ -2,38 +2,33 @@ import {stitchArrays} from '#sugar';
 import {getArtistNumContributions, sortAlphabetically} from '#wiki-data';
 
 export default {
-  contentDependencies: ['generateListingPage', 'linkArtist'],
+  contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
   extraDependencies: ['language', 'wikiData'],
 
-  sprawl({artistData}) {
-    return {artistData};
-  },
+  sprawl: ({artistData, wikiInfo}) =>
+    ({artistData, wikiInfo}),
 
-  query({artistData}, spec) {
-    return {
-      spec,
+  query: (sprawl, spec) => ({
+    spec,
 
-      artists: sortAlphabetically(artistData.slice()),
-    };
-  },
+    artists:
+      sortAlphabetically(sprawl.artistData.slice()),
+  }),
 
-  relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
 
-      artistLinks:
-        query.artists
-          .map(artist => relation('linkArtist', artist)),
-    };
-  },
+    artistLinks:
+      query.artists
+        .map(artist => relation('linkArtist', artist)),
+  }),
 
-  data(query) {
-    return {
-      counts:
-        query.artists
-          .map(artist => getArtistNumContributions(artist)),
-    };
-  },
+  data: (query) => ({
+    counts:
+      query.artists
+        .map(artist => getArtistNumContributions(artist)),
+  }),
 
   generate(data, relations, {language}) {
     return relations.page.slots({
diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js
index 43bf7dd5..599a82d3 100644
--- a/src/content/dependencies/listRandomPageLinks.js
+++ b/src/content/dependencies/listRandomPageLinks.js
@@ -1,48 +1,51 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: [
     'generateListingPage',
+    'generateListRandomPageLinksAllAlbumsSection',
     'generateListRandomPageLinksGroupSection',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupData}) {
-    return {groupData};
-  },
-
-  query(sprawl, spec) {
-    const group = directory =>
-      sprawl.groupData.find(group => group.directory === directory);
+  sprawl: ({wikiInfo}) => ({wikiInfo}),
 
-    return {
-      spec,
-      officialGroup: group('official'),
-      fandomGroup: group('fandom'),
-      beyondGroup: group('beyond'),
-    };
-  },
+  query: ({wikiInfo: {divideTrackListsByGroups: groups}}, spec) => ({
+    spec,
+    groups,
+    divideByGroups: !empty(groups),
+  }),
 
-  relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
+  relations: (relation, query) => ({
+    page: relation('generateListingPage', query.spec),
 
-      officialSection:
-        relation('generateListRandomPageLinksGroupSection', query.officialGroup),
+    allAlbumsSection:
+      (query.divideByGroups
+        ? null
+        : relation('generateListRandomPageLinksAllAlbumsSection')),
 
-      fandomSection:
-        relation('generateListRandomPageLinksGroupSection', query.fandomGroup),
-
-      beyondSection:
-        relation('generateListRandomPageLinksGroupSection', query.beyondGroup),
-    };
-  },
+    groupSections:
+      (query.divideByGroups
+        ? query.groups
+            .map(group => relation('generateListRandomPageLinksGroupSection', group))
+        : null),
+  }),
 
   generate(relations, {html, language}) {
     return relations.page.slots({
       type: 'custom',
       content: [
         html.tag('p',
-          language.$('listingPage.other.randomPages.chooseLinkLine')),
+          language.$('listingPage.other.randomPages.chooseLinkLine', {
+            fromPart:
+              (empty(relations.groupSections)
+                ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups')
+                : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups')),
+
+            browserSupportPart:
+              language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'),
+          })),
 
         html.tag('p',
           {class: 'js-hide-once-data'},
@@ -81,9 +84,8 @@ export default {
                   language.$('listingPage.other.randomPages.misc.randomTrackWholeSite'))),
             ])),
 
-          relations.officialSection,
-          relations.fandomSection,
-          relations.beyondSection,
+          relations.allAlbumsSection,
+          relations.groupSections,
         ]),
       ],
     });
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index e0350b86..a51723c4 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -107,6 +107,23 @@ export class Artist extends Thing {
     albumsAsBannerArtist:
       Artist.filterByContrib('albumData', 'bannerArtistContribs'),
 
+    albumsAsAny: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['albumData'],
+
+        compute: ({albumData, [Artist.instance]: artist}) =>
+          albumData?.filter((album) =>
+            [
+              ...album.artistContribs,
+              ...album.coverArtistContribs,
+              ...album.wallpaperArtistContribs,
+              ...album.bannerArtistContribs,
+            ].some(({who}) => who === artist)) ?? [],
+      },
+    },
+
     albumsAsCommentator: {
       flags: {expose: true},
 
diff --git a/src/listing-spec.js b/src/listing-spec.js
index 2b33744a..9433ee68 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -44,12 +44,14 @@ 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({
@@ -64,6 +66,15 @@ listingSpec.push({
   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',
diff --git a/src/static/client2.js b/src/static/client2.js
index 523b48d8..28882a88 100644
--- a/src/static/client2.js
+++ b/src/static/client2.js
@@ -149,6 +149,47 @@ function addRandomLinkListeners() {
           a.href = openAlbum(pick(albumData).directory);
           break;
 
+        case 'track':
+          a.href = openTrack(getRefDirectory(pick(tracks(albumData))));
+          break;
+
+        case 'album-in-group-dl': {
+          const albumLinks =
+            Array.from(a
+              .closest('dt')
+              .nextElementSibling
+              .querySelectorAll('li a'))
+
+          const albumDirectories =
+            albumLinks.map(a =>
+              getComputedStyle(a).getPropertyValue('--album-directory'));
+
+          a.href = openAlbum(pick(albumDirectories));
+          break;
+        }
+
+        case 'track-in-group-dl': {
+          const albumLinks =
+            Array.from(a
+              .closest('dt')
+              .nextElementSibling
+              .querySelectorAll('li a'))
+
+          const albumDirectories =
+            albumLinks.map(a =>
+              getComputedStyle(a).getPropertyValue('--album-directory'));
+
+          const filteredAlbumData =
+            albumData.filter(album =>
+              albumDirectories.includes(album.directory));
+
+          a.href = openTrack(getRefDirectory(pick(tracks(filteredAlbumData))));
+          break;
+        }
+
+        /* Legacy links, for old versions             *
+         * of generateListRandomPageLinksGroupSection */
+
         case 'album-in-official':
           a.href = openAlbum(pick(officialAlbumData).directory);
           break;
@@ -161,9 +202,7 @@ function addRandomLinkListeners() {
           a.href = openAlbum(pick(beyondAlbumData).directory);
           break;
 
-        case 'track':
-          a.href = openTrack(getRefDirectory(pick(tracks(albumData))));
-          break;
+        /* End legacy links */
 
         case 'track-in-album':
           a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks)));
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index a5a09280..6e975de7 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -1075,6 +1075,13 @@ listingPage:
 
   seeAlso: "Also check out: {LISTINGS}"
 
+  # skipToSection:
+  #   Some listings which use a chunked-list layout also show links
+  #   to scroll down to each of these sections - this is the title
+  #   for the list of those links.
+
+  skipToSection: "Skip to a section:"
+
   listAlbums:
 
     # listAlbums.byName:
@@ -1155,7 +1162,12 @@ listingPage:
     byContribs:
       title: "Artists - by Contributions"
       title.short: "...by Contributions"
-      item: "{ARTIST} ({CONTRIBUTIONS})"
+      chunk:
+        item: "{ARTIST} ({CONTRIBUTIONS})"
+        title:
+          trackContributors: "Contributed tracks:"
+          artContributors: "Contributed artworks:"
+          flashContributors: "Contributed to flashes & games:"
 
     # listArtists.byCommentary:
     #   Lists artists by number of commentary entries, most to least,
@@ -1180,31 +1192,44 @@ listingPage:
       title.short: "...by Duration"
       item: "{ARTIST} ({DURATION})"
 
+    # listArtists.byGroup:
+    #   Lists artists who have contributed to each of the main groups
+    #   of a wiki (its "Divide Track Lists By Groups" field), sorted
+    #   alphabetically. Artists who aren't credited for contributions
+    #   under each of the groups are exlcuded from the respective
+    #   list.
+
+    byGroup:
+      title: "Artists - by Group"
+      title.short: "...by Group"
+      item: "{ARTIST} ({CONTRIBUTIONS})"
+      chunk:
+        title: "Contributed to {GROUP}:"
+        item: "{ARTIST} ({CONTRIBUTIONS})"
+
     # listArtists.byLatest:
-    #   Lists artists by the date of their latest musical, artwork,
-    #   or flash contributions (with a separate section for each),
-    #   latest to longest ago, and chunks artists together by the
-    #   album/flash which their contribution was to. If two albums
-    #   (or flashes) released on the same date, they're sorted by
-    #   name, and artists within each album/flash are also sorted
-    #   alphabetically. If an artist has contributions of a given
-    #   kind, but those contributions aren't dated at all, they're
-    #   listed at the bottom; artists who aren't credited for any
-    #   contributions to each category are totally excluded from the
-    #   respective lists.
+    #   Lists artists by the date of their latest contribution
+    #   overall, and chunks artists together by the album or flash
+    #   which that contribution belongs to. Within albums, each
+    #   artist is accented with the kind of contribution they made -
+    #   tracks, artworks, or both - and sorted so those of the same
+    #   sort of contribution are bunched together, then by name.
+    #   Artists who aren't credited for any dated contributions are
+    #   included at the bottom under a separate chunk.
 
     byLatest:
       title: "Artists - by Latest Contribution"
       title.short: "...by Latest Contribution"
-
       chunk:
-        title.album: "{ALBUM} ({DATE})"
-        title.flash: "{FLASH} ({DATE})"
-        item: "{ARTIST}"
-
-      dateless:
-        title: "These artists' contributions aren't dated:"
-        item: "{ARTIST}"
+        title:
+          album: "{ALBUM} ({DATE})"
+          flash: "{FLASH} ({DATE})"
+          dateless: "These artists' contributions aren't dated:"
+        item:
+          _: "{ARTIST}"
+          tracks: "{ARTIST} (tracks)"
+          tracksAndArt: "{ARTIST} (tracks, art)"
+          art: "{ARTIST} (art)"
 
   listGroups:
 
@@ -1518,10 +1543,18 @@ listingPage:
       # chooseLinkLine:
       #   Introductory line explaining the links on this listing.
 
-      chooseLinkLine: >-
-        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.
+      chooseLinkLine:
+        _: "{FROM_PART} {BROWSER_SUPPORT_PART}"
+
+        fromPart:
+          dividedByGroups: >-
+            Choose a link to go to a random page in that group or album!
+          notDividedByGroups: >-
+            Choose a link to go to a random page in that album!
+
+        browserSupportPart: >-
+          If your browser doesn't support relatively modern JavaScript
+          or you've disabled it, these links won't work - sorry.
 
       # dataLoadingLine, dataLoadedLine:
       #   Since the links on this page depend on access to a fairly
@@ -1546,17 +1579,26 @@ listingPage:
         randomAlbumWholeSite: "Random Album (whole site)"
         randomTrackWholeSite: "Random Track (whole site)"
 
-      # group:
-      #   The remaining chunks are one for each of the main groups on
-      #   the site, and each includes a list of all the albums from
-      #   that group - clicking one brings to a random track from the
-      #   album.
+      # fromGroup:
+      #   Provided the wiki has "Divide Track Lists By Groups" set,
+      #   the remaining chunks are one for each of those groups, each
+      #   with a list of links for albums from the group that bring
+      #   you to a random track from the chosen album.
 
-      group:
+      fromGroup:
         _: "From {GROUP}: ({RANDOM_ALBUM}, {RANDOM_TRACK})"
         randomAlbum: "Random Album"
         randomTrack: "Random Track"
 
+      # fromAlbum:
+      #   If the wiki doesn't have "Divide Track Lists By Groups",
+      #   all albums across the wiki are grouped in one list.
+      #  (There aren't "random album" and "random track" links like
+      #   for groups because those are already included at the top,
+      #   under the "miscellaneous" chunk.)
+
+      fromAlbum: "From an album:"
+
       # album:
       #   Album entries under each group.
 
diff --git a/src/util/html.js b/src/util/html.js
index 282a52da..5b6743e0 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -181,6 +181,10 @@ export function tags(content, attributes = null) {
   return new Tag(null, attributes, content);
 }
 
+export function normalize(content) {
+  return Tag.normalize(content);
+}
+
 export class Tag {
   #tagName = '';
   #content = null;