« get me outta code hell

Merge branch 'preview' into commentary-entries - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/content/dependencies
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-11-15 10:57:55 -0400
committer(quasar) nebula <qznebula@protonmail.com>2023-11-15 10:57:55 -0400
commitdd5cbf9db64e994d44c922bca2ca8ec37e9f7983 (patch)
tree56ef644f10f1814c8d58ea04a259e7e6fe02f8bf /src/content/dependencies
parentf2a31006efa7c4d9c7c15823adc70cc40c46dedd (diff)
parent52cc83065f41472a4c32c2003b0a715a66d4739a (diff)
Merge branch 'preview' into commentary-entries
Diffstat (limited to 'src/content/dependencies')
-rw-r--r--src/content/dependencies/generateContentHeading.js26
-rw-r--r--src/content/dependencies/generateFlashActSidebar.js18
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js38
-rw-r--r--src/content/dependencies/generateFooterLocalizationLinks.js53
-rw-r--r--src/content/dependencies/generateListRandomPageLinksAlbumLink.js18
-rw-r--r--src/content/dependencies/generateListRandomPageLinksGroupSection.js81
-rw-r--r--src/content/dependencies/generateListingPage.js180
-rw-r--r--src/content/dependencies/generatePageLayout.js11
-rw-r--r--src/content/dependencies/generateWikiHomeAlbumsRow.js20
-rw-r--r--src/content/dependencies/linkExternal.js25
-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.js213
-rw-r--r--src/content/dependencies/listTracksByDate.js9
17 files changed, 950 insertions, 636 deletions
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
index ccaf1076..56f68cb3 100644
--- a/src/content/dependencies/generateContentHeading.js
+++ b/src/content/dependencies/generateContentHeading.js
@@ -1,19 +1,39 @@
 export default {
   extraDependencies: ['html'],
+  contentDependencies: ['generateColorStyleVariables'],
+
+  relations: (relation) => ({
+    colorVariables: relation('generateColorStyleVariables'),
+  }),
 
   slots: {
     title: {type: 'html'},
+    accent: {type: 'html'},
+
+    color: {validate: v => v.isColor},
+
     id: {type: 'string'},
     tag: {type: 'string', default: 'p'},
   },
 
-  generate(slots, {html}) {
+  generate(relations, slots, {html}) {
     return html.tag(slots.tag,
       {
         class: 'content-heading',
         id: slots.id,
         tabindex: '0',
-      },
-      slots.title);
+
+        style:
+          slots.color &&
+            relations.colorVariables
+              .slot('color', slots.color)
+              .content,
+      }, [
+        slots.title,
+
+        html.tag('span',
+          {[html.onlyIfContent]: true, class: 'content-heading-accent'},
+          slots.accent),
+      ]);
   }
 }
diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js
index bd6063c9..80072483 100644
--- a/src/content/dependencies/generateFlashActSidebar.js
+++ b/src/content/dependencies/generateFlashActSidebar.js
@@ -1,5 +1,6 @@
 import find from '#find';
 import {stitchArrays} from '#sugar';
