« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/content
diff options
context:
space:
mode:
Diffstat (limited to 'src/content')
-rw-r--r--src/content/dependencies/generateAbsoluteDatetimestamp.js41
-rw-r--r--src/content/dependencies/generateAdditionalNamesBox.js20
-rw-r--r--src/content/dependencies/generateAdditionalNamesBoxItem.js68
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js110
-rw-r--r--src/content/dependencies/generateAlbumCoverArtwork.js20
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js22
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js2
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js6
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js8
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js5
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js8
-rw-r--r--src/content/dependencies/generateColorStyleRules.js7
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js51
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js99
-rw-r--r--src/content/dependencies/generateCommentaryIndexPage.js4
-rw-r--r--src/content/dependencies/generateCommentarySection.js29
-rw-r--r--src/content/dependencies/generateContentHeading.js28
-rw-r--r--src/content/dependencies/generateContributionList.js1
-rw-r--r--src/content/dependencies/generateCoverArtwork.js7
-rw-r--r--src/content/dependencies/generateDatetimestampTemplate.js28
-rw-r--r--src/content/dependencies/generateFlashActSidebar.js42
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js38
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js2
-rw-r--r--src/content/dependencies/generateFooterLocalizationLinks.js53
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js117
-rw-r--r--src/content/dependencies/generateGroupSidebar.js1
-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/generateListingSidebar.js1
-rw-r--r--src/content/dependencies/generatePageLayout.js79
-rw-r--r--src/content/dependencies/generateRelativeDatetimestamp.js58
-rw-r--r--src/content/dependencies/generateReleaseInfoContributionsLine.js1
-rw-r--r--src/content/dependencies/generateStaticPage.js11
-rw-r--r--src/content/dependencies/generateTrackAdditionalNamesBox.js53
-rw-r--r--src/content/dependencies/generateTrackCoverArtwork.js32
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js99
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js5
-rw-r--r--src/content/dependencies/generateWikiHomeAlbumsRow.js20
-rw-r--r--src/content/dependencies/generateWikiHomeNewsBox.js1
-rw-r--r--src/content/dependencies/generateWikiHomePage.js1
-rw-r--r--src/content/dependencies/image.js34
-rw-r--r--src/content/dependencies/linkContribution.js92
-rw-r--r--src/content/dependencies/linkExternal.js152
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js71
-rw-r--r--src/content/dependencies/linkExternalFlash.js41
-rw-r--r--src/content/dependencies/linkTemplate.js8
-rw-r--r--src/content/dependencies/linkTrackDynamically.js34
-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.js222
-rw-r--r--src/content/dependencies/listTracksByDate.js9
-rw-r--r--src/content/dependencies/transformContent.js68
55 files changed, 1992 insertions, 1082 deletions
diff --git a/src/content/dependencies/generateAbsoluteDatetimestamp.js b/src/content/dependencies/generateAbsoluteDatetimestamp.js
new file mode 100644
index 00000000..63acecf2
--- /dev/null
+++ b/src/content/dependencies/generateAbsoluteDatetimestamp.js
@@ -0,0 +1,41 @@
+export default {
+  contentDependencies: ['generateDatetimestampTemplate'],
+  extraDependencies: ['html', 'language'],
+
+  data: (date) =>
+    ({date}),
+
+  relations: (relation) =>
+    ({template: relation('generateDatetimestampTemplate')}),
+
+  slots: {
+    style: {
+      validate: v => v.is('full', 'year'),
+      default: 'full',
+    },
+
+    // Only has an effect for 'year' style.
+    tooltip: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots, {language}) =>
+    relations.template.slots({
+      mainContent:
+        (slots.style === 'full'
+          ? language.formatDate(data.date)
+       : slots.style === 'year'
+          ? data.date.getFullYear().toString()
+          : null),
+
+      tooltipContent:
+        slots.tooltip &&
+        slots.style === 'year' &&
+          language.formatDate(data.date),
+
+      datetime:
+        data.date.toISOString(),
+    }),
+};
diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js
new file mode 100644
index 00000000..63427c58
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalNamesBox.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: ['generateAdditionalNamesBoxItem'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, additionalNames) => ({
+    items:
+      additionalNames
+        .map(entry => relation('generateAdditionalNamesBoxItem', entry)),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tag('div', {id: 'additional-names-box'}, [
+      html.tag('p',
+        language.$('misc.additionalNames.title')),
+
+      html.tag('ul',
+        relations.items
+          .map(item => html.tag('li', item))),
+    ]),
+};
diff --git a/src/content/dependencies/generateAdditionalNamesBoxItem.js b/src/content/dependencies/generateAdditionalNamesBoxItem.js
new file mode 100644
index 00000000..bb4c8477
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalNamesBoxItem.js
@@ -0,0 +1,68 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkTrack', 'transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    nameContent:
+      relation('transformContent', entry.name),
+
+    annotationContent:
+      (entry.annotation
+        ? relation('transformContent', entry.annotation)
+        : null),
+
+    trackLinks:
+      (entry.from
+        ? entry.from.map(track => relation('linkTrack', track))
+        : null),
+  }),
+
+  data: (entry) => ({
+    albumNames:
+      (entry.from
+        ? entry.from.map(track => track.album.name)
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) => {
+    const prefix = 'misc.additionalNames.item';
+
+    const itemParts = [prefix];
+    const itemOptions = {};
+
+    itemOptions.name =
+      html.tag('span', {class: 'additional-name'},
+        relations.nameContent.slot('mode', 'inline'));
+
+    const accentParts = [prefix, 'accent'];
+    const accentOptions = {};
+
+    if (relations.annotationContent) {
+      accentParts.push('withAnnotation');
+      accentOptions.annotation =
+        relations.annotationContent.slot('mode', 'inline');
+    }
+
+    if (relations.trackLinks) {
+      accentParts.push('withAlbums');
+      accentOptions.albums =
+        language.formatConjunctionList(
+          stitchArrays({
+            trackLink: relations.trackLinks,
+            albumName: data.albumNames,
+          }).map(({trackLink, albumName}) =>
+              trackLink.slot('content', albumName)));
+    }
+
+    if (accentParts.length > 2) {
+      itemParts.push('withAccent');
+      itemOptions.accent =
+        html.tag('span', {class: 'accent'},
+          language.$(...accentParts, accentOptions));
+    }
+
+    return language.$(...itemParts, itemOptions);
+  },
+};
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 3ad1549e..5a7142e5 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -1,4 +1,4 @@
-import {stitchArrays} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -6,13 +6,13 @@ export default {
     'generateAlbumNavAccent',
     'generateAlbumSidebarTrackSection',
     'generateAlbumStyleRules',
-    'generateColorStyleVariables',
+    'generateCommentaryEntry',
     'generateContentHeading',
     'generateTrackCoverArtwork',
     'generatePageLayout',
     'linkAlbum',
+    'linkExternal',
     'linkTrack',
-    'transformContent',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -33,13 +33,23 @@ export default {
       relation('generateAlbumNavAccent', album, null);
 
     if (album.commentary) {
+      relations.albumCommentaryHeading =
+        relation('generateContentHeading');
+
+      relations.albumCommentaryLink =
+        relation('linkAlbum', album);
+
+      relations.albumCommentaryListeningLinks =
+        album.urls.map(url => relation('linkExternal', url));
+
       if (album.hasCoverArt) {
         relations.albumCommentaryCover =
           relation('generateAlbumCoverArtwork', album);
       }
 
-      relations.albumCommentaryContent =
-        relation('transformContent', album.commentary);
+      relations.albumCommentaryEntries =
+        album.commentary
+          .map(entry => relation('generateCommentaryEntry', entry));
     }
 
     const tracksWithCommentary =
@@ -54,6 +64,11 @@ export default {
       tracksWithCommentary
         .map(track => relation('linkTrack', track));
 
+    relations.trackCommentaryListeningLinks =
+      tracksWithCommentary
+        .map(track =>
+          track.urls.map(url => relation('linkExternal', url)));
+
     relations.trackCommentaryCovers =
       tracksWithCommentary
         .map(track =>
@@ -61,16 +76,11 @@ export default {
             ? relation('generateTrackCoverArtwork', track)
             : null));
 
-    relations.trackCommentaryContent =
-      tracksWithCommentary
-        .map(track => relation('transformContent', track.commentary));
-
-    relations.trackCommentaryColorVariables =
+    relations.trackCommentaryEntries =
       tracksWithCommentary
         .map(track =>
-          (track.color === album.color
-            ? null
-            : relation('generateColorStyleVariables')));
+          track.commentary
+            .map(entry => relation('generateCommentaryEntry', entry)));
 
     relations.sidebarAlbumLink =
       relation('linkAlbum', album);
@@ -97,11 +107,15 @@ export default {
         ? [album, ...tracksWithCommentary]
         : tracksWithCommentary);
 
-    data.entryCount = thingsWithCommentary.length;
+    data.entryCount =
+      thingsWithCommentary
+        .flatMap(({commentary}) => commentary)
+        .length;
 
     data.wordCount =
       thingsWithCommentary
-        .map(({commentary}) => commentary)
+        .flatMap(({commentary}) => commentary)
+        .map(({body}) => body)
         .join(' ')
         .split(' ')
         .length;
@@ -146,40 +160,75 @@ export default {
                   language.countCommentaryEntries(data.entryCount, {unit: true})),
             })),
 
-          relations.albumCommentaryContent && [
-            html.tag('h3',
-              {class: ['content-heading']},
-              language.$('albumCommentaryPage.entry.title.albumCommentary')),
+          relations.albumCommentaryEntries && [
+            relations.albumCommentaryHeading.slots({
+              tag: 'h3',
+              color: data.color,
+
+              title:
+                language.$('albumCommentaryPage.entry.title.albumCommentary', {
+                  album: relations.albumCommentaryLink,
+                }),
+
+              accent:
+                !empty(relations.albumCommentaryListeningLinks) &&
+                  language.$('albumCommentaryPage.entry.title.albumCommentary.accent', {
+                    listeningLinks:
+                      language.formatUnitList(
+                        relations.albumCommentaryListeningLinks
+                          .map(link => link.slots({
+                            context: 'album',
+                            tab: 'separate',
+                          }))),
+                  }),
+            }),
 
             relations.albumCommentaryCover
               ?.slots({mode: 'commentary'}),
 
-            html.tag('blockquote',
-              relations.albumCommentaryContent),
+            relations.albumCommentaryEntries,
           ],
 
           stitchArrays({
             heading: relations.trackCommentaryHeadings,
             link: relations.trackCommentaryLinks,
+            listeningLinks: relations.trackCommentaryListeningLinks,
             directory: data.trackCommentaryDirectories,
             cover: relations.trackCommentaryCovers,
-            content: relations.trackCommentaryContent,
-            colorVariables: relations.trackCommentaryColorVariables,
+            entries: relations.trackCommentaryEntries,
             color: data.trackCommentaryColors,
-          }).map(({heading, link, directory, cover, content, colorVariables, color}) => [
+          }).map(({
+              heading,
+              link,
+              listeningLinks,
+              directory,
+              cover,
+              entries,
+              color,
+            }) => [
               heading.slots({
                 tag: 'h3',
                 id: directory,
-                title: link,
+                color,
+
+                title:
+                  language.$('albumCommentaryPage.entry.title.trackCommentary', {
+                    track: link,
+                  }),
+
+                accent:
+                  !empty(listeningLinks) &&
+                    language.$('albumCommentaryPage.entry.title.trackCommentary.accent', {
+                      listeningLinks:
+                        language.formatUnitList(
+                          listeningLinks.map(link =>
+                            link.slot('tab', 'separate'))),
+                    }),
               }),
 
               cover?.slots({mode: 'commentary'}),
 
-              html.tag('blockquote',
-                (color
-                  ? {style: colorVariables.slot('color', color).content}
-                  : {}),
-                content),
+              entries.map(entry => entry.slot('color', color)),
             ]),
         ],
 
@@ -201,6 +250,7 @@ export default {
         ],
 
         leftSidebarStickyMode: 'column',