+import {filterMultipleArrays} from '#wiki-data';
 
 export default {
   contentDependencies: ['linkFlash', 'linkFlashAct', 'linkFlashIndex'],
@@ -11,10 +12,12 @@ export default {
 
   query(sprawl, act, flash) {
     const findFlashAct = directory =>
-      find.flashAct(directory, sprawl.flashActData, {mode: 'error'});
+      find.flashAct(directory, sprawl.flashActData, {mode: 'quiet'});
+
+    const homestuckSide1 = findFlashAct('flash-act:a1');
 
     const sideFirstActs = [
-      findFlashAct('flash-act:a1'),
+      sprawl.flashActData[0],
       findFlashAct('flash-act:a6a1'),
       findFlashAct('flash-act:hiveswap'),
       findFlashAct('flash-act:cool-and-new-web-comic'),
@@ -22,7 +25,9 @@ export default {
     ];
 
     const sideNames = [
-      `Side 1 (Acts 1-5)`,
+      (homestuckSide1
+        ? `Side 1 (Acts 1-5)`
+        : `All flashes & games`),
       `Side 2 (Acts 6-7)`,
       `Additional Canon`,
       `Fan Adventures`,
@@ -30,13 +35,18 @@ export default {
     ];
 
     const sideColors = [
-      '#4ac925',
+      (homestuckSide1
+        ? '#4ac925'
+        : null),
       '#3796c6',
       '#f2a400',
       '#c466ff',
       '#32c7fe',
     ];
 
+    filterMultipleArrays(sideFirstActs, sideNames, sideColors,
+      firstAct => firstAct);
+
     const sideFirstActIndexes =
       sideFirstActs
         .map(act => sprawl.flashActData.indexOf(act));
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index ad1dab94..5fc62ab3 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -1,4 +1,4 @@
-import {stitchArrays} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -95,23 +95,25 @@ export default {
 
       mainClasses: ['flash-index'],
       mainContent: [
-        html.tag('p',
-          {class: 'quick-info'},
-          language.$('misc.jumpTo')),
-
-        html.tag('ul',
-          {class: 'quick-info'},
-          stitchArrays({
-            colorVariables: relations.jumpLinkColorVariables,
-            anchor: data.jumpLinkAnchors,
-            color: data.jumpLinkColors,
-            label: data.jumpLinkLabels,
-          }).map(({colorVariables, anchor, color, label}) =>
-              html.tag('li',
-                html.tag('a', {
-                  href: '#' + anchor,
-                  style: colorVariables.slot('color', color).content,
-                }, label)))),
+        !empty(data.jumpLinkLabels) && [
+          html.tag('p',
+            {class: 'quick-info'},
+            language.$('misc.jumpTo')),
+
+          html.tag('ul',
+            {class: 'quick-info'},
+            stitchArrays({
+              colorVariables: relations.jumpLinkColorVariables,
+              anchor: data.jumpLinkAnchors,
+              color: data.jumpLinkColors,
+              label: data.jumpLinkLabels,
+            }).map(({colorVariables, anchor, color, label}) =>
+                html.tag('li',
+                  html.tag('a', {
+                    href: '#' + anchor,
+                    style: colorVariables.slot('color', color).content,
+                  }, label)))),
+        ],
 
         stitchArrays({
           colorVariables: relations.actColorVariables,
diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js
index 5df83566..86e6c61a 100644
--- a/src/content/dependencies/generateFooterLocalizationLinks.js
+++ b/src/content/dependencies/generateFooterLocalizationLinks.js
@@ -1,3 +1,6 @@
+import {stitchArrays} from '#sugar';
+import {sortByName} from '#wiki-data';
+
 export default {
   extraDependencies: [
     'defaultLanguage',
@@ -16,25 +19,37 @@ export default {
     pagePath,
     to,
   }) {
-    const links = Object.entries(languages)
-      .filter(([code, language]) => code !== 'default' && !language.hidden)
-      .map(([code, language]) => language)
-      .sort(({name: a}, {name: b}) => (a < b ? -1 : a > b ? 1 : 0))
-      .map((language) =>
-        html.tag('span',
-          html.tag('a',
-            {
-              href:
-                language === defaultLanguage
-                  ? to(
-                      'localizedDefaultLanguage.' + pagePath[0],
-                      ...pagePath.slice(1))
-                  : to(
-                      'localizedWithBaseDirectory.' + pagePath[0],
-                      language.code,
-                      ...pagePath.slice(1)),
-            },
-            language.name)));
+    const switchableLanguages =
+      Object.entries(languages)
+        .filter(([code, language]) => code !== 'default' && !language.hidden)
+        .map(([code, language]) => language);
+
+    if (switchableLanguages.length <= 1) {
+      return html.blank();
+    }
+
+    sortByName(switchableLanguages);
+
+    const [pagePathSubkey, ...pagePathArgs] = pagePath;
+
+    const linkPaths =
+      switchableLanguages.map(language =>
+        (language === defaultLanguage
+          ? (['localizedDefaultLanguage.' + pagePathSubkey,
+              ...pagePathArgs])
+          : (['localizedWithBaseDirectory.' + pagePathSubkey,
+              language.code,
+              ...pagePathArgs])));
+
+    const links =
+      stitchArrays({
+        language: switchableLanguages,
+        linkPath: linkPaths,
+      }).map(({language, linkPath}) =>
+          html.tag('span',
+            html.tag('a',
+              {href: to(...linkPath)},
+              language.name)));
 
     return html.tag('div', {class: 'footer-localization-links'},
       language.$('misc.uiLanguage', {
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/generateListRandomPageLinksGroupSection.js b/src/content/dependencies/generateListRandomPageLinksGroupSection.js
deleted file mode 100644
index 2a684b19..00000000
--- a/src/content/dependencies/generateListRandomPageLinksGroupSection.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import {stitchArrays} from '#sugar';
-import {sortChronologically} from '#wiki-data';
-
-export default {
-  contentDependencies: ['generateColorStyleVariables', 'linkGroup'],
-  extraDependencies: ['html', 'language', 'wikiData'],
-
-  sprawl: ({albumData}) => ({albumData}),
-
-  query: (sprawl, group) => ({
-    albums:
-      sortChronologically(sprawl.albumData.slice())
-        .filter(album => album.groups.includes(group))
-        .filter(album => album.tracks.length > 1),
-  }),
-
-  relations: (relation, query, sprawl, group) => ({
-    groupLink:
-      relation('linkGroup', group),
-
-    albumColorVariables:
-      query.albums
-        .map(() => relation('generateColorStyleVariables')),
-  }),
-
-  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}) =>
-    html.tags([
-      html.tag('dt',
-        language.$('listingPage.other.randomPages.group', {
-          group: relations.groupLink,
-
-          randomAlbum:
-            html.tag('a',
-              {href: '#', 'data-random': 'album-in-' + data.groupDirectory},
-              language.$('listingPage.other.randomPages.group.randomAlbum')),
-
-          randomTrack:
-            html.tag('a',
-              {href: '#', 'data-random': 'track-in-' + data.groupDirectory},
-              language.$('listingPage.other.randomPages.group.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}) =>
-              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),
-                }))))),
-    ]),
-};
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
index 08eb40c6..2050d62d 100644
--- a/src/content/dependencies/generateListingPage.js
+++ b/src/content/dependencies/generateListingPage.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {bindOpts, empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -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
@@ -58,12 +62,42 @@ export default {
   },
 
   slots: {
-    type: {validate: v => v.is('rows', 'chunks', 'custom')},
+    type: {
+      validate: v => v.is('rows', 'chunks', 'custom'),
+    },
+
+    rows: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
 
-    rows: {validate: v => v.strictArrayOf(v.isObject)},
+    rowAttributes: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject))
+    },
+
+    chunkTitles: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
 
-    chunkTitles: {validate: v => v.strictArrayOf(v.isObject)},
-    chunkRows: {validate: v => v.strictArrayOf(v.isObject)},
+    chunkTitleAccents: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject)),
+    },
+
+    chunkRows: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
+
+    chunkRowAttributes: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject)),
+    },
+
+    showSkipToSection: {
+      type: 'boolean',
+      default: false,
+    },
+
+    chunkIDs: {
+      validate: v => v.strictArrayOf(v.optional(v.isString)),
+    },
 
     listStyle: {
       validate: v => v.is('ordered', 'unordered'),
@@ -74,26 +108,59 @@ export default {
   },
 
   generate(data, relations, slots, {html, language}) {
-    const listTag =
-      (slots.listStyle === 'ordered'
-        ? 'ol'
-        : 'ul');
+    function formatListingString({
+      context,
+      provided = {},
+    }) {
+      const parts = ['listingPage', data.stringsKey];
+
+      if (Array.isArray(context)) {
+        parts.push(...context);
+      } else {
+        parts.push(context);
+      }
 
-    const formatListingString = (contextStringsKey, options = {}) => {
-      const baseStringsKey = `listingPage.${data.stringsKey}`;
+      if (provided.stringsKey) {
+        parts.push(provided.stringsKey);
+      }
 
-      const parts = [baseStringsKey, contextStringsKey];
+      const options = {...provided};
+      delete options.stringsKey;
 
-      if (options.stringsKey) {
-        parts.push(options.stringsKey);
-        delete options.stringsKey;
-      }
+      return language.formatString(...parts, options);
+    }
 
-      return language.formatString(parts.join('.'), options);
-    };
+    const formatRow = ({context, row, attributes}) =>
+      (attributes?.href
+        ? html.tag('li',
+            html.tag('a',
+              attributes,
+              formatListingString({
+                context,
+                provided: row,
+              })))
+        : html.tag('li',
+            attributes,
+            formatListingString({
+              context,
+              provided: row,
+            })));
+
+    const formatRowList = ({context, rows, rowAttributes}) =>
+      html.tag(
+        (slots.listStyle === 'ordered' ? 'ol' : 'ul'),
+        stitchArrays({
+          row: rows,
+          attributes: rowAttributes ?? rows.map(() => null),
+        }).map(
+          bindOpts(formatRow, {
+            [bindOpts.bindIndex]: 0,
+            context,
+          })));
 
     return relations.layout.slots({
-      title: formatListingString('title'),
+      title: formatListingString({context: 'title'}),
+
       headingMode: 'sticky',
 
       mainContent: [
@@ -121,35 +188,78 @@ export default {
               listings: language.formatUnitList(relations.seeAlsoLinks),
             })),
 
+        slots.content,
+
         slots.type === 'rows' &&
-          html.tag(listTag,
-            slots.rows.map(row =>
-              html.tag('li',
-                formatListingString('item', row)))),
+          formatRowList({
+            context: 'item',
+            rows: slots.rows,
+            rowAttributes: slots.rowAttributes,
+          }),
 
         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({
+                                  context: 'chunk.title',
+                                  provided: title,
+                                }).toString()
+                                  .replace(/:$/, '')),
+                          }))))),
+            ],
+
             stitchArrays({
               title: slots.chunkTitles,
+              titleAccent: slots.chunkTitleAccents,
+              id: slots.chunkIDs,
               rows: slots.chunkRows,
-            }).map(({title, rows}) => [
+              rowAttributes: slots.chunkRowAttributes,
+            }).map(({title, titleAccent, id, rows, rowAttributes}) => [
                 relations.chunkHeading
                   .clone()
                   .slots({
                     tag: 'dt',
-                    title: formatListingString('chunk.title', title),
+                    id,
+
+                    title:
+                      formatListingString({
+                        context: 'chunk.title',
+                        provided: title,
+                      }),
+
+                    accent:
+                      titleAccent &&
+                        formatListingString({
+                          context: ['chunk.title', title.stringsKey, 'accent'],
+                          provided: titleAccent,
+                        }),
                   }),
 
                 html.tag('dd',
-                  html.tag(listTag,
-                    rows.map(row =>
-                      html.tag('li',
-                        {class: row.stringsKey === 'rerelease' && 'rerelease'},
-                        formatListingString('chunk.item', row))))),
-              ])),
-
-        slots.type === 'custom' &&
-          slots.content,
+                  formatRowList({
+                    context: 'chunk.item',
+                    rows,
+                    rowAttributes,
+                  })),
+              ]),
+          ]),
       ],
 
       navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index cd831ba7..5fa6e751 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -85,8 +85,10 @@ export default {
     relations.stickyHeadingContainer =
       relation('generateStickyHeadingContainer');
 
-    relations.defaultFooterContent =
-      relation('transformContent', sprawl.footerContent);
+    if (sprawl.footerContent) {
+      relations.defaultFooterContent =
+        relation('transformContent', sprawl.footerContent);
+    }
 
     relations.colorStyleRules =
       relation('generateColorStyleRules');
@@ -231,7 +233,7 @@ export default {
 
     let footerContent = slots.footerContent;
 
-    if (html.isBlank(footerContent)) {
+    if (html.isBlank(footerContent) && relations.defaultFooterContent) {
       footerContent = relations.defaultFooterContent
         .slot('mode', 'multiline');
     }
@@ -449,7 +451,8 @@ export default {
             {[html.onlyIfContent]: true, class: 'skipper-list'},
             processSkippers([
               {id: 'tracks', string: 'tracks'},
-              {id: 'art', string: 'flashes'},
+              {id: 'art', string: 'artworks'},
+              {id: 'flashes', string: 'flashes'},
               {id: 'contributors', string: 'contributors'},
               {id: 'references', string: 'references'},
               {id: 'referenced-by', string: 'referencedBy'},
diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js
index cb0860f5..a19f104c 100644
--- a/src/content/dependencies/generateWikiHomeAlbumsRow.js
+++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js
@@ -11,7 +11,7 @@ export default {
     'transformContent',
   ],
 
-  extraDependencies: ['wikiData'],
+  extraDependencies: ['language', 'wikiData'],
 
   sprawl({albumData}, row) {
     const sprawl = {};
@@ -90,12 +90,14 @@ export default {
     data.paths =
       sprawl.albums
         .map(album =>
-          ['media.albumCover', album.directory, album.coverArtFileExtension]);
+          (album.hasCoverArt
+            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
+            : null));
 
     return data;
   },
 
-  generate(data, relations) {
+  generate(data, relations, {language}) {
     // Grids and carousels share some slots! Very convenient.
     const commonSlots = {};
 
@@ -106,8 +108,16 @@ export default {
       stitchArrays({
         image: relations.images,
         path: data.paths,
-      }).map(({image, path}) =>
-          image.slot('path', path));
+        name: data.names ?? data.paths.slice().fill(null),
+      }).map(({image, path, name}) =>
+          image.slots({
+            path,
+            missingSourceContent:
+              name &&
+                language.$('misc.albumGrid.noCoverArt', {
+                  album: name,
+                }),
+            }));
 
     commonSlots.actionLinks =
       (relations.actionLinks
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index 73c656e3..5de612e2 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -3,10 +3,20 @@ const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
 const MASTODON_DOMAINS = ['types.pl'];
 
 export default {
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['html', 'language', 'wikiData'],
 
-  data(url) {
-    return {url};
+  sprawl: ({wikiInfo}) => ({wikiInfo}),
+
+  data(sprawl, url) {
+    const data = {url};
+
+    const {canonicalBase} = sprawl.wikiInfo;
+    if (canonicalBase) {
+      const {hostname: canonicalDomain} = new URL(canonicalBase);
+      Object.assign(data, {canonicalDomain});
+    }
+
+    return data;
   },
 
   slots: {
@@ -20,6 +30,7 @@ export default {
     let isLocal;
     let domain;
     let pathname;
+
     try {
       const url = new URL(data.url);
       domain = url.hostname;
@@ -28,6 +39,14 @@ export default {
       // No support for relative local URLs yet, sorry! (I.e, local URLs must
       // be absolute relative to the domain name in order to work.)
       isLocal = true;
+      domain = null;
+      pathname = null;
+    }
+
+    // isLocal also applies for URLs which match the 'Canonical Base' under
+    // wiki-info.yaml, if present.
+    if (data.canonicalDomain && domain === data.canonicalDomain) {
+      isLocal = true;
     }
 
     const link = html.tag('a',
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..0b904019 100644
--- a/src/content/dependencies/listRandomPageLinks.js
+++ b/src/content/dependencies/listRandomPageLinks.js
@@ -1,48 +1,118 @@
+import {empty} from '#sugar';
+import {sortChronologically} from '#wiki-data';
+
 export default {
   contentDependencies: [
     'generateListingPage',
-    'generateListRandomPageLinksGroupSection',
+    'generateListRandomPageLinksAlbumLink',
+    'linkGroup',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupData}) {
-    return {groupData};
-  },
+  sprawl: ({albumData, wikiInfo}) => ({albumData, wikiInfo}),
 
   query(sprawl, spec) {
-    const group = directory =>
-      sprawl.groupData.find(group => group.directory === directory);
-
-    return {
-      spec,
-      officialGroup: group('official'),
-      fandomGroup: group('fandom'),
-      beyondGroup: group('beyond'),
-    };
+    const query = {spec};
+
+    const groups = sprawl.wikiInfo.divideTrackListsByGroups;
+
+    query.divideByGroups = !empty(groups);
+
+    if (query.divideByGroups) {
+      query.groups = groups;
+
+      query.groupAlbums =
+        groups
+          .map(group =>
+            group.albums.filter(album => album.tracks.length > 1));
+    } else {
+      query.undividedAlbums =
+        sortChronologically(sprawl.albumData.slice())
+          .filter(album => album.tracks.length > 1);
+    }
+
+    return query;
   },
 
   relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    if (query.divideByGroups) {
+      relations.groupLinks =
+        query.groups
+          .map(group => relation('linkGroup', group));
+
+      relations.groupAlbumLinks =
+        query.groupAlbums
+          .map(albums => albums
+            .map(album =>
+              relation('generateListRandomPageLinksAlbumLink', album)));
+    } else {
+      relations.undividedAlbumLinks =
+        query.undividedAlbums
+          .map(album =>
+            relation('generateListRandomPageLinksAlbumLink', album));
+    }
+
+    return relations;
+  },
 
-      officialSection:
-        relation('generateListRandomPageLinksGroupSection', query.officialGroup),
+  data(query) {
+    const data = {};
 
-      fandomSection:
-        relation('generateListRandomPageLinksGroupSection', query.fandomGroup),
+    if (query.divideByGroups) {
+      data.groupDirectories =
+        query.groups
+          .map(group => group.directory);
+    }
 
-      beyondSection:
-        relation('generateListRandomPageLinksGroupSection', query.beyondGroup),
-    };
+    return data;
   },
 
-  generate(relations, {html, language}) {
+  generate(data, relations, {html, language}) {
+    const miscellaneousChunkRows = [
+      {
+        stringsKey: 'randomArtist',
+
+        mainLink:
+          html.tag('a',
+            {href: '#', 'data-random': 'artist'},
+            language.$('listingPage.other.randomPages.chunk.item.randomArtist.mainLink')),
+
+        atLeastTwoContributions:
+          html.tag('a',
+            {href: '#', 'data-random': 'artist-more-than-one-contrib'},
+            language.$('listingPage.other.randomPages.chunk.item.randomArtist.atLeastTwoContributions')),
+      },
+
+      {stringsKey: 'randomAlbumWholeSite'},
+      {stringsKey: 'randomTrackWholeSite'},
+    ];
+
+    const miscellaneousChunkRowAttributes = [
+      null,
+      {href: '#', 'data-random': 'album'},
+      {href: '#','data-random': 'track'},
+    ];
+
     return relations.page.slots({
-      type: 'custom',
+      type: 'chunks',
+
       content: [
         html.tag('p',
-          language.$('listingPage.other.randomPages.chooseLinkLine')),
+          language.$('listingPage.other.randomPages.chooseLinkLine', {
+            fromPart:
+              (relations.groupLinks
+                ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups')
+                : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups')),
+
+            browserSupportPart:
+              language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'),
+          })),
 
         html.tag('p',
           {class: 'js-hide-once-data'},
@@ -51,40 +121,71 @@ export default {
         html.tag('p',
           {class: 'js-show-once-data'},
           language.$('listingPage.other.randomPages.dataLoadedLine')),
+      ],
+
+      showSkipToSection: true,
+
+      chunkIDs:
+        (data.groupDirectories
+          ? [null, ...data.groupDirectories]
+          : null),
 
-        html.tag('dl', [
-          html.tag('dt',
-            language.$('listingPage.other.randomPages.misc')),
-
-          html.tag('dd',
-            html.tag('ul', [
-              html.tag('li', [
-                html.tag('a',
-                  {href: '#', 'data-random': 'artist'},
-                  language.$('listingPage.other.randomPages.misc.randomArtist')),
-
-                '(' +
-                html.tag('a',
-                  {href: '#', 'data-random': 'artist-more-than-one-contrib'},
-                  language.$('listingPage.other.randomPages.misc.atLeastTwoContributions')) +
-                ')',
+      chunkTitles: [
+        {stringsKey: 'misc'},
+
+        ...
+          (relations.groupLinks
+            ? relations.groupLinks.map(groupLink => ({
+                stringsKey: 'fromGroup',
+                group: groupLink,
+              }))
+            : [{stringsKey: 'fromAlbum'}]),
+      ],
+
+      chunkTitleAccents: [
+        null,
+
+        ...
+          (relations.groupLinks
+            ? relations.groupLinks.map(() => ({
+                randomAlbum:
+                  html.tag('a',
+                    {href: '#', 'data-random': 'album-in-group-dl'},
+                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomAlbum')),
+
+                randomTrack:
+                  html.tag('a',
+                    {href: '#', 'data-random': 'track-in-group-dl'},
+                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomTrack')),
+              }))
+            : [null]),
+      ],
+
+      chunkRows: [
+        miscellaneousChunkRows,
+
+        ...
+          (relations.groupAlbumLinks
+            ? relations.groupAlbumLinks.map(albumLinks =>
+                albumLinks.map(albumLink => ({
+                  stringsKey: 'album',
+                  album: albumLink,
+                })))
+            : [
+                relations.undividedAlbumLinks.map(albumLink => ({
+                  stringsKey: 'album',
+                  album: albumLink,
+                })),
               ]),
+      ],
 
-              html.tag('li',
-                html.tag('a',
-                  {href: '#', 'data-random': 'album'},
-                  language.$('listingPage.other.randomPages.misc.randomAlbumWholeSite'))),
-
-              html.tag('li',
-                html.tag('a',
-                  {href: '#', 'data-random': 'track'},
-                  language.$('listingPage.other.randomPages.misc.randomTrackWholeSite'))),
-            ])),
-
-          relations.officialSection,
-          relations.fandomSection,
-          relations.beyondSection,
-        ]),
+      chunkRowAttributes: [
+        miscellaneousChunkRowAttributes,
+        ...
+          (relations.groupAlbumLinks
+            ? relations.groupAlbumLinks.map(albumLinks =>
+                albumLinks.map(() => null))
+            : [relations.undividedAlbumLinks.map(() => null)]),
       ],
     });
   },
diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js
index d6546e67..25beb739 100644
--- a/src/content/dependencies/listTracksByDate.js
+++ b/src/content/dependencies/listTracksByDate.js
@@ -71,8 +71,15 @@ export default {
               rerelease: rereleases,
             }).map(({trackLink, rerelease}) =>
                 (rerelease
-                  ? {track: trackLink, stringsKey: 'rerelease'}
+                  ? {stringsKey: 'rerelease', track: trackLink}
                   : {track: trackLink}))),
+
+      chunkRowAttributes:
+        data.rereleases.map(rereleases =>
+          rereleases.map(rerelease =>
+            (rerelease
+              ? {class: 'rerelease'}
+              : null))),
     });
   },
 };