+        leftSidebarClass: 'commentary-track-list-sidebar-box',
         leftSidebarContent: [
           html.tag('h1', relations.sidebarAlbumLink),
           relations.sidebarTrackSections.map(section =>
diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js
index cbec930e..ce8cde21 100644
--- a/src/content/dependencies/generateAlbumCoverArtwork.js
+++ b/src/content/dependencies/generateAlbumCoverArtwork.js
@@ -1,12 +1,22 @@
 export default {
   contentDependencies: ['generateCoverArtwork'],
 
-  relations: (relation, album) =>
-    ({coverArtwork: relation('generateCoverArtwork', album.artTags)}),
+  relations: (relation, album) => ({
+    coverArtwork:
+      relation('generateCoverArtwork', album.artTags),
+  }),
 
-  data: (album) =>
-    ({path: ['media.albumCover', album.directory, album.coverArtFileExtension]}),
+  data: (album) => ({
+    path:
+      ['media.albumCover', album.directory, album.coverArtFileExtension],
+
+    color:
+      album.color,
+  }),
 
   generate: (data, relations) =>
-    relations.coverArtwork.slot('path', data.path),
+    relations.coverArtwork.slots({
+      path: data.path,
+      color: data.color,
+    }),
 };
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index 5fe27caf..90a120ca 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -17,6 +17,7 @@ export default {
     'generateAlbumStyleRules',
     'generateAlbumTrackList',
     'generateChronologyLinks',
+    'generateCommentarySection',
     'generateContentHeading',
     'generatePageLayout',
     'linkAlbum',
@@ -126,13 +127,8 @@ export default {
     // Section: Artist commentary
 
     if (album.commentary) {
-      const artistCommentary = sections.artistCommentary = {};
-
-      artistCommentary.heading =
-        relation('generateContentHeading');
-
-      artistCommentary.content =
-        relation('transformContent', album.commentary);
+      sections.artistCommentary =
+        relation('generateCommentarySection', album.commentary);
     }
 
     return relations;
@@ -235,17 +231,7 @@ export default {
             sec.additionalFiles.additionalFilesList,
           ],
 
-          sec.artistCommentary && [
-            sec.artistCommentary.heading
-              .slots({
-                id: 'artist-commentary',
-                title: language.$('releaseInfo.artistCommentary')
-              }),
-
-            html.tag('blockquote',
-              sec.artistCommentary.content
-                .slot('mode', 'multiline')),
-          ],
+          sec.artistCommentary,
         ],
 
         navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js
index 7eb1dac0..01c88bf7 100644
--- a/src/content/dependencies/generateAlbumNavAccent.js
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -92,7 +92,7 @@ export default {
         html.tag('a',
           {
             href: '#',
-            'data-random': 'track-in-album',
+            'data-random': 'track-in-sidebar',
             id: 'random-button',
           },
           (data.isTrackPage
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index d6405283..dd5baab9 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -94,7 +94,11 @@ export default {
             links:
               language.formatDisjunctionList(
                 relations.externalLinks
-                  .map(link => link.slot('mode', 'album'))),
+                  .map(link =>
+                    link.slots({
+                      context: 'album',
+                      style: 'normal',
+                    }))),
           })),
     ]);
   },
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
index a84f4357..5ef4501b 100644
--- a/src/content/dependencies/generateAlbumSidebar.js
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -30,6 +30,7 @@ export default {
 
   generate(data, relations, {html}) {
     const trackListBox = {
+      class: 'track-list-sidebar-box',
       content:
         html.tags([
           html.tag('h1', relations.albumLink),
@@ -40,8 +41,10 @@ export default {
     if (data.isAlbumPage) {
       const groupBoxes =
         relations.groupBoxes
-          .map(content => content.slot('mode', 'album'))
-          .map(content => ({content}));
+          .map(content => ({
+            class: 'individual-group-sidebar-box',
+            content: content.slot('mode', 'album'),
+          }));
 
       return {
         leftSidebarMultiple: [
@@ -52,6 +55,7 @@ export default {
     }
 
     const conjoinedGroupBox = {
+      class: 'conjoined-group-sidebar-box',
       content:
         relations.groupBoxes
           .flatMap((content, i, {length}) => [
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
index 331ddaba..f3705450 100644
--- a/src/content/dependencies/generateAlbumSidebarGroupBox.js
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -66,7 +66,10 @@ export default {
       !empty(relations.externalLinks) &&
         html.tag('p',
           language.$('releaseInfo.visitOn', {
-            links: language.formatDisjunctionList(relations.externalLinks),
+            links:
+              language.formatDisjunctionList(
+                relations.externalLinks
+                  .map(link => link.slot('context', 'group'))),
           })),
 
       slots.mode === 'album' &&
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index 03bc0af5..1b85680f 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -161,7 +161,13 @@ export default {
           sec.visit &&
             html.tag('p',
               language.$('releaseInfo.visitOn', {
-                links: language.formatDisjunctionList(sec.visit.externalLinks),
+                links:
+                  language.formatDisjunctionList(
+                    sec.visit.externalLinks.map(link =>
+                      link.slots({
+                        context: 'artist',
+                        style: 'platform',
+                      }))),
               })),
 
           sec.artworks?.artistGalleryLink &&
diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js
index 1b316a3c..3f1d0130 100644
--- a/src/content/dependencies/generateColorStyleRules.js
+++ b/src/content/dependencies/generateColorStyleRules.js
@@ -18,9 +18,12 @@ export default {
       `:root {`,
       ...(
         relations.variables
-          .slot('color', slots.color)
+          .slots({
+            color: slots.color,
+            context: 'page-root',
+            mode: 'property-list',
+          })
           .content
-          .split(';')
           .map(line => line + ';')),
       `}`,
     ].join('\n');
diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js
index f30d786b..7cd04bd1 100644
--- a/src/content/dependencies/generateColorStyleVariables.js
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -2,7 +2,23 @@ export default {
   extraDependencies: ['html', 'getColors'],
 
   slots: {
-    color: {validate: v => v.isColor},
+    color: {
+      validate: v => v.isColor,
+    },
+
+    context: {
+      validate: v => v.is(
+        'any-content',
+        'page-root',
+        'primary-only'),
+
+      default: 'any-content',
+    },
+
+    mode: {
+      validate: v => v.is('style', 'property-list'),
+      default: 'style',
+    },
   },
 
   generate(slots, {getColors}) {
@@ -18,7 +34,7 @@ export default {
       shadow,
     } = getColors(slots.color);
 
-    return [
+    let anyContent = [
       `--primary-color: ${primary}`,
       `--dark-color: ${dark}`,
       `--dim-color: ${dim}`,
@@ -26,6 +42,35 @@ export default {
       `--bg-color: ${bg}`,
       `--bg-black-color: ${bgBlack}`,
       `--shadow-color: ${shadow}`,
-    ].join('; ');
+    ];
+
+    let selectedProperties;
+
+    switch (slots.context) {
+      case 'any-content':
+        selectedProperties = anyContent;
+        break;
+
+      case 'page-root':
+        selectedProperties = [
+          ...anyContent,
+          `--page-primary-color: ${primary}`,
+        ];
+        break;
+
+      case 'primary-only':
+        selectedProperties = [
+          `--primary-color: ${primary}`,
+        ];
+        break;
+    }
+
+    switch (slots.mode) {
+      case 'style':
+        return selectedProperties.join('; ');
+
+      case 'property-list':
+        return selectedProperties;
+    }
   },
 };
diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
new file mode 100644
index 00000000..0b2b2558
--- /dev/null
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -0,0 +1,99 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleVariables',
+    'linkArtist',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    artistLinks:
+      (!empty(entry.artists) && !entry.artistDisplayText
+        ? entry.artists
+            .map(artist => relation('linkArtist', artist))
+        : null),
+
+    artistsContent:
+      (entry.artistDisplayText
+        ? relation('transformContent', entry.artistDisplayText)
+        : null),
+
+    annotationContent:
+      (entry.annotation
+        ? relation('transformContent', entry.annotation)
+        : null),
+
+    bodyContent:
+      (entry.body
+        ? relation('transformContent', entry.body)
+        : null),
+
+    colorVariables:
+      relation('generateColorStyleVariables'),
+  }),
+
+  data: (entry) => ({
+    date: entry.date,
+  }),
+
+  slots: {
+    color: {validate: v => v.isColor},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const artistsSpan =
+      html.tag('span', {class: 'commentary-entry-artists'},
+        (relations.artistsContent
+          ? relations.artistsContent.slot('mode', 'inline')
+       : relations.artistLinks
+          ? language.formatConjunctionList(relations.artistLinks)
+          : language.$('misc.artistCommentary.entry.title.noArtists')));
+
+    const accentParts = ['misc.artistCommentary.entry.title.accent'];
+    const accentOptions = {};
+
+    if (relations.annotationContent) {
+      accentParts.push('withAnnotation');
+      accentOptions.annotation =
+        relations.annotationContent.slot('mode', 'inline');
+    }
+
+    if (data.date) {
+      accentParts.push('withDate');
+      accentOptions.date =
+        language.formatDate(data.date);
+    }
+
+    const accent =
+      (accentParts.length > 1
+        ? html.tag('span', {class: 'commentary-entry-accent'},
+            language.$(...accentParts, accentOptions))
+        : null);
+
+    const titleParts = ['misc.artistCommentary.entry.title'];
+    const titleOptions = {artists: artistsSpan};
+
+    if (accent) {
+      titleParts.push('withAccent');
+      titleOptions.accent = accent;
+    }
+
+    const style =
+      (slots.color
+        ? relations.colorVariables
+            .slot('color', slots.color)
+            .content
+        : null);
+
+    return html.tags([
+      html.tag('p', {class: 'commentary-entry-heading', style},
+        language.$(...titleParts, titleOptions)),
+
+      html.tag('blockquote', {class: 'commentary-entry-body', style},
+        relations.bodyContent.slot('mode', 'multiline')),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js
index 1d381bff..5d38941a 100644
--- a/src/content/dependencies/generateCommentaryIndexPage.js
+++ b/src/content/dependencies/generateCommentaryIndexPage.js
@@ -19,13 +19,13 @@ export default {
       query.albums.map(album =>
         [album, ...album.tracks]
           .filter(({commentary}) => commentary)
-          .map(({commentary}) => commentary));
+          .flatMap(({commentary}) => commentary));
 
     query.wordCounts =
       entries.map(entries =>
         accumulateSum(
           entries,
-          entry => entry.split(' ').length));
+          entry => entry.body.split(' ').length));
 
     query.entryCounts =
       entries.map(entries => entries.length);
diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js
new file mode 100644
index 00000000..8ae1b2d0
--- /dev/null
+++ b/src/content/dependencies/generateCommentarySection.js
@@ -0,0 +1,29 @@
+export default {
+  contentDependencies: [
+    'transformContent',
+    'generateCommentaryEntry',
+    'generateContentHeading',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entries) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    entries:
+      entries.map(entry =>
+        relation('generateCommentaryEntry', entry)),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tags([
+      relations.heading
+        .slots({
+          id: 'artist-commentary',
+          title: language.$('misc.artistCommentary')
+        }),
+
+      relations.entries,
+    ]),
+};
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
index ccaf1076..0343409c 100644
--- a/src/content/dependencies/generateContentHeading.js
+++ b/src/content/dependencies/generateContentHeading.js
@@ -1,19 +1,41 @@
 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,
+      }, [
+        html.tag('span',
+          {[html.onlyIfContent]: true, class: 'content-heading-main-title'},
+          slots.title),
+
+        html.tag('span',
+          {[html.onlyIfContent]: true, class: 'content-heading-accent'},
+          slots.accent),
+      ]);
   }
 }
diff --git a/src/content/dependencies/generateContributionList.js b/src/content/dependencies/generateContributionList.js
index 731cfba5..6401e65e 100644
--- a/src/content/dependencies/generateContributionList.js
+++ b/src/content/dependencies/generateContributionList.js
@@ -16,5 +16,6 @@ export default {
               showIcons: true,
               showContribution: true,
               preventWrapping: false,
+              iconMode: 'tooltip',
             })))),
 };
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index aeba97de..e43963fb 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -31,6 +31,10 @@ export default {
       type: 'string',
     },
 
+    color: {
+      validate: v => v.isColor,
+    },
+
     mode: {
       validate: v => v.is('primary', 'thumbnail', 'commentary'),
       default: 'primary',
@@ -45,6 +49,7 @@ export default {
             .slots({
               path: slots.path,
               alt: slots.alt,
+              color: slots.color,
               thumb: 'medium',
               id: 'cover-art',
               reveal: true,
@@ -67,6 +72,7 @@ export default {
           .slots({
             path: slots.path,
             alt: slots.alt,
+            color: slots.color,
             thumb: 'small',
             reveal: false,
             link: false,
@@ -78,6 +84,7 @@ export default {
           .slots({
             path: slots.path,
             alt: slots.alt,
+            color: slots.color,
             thumb: 'medium',
             class: 'commentary-art',
             reveal: true,
diff --git a/src/content/dependencies/generateDatetimestampTemplate.js b/src/content/dependencies/generateDatetimestampTemplate.js
new file mode 100644
index 00000000..bfba647f
--- /dev/null
+++ b/src/content/dependencies/generateDatetimestampTemplate.js
@@ -0,0 +1,28 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    mainContent: {type: 'html'},
+    tooltipContent: {type: 'html'},
+    datetime: {type: 'string'},
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('span', {
+      [html.joinChildren]: '',
+
+      class: [
+        'datetimestamp',
+        slots.tooltipContent && 'has-tooltip',
+      ],
+    }, [
+      html.tag('time',
+        {datetime: slots.datetime},
+        slots.mainContent),
+
+      slots.tooltipContent &&
+        html.tag('span', {class: 'datetimestamp-tooltip'},
+          html.tag('span', {class: 'datetimestamp-tooltip-content'},
+            slots.tooltipContent)),
+    ]),
+};
diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js
index bd6063c9..29379644 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));
@@ -127,7 +137,7 @@ export default {
   }),
 
   generate(data, relations, {getColors, html, language}) {
-    const currentActBox = html.tags([
+    const currentActBoxContent = html.tags([
       html.tag('h1', relations.currentActLink),
 
       html.tag('details',
@@ -150,7 +160,7 @@ export default {
         ]),
     ]);
 
-    const sideMapBox = html.tags([
+    const sideMapBoxContent = html.tags([
       html.tag('h1', relations.flashIndexLink),
 
       stitchArrays({
@@ -178,17 +188,21 @@ export default {
           ])),
     ]);
 
+    const sideMapBox = {
+      class: 'flash-act-map-sidebar-box',
+      content: sideMapBoxContent,
+    };
+
+    const currentActBox = {
+      class: 'flash-current-act-sidebar-box',
+      content: currentActBoxContent,
+    };
+
     return {
       leftSidebarMultiple:
         (data.isFlashActPage
-          ? [
-              {content: sideMapBox},
-              {content: currentActBox},
-            ]
-          : [
-              {content: currentActBox},
-              {content: sideMapBox},
-            ]),
+          ? [sideMapBox, currentActBox]
+          : [currentActBox, sideMapBox]),
     };
   },
 };
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/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index 09c6b37c..c60f9696 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -133,7 +133,7 @@ export default {
               links:
                 language.formatDisjunctionList(
                   relations.externalLinks
-                    .map(link => link.slot('mode', 'flash'))),
+                    .map(link => link.slot('context', 'flash'))),
             })),
 
         sec.featuredTracks && [
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/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
index 0583755e..0e5d645b 100644
--- a/src/content/dependencies/generateGroupInfoPage.js
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -1,7 +1,9 @@
-import {empty} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateColorStyleVariables',
     'generateContentHeading',
     'generateGroupNavLinks',
     'generateGroupSecondaryNav',
@@ -62,18 +64,27 @@ export default {
       sec.albums.galleryLink =
         relation('linkGroupGallery', group);
 
-      sec.albums.entries =
-        group.albums.map(album => {
-          const links = {};
-          links.albumLink = relation('linkAlbum', album);
+      sec.albums.colorVariables =
+        group.albums
+          .map(() => relation('generateColorStyleVariables'));
 
-          const otherGroup = album.groups.find(g => g !== group);
-          if (otherGroup) {
-            links.groupLink = relation('linkGroup', otherGroup);
-          }
+      sec.albums.albumLinks =
+        group.albums
+          .map(album => relation('linkAlbum', album));
 
-          return links;
-        });
+      sec.albums.groupLinks =
+        group.albums
+          .map(album => album.groups.find(g => g !== group))
+          .map(group =>
+            (group
+              ? relation('linkGroup', group)
+              : null));
+
+      sec.albums.datetimestamps =
+        group.albums.map(album =>
+          (album.date
+            ? relation('generateAbsoluteDatetimestamp', album.date)
+            : null));
     }
 
     return relations;
@@ -85,11 +96,8 @@ export default {
     data.name = group.name;
     data.color = group.color;
 
-    if (!empty(group.albums)) {
-      data.albumYears =
-        group.albums
-          .map(album => album.date?.getFullYear());
-    }
+    data.albumColors =
+      group.albums.map(album => album.color);
 
     return data;
   },
@@ -107,7 +115,10 @@ export default {
           sec.info.visitLinks &&
             html.tag('p',
               language.$('releaseInfo.visitOn', {
-                links: language.formatDisjunctionList(sec.info.visitLinks),
+                links:
+                  language.formatDisjunctionList(
+                    sec.info.visitLinks
+                      .map(link => link.slot('context', 'group'))),
               })),
 
           html.tag('blockquote',
@@ -130,34 +141,50 @@ export default {
               })),
 
             html.tag('ul',
-              sec.albums.entries.map(({albumLink, groupLink}, index) => {
-                // All these strings are really jank, and should probably
-                // be implemented with the same 'const parts = [], opts = {}'
-                // form used elsewhere...
-                const year = data.albumYears[index];
-                const item =
-                  (year
-                    ? language.$('groupInfoPage.albumList.item', {
-                        year,
-                        album: albumLink,
-                      })
-                    : language.$('groupInfoPage.albumList.item.withoutYear', {
-                        album: albumLink,
-                      }));
-
-                return html.tag('li',
-                  (groupLink
-                    ? language.$('groupInfoPage.albumList.item.withAccent', {
-                        item,
-                        accent:
-                          html.tag('span', {class: 'other-group-accent'},
-                            language.$('groupInfoPage.albumList.item.otherGroupAccent', {
-                              group:
-                                groupLink.slot('color', false),
-                            })),
-                      })
-                    : item));
-              })),
+              stitchArrays({
+                albumLink: sec.albums.albumLinks,
+                groupLink: sec.albums.groupLinks,
+                datetimestamp: sec.albums.datetimestamps,
+                colorVariables: sec.albums.colorVariables,
+                albumColor: data.albumColors,
+              }).map(({
+                  albumLink,
+                  groupLink,
+                  datetimestamp,
+                  colorVariables,
+                  albumColor,
+                }) => {
+                  const prefix = 'groupInfoPage.albumList.item';
+                  const parts = [prefix];
+                  const options = {};
+
+                  options.album =
+                    albumLink.slot('color', false);
+
+                  if (datetimestamp) {
+                    parts.push('withYear');
+                    options.yearAccent =
+                      language.$(prefix, 'yearAccent', {
+                        year:
+                          datetimestamp.slots({style: 'year', tooltip: true}),
+                      });
+                  }
+
+                  if (groupLink) {
+                    parts.push('withOtherGroup');
+                    options.otherGroupAccent =
+                      html.tag('span', {class: 'other-group-accent'},
+                        language.$(prefix, 'otherGroupAccent', {
+                          group:
+                            groupLink.slot('color', false),
+                        }));
+                  }
+
+                  return (
+                    html.tag('li',
+                      {style: colorVariables.slot('color', albumColor).content},
+                      language.$(...parts, options)));
+                })),
           ],
         ],
 
diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js
index 6baf37f4..98b288fa 100644
--- a/src/content/dependencies/generateGroupSidebar.js
+++ b/src/content/dependencies/generateGroupSidebar.js
@@ -22,6 +22,7 @@ export default {
 
   generate(relations, slots, {html, language}) {
     return {
+      leftSidebarClass: 'category-map-sidebar-box',
       leftSidebarContent: [
         html.tag('h1',
           language.$('groupSidebar.title')),
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/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js
index fe2a08fa..1cdd236b 100644
--- a/src/content/dependencies/generateListingSidebar.js
+++ b/src/content/dependencies/generateListingSidebar.js
@@ -11,6 +11,7 @@ export default {
 
   generate(relations, {html}) {
     return {
+      leftSidebarClass: 'listing-map-sidebar-box',
       leftSidebarContent: [
         html.tag('h1', relations.listingIndexLink),
         relations.listingIndexList.slot('mode', 'sidebar'),
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 72dfbae5..1591223a 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -6,12 +6,19 @@ function sidebarSlots(side) {
     // if specified.
     [side + 'Content']: {type: 'html'},
 
-    // Multiple is an array of {content: (HTML)} objects. Each of these
-    // will generate one sidebar section.
+    // A single class to apply to the whole sidebar. If specifying multiple
+    // sections, this be added to the containing sidebar-column - specify a
+    // class on each section if that's more suitable.
+    [side + 'Class']: {type: 'string'},
+
+    // Multiple is an array of objects, each specifying content (HTML) and
+    // optionally class (a string). Each of these will generate one sidebar
+    // section.
     [side + 'Multiple']: {
       validate: v =>
         v.sparseArrayOf(
           v.validateProperties({
+            class: v.optional(v.isString),
             content: v.isHTML,
           })),
     },
@@ -27,6 +34,7 @@ function sidebarSlots(side) {
     // the whole section's containing box (or the sidebar column as a whole).
     [side + 'StickyMode']: {
       validate: v => v.is('last', 'column', 'static'),
+      default: 'static',
     },
 
     // Collapsing sidebars disappear when the viewport is sufficiently
@@ -85,8 +93,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');
@@ -98,6 +108,8 @@ export default {
     title: {type: 'html'},
     showWikiNameInTitle: {type: 'boolean', default: true},
 
+    additionalNames: {type: 'html'},
+
     cover: {type: 'html'},
 
     socialEmbed: {type: 'html'},
@@ -212,26 +224,29 @@ export default {
     const colors = getColors(slots.color ?? data.wikiColor);
     const hasSocialEmbed = !html.isBlank(slots.socialEmbed);
 
-    let titleHTML = null;
-
-    if (!html.isBlank(slots.title)) {
-      switch (slots.headingMode) {
-        case 'sticky':
-          titleHTML =
-            relations.stickyHeadingContainer.slots({
-              title: slots.title,
-              cover: slots.cover,
-            });
-          break;
-        case 'static':
-          titleHTML = html.tag('h1', slots.title);
-          break;
-      }
-    }
+    const titleContentsHTML =
+      (html.isBlank(slots.title)
+        ? null
+     : html.isBlank(slots.additionalNames)
+        ? language.sanitize(slots.title)
+        : html.tag('a', {
+            href: '#additional-names-box',
+            title: language.$('misc.additionalNames.tooltip').toString(),
+          }, language.sanitize(slots.title)));
+
+    const titleHTML =
+      (html.isBlank(slots.title)
+        ? null
+     : slots.headingMode === 'sticky'
+        ? relations.stickyHeadingContainer.slots({
+            title: titleContentsHTML,
+            cover: slots.cover,
+          })
+        : html.tag('h1', titleContentsHTML));
 
     let footerContent = slots.footerContent;
 
-    if (html.isBlank(footerContent)) {
+    if (html.isBlank(footerContent) && relations.defaultFooterContent) {
       footerContent = relations.defaultFooterContent
         .slot('mode', 'multiline');
     }
@@ -244,6 +259,7 @@ export default {
         titleHTML,
 
         slots.cover,
+        slots.additionalNames,
 
         html.tag('div',
           {
@@ -352,6 +368,7 @@ export default {
 
     const generateSidebarHTML = (side, id) => {
       const content = slots[side + 'Content'];
+      const topClass = slots[side + 'Class'];
       const multiple = slots[side + 'Multiple'];
       const stickyMode = slots[side + 'StickyMode'];
       const wide = slots[side + 'Wide'];
@@ -361,20 +378,18 @@ export default {
       let sidebarContent = html.blank();
 
       if (!html.isBlank(content)) {
-        sidebarClasses = ['sidebar'];
+        sidebarClasses = ['sidebar', topClass];
         sidebarContent = content;
       } else if (multiple) {
-        sidebarClasses = ['sidebar-multiple'];
+        sidebarClasses = ['sidebar-multiple', topClass];
         sidebarContent =
           multiple
             .filter(Boolean)
-            .map(({content}) =>
-              html.tag('div',
-                {
-                  [html.onlyIfContent]: true,
-                  class: 'sidebar',
-                },
-                content));
+            .map(box =>
+              html.tag('div', {
+                [html.onlyIfContent]: true,
+                class: ['sidebar', box.class],
+              }, box.content));
       }
 
       if (html.isBlank(sidebarContent)) {
@@ -609,7 +624,7 @@ export default {
 
             html.tag('link', {
               rel: 'stylesheet',
-              href: to('shared.staticFile', 'site5.css', cachebust),
+              href: to('shared.staticFile', 'site6.css', cachebust),
             }),
 
             html.tag('style', [
@@ -646,7 +661,7 @@ export default {
 
               html.tag('script', {
                 type: 'module',
-                src: to('shared.staticFile', 'client2.js', cachebust),
+                src: to('shared.staticFile', 'client3.js', cachebust),
               }),
             ]),
         ])
diff --git a/src/content/dependencies/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js
new file mode 100644
index 00000000..bbe33188
--- /dev/null
+++ b/src/content/dependencies/generateRelativeDatetimestamp.js
@@ -0,0 +1,58 @@
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateDatetimestampTemplate',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  data: (currentDate, referenceDate) =>
+    (currentDate.getTime() === referenceDate.getTime()
+      ? {equal: true, date: currentDate}
+      : {equal: false, currentDate, referenceDate}),
+
+  relations: (relation, currentDate) =>
+    ({template: relation('generateDatetimestampTemplate'),
+      fallback: relation('generateAbsoluteDatetimestamp', currentDate)}),
+
+  slots: {
+    style: {
+      validate: v => v.is('full', 'year'),
+      default: 'full',
+    },
+
+    tooltip: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate(data, relations, slots, {language}) {
+    if (data.comparison === 'equal') {
+      return relations.fallback.slots({
+        style: slots.style,
+        tooltip: slots.tooltip,
+      });
+    }
+
+    return relations.template.slots({
+      mainContent:
+        (slots.style === 'full'
+          ? language.formatDate(data.currentDate)
+       : slots.style === 'year'
+          ? data.currentDate.getFullYear().toString()
+          : null),
+
+      tooltipContent:
+        slots.tooltip &&
+          language.formatRelativeDate(data.currentDate, data.referenceDate, {
+            considerRoundingDays: true,
+            approximate: true,
+            absolute: slots.style === 'year',
+          }),
+
+      datetime:
+        data.currentDate.toISOString(),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js
index 1fa8dcca..2e6c4709 100644
--- a/src/content/dependencies/generateReleaseInfoContributionsLine.js
+++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js
@@ -35,6 +35,7 @@ export default {
             link.slots({
               showContribution: slots.showContribution,
               showIcons: slots.showIcons,
+              iconMode: 'tooltip',
             }))),
     });
   },
diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js
index 3e27fd43..226152c7 100644
--- a/src/content/dependencies/generateStaticPage.js
+++ b/src/content/dependencies/generateStaticPage.js
@@ -1,5 +1,6 @@
 export default {
   contentDependencies: ['generatePageLayout', 'transformContent'],
+  extraDependencies: ['html'],
 
   relations(relation, staticPage) {
     return {
@@ -12,10 +13,11 @@ export default {
     return {
       name: staticPage.name,
       stylesheet: staticPage.stylesheet,
+      script: staticPage.script,
     };
   },
 
-  generate(data, relations) {
+  generate(data, relations, {html}) {
     return relations.layout
       .slots({
         title: data.name,
@@ -27,7 +29,12 @@ export default {
             : []),
 
         mainClasses: ['long-content'],
-        mainContent: relations.content,
+        mainContent: [
+          relations.content,
+
+          data.script &&
+            html.tag('script', data.script),
+        ],
 
         navLinkStyle: 'hierarchical',
         navLinks: [
diff --git a/src/content/dependencies/generateTrackAdditionalNamesBox.js b/src/content/dependencies/generateTrackAdditionalNamesBox.js
new file mode 100644
index 00000000..bad04b74
--- /dev/null
+++ b/src/content/dependencies/generateTrackAdditionalNamesBox.js
@@ -0,0 +1,53 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['generateAdditionalNamesBox'],
+  extraDependencies: ['html'],
+
+  query: (track) => {
+    const {
+      additionalNames: own,
+      sharedAdditionalNames: shared,
+      inferredAdditionalNames: inferred,
+    } = track;
+
+    if (empty(own) && empty(shared) && empty(inferred)) {
+      return {combinedList: []};
+    }
+
+    const firstFilter =
+      (empty(own)
+        ? new Set()
+        : new Set(own.map(({name}) => name)));
+
+    const sharedFiltered =
+      shared.filter(({name}) => !firstFilter.has(name))
+
+    const secondFilter =
+      new Set([
+        ...firstFilter,
+        ...sharedFiltered.map(({name}) => name),
+      ]);
+
+    const inferredFiltered =
+      inferred.filter(({name}) => !secondFilter.has(name));
+
+    return {
+      combinedList: [
+        ...own,
+        ...sharedFiltered,
+        ...inferredFiltered,
+      ],
+    };
+  },
+
+  relations: (relation, query) => ({
+    box:
+      (empty(query.combinedList)
+        ? null
+        : relation('generateAdditionalNamesBox', query.combinedList)),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.box ?? html.blank(),
+};
diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js
index ec0488e2..6c056c9a 100644
--- a/src/content/dependencies/generateTrackCoverArtwork.js
+++ b/src/content/dependencies/generateTrackCoverArtwork.js
@@ -1,20 +1,28 @@
 export default {
   contentDependencies: ['generateCoverArtwork'],
 
-  relations: (relation, track) =>
-    ({coverArtwork:
-        relation('generateCoverArtwork',
-          (track.hasUniqueCoverArt
-            ? track.artTags
-            : track.album.artTags))}),
-
-  data: (track) =>
-    ({path:
+  relations: (relation, track) => ({
+    coverArtwork:
+      relation('generateCoverArtwork',
         (track.hasUniqueCoverArt
-          ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
-          : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension])}),
+          ? track.artTags
+          : track.album.artTags)),
+  }),
+
+  data: (track) => ({
+    path:
+      (track.hasUniqueCoverArt
+        ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
+        : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension]),
+
+    color:
+      track.color,
+  }),
 
   generate: (data, relations) =>
-    relations.coverArtwork.slot('path', data.path),
+    relations.coverArtwork.slots({
+      path: data.path,
+      color: data.color,
+    }),
 };
 
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 93334948..041f6bbc 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -1,19 +1,24 @@
-import {empty} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 import {sortAlbumsTracksChronologically, sortFlashesChronologically} from '#wiki-data';
 
 import getChronologyRelations from '../util/getChronologyRelations.js';
 
 export default {
   contentDependencies: [
+    'generateAbsoluteDatetimestamp',
     'generateAdditionalFilesShortcut',
     'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
     'generateAlbumSidebar',
     'generateAlbumStyleRules',
     'generateChronologyLinks',
+    'generateColorStyleVariables',
+    'generateCommentarySection',
     'generateContentHeading',
     'generateContributionList',
     'generatePageLayout',
+    'generateRelativeDatetimestamp',
+    'generateTrackAdditionalNamesBox',
     'generateTrackCoverArtwork',
     'generateTrackList',
     'generateTrackListDividedByGroups',
@@ -106,6 +111,10 @@ export default {
       list: relation('generateAlbumAdditionalFilesList', album, additionalFiles),
     });
 
+    // This'll take care of itself being blank if there's nothing to show here.
+    relations.additionalNamesBox =
+      relation('generateTrackAdditionalNamesBox', track);
+
     if (track.hasUniqueCoverArt || album.hasCoverArt) {
       relations.cover =
         relation('generateTrackCoverArtwork', track);
@@ -133,6 +142,29 @@ export default {
       otherReleases.heading =
         relation('generateContentHeading');
 
+      otherReleases.colorVariables =
+        track.otherReleases
+          .map(() => relation('generateColorStyleVariables'));
+
+      otherReleases.trackLinks =
+        track.otherReleases
+          .map(track => relation('linkTrack', track));
+
+      otherReleases.albumLinks =
+        track.otherReleases
+          .map(track => relation('linkAlbum', track.album));
+
+      otherReleases.datetimestamps =
+        track.otherReleases.map(track2 =>
+          (track2.date
+            ? (track.date
+                ? relation('generateRelativeDatetimestamp',
+                    track2.date,
+                    track.date)
+                : relation('generateAbsoluteDatetimestamp',
+                    track2.date))
+            : null));
+
       otherReleases.items =
         track.otherReleases.map(track => ({
           trackLink: relation('linkTrack', track),
@@ -268,13 +300,8 @@ export default {
     // Section: Artist commentary
 
     if (track.commentary) {
-      const artistCommentary = sections.artistCommentary = {};
-
-      artistCommentary.heading =
-        relation('generateContentHeading');
-
-      artistCommentary.content =
-        relation('transformContent', track.commentary);
+      sections.artistCommentary =
+        relation('generateCommentarySection', track.commentary);
     }
 
     return relations;
@@ -288,6 +315,9 @@ export default {
       hasTrackNumbers: track.album.hasTrackNumbers,
       trackNumber: track.album.tracks.indexOf(track) + 1,
 
+      otherReleaseColors:
+        track.otherReleases.map(track => track.color),
+
       numAdditionalFiles: track.additionalFiles.length,
     };
   },
@@ -300,6 +330,8 @@ export default {
         title: language.$('trackPage.title', {track: data.name}),
         headingMode: 'sticky',
 
+        additionalNames: relations.additionalNamesBox,
+
         color: data.color,
         styleRules: [relations.albumStyleRules],
 
@@ -352,12 +384,39 @@ export default {
               }),
 
             html.tag('ul',
-              sec.otherReleases.items.map(({trackLink, albumLink}) =>
-                html.tag('li',
-                  language.$('releaseInfo.alsoReleasedAs.item', {
-                    track: trackLink,
-                    album: albumLink,
-                  })))),
+              stitchArrays({
+                trackLink: sec.otherReleases.trackLinks,
+                albumLink: sec.otherReleases.albumLinks,
+                datetimestamp: sec.otherReleases.datetimestamps,
+                colorVariables: sec.otherReleases.colorVariables,
+                color: data.otherReleaseColors,
+              }).map(({
+                  trackLink,
+                  albumLink,
+                  datetimestamp,
+                  colorVariables,
+                  color,
+                }) => {
+                  const parts = ['releaseInfo.alsoReleasedAs.item'];
+                  const options = {};
+
+                  options.track = trackLink.slot('color', false);
+                  options.album = albumLink;
+
+                  if (datetimestamp) {
+                    parts.push('withYear');
+                    options.year =
+                      datetimestamp.slots({
+                        style: 'year',
+                        tooltip: true,
+                      });
+                  }
+
+                  return (
+                    html.tag('li',
+                      {style: colorVariables.slot('color', color).content},
+                      language.$(...parts, options)));
+                })),
           ],
 
           sec.contributors && [
@@ -491,17 +550,7 @@ export default {
             sec.additionalFiles.list,
           ],
 
-          sec.artistCommentary && [
-            sec.artistCommentary.heading
-              .slots({
-                id: 'artist-commentary',
-                title: language.$('releaseInfo.artistCommentary')
-              }),
-
-            html.tag('blockquote',
-              sec.artistCommentary.content
-                .slot('mode', 'multiline')),
-          ],
+          sec.artistCommentary,
         ],
 
         navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 9a7478ca..c347dbce 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -77,7 +77,10 @@ export default {
       html.tag('p',
         (relations.externalLinks
           ? language.$('releaseInfo.listenOn', {
-              links: language.formatDisjunctionList(relations.externalLinks),
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'track'))),
             })
           : language.$('releaseInfo.listenOn.noLinks', {
               name: html.tag('i', data.name),
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/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js
index 8acd426c..0d8303f1 100644
--- a/src/content/dependencies/generateWikiHomeNewsBox.js
+++ b/src/content/dependencies/generateWikiHomeNewsBox.js
@@ -42,6 +42,7 @@ export default {
     }
 
     return {
+      class: 'latest-news-sidebar-box',
       content: [
         html.tag('h1', language.$('homepage.news.title')),
 
diff --git a/src/content/dependencies/generateWikiHomePage.js b/src/content/dependencies/generateWikiHomePage.js
index 40a6b1c5..36fcc6f2 100644
--- a/src/content/dependencies/generateWikiHomePage.js
+++ b/src/content/dependencies/generateWikiHomePage.js
@@ -75,6 +75,7 @@ export default {
       leftSidebarMultiple: [
         (relations.customSidebarContent
           ? {
+              class: 'custom-content-sidebar-box',
               content:
                 relations.customSidebarContent
                   .slot('mode', 'multiline'),
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index 8aa9753b..3c78abe3 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -14,6 +14,12 @@ export default {
     'to',
   ],
 
+  contentDependencies: ['generateColorStyleVariables'],
+
+  relations: (relation) => ({
+    colorVariables: relation('generateColorStyleVariables'),
+  }),
+
   data(artTags) {
     const data = {};
 
@@ -43,6 +49,10 @@ export default {
       default: false,
     },
 
+    color: {
+      validate: v => v.isColor,
+    },
+
     reveal: {type: 'boolean', default: true},
     lazy: {type: 'boolean', default: false},
     square: {type: 'boolean', default: false},
@@ -56,7 +66,7 @@ export default {
     missingSourceContent: {type: 'html'},
   },
 
-  generate(data, slots, {
+  generate(data, relations, slots, {
     checkIfImagePathHasCachedThumbnails,
     getDimensionsOfImagePath,
     getSizeOfImagePath,
@@ -110,6 +120,12 @@ export default {
       !isMissingImageFile &&
       !empty(data.contentWarnings);
 
+    const colorStyle =
+      slots.color &&
+        relations.colorVariables
+          .slot('color', slots.color)
+          .content;
+
     const willSquare = slots.square;
 
     const idOnImg = willLink ? null : slots.id;
@@ -118,6 +134,9 @@ export default {
     const classOnImg = willLink ? null : slots.class;
     const classOnLink = willLink ? slots.class : null;
 
+    const styleOnContainer = willLink ? null : colorStyle;
+    const styleOnLink = willLink ? colorStyle : null;
+
     if (!originalSrc || isMissingImageFile) {
       return prepare(
         html.tag('div', {class: 'image-text-area'},
@@ -191,7 +210,7 @@ export default {
       imgAttributes['data-no-image-preview'] = true;
     }
 
-    // These attributes are only relevant when a thumbnail are available *and*
+    // These attributes are only relevant when a thumbnail is available *and*
     // being used.
     if (hasThumbnails && slots.thumb) {
       if (fileSize) {
@@ -238,9 +257,13 @@ export default {
       let wrapped = content;
 
       wrapped =
-        html.tag('div', {class: ['image-container', !originalSrc && 'placeholder-image']},
+        html.tag('div', {
+          class: ['image-container', !originalSrc && 'placeholder-image'],
+          style: styleOnContainer,
+        }, [
           html.tag('div', {class: 'image-inner-area'},
-            wrapped));
+            wrapped),
+        ]);
 
       if (willReveal) {
         wrapped =
@@ -270,6 +293,7 @@ export default {
         wrapped = html.tag('a',
           {
             id: idOnLink,
+
             class: [
               'box',
               'image-link',
@@ -277,6 +301,8 @@ export default {
               classOnLink,
             ],
 
+            style: styleOnLink,
+
             href:
               (typeof slots.link === 'string'
                 ? slots.link
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index 8e42f247..790afa4f 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -1,15 +1,8 @@
 import {empty} from '#sugar';
 
 export default {
-  contentDependencies: [
-    'linkArtist',
-    'linkExternalAsIcon',
-  ],
-
-  extraDependencies: [
-    'html',
-    'language',
-  ],
+  contentDependencies: ['linkArtist', 'linkExternalAsIcon'],
+  extraDependencies: ['html', 'language'],
 
   relations(relation, contribution) {
     const relations = {};
@@ -20,7 +13,6 @@ export default {
     if (!empty(contribution.who.urls)) {
       relations.artistIcons =
         contribution.who.urls
-          .slice(0, 4)
           .map(url => relation('linkExternalAsIcon', url));
     }
 
@@ -37,37 +29,81 @@ export default {
     showContribution: {type: 'boolean', default: false},
     showIcons: {type: 'boolean', default: false},
     preventWrapping: {type: 'boolean', default: true},
+
+    iconMode: {
+      validate: v => v.is('inline', 'tooltip'),
+      default: 'inline'
+    },
   },
 
   generate(data, relations, slots, {html, language}) {
-    const hasContributionPart = !!(slots.showContribution && data.what);
-    const hasExternalPart = !!(slots.showIcons && relations.artistIcons);
-
-    const externalLinks = hasExternalPart &&
-      html.tag('span',
-        {[html.noEdgeWhitespace]: true, class: 'icons'},
-        language.formatUnitList(relations.artistIcons));
+    const hasContribution = !!(slots.showContribution && data.what);
+    const hasExternalIcons = !!(slots.showIcons && relations.artistIcons);
 
     const parts = ['misc.artistLink'];
     const options = {artist: relations.artistLink};
 
-    if (hasContributionPart) {
+    if (hasContribution) {
       parts.push('withContribution');
       options.contrib = data.what;
     }
 
-    if (hasExternalPart) {
+    if (hasExternalIcons && slots.iconMode === 'inline') {
       parts.push('withExternalLinks');
-      options.links = externalLinks;
+      options.links =
+        html.tag('span',
+          {
+            [html.noEdgeWhitespace]: true,
+            class: ['icons', 'icons-inline'],
+          },
+          language.formatUnitList(
+            relations.artistIcons
+              .slice(0, 4)
+              .map(icon => icon.slot('context', 'artist'))));
     }
 
-    const content = language.formatString(parts.join('.'), options);
+    let content = language.formatString(parts.join('.'), options);
 
-    return (
-      (parts.length > 1 && slots.preventWrapping
-        ? html.tag('span',
-            {[html.noEdgeWhitespace]: true, class: 'nowrap'},
-            content)
-        : content));
-    },
+    if (hasExternalIcons && slots.iconMode === 'tooltip') {
+      content = [
+        content,
+        html.tag('span',
+          {
+            [html.noEdgeWhitespace]: true,
+            class: ['icons', 'icons-tooltip'],
+            inert: true,
+          },
+          html.tag('span',
+            {
+              [html.noEdgeWhitespace]: true,
+              [html.joinChildren]: '',
+              class: 'icons-tooltip-content',
+            },
+            relations.artistIcons
+              .map(icon => icon.slots({context: 'artist', withText: true})))),
+      ];
+    }
+
+    if (hasContribution || hasExternalIcons) {
+      content =
+        html.tag('span', {
+          [html.noEdgeWhitespace]: true,
+          [html.joinChildren]: '',
+
+          class: [
+            'contribution',
+
+            hasExternalIcons &&
+            slots.iconMode === 'tooltip' &&
+              'has-tooltip',
+
+            parts.length > 1 &&
+            slots.preventWrapping &&
+              'nowrap',
+          ],
+        }, content);
+    }
+
+    return content;
+  }
 };
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index 5de612e2..70e1ccff 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -1,140 +1,42 @@
-// TODO: Define these as extra dependencies and pass them somewhere
-const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
-const MASTODON_DOMAINS = ['types.pl'];
+import {isExternalLinkContext, isExternalLinkStyle} from '#external-links';
 
 export default {
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  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;
-  },
+  data: (url) => ({url}),
 
   slots: {
-    mode: {
-      validate: v => v.is('generic', 'album', 'flash'),
-      default: 'generic',
+    style: {
+      // This awkward syntax is because the slot descriptor validator can't
+      // differentiate between a function that returns a validator (the usual
+      // syntax) and a function that is itself a validator.
+      validate: () => isExternalLinkStyle,
+      default: 'normal',
     },
-  },
 
-  generate(data, slots, {html, language}) {
-    let isLocal;
-    let domain;
-    let pathname;
-
-    try {
-      const url = new URL(data.url);
-      domain = url.hostname;
-      pathname = url.pathname;
-    } catch (error) {
-      // 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;
-    }
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
 
-    // isLocal also applies for URLs which match the 'Canonical Base' under
-    // wiki-info.yaml, if present.
-    if (data.canonicalDomain && domain === data.canonicalDomain) {
-      isLocal = true;
-    }
+    tab: {
+      validate: v => v.is('default', 'separate'),
+      default: 'default',
+    },
+  },
 
-    const link = html.tag('a',
+  generate: (data, slots, {html, language}) =>
+    html.tag('a',
       {
         href: data.url,
         class: 'nowrap',
+        target:
+          (slots.tab === 'separate'
+            ? '_blank'
+            : null),
       },
-
-      // truly unhinged indentation here
-      isLocal
-        ? language.$('misc.external.local')
-
-    : domain.includes('bandcamp.com')
-        ? language.$('misc.external.bandcamp')
-
-    : BANDCAMP_DOMAINS.includes(domain)
-        ? language.$('misc.external.bandcamp.domain', {domain})
-
-    : MASTODON_DOMAINS.includes(domain)
-        ? language.$('misc.external.mastodon.domain', {domain})
-
-    : domain.includes('youtu')
-        ? slots.mode === 'album'
-          ? data.url.includes('list=')
-            ? language.$('misc.external.youtube.playlist')
-            : language.$('misc.external.youtube.fullAlbum')
-          : language.$('misc.external.youtube')
-
-    : domain.includes('soundcloud')
-        ? language.$('misc.external.soundcloud')
-
-    : domain.includes('tumblr.com')
-        ? language.$('misc.external.tumblr')
-
-    : domain.includes('twitter.com')
-        ? language.$('misc.external.twitter')
-
-    : domain.includes('deviantart.com')
-        ? language.$('misc.external.deviantart')
-
-    : domain.includes('wikipedia.org')
-        ? language.$('misc.external.wikipedia')
-
-    : domain.includes('poetryfoundation.org')
-        ? language.$('misc.external.poetryFoundation')
-
-    : domain.includes('instagram.com')
-        ? language.$('misc.external.instagram')
-
-    : domain.includes('patreon.com')
-        ? language.$('misc.external.patreon')
-
-    : domain.includes('spotify.com')
-        ? language.$('misc.external.spotify')
-
-    : domain.includes('newgrounds.com')
-        ? language.$('misc.external.newgrounds')
-
-        : domain);
-
-    switch (slots.mode) {
-      case 'flash': {
-        const wrap = content =>
-          html.tag('span', {class: 'nowrap'}, content);
-
-        if (domain.includes('homestuck.com')) {
-          const match = pathname.match(/\/story\/(.*)\/?/);
-          if (match) {
-            if (isNaN(Number(match[1]))) {
-              return wrap(language.$('misc.external.flash.homestuck.secret', {link}));
-            } else {
-              return wrap(language.$('misc.external.flash.homestuck.page', {
-                link,
-                page: match[1],
-              }));
-            }
-          }
-        } else if (domain.includes('bgreco.net')) {
-          return wrap(language.$('misc.external.flash.bgreco', {link}));
-        } else if (domain.includes('youtu')) {
-          return wrap(language.$('misc.external.flash.youtube', {link}));
-        }
-
-        return link;
-      }
-
-      default:
-        return link;
-    }
-  }
+      language.formatExternalLink(data.url, {
+        style: slots.style,
+        context: slots.context,
+      })),
 };
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
index cd168992..357c835c 100644
--- a/src/content/dependencies/linkExternalAsIcon.js
+++ b/src/content/dependencies/linkExternalAsIcon.js
@@ -1,46 +1,45 @@
-// TODO: Define these as extra dependencies and pass them somewhere
-const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com'];
-const MASTODON_DOMAINS = ['types.pl'];
+import {isExternalLinkContext} from '#external-links';
 
 export default {
   extraDependencies: ['html', 'language', 'to'],
 
-  data(url) {
-    return {url};
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      // This awkward syntax is because the slot descriptor validator can't
+      // differentiate between a function that returns a validator (the usual
+      // syntax) and a function that is itself a validator.
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+
+    withText: {type: 'boolean'},
   },
 
-  generate(data, {html, language, to}) {
-    const domain = new URL(data.url).hostname;
-    const [id, msg] = (
-      domain.includes('bandcamp.com')
-        ? ['bandcamp', language.$('misc.external.bandcamp')]
-      : BANDCAMP_DOMAINS.includes(domain)
-        ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})]
-      : MASTODON_DOMAINS.includes(domain)
-        ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})]
-      : domain.includes('youtu')
-        ? ['youtube', language.$('misc.external.youtube')]
-      : domain.includes('soundcloud')
-        ? ['soundcloud', language.$('misc.external.soundcloud')]
-      : domain.includes('tumblr.com')
-        ? ['tumblr', language.$('misc.external.tumblr')]
-      : domain.includes('twitter.com')
-        ? ['twitter', language.$('misc.external.twitter')]
-      : domain.includes('deviantart.com')
-        ? ['deviantart', language.$('misc.external.deviantart')]
-      : domain.includes('instagram.com')
-        ? ['instagram', language.$('misc.external.bandcamp')]
-      : domain.includes('newgrounds.com')
-        ? ['newgrounds', language.$('misc.external.newgrounds')]
-        : ['globe', language.$('misc.external.domain', {domain})]);
+  generate(data, slots, {html, language, to}) {
+    const format = style =>
+      language.formatExternalLink(data.url, {style, context: slots.context});
+
+    const normalText = format('normal');
+    const compactText = format('compact');
+    const iconId = format('icon-id');
 
     return html.tag('a',
-      {href: data.url, class: 'icon'},
-      html.tag('svg', [
-        html.tag('title', msg),
-        html.tag('use', {
-          href: to('shared.staticIcon', id),
-        }),
-      ]));
+      {href: data.url, class: ['icon', slots.withText && 'has-text']},
+      [
+        html.tag('svg', [
+          !slots.withText &&
+            html.tag('title', normalText),
+
+          html.tag('use', {
+            href: to('shared.staticIcon', iconId),
+          }),
+        ]),
+
+        slots.withText &&
+          html.tag('span', {class: 'icon-text'},
+            compactText ?? normalText),
+      ]);
   },
 };
diff --git a/src/content/dependencies/linkExternalFlash.js b/src/content/dependencies/linkExternalFlash.js
deleted file mode 100644
index 65158ff8..00000000
--- a/src/content/dependencies/linkExternalFlash.js
+++ /dev/null
@@ -1,41 +0,0 @@
-// Note: This function is seriously hard-coded for HSMusic, with custom
-// presentation of links to Homestuck flashes hosted various places.
-
-export default {
-  contentDependencies: ['linkExternal'],
-  extraDependencies: ['html', 'language'],
-
-  relations(relation, url) {
-    return {
-      link: relation('linkExternal', url),
-    };
-  },
-
-  data(url, flash) {
-    return {
-      url,
-      page: flash.page,
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    const {link} = relations;
-    const {url, page} = data;
-
-    return html.tag('span',
-      {class: 'nowrap'},
-
-      url.includes('homestuck.com')
-        ? isNaN(Number(page))
-          ? language.$('misc.external.flash.homestuck.secret', {link})
-          : language.$('misc.external.flash.homestuck.page', {link, page})
-
-    : url.includes('bgreco.net')
-        ? language.$('misc.external.flash.bgreco', {link})
-
-    : url.includes('youtu')
-        ? language.$('misc.external.flash.youtube', {link})
-
-        : link);
-  },
-};
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/linkTrackDynamically.js b/src/content/dependencies/linkTrackDynamically.js
new file mode 100644
index 00000000..242cd4cb
--- /dev/null
+++ b/src/content/dependencies/linkTrackDynamically.js
@@ -0,0 +1,34 @@
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, track) => ({
+    infoLink: relation('linkTrack', track),
+  }),
+
+  data: (track) => ({
+    trackDirectory:
+      track.directory,
+
+    albumDirectory:
+      track.album.directory,
+
+    trackHasCommentary:
+      !!track.commentary,
+  }),
+
+  generate(data, relations, {pagePath}) {
+    if (
+      pagePath[0] === 'albumCommentary' &&
+      pagePath[1] === data.albumDirectory &&
+      data.trackHasCommentary
+    ) {
+      relations.infoLink.setSlots({
+        anchor: true,
+        hash: data.trackDirectory,
+      });
+    }
+
+    return relations.infoLink;
+  },
+};
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..18585696 100644
--- a/src/content/dependencies/listRandomPageLinks.js
+++ b/src/content/dependencies/listRandomPageLinks.js
@@ -1,90 +1,192 @@
+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')),
 
-        html.tag('p',
-          {class: 'js-hide-once-data'},
+            browserSupportPart:
+              language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'),
+          })),
+
+        html.tag('p', {id: 'data-loading-line'},
           language.$('listingPage.other.randomPages.dataLoadingLine')),
 
-        html.tag('p',
-          {class: 'js-show-once-data'},
+        html.tag('p', {id: 'data-loaded-line'},
           language.$('listingPage.other.randomPages.dataLoadedLine')),
 
-        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')) +
-                ')',
+        html.tag('p', {id: 'data-error-line'},
+          language.$('listingPage.other.randomPages.dataErrorLine')),
+      ],
+
+      showSkipToSection: true,
+
+      chunkIDs:
+        (data.groupDirectories
+          ? [null, ...data.groupDirectories]
+          : null),
+
+      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))),
     });
   },
 };
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 3c2c3521..2002ebee 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -1,7 +1,7 @@
 import {bindFind} from '#find';
 import {parseInput} from '#replacer';
 
-import {marked} from 'marked';
+import {Marked} from 'marked';
 
 export const replacerSpec = {
   album: {
@@ -130,7 +130,7 @@ const linkThingRelationMap = {
   newsEntry: 'linkNewsEntry',
   staticPage: 'linkStaticPage',
   tag: 'linkArtTag',
-  track: 'linkTrack',
+  track: 'linkTrackDynamically',
 };
 
 const linkValueRelationMap = {
@@ -147,6 +147,29 @@ const linkIndexRelationMap = {
   newsIndex: 'linkNewsIndex',
 };
 
+const commonMarkedOptions = {
+  headerIds: false,
+  mangle: false,
+};
+
+const multilineMarked = new Marked({
+  ...commonMarkedOptions,
+});
+
+const inlineMarked = new Marked({
+  ...commonMarkedOptions,
+
+  renderer: {
+    paragraph(text) {
+      return text;
+    },
+  },
+});
+
+const lyricsMarked = new Marked({
+  ...commonMarkedOptions,
+});
+
 function getPlaceholder(node, content) {
   return {type: 'text', data: content.slice(node.i, node.iEnd)};
 }
@@ -447,19 +470,9 @@ export default {
       return link.data;
     }
 
-    // In inline mode, no further processing is needed!
-
-    if (slots.mode === 'inline') {
-      return html.tags(contentFromNodes.map(node => node.data));
-    }
-
-    // Multiline mode has a secondary processing stage where it's passed...
-    // through marked! Rolling your own Markdown only gets you so far :D
-
-    const markedOptions = {
-      headerIds: false,
-      mangle: false,
-    };
+    // Content always goes through marked (i.e. parsing as Markdown).
+    // This does require some attention to detail, mostly to do with line
+    // breaks (in multiline mode) and extracting/re-inserting non-text nodes.
 
     // The content of non-text nodes can end up getting mangled by marked.
     // To avoid this, we replace them with mundane placeholders, then
@@ -534,23 +547,36 @@ export default {
       return html.tags(tags, {[html.joinChildren]: ''});
     };
 
+    if (slots.mode === 'inline') {
+      const markedInput =
+        extractNonTextNodes();
+
+      const markedOutput =
+        inlineMarked.parse(markedInput);
+
+      return reinsertNonTextNodes(markedOutput);
+    }
+
     // This is separated into its own function just since we're gonna reuse
     // it in a minute if everything goes to heck in lyrics mode.
     const transformMultiline = () => {
       const markedInput =
         extractNonTextNodes()
-          // Compress multiple line breaks into single line breaks.
-          .replace(/\n{2,}/g, '\n')
+          // Compress multiple line breaks into single line breaks,
+          // except when they're preceding or following indented
+          // text (by at least two spaces).
+          .replace(/(?<!  .*)\n{2,}(?!^  )/gm, '\n') /* eslint-disable-line no-regex-spaces */
           // Expand line breaks which don't follow a list, quote,
-          // or <br> / "  ".
-          .replace(/(?<!^ *-.*|^>.*|  $|<br>$)\n+/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
+          // or <br> / "  ", and which don't precede or follow
+          // indented text (by at least two spaces).
+          .replace(/(?<!^ *-.*|^>.*|^  .*\n*|  $|<br>$)\n+(?!  |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
           // Expand line breaks which are at the end of a list.
           .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n')
           // Expand line breaks which are at the end of a quote.
           .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n');
 
       const markedOutput =
-        marked.parse(markedInput, markedOptions);
+        multilineMarked.parse(markedInput);
 
       return reinsertNonTextNodes(markedOutput);
     }
@@ -600,7 +626,7 @@ export default {
         });
 
       const markedOutput =
-        marked.parse(markedInput, markedOptions);
+        lyricsMarked.parse(markedInput);
 
       return reinsertNonTextNodes(markedOutput);
     }