« 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/dependencies
diff options
context:
space:
mode:
Diffstat (limited to 'src/content/dependencies')
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js103
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunk.js81
-rw-r--r--src/content/dependencies/generateAdditionalFilesShortcut.js27
-rw-r--r--src/content/dependencies/generateAdditionalNamesBox.js22
-rw-r--r--src/content/dependencies/generateAdditionalNamesBoxItem.js35
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js59
-rw-r--r--src/content/dependencies/generateAlbumArtInfoBox.js39
-rw-r--r--src/content/dependencies/generateAlbumArtworkColumn.js38
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js274
-rw-r--r--src/content/dependencies/generateAlbumCommentarySidebar.js73
-rw-r--r--src/content/dependencies/generateAlbumCoverArtwork.js22
-rw-r--r--src/content/dependencies/generateAlbumGalleryAlbumGrid.js90
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js243
-rw-r--r--src/content/dependencies/generateAlbumGalleryTrackGrid.js122
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js338
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js164
-rw-r--r--src/content/dependencies/generateAlbumReferencedArtworksPage.js58
-rw-r--r--src/content/dependencies/generateAlbumReferencingArtworksPage.js58
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js110
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNav.js233
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNavGroupPart.js94
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js94
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js212
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js88
-rw-r--r--src/content/dependencies/generateAlbumSidebarSeriesBox.js102
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackListBox.js31
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js119
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbed.js64
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbedDescription.js77
-rw-r--r--src/content/dependencies/generateAlbumStyleRules.js72
-rw-r--r--src/content/dependencies/generateAlbumStyleTags.js65
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js69
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js151
-rw-r--r--src/content/dependencies/generateAlbumTrackListMissingDuration.js33
-rw-r--r--src/content/dependencies/generateAlbumWallpaperStyleTag.js38
-rw-r--r--src/content/dependencies/generateArtTagAncestorDescendantMapList.js153
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js242
-rw-r--r--src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js23
-rw-r--r--src/content/dependencies/generateArtTagGalleryPageShowingLine.js22
-rw-r--r--src/content/dependencies/generateArtTagInfoPage.js281
-rw-r--r--src/content/dependencies/generateArtTagNavLinks.js81
-rw-r--r--src/content/dependencies/generateArtTagSidebar.js124
-rw-r--r--src/content/dependencies/generateArtistArtworkColumn.js13
-rw-r--r--src/content/dependencies/generateArtistCredit.js194
-rw-r--r--src/content/dependencies/generateArtistCreditWikiEditsPart.js55
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js159
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js324
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js537
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunk.js50
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js111
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js285
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunk.js53
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkItem.js105
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkedList.js11
-rw-r--r--src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js295
-rw-r--r--src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js75
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunk.js34
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js34
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js177
-rw-r--r--src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js35
-rw-r--r--src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js61
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunk.js67
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkItem.js146
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkedList.js334
-rw-r--r--src/content/dependencies/generateArtistNavLinks.js136
-rw-r--r--src/content/dependencies/generateBackToAlbumLink.js15
-rw-r--r--src/content/dependencies/generateBackToTrackLink.js15
-rw-r--r--src/content/dependencies/generateChronologyLinks.js82
-rw-r--r--src/content/dependencies/generateColorStyleRules.js42
-rw-r--r--src/content/dependencies/generateColorStyleTag.js51
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js20
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js130
-rw-r--r--src/content/dependencies/generateCommentaryEntryDate.js93
-rw-r--r--src/content/dependencies/generateCommentaryIndexPage.js86
-rw-r--r--src/content/dependencies/generateCommentarySection.js29
-rw-r--r--src/content/dependencies/generateContentContentHeading.js39
-rw-r--r--src/content/dependencies/generateContentHeading.js24
-rw-r--r--src/content/dependencies/generateContributionList.js32
-rw-r--r--src/content/dependencies/generateContributionTooltip.js48
-rw-r--r--src/content/dependencies/generateContributionTooltipChronologySection.js129
-rw-r--r--src/content/dependencies/generateContributionTooltipExternalLinkSection.js70
-rw-r--r--src/content/dependencies/generateCoverArtwork.js221
-rw-r--r--src/content/dependencies/generateCoverArtworkArtTagDetails.js75
-rw-r--r--src/content/dependencies/generateCoverArtworkArtistDetails.js25
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js170
-rw-r--r--src/content/dependencies/generateCoverArtworkReferenceDetails.js60
-rw-r--r--src/content/dependencies/generateCoverCarousel.js15
-rw-r--r--src/content/dependencies/generateCoverGrid.js73
-rw-r--r--src/content/dependencies/generateDatetimestampTemplate.js8
-rw-r--r--src/content/dependencies/generateDotSwitcherTemplate.js41
-rw-r--r--src/content/dependencies/generateExpandableGallerySection.js92
-rw-r--r--src/content/dependencies/generateExternalHandle.js20
-rw-r--r--src/content/dependencies/generateExternalIcon.js26
-rw-r--r--src/content/dependencies/generateExternalPlatform.js20
-rw-r--r--src/content/dependencies/generateFlashActGalleryPage.js84
-rw-r--r--src/content/dependencies/generateFlashActNavAccent.js83
-rw-r--r--src/content/dependencies/generateFlashActSidebar.js224
-rw-r--r--src/content/dependencies/generateFlashActSidebarCurrentActBox.js64
-rw-r--r--src/content/dependencies/generateFlashActSidebarSideMapBox.js85
-rw-r--r--src/content/dependencies/generateFlashArtworkColumn.js11
-rw-r--r--src/content/dependencies/generateFlashCoverArtwork.js12
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js146
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js257
-rw-r--r--src/content/dependencies/generateFlashNavAccent.js81
-rw-r--r--src/content/dependencies/generateGridActionLinks.js20
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js248
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumGrid.js66
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js39
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js26
-rw-r--r--src/content/dependencies/generateGroupGalleryPageSeriesSection.js156
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js333
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js47
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js87
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListItem.js137
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsSection.js93
-rw-r--r--src/content/dependencies/generateGroupNavAccent.js53
-rw-r--r--src/content/dependencies/generateGroupNavLinks.js131
-rw-r--r--src/content/dependencies/generateGroupSecondaryNav.js102
-rw-r--r--src/content/dependencies/generateGroupSecondaryNavCategoryPart.js79
-rw-r--r--src/content/dependencies/generateGroupSidebar.js54
-rw-r--r--src/content/dependencies/generateGroupSidebarCategoryDetails.js65
-rw-r--r--src/content/dependencies/generateImageOverlay.js50
-rw-r--r--src/content/dependencies/generateInterpageDotSwitcher.js31
-rw-r--r--src/content/dependencies/generateIntrapageDotSwitcher.js49
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js22
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js51
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesChunk.js145
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js23
-rw-r--r--src/content/dependencies/generateListingIndexList.js4
-rw-r--r--src/content/dependencies/generateListingPage.js74
-rw-r--r--src/content/dependencies/generateListingSidebar.js46
-rw-r--r--src/content/dependencies/generateListingsIndexPage.js2
-rw-r--r--src/content/dependencies/generateLyricsEntry.js91
-rw-r--r--src/content/dependencies/generateLyricsSection.js81
-rw-r--r--src/content/dependencies/generateNewsEntryNavAccent.js40
-rw-r--r--src/content/dependencies/generateNewsEntryPage.js172
-rw-r--r--src/content/dependencies/generateNewsIndexPage.js67
-rw-r--r--src/content/dependencies/generateNextLink.js13
-rw-r--r--src/content/dependencies/generatePageLayout.js559
-rw-r--r--src/content/dependencies/generatePageSidebar.js90
-rw-r--r--src/content/dependencies/generatePageSidebarBox.js30
-rw-r--r--src/content/dependencies/generatePageSidebarConjoinedBox.js38
-rw-r--r--src/content/dependencies/generatePreviousLink.js13
-rw-r--r--src/content/dependencies/generatePreviousNextLink.js58
-rw-r--r--src/content/dependencies/generatePreviousNextLinks.js50
-rw-r--r--src/content/dependencies/generateQuickDescription.js134
-rw-r--r--src/content/dependencies/generateReferencedArtworksPage.js100
-rw-r--r--src/content/dependencies/generateReferencingArtworksPage.js100
-rw-r--r--src/content/dependencies/generateReleaseInfoContributionsLine.js53
-rw-r--r--src/content/dependencies/generateReleaseInfoListenLine.js150
-rw-r--r--src/content/dependencies/generateSearchSidebarBox.js82
-rw-r--r--src/content/dependencies/generateSecondaryNav.js16
-rw-r--r--src/content/dependencies/generateSecondaryNavParentSiblingsPart.js115
-rw-r--r--src/content/dependencies/generateSocialEmbed.js21
-rw-r--r--src/content/dependencies/generateStaticPage.js14
-rw-r--r--src/content/dependencies/generateStaticURLStyleTag.js23
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js53
-rw-r--r--src/content/dependencies/generateStyleTag.js48
-rw-r--r--src/content/dependencies/generateTextWithTooltip.js9
-rw-r--r--src/content/dependencies/generateTooltip.js4
-rw-r--r--src/content/dependencies/generateTrackAdditionalNamesBox.js53
-rw-r--r--src/content/dependencies/generateTrackArtistCommentarySection.js147
-rw-r--r--src/content/dependencies/generateTrackArtworkColumn.js33
-rw-r--r--src/content/dependencies/generateTrackCoverArtwork.js28
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js764
-rw-r--r--src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js63
-rw-r--r--src/content/dependencies/generateTrackInfoPageOtherReleasesList.js42
-rw-r--r--src/content/dependencies/generateTrackList.js72
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js170
-rw-r--r--src/content/dependencies/generateTrackListItem.js107
-rw-r--r--src/content/dependencies/generateTrackListMissingDuration.js35
-rw-r--r--src/content/dependencies/generateTrackNavLinks.js64
-rw-r--r--src/content/dependencies/generateTrackReferencedArtworksPage.js47
-rw-r--r--src/content/dependencies/generateTrackReferencingArtworksPage.js47
-rw-r--r--src/content/dependencies/generateTrackReleaseBox.js46
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js77
-rw-r--r--src/content/dependencies/generateTrackSocialEmbed.js74
-rw-r--r--src/content/dependencies/generateTrackSocialEmbedDescription.js73
-rw-r--r--src/content/dependencies/generateUnsafeMunchy.js10
-rw-r--r--src/content/dependencies/generateWallpaperStyleTag.js80
-rw-r--r--src/content/dependencies/generateWikiHomeAlbumsRow.js150
-rw-r--r--src/content/dependencies/generateWikiHomeContentRow.js28
-rw-r--r--src/content/dependencies/generateWikiHomeNewsBox.js82
-rw-r--r--src/content/dependencies/generateWikiHomePage.js105
-rw-r--r--src/content/dependencies/generateWikiHomepageActionsRow.js22
-rw-r--r--src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js22
-rw-r--r--src/content/dependencies/generateWikiHomepageAlbumGridRow.js78
-rw-r--r--src/content/dependencies/generateWikiHomepageNewsBox.js86
-rw-r--r--src/content/dependencies/generateWikiHomepagePage.js97
-rw-r--r--src/content/dependencies/generateWikiHomepageSection.js39
-rw-r--r--src/content/dependencies/generateWikiWallpaperStyleTag.js38
-rw-r--r--src/content/dependencies/image.js150
-rw-r--r--src/content/dependencies/index.js9
-rw-r--r--src/content/dependencies/linkAdditionalFile.js29
-rw-r--r--src/content/dependencies/linkAlbumAdditionalFile.js24
-rw-r--r--src/content/dependencies/linkAlbumDynamically.js59
-rw-r--r--src/content/dependencies/linkAlbumReferencedArtworks.js8
-rw-r--r--src/content/dependencies/linkAlbumReferencingArtworks.js8
-rw-r--r--src/content/dependencies/linkAnythingMan.js28
-rw-r--r--src/content/dependencies/linkArtTagDynamically.js14
-rw-r--r--src/content/dependencies/linkArtTagGallery.js8
-rw-r--r--src/content/dependencies/linkArtTagInfo.js (renamed from src/content/dependencies/linkArtTag.js)2
-rw-r--r--src/content/dependencies/linkArtwork.js20
-rw-r--r--src/content/dependencies/linkContribution.js167
-rw-r--r--src/content/dependencies/linkExternal.js140
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js49
-rw-r--r--src/content/dependencies/linkFlashAct.js26
-rw-r--r--src/content/dependencies/linkFlashSide.js22
-rw-r--r--src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js62
-rw-r--r--src/content/dependencies/linkPathFromMedia.js57
-rw-r--r--src/content/dependencies/linkReferencedArtworks.js24
-rw-r--r--src/content/dependencies/linkReferencingArtworks.js24
-rw-r--r--src/content/dependencies/linkTemplate.js24
-rw-r--r--src/content/dependencies/linkTrackDynamically.js4
-rw-r--r--src/content/dependencies/linkTrackReferencedArtworks.js8
-rw-r--r--src/content/dependencies/linkTrackReferencingArtworks.js8
-rw-r--r--src/content/dependencies/linkWikiHomepage.js (renamed from src/content/dependencies/linkWikiHome.js)0
-rw-r--r--src/content/dependencies/listAllAdditionalFilesTemplate.js197
-rw-r--r--src/content/dependencies/listArtTagNetwork.js367
-rw-r--r--src/content/dependencies/listArtTagsByName.js (renamed from src/content/dependencies/listTagsByName.js)15
-rw-r--r--src/content/dependencies/listArtTagsByUses.js54
-rw-r--r--src/content/dependencies/listArtistsByContributions.js36
-rw-r--r--src/content/dependencies/listArtistsByDuration.js7
-rw-r--r--src/content/dependencies/listArtistsByGroup.js176
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js8
-rw-r--r--src/content/dependencies/listGroupsByDuration.js2
-rw-r--r--src/content/dependencies/listRandomPageLinks.js56
-rw-r--r--src/content/dependencies/listTagsByUses.js59
-rw-r--r--src/content/dependencies/listTracksByDate.js74
-rw-r--r--src/content/dependencies/listTracksWithLyrics.js2
-rw-r--r--src/content/dependencies/transformContent.js537
231 files changed, 13473 insertions, 6840 deletions
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js
index 92948c7a..7e05b5b5 100644
--- a/src/content/dependencies/generateAdditionalFilesList.js
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -1,97 +1,22 @@
-import {empty} from '#sugar';
-
-function validateFileMapping(v, validateValue) {
-  return value => {
-    v.isObject(value);
-
-    const valueErrors = [];
-    for (const [fileKey, fileValue] of Object.entries(value)) {
-      if (fileValue === null) {
-        continue;
-      }
-
-      try {
-        validateValue(fileValue);
-      } catch (error) {
-        error.message = `(${fileKey}) ` + error.message;
-        valueErrors.push(error);
-      }
-    }
-
-    if (!empty(valueErrors)) {
-      throw new AggregateError(valueErrors, `Errors validating values`);
-    }
-  };
-}
-
 export default {
-  extraDependencies: ['html', 'language'],
+  contentDependencies: ['generateAdditionalFilesListChunk'],
+  extraDependencies: ['html'],
 
-  data(additionalFiles) {
-    return {
-      // Additional files are already a serializable format.
-      additionalFiles,
-    };
-  },
+  relations: (relation, additionalFiles) => ({
+    chunks:
+      additionalFiles
+        .map(file => relation('generateAdditionalFilesListChunk', file)),
+  }),
 
   slots: {
-    fileLinks: {
-      validate: v => validateFileMapping(v, v.isHTML),
-    },
-
-    fileSizes: {
-      validate: v => validateFileMapping(v, v.isWholeNumber),
-    },
+    showFileSizes: {type: 'boolean', default: true},
   },
 
-  generate(data, slots, {html, language}) {
-    if (!slots.fileLinks) {
-      return html.blank();
-    }
+  generate: (relations, slots, {html}) =>
+    html.tag('ul', {class: 'additional-files-list'},
+      {[html.onlyIfContent]: true},
 
-    const filesWithLinks = new Set(
-      Object.entries(slots.fileLinks)
-        .filter(([key, value]) => value)
-        .map(([key]) => key));
-
-    if (empty(filesWithLinks)) {
-      return html.blank();
-    }
-
-    const filteredFileGroups = data.additionalFiles
-      .map(({title, description, files}) => ({
-        title,
-        description,
-        files: files.filter(f => filesWithLinks.has(f)),
-      }))
-      .filter(({files}) => !empty(files));
-
-    if (empty(filteredFileGroups)) {
-      return html.blank();
-    }
-
-    return html.tag('dl',
-      filteredFileGroups.flatMap(({title, description, files}) => [
-        html.tag('dt',
-          (description
-            ? language.$('releaseInfo.additionalFiles.entry.withDescription', {
-                title,
-                description,
-              })
-            : language.$('releaseInfo.additionalFiles.entry', {title}))),
-
-        html.tag('dd',
-          html.tag('ul',
-            files.map(file =>
-              html.tag('li',
-                (slots.fileSizes?.[file]
-                  ? language.$('releaseInfo.additionalFiles.file.withSize', {
-                      file: slots.fileLinks[file],
-                      size: language.formatFileSize(slots.fileSizes[file]),
-                    })
-                  : language.$('releaseInfo.additionalFiles.file', {
-                      file: slots.fileLinks[file],
-                    })))))),
-      ]));
-  },
+      relations.chunks.map(chunk => chunk.slots({
+        showFileSizes: slots.showFileSizes,
+      }))),
 };
diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js
new file mode 100644
index 00000000..3cac851b
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesListChunk.js
@@ -0,0 +1,81 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkAdditionalFile', 'transformContent'],
+  extraDependencies: ['getSizeOfMediaFile', 'html', 'language', 'urls'],
+
+  relations: (relation, file) => ({
+    description:
+      relation('transformContent', file.description),
+
+    links:
+      file.filenames
+        .map(filename => relation('linkAdditionalFile', file, filename)),
+  }),
+
+  data: (file) => ({
+    title:
+      file.title,
+
+    paths:
+      file.paths,
+  }),
+
+  slots: {
+    showFileSizes: {
+      type: 'boolean',
+    },
+  },
+
+  generate: (data, relations, slots, {getSizeOfMediaFile, html, language, urls}) =>
+    language.encapsulate('releaseInfo.additionalFiles', capsule =>
+      html.tag('li',
+        html.tag('details',
+          html.isBlank(relations.links) &&
+            {open: true},
+
+          [
+            html.tag('summary',
+              html.tag('span',
+                language.$(capsule, 'entry', {
+                  title:
+                    html.tag('b', data.title),
+                }))),
+
+            html.tag('ul', [
+              html.tag('li', {class: 'entry-description'},
+                {[html.onlyIfContent]: true},
+
+                relations.description.slot('mode', 'inline')),
+
+              (html.isBlank(relations.links)
+                ? html.tag('li',
+                    language.$(capsule, 'entry.noFilesAvailable'))
+
+                : stitchArrays({
+                    link: relations.links,
+                    path: data.paths,
+                  }).map(({link, path}) =>
+                      html.tag('li',
+                        language.encapsulate(capsule, 'file', workingCapsule => {
+                          const workingOptions = {file: link};
+
+                          if (slots.showFileSizes) {
+                            const fileSize =
+                              getSizeOfMediaFile(
+                                urls
+                                  .from('media.root')
+                                  .to(...path));
+
+                            if (fileSize) {
+                              workingCapsule += '.withSize';
+                              workingOptions.size =
+                                language.formatFileSize(fileSize);
+                            }
+                          }
+
+                          return language.$(workingCapsule, workingOptions);
+                        })))),
+            ]),
+          ]))),
+};
diff --git a/src/content/dependencies/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js
deleted file mode 100644
index 9e119bce..00000000
--- a/src/content/dependencies/generateAdditionalFilesShortcut.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import {empty} from '#sugar';
-
-export default {
-  extraDependencies: ['html', 'language'],
-
-  data(additionalFiles) {
-    return {
-      titles: additionalFiles.map(fileGroup => fileGroup.title),
-    };
-  },
-
-  generate(data, {html, language}) {
-    if (empty(data.titles)) {
-      return html.blank();
-    }
-
-    return language.$('releaseInfo.additionalFiles.shortcut', {
-      anchorLink:
-        html.tag('a',
-          {href: '#additional-files'},
-          language.$('releaseInfo.additionalFiles.shortcut.anchorLink')),
-
-      titles:
-        language.formatUnitList(data.titles),
-    });
-  },
-}
diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js
index 63427c58..b7392dfd 100644
--- a/src/content/dependencies/generateAdditionalNamesBox.js
+++ b/src/content/dependencies/generateAdditionalNamesBox.js
@@ -9,12 +9,20 @@ export default {
   }),
 
   generate: (relations, {html, language}) =>
-    html.tag('div', {id: 'additional-names-box'}, [
-      html.tag('p',
-        language.$('misc.additionalNames.title')),
+    html.tag('div', {id: 'additional-names-box'},
+      {class: 'drop'},
+      {[html.onlyIfContent]: true},
 
-      html.tag('ul',
-        relations.items
-          .map(item => html.tag('li', item))),
-    ]),
+      [
+        html.tag('p',
+          {[html.onlyIfSiblings]: true},
+
+          language.$('misc.additionalNames.title')),
+
+        html.tag('ul',
+          {[html.onlyIfContent]: true},
+
+          relations.items
+            .map(item => html.tag('li', item))),
+      ]),
 };
diff --git a/src/content/dependencies/generateAdditionalNamesBoxItem.js b/src/content/dependencies/generateAdditionalNamesBoxItem.js
index 7515b5b0..e3e59a34 100644
--- a/src/content/dependencies/generateAdditionalNamesBoxItem.js
+++ b/src/content/dependencies/generateAdditionalNamesBoxItem.js
@@ -1,7 +1,5 @@
-import {stitchArrays} from '#sugar';
-
 export default {
-  contentDependencies: ['linkTrack', 'transformContent'],
+  contentDependencies: ['transformContent'],
   extraDependencies: ['html', 'language'],
 
   relations: (relation, entry) => ({
@@ -12,21 +10,9 @@ export default {
       (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}) => {
+  generate: (relations, {html, language}) => {
     const prefix = 'misc.additionalNames.item';
 
     const itemParts = [prefix];
@@ -42,19 +28,10 @@ export default {
     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',
-                language.sanitize(albumName))));
+        relations.annotationContent.slots({
+          mode: 'inline',
+          absorbPunctuationFollowingExternalLinks: false,
+        });
     }
 
     if (accentParts.length > 2) {
diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js
deleted file mode 100644
index 23f32bf5..00000000
--- a/src/content/dependencies/generateAlbumAdditionalFilesList.js
+++ /dev/null
@@ -1,59 +0,0 @@
-export default {
-  contentDependencies: [
-    'generateAdditionalFilesList',
-    'linkAlbumAdditionalFile',
-  ],
-
-  extraDependencies: [
-    'getSizeOfAdditionalFile',
-    'html',
-    'urls',
-  ],
-
-  data(album, additionalFiles) {
-    return {
-      albumDirectory: album.directory,
-      fileLocations: additionalFiles.flatMap(({files}) => files),
-    };
-  },
-
-  relations(relation, album, additionalFiles) {
-    return {
-      additionalFilesList:
-        relation('generateAdditionalFilesList', additionalFiles),
-
-      additionalFileLinks:
-        Object.fromEntries(
-          additionalFiles
-            .flatMap(({files}) => files)
-            .map(file => [
-              file,
-              relation('linkAlbumAdditionalFile', album, file),
-            ])),
-    };
-  },
-
-  slots: {
-    showFileSizes: {type: 'boolean', default: true},
-  },
-
-  generate(data, relations, slots, {
-    getSizeOfAdditionalFile,
-    urls,
-  }) {
-    return relations.additionalFilesList
-      .slots({
-        fileLinks: relations.additionalFileLinks,
-        fileSizes:
-          Object.fromEntries(data.fileLocations.map(file => [
-            file,
-            (slots.showFileSizes
-              ? getSizeOfAdditionalFile(
-                  urls
-                    .from('media.root')
-                    .to('media.albumAdditionalFile', data.albumDirectory, file))
-              : 0),
-          ])),
-      });
-  },
-};
diff --git a/src/content/dependencies/generateAlbumArtInfoBox.js b/src/content/dependencies/generateAlbumArtInfoBox.js
new file mode 100644
index 00000000..8c44c930
--- /dev/null
+++ b/src/content/dependencies/generateAlbumArtInfoBox.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: ['generateReleaseInfoContributionsLine'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    wallpaperArtistContributionsLine:
+      (album.wallpaperArtwork
+        ? relation('generateReleaseInfoContributionsLine',
+            album.wallpaperArtwork.artistContribs)
+        : null),
+
+    bannerArtistContributionsLine:
+      (album.bannerArtwork
+        ? relation('generateReleaseInfoContributionsLine',
+            album.bannerArtwork.artistContribs)
+        : null),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule =>
+      html.tag('div', {class: 'album-art-info'},
+        {[html.onlyIfContent]: true},
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.wallpaperArtistContributionsLine?.slots({
+              stringKey: capsule + '.wallpaperArtBy',
+              chronologyKind: 'wallpaperArt',
+            }),
+
+            relations.bannerArtistContributionsLine?.slots({
+              stringKey: capsule + '.bannerArtBy',
+              chronologyKind: 'bannerArt',
+            }),
+          ]))),
+};
diff --git a/src/content/dependencies/generateAlbumArtworkColumn.js b/src/content/dependencies/generateAlbumArtworkColumn.js
new file mode 100644
index 00000000..e6762463
--- /dev/null
+++ b/src/content/dependencies/generateAlbumArtworkColumn.js
@@ -0,0 +1,38 @@
+export default {
+  contentDependencies: ['generateAlbumArtInfoBox', 'generateCoverArtwork'],
+  extraDependencies: ['html'],
+
+  relations: (relation, album) => ({
+    firstCover:
+      (album.hasCoverArt
+        ? relation('generateCoverArtwork', album.coverArtworks[0])
+        : null),
+
+    restCovers:
+      (album.hasCoverArt
+        ? album.coverArtworks.slice(1).map(artwork =>
+            relation('generateCoverArtwork', artwork))
+        : []),
+
+    albumArtInfoBox:
+      relation('generateAlbumArtInfoBox', album),
+  }),
+
+  generate: (relations, {html}) =>
+    html.tags([
+      relations.firstCover?.slots({
+        showOriginDetails: true,
+        showArtTagDetails: true,
+        showReferenceDetails: true,
+      }),
+
+      relations.albumArtInfoBox,
+
+      relations.restCovers.map(cover =>
+        cover.slots({
+          showOriginDetails: true,
+          showArtTagDetails: true,
+          showReferenceDetails: true,
+        })),
+    ]),
+};
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 5a7142e5..3529c4dc 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -2,13 +2,13 @@ import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateAlbumCoverArtwork',
+    'generateAlbumCommentarySidebar',
     'generateAlbumNavAccent',
-    'generateAlbumSidebarTrackSection',
-    'generateAlbumStyleRules',
+    'generateAlbumSecondaryNav',
+    'generateAlbumStyleTags',
     'generateCommentaryEntry',
     'generateContentHeading',
-    'generateTrackCoverArtwork',
+    'generateCoverArtwork',
     'generatePageLayout',
     'linkAlbum',
     'linkExternal',
@@ -17,14 +17,35 @@ export default {
 
   extraDependencies: ['html', 'language'],
 
-  relations(relation, album) {
+  query(album) {
+    const query = {};
+
+    query.tracksWithCommentary =
+      album.tracks
+        .filter(({commentary}) => !empty(commentary));
+
+    query.thingsWithCommentary =
+      (empty(album.commentary)
+        ? query.tracksWithCommentary
+        : [album, ...query.tracksWithCommentary]);
+
+    return query;
+  },
+
+  relations(relation, query, album) {
     const relations = {};
 
     relations.layout =
       relation('generatePageLayout');
 
-    relations.albumStyleRules =
-      relation('generateAlbumStyleRules', album, null);
+    relations.secondaryNav =
+      relation('generateAlbumSecondaryNav', album);
+
+    relations.sidebar =
+      relation('generateAlbumCommentarySidebar', album);
+
+    relations.albumStyleTags =
+      relation('generateAlbumStyleTags', album, null);
 
     relations.albumLink =
       relation('linkAlbum', album);
@@ -32,7 +53,7 @@ export default {
     relations.albumNavAccent =
       relation('generateAlbumNavAccent', album, null);
 
-    if (album.commentary) {
+    if (!empty(album.commentary)) {
       relations.albumCommentaryHeading =
         relation('generateContentHeading');
 
@@ -44,7 +65,7 @@ export default {
 
       if (album.hasCoverArt) {
         relations.albumCommentaryCover =
-          relation('generateAlbumCoverArtwork', album);
+          relation('generateCoverArtwork', album.coverArtworks[0]);
       }
 
       relations.albumCommentaryEntries =
@@ -52,80 +73,65 @@ export default {
           .map(entry => relation('generateCommentaryEntry', entry));
     }
 
-    const tracksWithCommentary =
-      album.tracks
-        .filter(({commentary}) => commentary);
-
     relations.trackCommentaryHeadings =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(() => relation('generateContentHeading'));
 
     relations.trackCommentaryLinks =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track => relation('linkTrack', track));
 
     relations.trackCommentaryListeningLinks =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track =>
           track.urls.map(url => relation('linkExternal', url)));
 
     relations.trackCommentaryCovers =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track =>
           (track.hasUniqueCoverArt
-            ? relation('generateTrackCoverArtwork', track)
+            ? relation('generateCoverArtwork', track.trackArtworks[0])
             : null));
 
     relations.trackCommentaryEntries =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track =>
           track.commentary
             .map(entry => relation('generateCommentaryEntry', entry)));
 
-    relations.sidebarAlbumLink =
-      relation('linkAlbum', album);
-
-    relations.sidebarTrackSections =
-      album.trackSections.map(trackSection =>
-        relation('generateAlbumSidebarTrackSection', album, null, trackSection));
-
     return relations;
   },
 
-  data(album) {
+  data(query, album) {
     const data = {};
 
     data.name = album.name;
     data.color = album.color;
-
-    const tracksWithCommentary =
-      album.tracks
-        .filter(({commentary}) => commentary);
-
-    const thingsWithCommentary =
-      (album.commentary
-        ? [album, ...tracksWithCommentary]
-        : tracksWithCommentary);
+    data.date = album.date;
 
     data.entryCount =
-      thingsWithCommentary
+      query.thingsWithCommentary
         .flatMap(({commentary}) => commentary)
         .length;
 
     data.wordCount =
-      thingsWithCommentary
+      query.thingsWithCommentary
         .flatMap(({commentary}) => commentary)
         .map(({body}) => body)
         .join(' ')
         .split(' ')
         .length;
 
+    data.trackCommentaryTrackDates =
+      query.tracksWithCommentary
+        .map(track => track.dateFirstReleased);
+
     data.trackCommentaryDirectories =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track => track.directory);
 
     data.trackCommentaryColors =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track =>
           (track.color === album.color
             ? null
@@ -134,60 +140,90 @@ export default {
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumCommentaryPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('albumCommentaryPage.title', {
+          language.$(pageCapsule, 'title', {
             album: data.name,
           }),
 
         headingMode: 'sticky',
 
         color: data.color,
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
         mainClasses: ['long-content'],
         mainContent: [
           html.tag('p',
-            language.$('albumCommentaryPage.infoLine', {
-              words:
-                html.tag('b',
-                  language.formatWordCount(data.wordCount, {unit: true})),
-
-              entries:
-                html.tag('b',
-                  language.countCommentaryEntries(data.entryCount, {unit: true})),
-            })),
-
-          relations.albumCommentaryEntries && [
-            relations.albumCommentaryHeading.slots({
-              tag: 'h3',
-              color: data.color,
-
-              title:
-                language.$('albumCommentaryPage.entry.title.albumCommentary', {
-                  album: relations.albumCommentaryLink,
+            {[html.joinChildren]: html.tag('br')},
+
+            [
+              data.date &&
+              data.entryCount >= 1 &&
+                language.$('releaseInfo.albumReleased', {
+                  date:
+                    html.tag('b',
+                      language.formatDate(data.date)),
                 }),
 
-              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'}),
-
-            relations.albumCommentaryEntries,
-          ],
+              language.encapsulate(pageCapsule, 'infoLine', workingCapsule => {
+                const workingOptions = {};
+
+                if (data.entryCount >= 1) {
+                  workingOptions.words =
+                    html.tag('b',
+                      language.formatWordCount(data.wordCount, {unit: true}));
+
+                  workingOptions.entries =
+                    html.tag('b',
+                      language.countCommentaryEntries(data.entryCount, {unit: true}));
+                }
+
+                if (data.entryCount === 0) {
+                  workingCapsule += '.withoutCommentary';
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })
+            ]),
+
+          relations.albumCommentaryEntries &&
+            language.encapsulate(pageCapsule, 'entry', entryCapsule => [
+              language.encapsulate(entryCapsule, 'title.albumCommentary', titleCapsule =>
+                relations.albumCommentaryHeading.slots({
+                  tag: 'h3',
+                  attributes: {id: 'album-commentary'},
+                  color: data.color,
+
+                  title:
+                    language.$(titleCapsule, {
+                      album: relations.albumCommentaryLink,
+                    }),
+
+                  stickyTitle:
+                    language.$(titleCapsule, 'sticky', {
+                      album: data.name,
+                    }),
+
+                  accent:
+                    language.$(titleCapsule, 'accent', {
+                      [language.onlyIfOptions]: ['listeningLinks'],
+                      listeningLinks:
+                        language.formatUnitList(
+                          relations.albumCommentaryListeningLinks
+                            .map(link => link.slots({
+                              context: 'album',
+                              tab: 'separate',
+                            }))),
+                    }),
+                })),
+
+              relations.albumCommentaryCover
+                ?.slots({mode: 'commentary'}),
+
+              relations.albumCommentaryEntries,
+            ]),
 
           stitchArrays({
             heading: relations.trackCommentaryHeadings,
@@ -197,6 +233,7 @@ export default {
             cover: relations.trackCommentaryCovers,
             entries: relations.trackCommentaryEntries,
             color: data.trackCommentaryColors,
+            trackDate: data.trackCommentaryTrackDates,
           }).map(({
               heading,
               link,
@@ -205,31 +242,44 @@ export default {
               cover,
               entries,
               color,
-            }) => [
-              heading.slots({
-                tag: 'h3',
-                id: directory,
-                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'))),
-                    }),
+              trackDate,
+            }) =>
+              language.encapsulate(pageCapsule, 'entry', entryCapsule => [
+                language.encapsulate(entryCapsule, 'title.trackCommentary', titleCapsule =>
+                  heading.slots({
+                    tag: 'h3',
+                    attributes: {id: directory},
+                    color,
+
+                    title:
+                      language.$(titleCapsule, {
+                        track: link,
+                      }),
+
+                    accent:
+                      language.$(titleCapsule, 'accent', {
+                        [language.onlyIfOptions]: ['listeningLinks'],
+                        listeningLinks:
+                          language.formatUnitList(
+                            listeningLinks.map(link =>
+                              link.slot('tab', 'separate'))),
+                      }),
+                  })),
+
+              cover?.slots({
+                mode: 'commentary',
+                color: true,
               }),
 
-              cover?.slots({mode: 'commentary'}),
+              trackDate &&
+              trackDate !== data.date &&
+                html.tag('p', {class: 'track-info'},
+                  language.$('releaseInfo.trackReleased', {
+                    date: language.formatDate(trackDate),
+                  })),
 
               entries.map(entry => entry.slot('color', color)),
-            ]),
+            ])),
         ],
 
         navLinkStyle: 'hierarchical',
@@ -249,17 +299,11 @@ export default {
           },
         ],
 
-        leftSidebarStickyMode: 'column',
-        leftSidebarClass: 'commentary-track-list-sidebar-box',
-        leftSidebarContent: [
-          html.tag('h1', relations.sidebarAlbumLink),
-          relations.sidebarTrackSections.map(section =>
-            section.slots({
-              anchor: true,
-              open: true,
-              mode: 'commentary',
-            })),
-        ],
-      });
-  },
+        secondaryNav:
+          relations.secondaryNav.slots({
+            alwaysVisible: true,
+          }),
+
+        leftSidebar: relations.sidebar,
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumCommentarySidebar.js b/src/content/dependencies/generateAlbumCommentarySidebar.js
new file mode 100644
index 00000000..9ecec66d
--- /dev/null
+++ b/src/content/dependencies/generateAlbumCommentarySidebar.js
@@ -0,0 +1,73 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarTrackSection',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackSections:
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection',
+          album,
+          null,
+          trackSection)),
+  }),
+
+  data: (album) => ({
+    albumHasCommentary:
+      !empty(album.commentary),
+
+    anyTrackHasCommentary:
+      album.tracks.some(track => !empty(track.commentary)),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumCommentaryPage', pageCapsule =>
+      relations.sidebar.slots({
+        stickyMode: 'column',
+        boxes: [
+          relations.sidebarBox.slots({
+            attributes: {class: 'commentary-track-list-sidebar-box'},
+            content: [
+              html.tag('h1', relations.albumLink),
+
+              html.tag('p', {[html.onlyIfContent]: true},
+                language.encapsulate(pageCapsule, 'sidebar', workingCapsule => {
+                  if (data.anyTrackHasCommentary) return html.blank();
+
+                  if (data.albumHasCommentary) {
+                    workingCapsule += '.noTrackCommentary';
+                  } else {
+                    workingCapsule += '.noCommentary';
+                  }
+
+                  return language.$(workingCapsule);
+                })),
+
+              data.anyTrackHasCommentary &&
+                relations.trackSections.map(section =>
+                  section.slots({
+                    anchor: true,
+                    open: true,
+                    mode: 'commentary',
+                  })),
+            ],
+          }),
+        ]
+      })),
+}
diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js
deleted file mode 100644
index ce8cde21..00000000
--- a/src/content/dependencies/generateAlbumCoverArtwork.js
+++ /dev/null
@@ -1,22 +0,0 @@
-export default {
-  contentDependencies: ['generateCoverArtwork'],
-
-  relations: (relation, album) => ({
-    coverArtwork:
-      relation('generateCoverArtwork', album.artTags),
-  }),
-
-  data: (album) => ({
-    path:
-      ['media.albumCover', album.directory, album.coverArtFileExtension],
-
-    color:
-      album.color,
-  }),
-
-  generate: (data, relations) =>
-    relations.coverArtwork.slots({
-      path: data.path,
-      color: data.color,
-    }),
-};
diff --git a/src/content/dependencies/generateAlbumGalleryAlbumGrid.js b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js
new file mode 100644
index 00000000..7f152871
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js
@@ -0,0 +1,90 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCoverGrid',
+    'image',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (album) => ({
+    artworks:
+      (album.hasCoverArt
+        ? album.coverArtworks
+        : []),
+  }),
+
+  relations: (relation, query, album) => ({
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    albumLinks:
+      query.artworks.map(_artwork =>
+        relation('linkAlbum', album)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, album) => ({
+    albumName:
+      album.name,
+
+    artworkLabels:
+      query.artworks
+        .map(artwork => artwork.label),
+
+    artworkArtists:
+      query.artworks
+        .map(artwork => artwork.artistContribs
+          .map(contrib => contrib.artist.name)),
+  }),
+
+  slots: {
+    attributes: {type: 'attributes', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+
+      slots.attributes,
+
+      [
+        relations.coverArtistsLine,
+
+        relations.coverGrid.slots({
+          links:
+            relations.albumLinks,
+
+          names:
+            data.artworkLabels
+              .map(label => label ?? data.albumName),
+
+          images:
+            stitchArrays({
+              image: relations.images,
+              label: data.artworkLabels,
+            }).map(({image, label}) =>
+                image.slots({
+                  missingSourceContent:
+                    language.$('misc.albumGalleryGrid.noCoverArt', {
+                      name:
+                        label ?? data.albumName,
+                    }),
+                })),
+
+          info:
+            data.artworkArtists.map(artists =>
+              language.$('misc.coverGrid.details.coverArtists', {
+                [language.onlyIfOptions]: ['artists'],
+
+                artists:
+                  language.formatUnitList(artists),
+              })),
+        }),
+      ]),
+};
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
index f61b1983..516a7ca8 100644
--- a/src/content/dependencies/generateAlbumGalleryPage.js
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -1,18 +1,18 @@
-import {compareArrays, stitchArrays} from '#sugar';
+import {stitchArrays, unique} from '#sugar';
+import {getKebabCase} from '#wiki-data';
 
 export default {
   contentDependencies: [
-    'generateAlbumGalleryCoverArtistsLine',
+    'generateAlbumGalleryAlbumGrid',
     'generateAlbumGalleryNoTrackArtworksLine',
     'generateAlbumGalleryStatsLine',
+    'generateAlbumGalleryTrackGrid',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
-    'generateAlbumStyleRules',
-    'generateCoverGrid',
+    'generateAlbumStyleTags',
+    'generateIntrapageDotSwitcher',
     'generatePageLayout',
-    'image',
     'linkAlbum',
-    'linkTrack',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -20,176 +20,130 @@ export default {
   query(album) {
     const query = {};
 
-    const tracksWithUniqueCoverArt =
+    const trackArtworkLabels =
       album.tracks
-        .filter(track => track.hasUniqueCoverArt);
-
-    // Don't display "all artwork by..." for albums where there's
-    // only one unique artwork in the first place.
-    if (tracksWithUniqueCoverArt.length > 1) {
-      const allCoverArtistArrays =
-        tracksWithUniqueCoverArt
-          .map(track => track.coverArtistContribs)
-          .map(contribs => contribs.map(contrib => contrib.who));
-
-      const allSameCoverArtists =
-        allCoverArtistArrays
-          .slice(1)
-          .every(artists => compareArrays(artists, allCoverArtistArrays[0]));
-
-      if (allSameCoverArtists) {
-        query.coverArtistsForAllTracks =
-          allCoverArtistArrays[0];
-      }
-    }
+        .map(track => track.trackArtworks
+          .map(artwork => artwork.label));
+
+    const recurranceThreshold = 2;
+
+    // This list may include null, if some artworks are not labelled!
+    // That's expected.
+    query.recurringTrackArtworkLabels =
+      unique(trackArtworkLabels.flat())
+        .filter(label =>
+          trackArtworkLabels
+            .filter(labels => labels.includes(label))
+            .length >=
+          (label === null
+            ? 1
+            : recurranceThreshold));
 
     return query;
   },
 
-  relations(relation, query, album) {
-    const relations = {};
+  relations: (relation, query, album) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    relations.layout =
-      relation('generatePageLayout');
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
-    relations.albumStyleRules =
-      relation('generateAlbumStyleRules', album, null);
-
-    relations.albumLink =
-      relation('linkAlbum', album);
-
-    relations.albumNavAccent =
-      relation('generateAlbumNavAccent', album, null);
-
-    relations.secondaryNav =
-      relation('generateAlbumSecondaryNav', album);
-
-    relations.statsLine =
-      relation('generateAlbumGalleryStatsLine', album);
-
-    if (album.tracks.every(track => !track.hasUniqueCoverArt)) {
-      relations.noTrackArtworksLine =
-        relation('generateAlbumGalleryNoTrackArtworksLine');
-    }
-
-    if (query.coverArtistsForAllTracks) {
-      relations.coverArtistsLine =
-        relation('generateAlbumGalleryCoverArtistsLine', query.coverArtistsForAllTracks);
-    }
-
-    relations.coverGrid =
-      relation('generateCoverGrid');
-
-    relations.links = [
+    albumLink:
       relation('linkAlbum', album),
 
-      ...
-        album.tracks
-          .map(track => relation('linkTrack', track)),
-    ];
-
-    relations.images = [
-      (album.hasCoverArt
-        ? relation('image', album.artTags)
-        : relation('image')),
+    albumNavAccent:
+      relation('generateAlbumNavAccent', album, null),
 
-      ...
-        album.tracks.map(track =>
-          (track.hasUniqueCoverArt
-            ? relation('image', track.artTags)
-            : relation('image'))),
-    ];
-
-    return relations;
-  },
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', album),
 
-  data(query, album) {
-    const data = {};
+    statsLine:
+      relation('generateAlbumGalleryStatsLine', album),
 
-    data.name = album.name;
-    data.color = album.color;
-
-    data.names = [
-      album.name,
-      ...album.tracks.map(track => track.name),
-    ];
-
-    data.coverArtists = [
-      (album.hasCoverArt
-        ? album.coverArtistContribs.map(({who: artist}) => artist.name)
+    noTrackArtworksLine:
+      (album.tracks.every(track => !track.hasUniqueCoverArt)
+        ? relation('generateAlbumGalleryNoTrackArtworksLine')
         : null),
 
-      ...
-        album.tracks.map(track => {
-          if (query.coverArtistsForAllTracks) {
-            return null;
-          }
+    setSwitcher:
+      relation('generateIntrapageDotSwitcher'),
 
-          if (track.hasUniqueCoverArt) {
-            return track.coverArtistContribs.map(({who: artist}) => artist.name);
-          }
+    albumGrid:
+      relation('generateAlbumGalleryAlbumGrid', album),
 
-          return null;
-        }),
-    ];
+    trackGrids:
+      query.recurringTrackArtworkLabels.map(label =>
+        relation('generateAlbumGalleryTrackGrid', album, label)),
+  }),
 
-    data.paths = [
-      (album.hasCoverArt
-        ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-        : null),
+  data: (query, album) => ({
+    trackGridLabels:
+      query.recurringTrackArtworkLabels,
 
-      ...
-        album.tracks.map(track =>
-          (track.hasUniqueCoverArt
-            ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
-            : null)),
-    ];
+    trackGridIDs:
+      query.recurringTrackArtworkLabels.map(label =>
+        'track-grid-' +
+          (label
+            ? getKebabCase(label)
+            : 'no-label')),
 
-    return data;
-  },
+    name:
+      album.name,
+
+    color:
+      album.color,
+  }),
 
-  generate(data, relations, {language}) {
-    return relations.layout
-      .slots({
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumGalleryPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('albumGalleryPage.title', {
+          language.$(pageCapsule, 'title', {
             album: data.name,
           }),
 
         headingMode: 'static',
 
         color: data.color,
-        styleRules: [relations.albumStyleRules],
+        styleTags: relations.albumStyleTags,
 
         mainClasses: ['top-index'],
         mainContent: [
           relations.statsLine,
-          relations.coverArtistsLine,
+
+          relations.albumGrid,
+
           relations.noTrackArtworksLine,
 
-          relations.coverGrid
-            .slots({
-              links: relations.links,
-              names: data.names,
-              images:
-                stitchArrays({
-                  image: relations.images,
-                  path: data.paths,
-                  name: data.names,
-                }).map(({image, path, name}) =>
-                    image.slots({
-                      path,
-                      missingSourceContent:
-                        language.$('misc.albumGalleryGrid.noCoverArt', {name}),
-                    })),
-              info:
-                data.coverArtists.map(names =>
-                  (names === null
-                    ? null
-                    : language.$('misc.albumGrid.details.coverArtists', {
-                        artists: language.formatUnitList(names),
-                      }))),
-            }),
+          data.trackGridLabels.some(value => value !== null) &&
+            html.tag('p', {class: 'gallery-set-switcher'},
+              language.encapsulate(pageCapsule, 'setSwitcher', switcherCapsule =>
+                language.$(switcherCapsule, {
+                  sets:
+                    relations.setSwitcher.slots({
+                      initialOptionIndex: 0,
+
+                      titles:
+                        data.trackGridLabels.map(label =>
+                          label ??
+                          language.$(switcherCapsule, 'unlabeledSet')),
+
+                      targetIDs:
+                        data.trackGridIDs,
+                    }),
+                }))),
+
+          stitchArrays({
+            grid: relations.trackGrids,
+            id: data.trackGridIDs,
+          }).map(({grid, id}, index) =>
+              grid.slots({
+                attributes: [
+                  {id},
+                  index >= 1 && {style: 'display: none'},
+                ],
+              })),
         ],
 
         navLinkStyle: 'hierarchical',
@@ -209,6 +163,5 @@ export default {
         ],
 
         secondaryNav: relations.secondaryNav,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumGalleryTrackGrid.js b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
new file mode 100644
index 00000000..fb5ed7ea
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
@@ -0,0 +1,122 @@
+import {compareArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumGalleryCoverArtistsLine',
+    'generateCoverGrid',
+    'image',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album, label) {
+    const query = {};
+
+    query.artworks =
+      album.tracks.map(track =>
+        track.trackArtworks.find(artwork => artwork.label === label) ??
+        null);
+
+    const presentArtworks =
+      query.artworks.filter(Boolean);
+
+    if (presentArtworks.length > 1) {
+      const allArtistArrays =
+        presentArtworks
+          .map(artwork => artwork.artistContribs
+            .map(contrib => contrib.artist));
+
+      const allSameArtists =
+        allArtistArrays
+          .slice(1)
+          .every(artists => compareArrays(artists, allArtistArrays[0]));
+
+      if (allSameArtists) {
+        query.artistsForAllTrackArtworks =
+          allArtistArrays[0];
+      }
+    }
+
+    return query;
+  },
+
+  relations: (relation, query, album, _label) => ({
+    coverArtistsLine:
+      (query.artistsForAllTrackArtworks
+        ? relation('generateAlbumGalleryCoverArtistsLine',
+            query.artistsForAllTrackArtworks)
+        : null),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackLinks:
+      album.tracks
+        .map(track => relation('linkTrack', track)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, album, _label) => ({
+    trackNames:
+      album.tracks
+        .map(track => track.name),
+
+    artworkArtists:
+      query.artworks.map(artwork =>
+        (query.artistsForAllTrackArtworks
+          ? null
+       : artwork
+          ? artwork.artistContribs
+              .map(contrib => contrib.artist.name)
+          : null)),
+  }),
+
+  slots: {
+    attributes: {type: 'attributes', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+
+      slots.attributes,
+
+      [
+        relations.coverArtistsLine,
+
+        relations.coverGrid.slots({
+          links:
+            relations.trackLinks,
+
+          names:
+            data.trackNames,
+
+          images:
+            stitchArrays({
+              image: relations.images,
+              name: data.trackNames,
+            }).map(({image, name}) =>
+                image.slots({
+                  missingSourceContent:
+                    language.$('misc.albumGalleryGrid.noCoverArt', {name}),
+                })),
+
+          info:
+            data.artworkArtists.map(artists =>
+              language.$('misc.coverGrid.details.coverArtists', {
+                [language.onlyIfOptions]: ['artists'],
+
+                artists:
+                  language.formatUnitList(artists),
+              })),
+        }),
+      ]),
+};
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index 5853f115..1664c788 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -1,176 +1,119 @@
-import {sortAlbumsTracksChronologically} from '#sort';
 import {empty} from '#sugar';
 
-import getChronologyRelations from '../util/getChronologyRelations.js';
-
 export default {
   contentDependencies: [
-    'generateAdditionalFilesShortcut',
-    'generateAlbumAdditionalFilesList',
+    'generateAdditionalFilesList',
+    'generateAdditionalNamesBox',
+    'generateAlbumArtworkColumn',
     'generateAlbumBanner',
-    'generateAlbumCoverArtwork',
     'generateAlbumNavAccent',
     'generateAlbumReleaseInfo',
     'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
     'generateAlbumSocialEmbed',
-    'generateAlbumStyleRules',
+    'generateAlbumStyleTags',
     'generateAlbumTrackList',
-    'generateChronologyLinks',
-    'generateCommentarySection',
+    'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generatePageLayout',
-    'linkAlbum',
     'linkAlbumCommentary',
     'linkAlbumGallery',
-    'linkArtist',
-    'linkTrack',
-    'transformContent',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  relations(relation, album) {
-    const relations = {};
-    const sections = relations.sections = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.albumStyleRules =
-      relation('generateAlbumStyleRules', album, null);
-
-    relations.socialEmbed =
-      relation('generateAlbumSocialEmbed', album);
-
-    relations.coverArtistChronologyContributions =
-      getChronologyRelations(album, {
-        contributions: album.coverArtistContribs ?? [],
-
-        linkArtist: artist => relation('linkArtist', artist),
-
-        linkThing: trackOrAlbum =>
-          (trackOrAlbum.album
-            ? relation('linkTrack', trackOrAlbum)
-            : relation('linkAlbum', trackOrAlbum)),
-
-        getThings(artist) {
-          const getDate = thing => thing.coverArtDate ?? thing.date;
-
-          const things = [
-            ...artist.albumsAsCoverArtist,
-            ...artist.tracksAsCoverArtist,
-          ].filter(getDate);
-
-          return sortAlbumsTracksChronologically(things, {getDate});
-        },
-      });
-
-    relations.albumNavAccent =
-      relation('generateAlbumNavAccent', album, null);
-
-    relations.chronologyLinks =
-      relation('generateChronologyLinks');
-
-    relations.secondaryNav =
-      relation('generateAlbumSecondaryNav', album);
-
-    relations.sidebar =
-      relation('generateAlbumSidebar', album, null);
-
-    if (album.hasCoverArt) {
-      relations.cover =
-        relation('generateAlbumCoverArtwork', album);
-    }
+  relations: (relation, album) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    if (album.hasBannerArt) {
-      relations.banner =
-        relation('generateAlbumBanner', album);
-    }
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
 
-    // Section: Release info
+    socialEmbed:
+      relation('generateAlbumSocialEmbed', album),
 
-    relations.releaseInfo =
-      relation('generateAlbumReleaseInfo', album);
+    albumNavAccent:
+      relation('generateAlbumNavAccent', album, null),
 
-    // Section: Extra links
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', album),
 
-    const extra = sections.extra = {};
+    sidebar:
+      relation('generateAlbumSidebar', album, null),
 
-    if (album.tracks.some(t => t.hasUniqueCoverArt)) {
-      extra.galleryLink =
-        relation('linkAlbumGallery', album);
-    }
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', album.additionalNames),
 
-    if (album.commentary || album.tracks.some(t => t.commentary)) {
-      extra.commentaryLink =
-        relation('linkAlbumCommentary', album);
-    }
+    artworkColumn:
+      relation('generateAlbumArtworkColumn', album),
 
-    if (!empty(album.additionalFiles)) {
-      extra.additionalFilesShortcut =
-        relation('generateAdditionalFilesShortcut', album.additionalFiles);
-    }
+    banner:
+      (album.hasBannerArt
+        ? relation('generateAlbumBanner', album)
+        : null),
 
-    // Section: Track list
+    contentHeading:
+      relation('generateContentHeading'),
 
-    relations.trackList =
-      relation('generateAlbumTrackList', album);
+    contentContentHeading:
+      relation('generateContentContentHeading', album),
 
-    // Section: Additional files
+    releaseInfo:
+      relation('generateAlbumReleaseInfo', album),
 
-    if (!empty(album.additionalFiles)) {
-      const additionalFiles = sections.additionalFiles = {};
+    galleryLink:
+      (album.tracks.some(t => t.hasUniqueCoverArt)
+        ? relation('linkAlbumGallery', album)
+        : null),
 
-      additionalFiles.heading =
-        relation('generateContentHeading');
+    commentaryLink:
+      ([album, ...album.tracks].some(({commentary}) => !empty(commentary))
+        ? relation('linkAlbumCommentary', album)
+        : null),
 
-      additionalFiles.additionalFilesList =
-        relation('generateAlbumAdditionalFilesList', album, album.additionalFiles);
-    }
+    trackList:
+      relation('generateAlbumTrackList', album),
 
-    // Section: Artist commentary
+    additionalFilesList:
+      relation('generateAdditionalFilesList', album.additionalFiles),
 
-    if (album.commentary) {
-      sections.artistCommentary =
-        relation('generateCommentarySection', album.commentary);
-    }
+    artistCommentaryEntries:
+      album.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
 
-    return relations;
-  },
+    creditSourceEntries:
+      album.creditingSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
+  }),
 
-  data(album) {
-    const data = {};
+  data: (album) => ({
+    name:
+      album.name,
 
-    data.name = album.name;
-    data.color = album.color;
+    color:
+      album.color,
 
-    if (!empty(album.additionalFiles)) {
-      data.numAdditionalFiles = album.additionalFiles.length;
-    }
+    dateAddedToWiki:
+      album.dateAddedToWiki,
+  }),
 
-    data.dateAddedToWiki = album.dateAddedToWiki;
-
-    return data;
-  },
-
-  generate(data, relations, {html, language}) {
-    const {sections: sec} = relations;
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            album: data.name,
+          }),
 
-    return relations.layout
-      .slots({
-        title: language.$('albumPage.title', {album: data.name}),
+        color: data.color,
         headingMode: 'sticky',
+        styleTags: relations.albumStyleTags,
 
-        color: data.color,
-        styleRules: [relations.albumStyleRules],
+        additionalNames: relations.additionalNamesBox,
 
-        cover:
-          relations.cover
-            ?.slots({
-              alt: language.$('misc.alt.albumCover'),
-            })
-            ?? null,
+        artworkColumnContent:
+          relations.artworkColumn,
 
         mainContent: [
           relations.releaseInfo,
@@ -179,33 +122,53 @@ export default {
             {[html.onlyIfContent]: true},
             {[html.joinChildren]: html.tag('br')},
 
-            [
-              sec.extra.additionalFilesShortcut,
-
-              sec.extra.galleryLink && sec.extra.commentaryLink &&
-                language.$('releaseInfo.viewGalleryOrCommentary', {
-                  gallery:
-                    sec.extra.galleryLink
-                      .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.gallery')),
-                  commentary:
-                    sec.extra.commentaryLink
-                      .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.commentary')),
-                }),
-
-              sec.extra.galleryLink && !sec.extra.commentaryLink &&
-                language.$('releaseInfo.viewGallery', {
-                  link:
-                    sec.extra.galleryLink
-                      .slot('content', language.$('releaseInfo.viewGallery.link')),
+            language.encapsulate('releaseInfo', capsule => [
+              !html.isBlank(relations.additionalFilesList) &&
+                language.$(capsule, 'additionalFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#additional-files'},
+                    language.$(capsule, 'additionalFiles.shortcut.link')),
                 }),
 
-              !sec.extra.galleryLink && sec.extra.commentaryLink &&
-                language.$('releaseInfo.viewCommentary', {
-                  link:
-                    sec.extra.commentaryLink
-                      .slot('content', language.$('releaseInfo.viewCommentary.link')),
-                }),
-            ]),
+              (relations.galleryLink && relations.commentaryLink
+                ? language.encapsulate(capsule, 'viewGalleryOrCommentary', capsule =>
+                    language.$(capsule, {
+                      gallery:
+                        relations.galleryLink
+                          .slot('content', language.$(capsule, 'gallery')),
+
+                      commentary:
+                        relations.commentaryLink
+                          .slot('content', language.$(capsule, 'commentary')),
+                    }))
+
+             : relations.galleryLink
+                ? language.encapsulate(capsule, 'viewGallery', capsule =>
+                    language.$(capsule, {
+                      link:
+                        relations.galleryLink
+                          .slot('content', language.$(capsule, 'link')),
+                    }))
+
+             : relations.commentaryLink
+                ? language.encapsulate(capsule, 'viewCommentary', capsule =>
+                    language.$(capsule, {
+                      link:
+                        relations.commentaryLink
+                          .slot('content', language.$(capsule, 'link')),
+                    }))
+
+                : html.blank()),
+
+              !html.isBlank(relations.creditSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditingSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#crediting-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
 
           relations.trackList,
 
@@ -213,28 +176,48 @@ export default {
             {[html.onlyIfContent]: true},
             {[html.joinChildren]: html.tag('br')},
 
-            [
-              data.dateAddedToWiki &&
-                language.$('releaseInfo.addedToWiki', {
-                  date: language.formatDate(data.dateAddedToWiki),
+            language.encapsulate('releaseInfo', capsule => [
+              language.$(capsule, 'addedToWiki', {
+                [language.onlyIfOptions]: ['date'],
+                date: language.formatDate(data.dateAddedToWiki),
+              }),
+            ])),
+
+          (!html.isBlank(relations.artistCommentaryEntries) ||
+           !html.isBlank(relations.creditSourceEntries))
+          &&
+            html.tag('hr', {class: 'main-separator'}),
+
+          language.encapsulate('releaseInfo.additionalFiles', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'additional-files'},
+                  title: language.$(capsule, 'heading'),
                 }),
-            ]),
 
-          sec.additionalFiles && [
-            sec.additionalFiles.heading
+              relations.additionalFilesList,
+            ])),
+
+          html.tags([
+            relations.contentContentHeading.clone()
               .slots({
-                id: 'additional-files',
-                title:
-                  language.$('releaseInfo.additionalFiles.heading', {
-                    additionalFiles:
-                      language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
-                  }),
+                attributes: {id: 'artist-commentary'},
+                string: 'misc.artistCommentary',
               }),
 
-            sec.additionalFiles.additionalFilesList,
-          ],
+            relations.artistCommentaryEntries,
+          ]),
 
-          sec.artistCommentary,
+          html.tags([
+            relations.contentContentHeading.clone()
+              .slots({
+                attributes: {id: 'crediting-sources'},
+                string: 'misc.creditingSources',
+              }),
+
+            relations.creditSourceEntries,
+          ]),
         ],
 
         navLinkStyle: 'hierarchical',
@@ -250,24 +233,13 @@ export default {
           },
         ],
 
-        navContent:
-          relations.chronologyLinks.slots({
-            chronologyInfoSets: [
-              {
-                headingString: 'misc.chronology.heading.coverArt',
-                contributions: relations.coverArtistChronologyContributions,
-              },
-            ],
-          }),
-
         banner: relations.banner ?? null,
         bannerPosition: 'top',
 
         secondaryNav: relations.secondaryNav,
 
-        ...relations.sidebar,
+        leftSidebar: relations.sidebar,
 
         socialEmbed: relations.socialEmbed,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js
index 121af439..432c5f3d 100644
--- a/src/content/dependencies/generateAlbumNavAccent.js
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -1,8 +1,10 @@
-import {empty} from '#sugar';
+import {atOffset, empty} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generatePreviousNextLinks',
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
     'linkTrack',
     'linkAlbumCommentary',
     'linkAlbumGallery',
@@ -10,47 +12,68 @@ export default {
 
   extraDependencies: ['html', 'language'],
 
-  relations(relation, album, track) {
-    const relations = {};
+  query(album, track) {
+    const query = {};
 
-    relations.previousNextLinks =
-      relation('generatePreviousNextLinks');
+    const index =
+      (track
+        ? album.tracks.indexOf(track)
+        : null);
 
-    relations.previousTrackLink = null;
-    relations.nextTrackLink = null;
+    query.previousTrack =
+      (track
+        ? atOffset(album.tracks, index, -1)
+        : null);
 
-    if (track) {
-      const index = album.tracks.indexOf(track);
+    query.nextTrack =
+      (track
+        ? atOffset(album.tracks, index, +1)
+        : null);
 
-      if (index > 0) {
-        relations.previousTrackLink =
-          relation('linkTrack', album.tracks[index - 1]);
-      }
+    return query;
+  },
 
-      if (index < album.tracks.length - 1) {
-        relations.nextTrackLink =
-          relation('linkTrack', album.tracks[index + 1]);
-      }
-    }
+  relations: (relation, query, album, _track) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
 
-    relations.albumGalleryLink =
-      relation('linkAlbumGallery', album);
+    previousLink:
+      relation('generatePreviousLink'),
 
-    if (album.commentary || album.tracks.some(t => t.commentary)) {
-      relations.albumCommentaryLink =
-        relation('linkAlbumCommentary', album);
-    }
+    nextLink:
+      relation('generateNextLink'),
 
-    return relations;
-  },
+    previousTrackLink:
+      (query.previousTrack
+        ? relation('linkTrack', query.previousTrack)
+        : null),
 
-  data(album, track) {
-    return {
-      hasMultipleTracks: album.tracks.length > 1,
-      galleryIsStub: album.tracks.every(t => !t.hasUniqueCoverArt),
-      isTrackPage: !!track,
-    };
-  },
+    nextTrackLink:
+      (query.nextTrack
+        ? relation('linkTrack', query.nextTrack)
+        : null),
+
+    albumGalleryLink:
+      relation('linkAlbumGallery', album),
+
+    albumCommentaryLink:
+      relation('linkAlbumCommentary', album),
+  }),
+
+  data: (query, album, track) => ({
+    hasMultipleTracks:
+      album.tracks.length > 1,
+
+    commentaryPageIsStub:
+      [album, ...album.tracks]
+        .every(({commentary}) => empty(commentary)),
+
+    galleryIsStub:
+      album.tracks.every(t => !t.hasUniqueCoverArt),
+
+    isTrackPage:
+      !!track,
+  }),
 
   slots: {
     showTrackNavigation: {type: 'boolean', default: false},
@@ -62,51 +85,58 @@ export default {
   },
 
   generate(data, relations, slots, {html, language}) {
-    const {content: extraLinks = []} =
-      slots.showExtraLinks &&
-        {content: [
-          (!data.galleryIsStub || slots.currentExtra === 'gallery') &&
-            relations.albumGalleryLink?.slots({
-              attributes: {class: slots.currentExtra === 'gallery' && 'current'},
-              content: language.$('albumPage.nav.gallery'),
-            }),
-
-          relations.albumCommentaryLink?.slots({
-            attributes: {class: slots.currentExtra === 'commentary' && 'current'},
-            content: language.$('albumPage.nav.commentary'),
-          }),
-        ]};
-
-    const {content: previousNextLinks = []} =
-      slots.showTrackNavigation &&
+    const albumNavCapsule = language.encapsulate('albumPage.nav');
+    const trackNavCapsule = language.encapsulate('trackPage.nav');
+
+    const previousLink =
       data.isTrackPage &&
-      data.hasMultipleTracks &&
-        relations.previousNextLinks.slots({
-          previousLink: relations.previousTrackLink,
-          nextLink: relations.nextTrackLink,
+        relations.previousLink.slot('link', relations.previousTrackLink);
+
+    const nextLink =
+      data.isTrackPage &&
+        relations.nextLink.slot('link', relations.nextTrackLink);
+
+    const galleryLink =
+      (!data.galleryIsStub || slots.currentExtra === 'gallery') &&
+        relations.albumGalleryLink.slots({
+          attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+          content: language.$(albumNavCapsule, 'gallery'),
+        });
+
+    const commentaryLink =
+      (!data.commentaryPageIsStub || slots.currentExtra === 'commentary') &&
+        relations.albumCommentaryLink.slots({
+          attributes: {class: slots.currentExtra === 'commentary' && 'current'},
+          content: language.$(albumNavCapsule, 'commentary'),
         });
 
     const randomLink =
-      slots.showTrackNavigation &&
       data.hasMultipleTracks &&
         html.tag('a',
           {id: 'random-button'},
           {href: '#', 'data-random': 'track-in-sidebar'},
 
           (data.isTrackPage
-            ? language.$('trackPage.nav.random')
-            : language.$('albumPage.nav.randomTrack')));
+            ? language.$(trackNavCapsule, 'random')
+            : language.$(albumNavCapsule, 'randomTrack')));
+
+    return relations.switcher.slots({
+      links: [
+        slots.showTrackNavigation &&
+          previousLink,
+
+        slots.showTrackNavigation &&
+          nextLink,
 
-    const allLinks = [
-      ...previousNextLinks,
-      ...extraLinks,
-      randomLink,
-    ].filter(Boolean);
+        slots.showExtraLinks &&
+          galleryLink,
 
-    if (empty(allLinks)) {
-      return html.blank();
-    }
+        slots.showExtraLinks &&
+          commentaryLink,
 
-    return `(${language.formatUnitList(allLinks)})`;
+        slots.showTrackNavigation &&
+          randomLink,
+      ],
+    });
   },
 };
diff --git a/src/content/dependencies/generateAlbumReferencedArtworksPage.js b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
new file mode 100644
index 00000000..52c78dc2
--- /dev/null
+++ b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
@@ -0,0 +1,58 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleTags',
+    'generateBackToAlbumLink',
+    'generateReferencedArtworksPage',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    page:
+      relation('generateReferencedArtworksPage', album.coverArtworks[0]),
+
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    backToAlbumLink:
+      relation('generateBackToAlbumLink', album),
+  }),
+
+  data: (album) => ({
+    name:
+      album.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.page.slots({
+      title:
+        language.$('albumPage.title', {
+          album:
+            data.name,
+        }),
+
+      styleTags: relations.albumStyleTags,
+
+      navLinks: [
+        {auto: 'home'},
+
+        {
+          html:
+            relations.albumLink
+              .slot('attributes', {class: 'current'}),
+
+          accent:
+            html.tag('a', {href: ''},
+              {class: 'current'},
+
+              language.$('referencedArtworksPage.subtitle')),
+        },
+      ],
+
+      navBottomRowContent: relations.backToAlbumLink,
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumReferencingArtworksPage.js b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
new file mode 100644
index 00000000..bc36ae06
--- /dev/null
+++ b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
@@ -0,0 +1,58 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleTags',
+    'generateBackToAlbumLink',
+    'generateReferencingArtworksPage',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    page:
+      relation('generateReferencingArtworksPage', album.coverArtworks[0]),
+
+    albumStyleTags:
+      relation('generateAlbumStyleTags', album, null),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    backToAlbumLink:
+      relation('generateBackToAlbumLink', album),
+  }),
+
+  data: (album) => ({
+    name:
+      album.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.page.slots({
+      title:
+        language.$('albumPage.title', {
+          album:
+            data.name,
+        }),
+
+      styleTags: relations.albumStyleTags,
+
+      navLinks: [
+        {auto: 'home'},
+
+        {
+          html:
+            relations.albumLink
+              .slot('attributes', {class: 'current'}),
+
+          accent:
+            html.tag('a', {href: ''},
+              {class: 'current'},
+
+              language.$('referencingArtworksPage.subtitle')),
+        },
+      ],
+
+      navBottomRowContent: relations.backToAlbumLink,
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index 5128fbac..2a958244 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -3,7 +3,7 @@ import {accumulateSum, empty} from '#sugar';
 export default {
   contentDependencies: [
     'generateReleaseInfoContributionsLine',
-    'linkExternal',
+    'generateReleaseInfoListenLine',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -14,20 +14,8 @@ export default {
     relations.artistContributionsLine =
       relation('generateReleaseInfoContributionsLine', album.artistContribs);
 
-    relations.coverArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.coverArtistContribs);
-
-    relations.wallpaperArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs);
-
-    relations.bannerArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
-
-    if (!empty(album.urls)) {
-      relations.externalLinks =
-        album.urls.map(url =>
-          relation('linkExternal', url));
-    }
+    relations.listenLine =
+      relation('generateReleaseInfoListenLine', album);
 
     return relations;
   },
@@ -43,71 +31,65 @@ export default {
       data.coverArtDate = album.coverArtDate;
     }
 
-    data.duration = accumulateSum(album.tracks, track => track.duration);
-    data.durationApproximate = album.tracks.length > 1;
+    const durationTerms =
+      album.tracks
+        .map(track => track.duration)
+        .filter(value => value > 0);
+
+    if (empty(durationTerms)) {
+      data.duration = null;
+      data.durationApproximate = null;
+    } else {
+      data.duration = accumulateSum(durationTerms);
+      data.durationApproximate = album.tracks.length > 1;
+    }
 
     data.numTracks = album.tracks.length;
 
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return html.tags([
-      html.tag('p',
-        {[html.onlyIfContent]: true},
-        {[html.joinChildren]: html.tag('br')},
-
-        [
-          relations.artistContributionsLine
-            .slots({stringKey: 'releaseInfo.by'}),
-
-          relations.coverArtistContributionsLine
-            .slots({stringKey: 'releaseInfo.coverArtBy'}),
-
-          relations.wallpaperArtistContributionsLine
-            .slots({stringKey: 'releaseInfo.wallpaperArtBy'}),
-
-          relations.bannerArtistContributionsLine
-            .slots({stringKey: 'releaseInfo.bannerArtBy'}),
-
-          data.date &&
-            language.$('releaseInfo.released', {
-              date: language.formatDate(data.date),
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule =>
+      html.tags([
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.artistContributionsLine.slots({
+              stringKey: capsule + '.by',
+              featuringStringKey: capsule + '.by.featuring',
+              chronologyKind: 'album',
             }),
 
-          data.coverArtDate &&
-            language.$('releaseInfo.artReleased', {
-              date: language.formatDate(data.coverArtDate),
+            language.$(capsule, 'released', {
+              [language.onlyIfOptions]: ['date'],
+              date: language.formatDate(data.date),
             }),
 
-          data.duration &&
-            language.$('releaseInfo.duration', {
+            language.$(capsule, 'duration', {
+              [language.onlyIfOptions]: ['duration'],
               duration:
                 language.formatDuration(data.duration, {
                   approximate: data.durationApproximate,
                 }),
             }),
-        ]),
+          ]),
 
-      relations.externalLinks &&
         html.tag('p',
-          language.$('releaseInfo.listenOn', {
-            links:
-              language.formatDisjunctionList(
-                relations.externalLinks
-                  .map(link =>
-                    link.slots({
-                      context: [
-                        'album',
-                        (data.numTracks === 0
-                          ? 'albumNoTracks'
-                       : data.numTracks === 1
-                          ? 'albumOneTrack'
-                          : 'albumMultipleTracks'),
-                      ],
-                      style: 'normal',
-                    }))),
+          {[html.onlyIfContent]: true},
+
+          relations.listenLine.slots({
+            context: [
+              'album',
+
+              (data.numTracks === 0
+                ? 'albumNoTracks'
+             : data.numTracks === 1
+                ? 'albumOneTrack'
+                : 'albumMultipleTracks'),
+            ],
           })),
-    ]);
-  },
+      ])),
 };
diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js
index 400420ba..bfa48f03 100644
--- a/src/content/dependencies/generateAlbumSecondaryNav.js
+++ b/src/content/dependencies/generateAlbumSecondaryNav.js
@@ -1,168 +1,127 @@
-import {sortChronologically} from '#sort';
-import {atOffset, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateColorStyleAttribute',
-    'generatePreviousNextLinks',
+    'generateAlbumSecondaryNavGroupPart',
+    'generateAlbumSecondaryNavSeriesPart',
+    'generateDotSwitcherTemplate',
     'generateSecondaryNav',
-    'linkAlbumDynamically',
-    'linkGroup',
-    'linkTrack',
   ],
 
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['html', 'wikiData'],
 
-  query(album) {
+  sprawl: ({groupData}) => ({
+    // TODO: Series aren't their own things, so we access them weirdly.
+    seriesData:
+      groupData.flatMap(group => group.serieses),
+  }),
+
+  query(sprawl, album) {
     const query = {};
 
     query.groups =
       album.groups;
 
-    if (album.date) {
-      // Sort by latest first. This matches the sorting order used on group
-      // gallery pages, ensuring that previous/next matches moving up/down
-      // the gallery. Note that this makes the index offsets "backwards"
-      // compared to how latest-last chronological lists are accessed.
-      const groupAlbums =
-        query.groups.map(group =>
-          sortChronologically(
-            group.albums.filter(album => album.date),
-            {latestFirst: true}));
-
-      const groupCurrentIndex =
-        groupAlbums.map(albums =>
-          albums.indexOf(album));
-
-      query.groupPreviousAlbum =
-        stitchArrays({
-          albums: groupAlbums,
-          index: groupCurrentIndex,
-        }).map(({albums, index}) =>
-            atOffset(albums, index, +1));
-
-      query.groupNextAlbum =
-        stitchArrays({
-          albums: groupAlbums,
-          index: groupCurrentIndex,
-        }).map(({albums, index}) =>
-            atOffset(albums, index, -1));
-    }
+    query.groupSerieses =
+      query.groups
+        .map(group =>
+          group.serieses
+            .filter(series => series.albums.includes(album)));
+
+    query.disconnectedSerieses =
+      sprawl.seriesData
+        .filter(series =>
+          series.albums.includes(album) &&
+          !query.groups.includes(series.group));
 
     return query;
   },
 
-  relations(relation, query, album) {
-    const relations = {};
-
-    relations.secondaryNav =
-      relation('generateSecondaryNav');
-
-    relations.groupLinks =
-      album.groups
-        .map(group => relation('linkGroup', group));
-
-    relations.colorStyles =
-      album.groups
-        .map(group => relation('generateColorStyleAttribute', group.color));
-
-    if (album.date) {
-      relations.previousNextLinks =
-        stitchArrays({
-          previousAlbum: query.groupPreviousAlbum,
-          nextAlbum: query.groupNextAlbum
-        }).map(({previousAlbum, nextAlbum}) =>
-            (previousAlbum || nextAlbum
-              ? relation('generatePreviousNextLinks')
-              : null));
-
-      relations.previousAlbumLinks =
-        query.groupPreviousAlbum.map(previousAlbum =>
-          (previousAlbum
-            ? relation('linkAlbumDynamically', previousAlbum)
-            : null));
-
-      relations.nextAlbumLinks =
-        query.groupNextAlbum.map(nextAlbum =>
-          (nextAlbum
-            ? relation('linkAlbumDynamically', nextAlbum)
-            : null));
-    }
-
-    return relations;
-  },
+  relations: (relation, query, _sprawl, album) => ({
+    secondaryNav:
+      relation('generateSecondaryNav'),
+
+    // Just use a generic dot switcher here. We want the common behavior,
+    // but the "options" may each contain multiple links (group + series),
+    // so this is a different use than typical interpage dot switchers.
+    switcher:
+      relation('generateDotSwitcherTemplate'),
+
+    groupParts:
+      query.groups
+        .map(group =>
+          relation('generateAlbumSecondaryNavGroupPart',
+            group,
+            album)),
+
+    seriesParts:
+      query.groupSerieses
+        .map(serieses => serieses
+          .map(series =>
+            relation('generateAlbumSecondaryNavSeriesPart',
+              series,
+              album))),
+
+    disconnectedSeriesParts:
+      query.disconnectedSerieses
+        .map(series =>
+          relation('generateAlbumSecondaryNavSeriesPart',
+            series,
+            album)),
+  }),
 
   slots: {
     mode: {
       validate: v => v.is('album', 'track'),
       default: 'album',
     },
+
+    alwaysVisible: {
+      type: 'boolean',
+      default: false,
+    },
   },
 
-  generate(relations, slots, {html, language}) {
-    const navLinksShouldShowPreviousNext =
-      (slots.mode === 'track'
-        ? Array.from(relations.previousNextLinks, () => false)
-        : stitchArrays({
-            previousAlbumLink: relations.previousAlbumLinks ?? null,
-            nextAlbumLink: relations.nextAlbumLinks ?? null,
-          }).map(({previousAlbumLink, nextAlbumLink}) =>
-              previousAlbumLink ||
-              nextAlbumLink));
-
-    const navLinkPreviousNextLinks =
+  generate(relations, slots, {html}) {
+    const groupConnectedParts =
       stitchArrays({
-        showPreviousNext: navLinksShouldShowPreviousNext,
-        previousNextLinks: relations.previousNextLinks ?? null,
-        previousAlbumLink: relations.previousAlbumLinks ?? null,
-        nextAlbumLink: relations.nextAlbumLinks ?? null,
-      }).map(({
-          showPreviousNext,
-          previousNextLinks,
-          previousAlbumLink,
-          nextAlbumLink,
-        }) =>
-          (showPreviousNext
-            ? previousNextLinks.slots({
-                previousLink: previousAlbumLink,
-                nextLink: nextAlbumLink,
-                id: false,
-              })
-            : null));
-
-    for (const groupLink of relations.groupLinks) {
-      groupLink.setSlot('color', false);
-    }
-
-    const navLinkContents =
-      stitchArrays({
-        groupLink: relations.groupLinks,
-        previousNextLinks: navLinkPreviousNextLinks,
-      }).map(({groupLink, previousNextLinks}) => [
-          language.$('albumSidebar.groupBox.title', {
-            group: groupLink,
-          }),
-
-          previousNextLinks &&
-            `(${language.formatUnitList(previousNextLinks.content)})`,
-        ]);
-
-    const navLinks =
-      stitchArrays({
-        content: navLinkContents,
-        colorStyle: relations.colorStyles,
-      }).map(({content, colorStyle}, index) =>
-          html.tag('span', {class: 'nav-link'},
-            index > 0 &&
-              {class: 'has-divider'},
+        groupPart: relations.groupParts,
+        seriesParts: relations.seriesParts,
+      }).map(({groupPart, seriesParts}) => {
+          for (const part of [groupPart, ...seriesParts]) {
+            part.setSlot('mode', slots.mode);
+          }
+
+          if (html.isBlank(seriesParts)) {
+            return groupPart;
+          } else {
+            return (
+              html.tag('span', {class: 'group-with-series'},
+                {[html.joinChildren]: ''},
+
+                [groupPart, ...seriesParts]));
+          }
+        });
+
+    const allParts = [
+      ...relations.disconnectedSeriesParts,
+      ...groupConnectedParts,
+    ];
 
-            colorStyle.slot('context', 'primary-only'),
+    return relations.secondaryNav.slots({
+      alwaysVisible: slots.alwaysVisible,
 
-            content));
+      attributes: [
+        {class: 'album-secondary-nav'},
 
-    return relations.secondaryNav.slots({
-      class: 'nav-links-groups',
-      content: navLinks,
+        slots.mode === 'album' &&
+          {class: 'with-previous-next'},
+      ],
+
+      content:
+        (slots.mode === 'album'
+          ? allParts
+          : relations.switcher.slot('options', allParts)),
     });
   },
 };
diff --git a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js
new file mode 100644
index 00000000..22dfa51c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js
@@ -0,0 +1,94 @@
+import {sortChronologically} from '#sort';
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateSecondaryNavParentSiblingsPart',
+    'linkAlbumDynamically',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html'],
+
+  query(group, album) {
+    const query = {};
+
+    if (album.date) {
+      // Sort by latest first. This matches the sorting order used on group
+      // gallery pages, ensuring that previous/next matches moving up/down
+      // the gallery. Note that this makes the index offsets "backwards"
+      // compared to how latest-last chronological lists are accessed.
+      const albums =
+        sortChronologically(
+          group.albums.filter(album => album.date),
+          {latestFirst: true});
+
+      const currentIndex =
+        albums.indexOf(album);
+
+      query.previousAlbum =
+        atOffset(albums, currentIndex, +1);
+
+      query.nextAlbum =
+        atOffset(albums, currentIndex, -1);
+    }
+
+    return query;
+  },
+
+  relations: (relation, query, group, _album) => ({
+    parentSiblingsPart:
+      relation('generateSecondaryNavParentSiblingsPart'),
+
+    groupLink:
+      relation('linkGroup', group),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', group.color),
+
+    previousAlbumLink:
+      (query.previousAlbum
+        ? relation('linkAlbumDynamically', query.previousAlbum)
+        : null),
+
+    nextAlbumLink:
+      (query.nextAlbum
+        ? relation('linkAlbumDynamically', query.nextAlbum)
+        : null),
+  }),
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'album',
+    },
+  },
+
+  generate: (relations, slots) =>
+    relations.parentSiblingsPart.slots({
+      attributes: {class: 'group-nav-links'},
+
+      showPreviousNext: slots.mode === 'album',
+
+      colorStyle: relations.colorStyle,
+      mainLink: relations.groupLink,
+
+      previousLink:
+        (relations.previousAlbumLink
+          ? relations.previousAlbumLink.slots({
+              linkCommentaryPages: true,
+            })
+          : null),
+
+      nextLink:
+        (relations.nextAlbumLink
+          ? relations.nextAlbumLink.slots({
+              linkCommentaryPages: true,
+            })
+          : null),
+
+      stringsKey: 'albumSecondaryNav.group',
+      mainLinkOption: 'group',
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js
new file mode 100644
index 00000000..16f205e3
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js
@@ -0,0 +1,94 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateSecondaryNavParentSiblingsPart',
+    'linkAlbumDynamically',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(series, album) {
+    const query = {};
+
+    const albums =
+      series.albums;
+
+    const currentIndex =
+      albums.indexOf(album);
+
+    query.previousAlbum =
+      atOffset(albums, currentIndex, -1);
+
+    query.nextAlbum =
+      atOffset(albums, currentIndex, +1);
+
+    return query;
+  },
+
+  relations: (relation, query, series, _album) => ({
+    parentSiblingsPart:
+      relation('generateSecondaryNavParentSiblingsPart'),
+
+    groupLink:
+      relation('linkGroup', series.group),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', series.group.color),
+
+    previousAlbumLink:
+      (query.previousAlbum
+        ? relation('linkAlbumDynamically', query.previousAlbum)
+        : null),
+
+    nextAlbumLink:
+      (query.nextAlbum
+        ? relation('linkAlbumDynamically', query.nextAlbum)
+        : null),
+  }),
+
+  data: (_query, series) => ({
+    name: series.name,
+  }),
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'album',
+    },
+  },
+
+  generate: (data, relations, slots, {language}) =>
+    relations.parentSiblingsPart.slots({
+      attributes: {class: 'series-nav-links'},
+
+      showPreviousNext: slots.mode === 'album',
+
+      colorStyle: relations.colorStyle,
+
+      mainLink:
+        relations.groupLink.slots({
+          attributes: {class: 'series'},
+          content: language.sanitize(data.name),
+        }),
+
+      previousLink:
+        (relations.previousAlbumLink
+          ? relations.previousAlbumLink.slots({
+              linkCommentaryPages: true,
+            })
+          : null),
+
+      nextLink:
+        (relations.nextAlbumLink
+          ? relations.nextAlbumLink.slots({
+              linkCommentaryPages: true,
+            })
+          : null),
+
+      stringsKey: 'albumSecondaryNav.series',
+      mainLinkOption: 'series',
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
index 5ef4501b..7cf689cc 100644
--- a/src/content/dependencies/generateAlbumSidebar.js
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -1,79 +1,171 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import {stitchArrays, transposeArrays} from '#sugar';
+
 export default {
   contentDependencies: [
     'generateAlbumSidebarGroupBox',
-    'generateAlbumSidebarTrackSection',
-    'linkAlbum',
+    'generateAlbumSidebarSeriesBox',
+    'generateAlbumSidebarTrackListBox',
+    'generatePageSidebar',
+    'generatePageSidebarConjoinedBox',
+    'generateTrackReleaseBox',
   ],
 
-  extraDependencies: ['html'],
+  extraDependencies: ['html', 'wikiData'],
 
-  relations(relation, album, track) {
-    const relations = {};
+  sprawl: ({groupData}) => ({
+    // TODO: Series aren't their own things, so we access them weirdly.
+    seriesData:
+      groupData.flatMap(group => group.serieses),
+  }),
 
-    relations.albumLink =
-      relation('linkAlbum', album);
+  query(sprawl, album, track) {
+    const query = {};
 
-    relations.groupBoxes =
-      album.groups.map(group =>
-        relation('generateAlbumSidebarGroupBox', album, group));
+    query.groups =
+      album.groups;
 
-    relations.trackSections =
-      album.trackSections.map(trackSection =>
-        relation('generateAlbumSidebarTrackSection', album, track, trackSection));
+    query.groupSerieses =
+      query.groups
+        .map(group =>
+          group.serieses
+            .filter(series => series.albums.includes(album)));
 
-    return relations;
-  },
+    query.disconnectedSerieses =
+      sprawl.seriesData
+        .filter(series =>
+          series.albums.includes(album) &&
+          !query.groups.includes(series.group));
+
+    if (track) {
+      const albumTrackMap =
+        new Map(transposeArrays([
+          track.allReleases.map(t => t.album),
+          track.allReleases,
+        ]));
+
+      const allReleaseAlbums =
+        sortAlbumsTracksChronologically(
+          Array.from(albumTrackMap.keys()));
+
+      const currentReleaseIndex =
+        allReleaseAlbums.indexOf(track.album);
+
+      const earlierReleaseAlbums =
+        allReleaseAlbums.slice(0, currentReleaseIndex);
+
+      const laterReleaseAlbums =
+        allReleaseAlbums.slice(currentReleaseIndex + 1);
 
-  data(album, track) {
-    return {isAlbumPage: !track};
+      query.earlierReleaseTracks =
+        earlierReleaseAlbums.map(album => albumTrackMap.get(album));
+
+      query.laterReleaseTracks =
+        laterReleaseAlbums.map(album => albumTrackMap.get(album));
+    }
+
+    return query;
   },
 
+  relations: (relation, query, _sprawl, album, track) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    conjoinedBox:
+      relation('generatePageSidebarConjoinedBox'),
+
+    trackListBox:
+      relation('generateAlbumSidebarTrackListBox', album, track),
+
+    groupBoxes:
+      query.groups
+        .map(group =>
+          relation('generateAlbumSidebarGroupBox', album, group)),
+
+    seriesBoxes:
+      query.groupSerieses
+        .map(serieses => serieses
+          .map(series =>
+            relation('generateAlbumSidebarSeriesBox', album, series))),
+
+    disconnectedSeriesBoxes:
+      query.disconnectedSerieses
+        .map(series =>
+          relation('generateAlbumSidebarSeriesBox', album, series)),
+
+    earlierTrackReleaseBoxes:
+      (track
+        ? query.earlierReleaseTracks
+            .map(track =>
+              relation('generateTrackReleaseBox', track))
+        : null),
+
+    laterTrackReleaseBoxes:
+      (track
+        ? query.laterReleaseTracks
+            .map(track =>
+              relation('generateTrackReleaseBox', track))
+        : null),
+  }),
+
+  data: (_query, _sprawl, _album, track) => ({
+    isAlbumPage: !track,
+    isTrackPage: !!track,
+  }),
+
   generate(data, relations, {html}) {
-    const trackListBox = {
-      class: 'track-list-sidebar-box',
-      content:
-        html.tags([
-          html.tag('h1', relations.albumLink),
-          relations.trackSections,
-        ]),
-    };
-
-    if (data.isAlbumPage) {
-      const groupBoxes =
-        relations.groupBoxes
-          .map(content => ({
-            class: 'individual-group-sidebar-box',
-            content: content.slot('mode', 'album'),
-          }));
-
-      return {
-        leftSidebarMultiple: [
-          ...groupBoxes,
-          trackListBox,
-        ],
-      };
+    for (const box of [
+      ...relations.groupBoxes,
+      ...relations.seriesBoxes.flat(),
+      ...relations.disconnectedSeriesBoxes,
+    ]) {
+      box.setSlot('mode',
+        data.isAlbumPage ? 'album' : 'track');
     }
 
-    const conjoinedGroupBox = {
-      class: 'conjoined-group-sidebar-box',
-      content:
-        relations.groupBoxes
-          .flatMap((content, i, {length}) => [
-            content.slot('mode', 'track'),
-            i < length - 1 &&
-              html.tag('hr', {
-                style: `border-color: var(--primary-color); border-style: none none dotted none`
-              }),
-          ])
-          .filter(Boolean),
-    };
-
-    return {
-      // leftSidebarStickyMode: 'column',
-      leftSidebarMultiple: [
-        trackListBox,
-        conjoinedGroupBox,
+    return relations.sidebar.slots({
+      boxes: [
+        data.isAlbumPage && [
+          relations.disconnectedSeriesBoxes,
+
+          stitchArrays({
+            groupBox: relations.groupBoxes,
+            seriesBoxes: relations.seriesBoxes,
+          }).map(({groupBox, seriesBoxes}) => [
+              groupBox,
+              seriesBoxes.map(seriesBox => [
+                html.tag('div',
+                  {class: 'sidebar-box-joiner'},
+                  {class: 'collapsible'}),
+                seriesBox,
+              ]),
+            ]),
+        ],
+
+        data.isTrackPage &&
+          relations.earlierTrackReleaseBoxes,
+
+        relations.trackListBox,
+
+        data.isTrackPage &&
+          relations.laterTrackReleaseBoxes,
+
+        data.isTrackPage &&
+          relations.conjoinedBox.slots({
+            attributes: {class: 'conjoined-group-sidebar-box'},
+            boxes:
+              ([relations.disconnectedSeriesBoxes,
+                stitchArrays({
+                  groupBox: relations.groupBoxes,
+                  seriesBoxes: relations.seriesBoxes,
+                }).flatMap(({groupBox, seriesBoxes}) => [
+                    groupBox,
+                    ...seriesBoxes,
+                  ]),
+              ]).flat()
+                .map(box => box.content), /* TODO: Kludge. */
+          }),
       ],
-    };
+    });
   },
 };
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
index 93ebf5d4..f3be74f7 100644
--- a/src/content/dependencies/generateAlbumSidebarGroupBox.js
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -1,8 +1,9 @@
 import {sortChronologically} from '#sort';
-import {atOffset, empty} from '#sugar';
+import {atOffset} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generatePageSidebarBox',
     'linkAlbum',
     'linkExternal',
     'linkGroup',
@@ -40,6 +41,9 @@ export default {
   relations(relation, query, album, group) {
     const relations = {};
 
+    relations.box =
+      relation('generatePageSidebarBox');
+
     relations.groupLink =
       relation('linkGroup', group);
 
@@ -72,39 +76,51 @@ export default {
     },
   },
 
-  generate(relations, slots, {html, language}) {
-    return html.tags([
-      html.tag('h1',
-        language.$('albumSidebar.groupBox.title', {
-          group: relations.groupLink,
-        })),
-
-      slots.mode === 'album' &&
-        relations.description
-          ?.slot('mode', 'multiline'),
-
-      !empty(relations.externalLinks) &&
-        html.tag('p',
-          language.$('releaseInfo.visitOn', {
-            links:
-              language.formatDisjunctionList(
-                relations.externalLinks
-                  .map(link => link.slot('context', 'group'))),
-          })),
-
-      slots.mode === 'album' &&
-      relations.nextAlbumLink &&
-        html.tag('p', {class: 'group-chronology-link'},
-          language.$('albumSidebar.groupBox.next', {
-            album: relations.nextAlbumLink,
-          })),
-
-      slots.mode === 'album' &&
-      relations.previousAlbumLink &&
-        html.tag('p', {class: 'group-chronology-link'},
-          language.$('albumSidebar.groupBox.previous', {
-            album: relations.previousAlbumLink,
-          })),
-    ]);
-  },
+  generate: (relations, slots, {html, language}) =>
+    language.encapsulate('albumSidebar.groupBox', boxCapsule =>
+      relations.box.slots({
+        attributes: {class: 'individual-group-sidebar-box'},
+        content: [
+          html.tag('h1',
+            language.$(boxCapsule, 'title', {
+              group: relations.groupLink,
+            })),
+
+          slots.mode === 'album' &&
+            relations.description
+              ?.slot('mode', 'multiline'),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'group'))),
+            })),
+
+          slots.mode === 'album' &&
+            html.tag('p', {class: 'group-chronology-link'},
+              {[html.onlyIfContent]: true},
+
+              language.$(boxCapsule, 'next', {
+                [language.onlyIfOptions]: ['album'],
+
+                album: relations.nextAlbumLink,
+              })),
+
+          slots.mode === 'album' &&
+            html.tag('p', {class: 'group-chronology-link'},
+              {[html.onlyIfContent]: true},
+
+              language.$(boxCapsule, 'previous', {
+                [language.onlyIfOptions]: ['album'],
+
+                album: relations.previousAlbumLink,
+              })),
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumSidebarSeriesBox.js b/src/content/dependencies/generateAlbumSidebarSeriesBox.js
new file mode 100644
index 00000000..37616cb2
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarSeriesBox.js
@@ -0,0 +1,102 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkAlbum',
+    'linkGroup',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album, series) {
+    const query = {};
+
+    const albums =
+      series.albums;
+
+    const index =
+      albums.indexOf(album);
+
+    query.previousAlbum =
+      atOffset(albums, index, -1);
+
+    query.nextAlbum =
+      atOffset(albums, index, +1);
+
+    return query;
+  },
+
+  relations: (relation, query, _album, series) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    groupLink:
+      relation('linkGroup', series.group),
+
+    description:
+      relation('transformContent', series.description),
+
+    previousAlbumLink:
+      (query.previousAlbum
+        ? relation('linkAlbum', query.previousAlbum)
+        : null),
+
+    nextAlbumLink:
+      (query.nextAlbum
+        ? relation('linkAlbum', query.nextAlbum)
+        : null),
+  }),
+
+  data: (_query, _album, series) => ({
+    name: series.name,
+  }),
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'track',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('albumSidebar.groupBox', boxCapsule =>
+      relations.box.slots({
+        attributes: {class: 'individual-series-sidebar-box'},
+        content: [
+          html.tag('h1',
+            language.$(boxCapsule, 'title', {
+              group:
+                relations.groupLink.slots({
+                  attributes: {class: 'series'},
+                  content: language.sanitize(data.name),
+                }),
+            })),
+
+          slots.mode === 'album' &&
+            relations.description
+              ?.slot('mode', 'multiline'),
+
+          slots.mode === 'album' &&
+            html.tag('p', {class: 'series-chronology-link'},
+              {[html.onlyIfContent]: true},
+
+              language.$(boxCapsule, 'next', {
+                [language.onlyIfOptions]: ['album'],
+
+                album: relations.nextAlbumLink,
+              })),
+
+          slots.mode === 'album' &&
+            html.tag('p', {class: 'series-chronology-link'},
+              {[html.onlyIfContent]: true},
+
+              language.$(boxCapsule, 'previous', {
+                [language.onlyIfOptions]: ['album'],
+
+                album: relations.previousAlbumLink,
+              })),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateAlbumSidebarTrackListBox.js b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
new file mode 100644
index 00000000..3a244e3a
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
@@ -0,0 +1,31 @@
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarTrackSection',
+    'generatePageSidebarBox',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album, track) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackSections:
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection', album, track, trackSection)),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.box.slots({
+      attributes: {class: 'track-list-sidebar-box'},
+
+      content: [
+        html.tag('h1', relations.albumLink),
+        relations.trackSections,
+      ],
+    })
+};
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
index aa5c723d..dae5fa03 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackSection.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -1,3 +1,5 @@
+import {empty, stitchArrays} from '#sugar';
+
 export default {
   contentDependencies: ['linkTrack'],
   extraDependencies: ['getColors', 'html', 'language'],
@@ -15,23 +17,25 @@ export default {
   data(album, track, trackSection) {
     const data = {};
 
-    data.hasTrackNumbers = album.hasTrackNumbers;
+    data.hasTrackNumbers =
+      album.hasTrackNumbers &&
+      !empty(trackSection.tracks);
+
     data.isTrackPage = !!track;
 
     data.name = trackSection.name;
     data.color = trackSection.color;
     data.isDefaultTrackSection = trackSection.isDefaultTrackSection;
 
-    data.firstTrackNumber = trackSection.startIndex + 1;
-    data.lastTrackNumber = trackSection.startIndex + trackSection.tracks.length;
+    data.firstTrackNumber =
+      (data.hasTrackNumbers
+        ? trackSection.tracks.at(0).trackNumber
+        : null);
 
-    if (track) {
-      const index = trackSection.tracks.indexOf(track);
-      if (index !== -1) {
-        data.includesCurrentTrack = true;
-        data.currentTrackIndex = index;
-      }
-    }
+    data.lastTrackNumber =
+      (data.hasTrackNumbers
+        ? trackSection.tracks.at(-1).trackNumber
+        : null);
 
     data.trackDirectories =
       trackSection.tracks
@@ -39,7 +43,14 @@ export default {
 
     data.tracksAreMissingCommentary =
       trackSection.tracks
-        .map(track => !track.commentary);
+        .map(track => empty(track.commentary));
+
+    data.tracksAreCurrentTrack =
+      trackSection.tracks
+        .map(traaaaaaaack => traaaaaaaack === track);
+
+    data.includesCurrentTrack =
+      data.tracksAreCurrentTrack.includes(true);
 
     return data;
   },
@@ -55,10 +66,12 @@ export default {
   },
 
   generate(data, relations, slots, {getColors, html, language}) {
+    const capsule = language.encapsulate('albumSidebar.trackList');
+
     const sectionName =
-      html.tag('span', {class: 'group-name'},
+      html.tag('b',
         (data.isDefaultTrackSection
-          ? language.$('albumSidebar.trackList.fallbackSectionName')
+          ? language.$(capsule, 'fallbackSectionName')
           : data.name));
 
     let colorStyle;
@@ -68,29 +81,39 @@ export default {
     }
 
     const trackListItems =
-      relations.trackLinks.map((trackLink, index) =>
-        html.tag('li',
-          data.includesCurrentTrack &&
-          index === data.currentTrackIndex &&
-            {class: 'current'},
-
-          slots.mode === 'commentary' &&
-          data.tracksAreMissingCommentary[index] &&
-            {class: 'no-commentary'},
-
-          language.$('albumSidebar.trackList.item', {
-            track:
-              (slots.mode === 'commentary' && data.tracksAreMissingCommentary[index]
-                ? trackLink.slots({
-                    linkless: true,
-                  })
-             : slots.anchor
-                ? trackLink.slots({
-                    anchor: true,
-                    hash: data.trackDirectories[index],
-                  })
-                : trackLink),
-          })));
+      stitchArrays({
+        trackLink: relations.trackLinks,
+        directory: data.trackDirectories,
+        isCurrentTrack: data.tracksAreCurrentTrack,
+        missingCommentary: data.tracksAreMissingCommentary,
+      }).map(({
+          trackLink,
+          directory,
+          isCurrentTrack,
+          missingCommentary,
+        }) =>
+          html.tag('li',
+            data.includesCurrentTrack &&
+            isCurrentTrack &&
+              {class: 'current'},
+
+            slots.mode === 'commentary' &&
+            missingCommentary &&
+              {class: 'no-commentary'},
+
+            language.$(capsule, 'item', {
+              track:
+                (slots.mode === 'commentary' && missingCommentary
+                  ? trackLink.slots({
+                      linkless: true,
+                    })
+               : slots.anchor
+                  ? trackLink.slots({
+                      anchor: true,
+                      hash: directory,
+                    })
+                  : trackLink),
+            })));
 
     return html.tag('details',
       data.includesCurrentTrack &&
@@ -117,14 +140,22 @@ export default {
           colorStyle,
 
           html.tag('span',
-            (data.hasTrackNumbers
-              ? language.$('albumSidebar.trackList.group.withRange', {
-                  group: sectionName,
-                  range: `${data.firstTrackNumber}–${data.lastTrackNumber}`
-                })
-              : language.$('albumSidebar.trackList.group', {
-                  group: sectionName,
-                })))),
+            language.encapsulate(capsule, 'group', groupCapsule =>
+              language.encapsulate(groupCapsule, workingCapsule => {
+                const workingOptions = {group: sectionName};
+
+                if (data.hasTrackNumbers) {
+                  workingCapsule += '.withRange';
+                  workingOptions.rangePart =
+                    html.tag('span', {class: 'track-section-range'},
+                      language.$(groupCapsule, 'withRange.rangePart', {
+                        range:
+                          `${data.firstTrackNumber}–${data.lastTrackNumber}`,
+                      }));
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })))),
 
         (data.hasTrackNumbers
           ? html.tag('ol',
diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js
index c8b123fe..e28a3fd0 100644
--- a/src/content/dependencies/generateAlbumSocialEmbed.js
+++ b/src/content/dependencies/generateAlbumSocialEmbed.js
@@ -6,7 +6,7 @@ export default {
     'generateAlbumSocialEmbedDescription',
   ],
 
-  extraDependencies: ['absoluteTo', 'language', 'urls'],
+  extraDependencies: ['absoluteTo', 'language'],
 
   relations(relation, album) {
     return {
@@ -25,15 +25,14 @@ export default {
 
     if (data.hasHeading) {
       const firstGroup = album.groups[0];
-      data.headingGroupName = firstGroup.directory;
+      data.headingGroupName = firstGroup.name;
       data.headingGroupDirectory = firstGroup.directory;
     }
 
     data.hasImage = album.hasCoverArt;
 
     if (data.hasImage) {
-      data.coverArtDirectory = album.directory;
-      data.coverArtFileExtension = album.coverArtFileExtension;
+      data.imagePath = album.coverArtworks[0].path;
     }
 
     data.albumName = album.name;
@@ -41,34 +40,31 @@ export default {
     return data;
   },
 
-  generate(data, relations, {absoluteTo, language, urls}) {
-    return relations.socialEmbed.slots({
-      title:
-        language.$('albumPage.socialEmbed.title', {
-          album: data.albumName,
-        }),
-
-      description: relations.description,
-
-      headingContent:
-        (data.hasHeading
-          ? language.$('albumPage.socialEmbed.heading', {
-              group: data.headingGroupName,
-            })
-          : null),
-
-      headingLink:
-        (data.hasHeading
-          ? absoluteTo('localized.groupGallery', data.headingGroupDirectory)
-          : null),
-
-      imagePath:
-        (data.hasImage
-          ? '/' +
-            urls
-              .from('shared.root')
-              .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension)
-          : null),
-    });
-  },
+  generate: (data, relations, {absoluteTo, language}) =>
+    language.encapsulate('albumPage.socialEmbed', embedCapsule =>
+      relations.socialEmbed.slots({
+        title:
+          language.$(embedCapsule, 'title', {
+            album: data.albumName,
+          }),
+
+        description: relations.description,
+
+        headingContent:
+          (data.hasHeading
+            ? language.$(embedCapsule, 'heading', {
+                group: data.headingGroupName,
+              })
+            : null),
+
+        headingLink:
+          (data.hasHeading
+            ? absoluteTo('localized.groupGallery', data.headingGroupDirectory)
+            : null),
+
+        imagePath:
+          (data.hasImage
+            ? data.imagePath
+            : null),
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumSocialEmbedDescription.js b/src/content/dependencies/generateAlbumSocialEmbedDescription.js
index 7099616a..69c39c3a 100644
--- a/src/content/dependencies/generateAlbumSocialEmbedDescription.js
+++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js
@@ -3,46 +3,39 @@ import {accumulateSum} from '#sugar';
 export default {
   extraDependencies: ['language'],
 
-  data(album) {
-    const data = {};
-
-    const duration = accumulateSum(album.tracks, track => track.duration);
-
-    data.hasDuration = duration > 0;
-    data.hasTracks = album.tracks.length > 0;
-    data.hasDate = !!album.date;
-    data.hasAny = (data.hasDuration || data.hasTracks || data.hasDuration);
-
-    if (!data.hasAny)
-      return data;
-
-    if (data.hasDuration)
-      data.duration = duration;
-
-    if (data.hasTracks)
-      data.tracks = album.tracks.length;
-
-    if (data.hasDate)
-      data.date = album.date;
-
-    return data;
-  },
-
-  generate(data, {language}) {
-    return language.formatString(
-      'albumPage.socialEmbed.body' + [
-        data.hasDuration && '.withDuration',
-        data.hasTracks && '.withTracks',
-        data.hasDate && '.withReleaseDate',
-      ].filter(Boolean).join(''),
-
-      Object.fromEntries([
-        data.hasDuration &&
-          ['duration', language.formatDuration(data.duration)],
-        data.hasTracks &&
-          ['tracks', language.countTracks(data.tracks, {unit: true})],
-        data.hasDate &&
-          ['date', language.formatDate(data.date)],
-      ].filter(Boolean)));
-  },
+  data: (album) => ({
+    duration:
+      accumulateSum(album.tracks, track => track.duration),
+
+    tracks:
+      album.tracks.length,
+
+    date:
+      album.date,
+  }),
+
+  generate: (data, {language}) =>
+    language.encapsulate('albumPage.socialEmbed.body', workingCapsule => {
+      const workingOptions = {};
+
+      if (data.duration > 0) {
+        workingCapsule += '.withDuration';
+        workingOptions.duration =
+          language.formatDuration(data.duration);
+      }
+
+      if (data.tracks > 0) {
+        workingCapsule += '.withTracks';
+        workingOptions.tracks =
+          language.countTracks(data.tracks, {unit: true});
+      }
+
+      if (data.date) {
+        workingCapsule += '.withReleaseDate';
+        workingOptions.date =
+          language.formatDate(data.date);
+      }
+
+      return language.$(workingCapsule, workingOptions);
+    }),
 };
diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js
deleted file mode 100644
index c5acf374..00000000
--- a/src/content/dependencies/generateAlbumStyleRules.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import {empty} from '#sugar';
-
-export default {
-  extraDependencies: ['to'],
-
-  data(album, track) {
-    const data = {};
-
-    data.hasWallpaper = !empty(album.wallpaperArtistContribs);
-    data.hasBanner = !empty(album.bannerArtistContribs);
-
-    if (data.hasWallpaper) {
-      data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension];
-      data.wallpaperStyle = album.wallpaperStyle;
-    }
-
-    if (data.hasBanner) {
-      data.hasBannerStyle = !!album.bannerStyle;
-      data.bannerStyle = album.bannerStyle;
-    }
-
-    data.albumDirectory = album.directory;
-
-    if (track) {
-      data.trackDirectory = track.directory;
-    }
-
-    return data;
-  },
-
-  generate(data, {to}) {
-    const indent = parts =>
-      (parts ?? [])
-        .filter(Boolean)
-        .join('\n')
-        .split('\n')
-        .map(line => ' '.repeat(4) + line)
-        .join('\n');
-
-    const rule = (selector, parts) =>
-      (!empty(parts.filter(Boolean))
-        ? [`${selector} {`, indent(parts), `}`]
-        : []);
-
-    const wallpaperRule =
-      data.hasWallpaper &&
-        rule(`body::before`, [
-          `background-image: url("${to(...data.wallpaperPath)}");`,
-          data.wallpaperStyle,
-        ]);
-
-    const bannerRule =
-      data.hasBanner &&
-        rule(`#banner img`, [
-          data.bannerStyle,
-        ]);
-
-    const dataRule =
-      rule(`:root`, [
-        data.albumDirectory &&
-          `--album-directory: ${data.albumDirectory};`,
-        data.trackDirectory &&
-          `--track-directory: ${data.trackDirectory};`,
-      ]);
-
-    return (
-      [wallpaperRule, bannerRule, dataRule]
-        .filter(Boolean)
-        .flat()
-        .join('\n'));
-  },
-};
diff --git a/src/content/dependencies/generateAlbumStyleTags.js b/src/content/dependencies/generateAlbumStyleTags.js
new file mode 100644
index 00000000..4cdc6581
--- /dev/null
+++ b/src/content/dependencies/generateAlbumStyleTags.js
@@ -0,0 +1,65 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['generateAlbumWallpaperStyleTag', 'generateStyleTag'],
+  extraDependencies: ['html'],
+
+  relations: (relation, album, _track) => ({
+    styleTag:
+      relation('generateStyleTag'),
+
+    wallpaperStyleTag:
+      relation('generateAlbumWallpaperStyleTag', album),
+  }),
+
+  data(album, track) {
+    const data = {};
+
+    data.hasBanner = !empty(album.bannerArtistContribs);
+
+    if (data.hasBanner) {
+      data.hasBannerStyle = !!album.bannerStyle;
+      data.bannerStyle = album.bannerStyle;
+    }
+
+    data.albumDirectory = album.directory;
+
+    if (track) {
+      data.trackDirectory = track.directory;
+    }
+
+    return data;
+  },
+
+  generate: (data, relations, {html}) =>
+    html.tags([
+      relations.wallpaperStyleTag,
+
+      relations.styleTag.clone().slots({
+        attributes: {class: 'album-banner-style'},
+
+        rules: [
+          data.hasBanner && {
+            select: '#banner img',
+            declare: [data.bannerStyle],
+          },
+        ],
+      }),
+
+      relations.styleTag.clone().slots({
+        attributes: {class: 'album-directory-style'},
+
+        rules: [
+          {
+            select: ':root',
+            declare: [
+              data.albumDirectory &&
+                `--album-directory: ${data.albumDirectory};`,
+              data.trackDirectory &&
+                `--track-directory: ${data.trackDirectory};`,
+            ],
+          },
+        ]
+      }),
+    ], {[html.joinChildren]: ''}),
+};
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
index ee06b9e6..0a949ded 100644
--- a/src/content/dependencies/generateAlbumTrackList.js
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -35,7 +35,12 @@ function getDisplayMode(album) {
 }
 
 export default {
-  contentDependencies: ['generateAlbumTrackListItem', 'generateContentHeading'],
+  contentDependencies: [
+    'generateAlbumTrackListItem',
+    'generateContentHeading',
+    'transformContent',
+  ],
+
   extraDependencies: ['html', 'language'],
 
   query(album) {
@@ -53,6 +58,10 @@ export default {
           album.trackSections.map(() =>
             relation('generateContentHeading'));
 
+        relations.trackSectionDescriptions =
+          album.trackSections.map(section =>
+            relation('transformContent', section.description));
+
         relations.trackSectionItems =
           album.trackSections.map(section =>
             section.tracks.map(track =>
@@ -93,11 +102,11 @@ export default {
             .map(section => section.tracks.length > 1);
 
         if (album.hasTrackNumbers) {
-          data.trackSectionStartIndices =
+          data.trackSectionsStartCountingFrom =
             album.trackSections
-              .map(section => section.startIndex);
+              .map(section => section.startCountingFrom);
         } else {
-          data.trackSectionStartIndices =
+          data.trackSectionsStartCountingFrom =
             album.trackSections
               .map(() => null);
         }
@@ -132,43 +141,59 @@ export default {
         return html.tag('dl', {class: 'album-group-list'},
           stitchArrays({
             heading: relations.trackSectionHeadings,
+            description: relations.trackSectionDescriptions,
             items: relations.trackSectionItems,
 
             name: data.trackSectionNames,
             duration: data.trackSectionDurations,
             durationApproximate: data.trackSectionDurationsApproximate,
-            startIndex: data.trackSectionStartIndices,
+            startCountingFrom: data.trackSectionsStartCountingFrom,
           }).map(({
               heading,
+              description,
               items,
 
               name,
               duration,
               durationApproximate,
-              startIndex,
+              startCountingFrom,
             }) => [
-              heading.slots({
-                tag: 'dt',
-                title:
-                  (duration === 0
-                    ? language.$('trackList.section', {
-                        section: name,
-                      })
-                    : language.$('trackList.section.withDuration', {
-                        section: name,
-                        duration:
+              language.encapsulate('trackList.section', capsule =>
+                heading.slots({
+                  tag: 'dt',
+
+                  title:
+                    language.encapsulate(capsule, capsule => {
+                      const options = {section: name};
+
+                      if (duration !== 0) {
+                        capsule += '.withDuration';
+                        options.duration =
                           language.formatDuration(duration, {
                             approximate: durationApproximate,
-                          }),
-                      })),
-              }),
+                          });
+                      }
+
+                      return language.$(capsule, options);
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky', {
+                      section: name,
+                    }),
+                })),
+
+              html.tag('dd', [
+                html.tag('blockquote',
+                  {[html.onlyIfContent]: true},
+                  description),
 
-              html.tag('dd',
                 html.tag(listTag,
                   data.hasTrackNumbers &&
-                    {start: startIndex + 1},
+                    {start: startCountingFrom},
 
-                  slotItems(items))),
+                  slotItems(items)),
+              ]),
             ]));
 
       case 'tracks':
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
index 11b6a1b0..44297c15 100644
--- a/src/content/dependencies/generateAlbumTrackListItem.js
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -1,75 +1,36 @@
-import {compareArrays, empty} from '#sugar';
-
 export default {
-  contentDependencies: [
-    'generateAlbumTrackListMissingDuration',
-    'linkContribution',
-    'linkTrack',
-  ],
-
-  extraDependencies: ['getColors', 'html', 'language'],
-
-  query(track, album) {
-    const query = {};
+  contentDependencies: ['generateTrackListItem'],
+  extraDependencies: ['html'],
 
-    query.duration = track.duration ?? 0;
+  query: (track, album) => ({
+    trackHasDuration:
+      !!track.duration,
 
-    query.trackHasDuration = !!track.duration;
-
-    query.sectionHasDuration =
+    sectionHasDuration:
       !album.trackSections
         .some(section =>
           section.tracks.every(track => !track.duration) &&
-          section.tracks.includes(track));
-
-    query.albumHasDuration =
-      album.tracks.some(track => track.duration);
-
-    return query;
-  },
-
-  relations(relation, query, track) {
-    const relations = {};
-
-    if (!empty(track.artistContribs)) {
-      relations.contributionLinks =
-        track.artistContribs
-          .map(contrib => relation('linkContribution', contrib));
-    }
+          section.tracks.includes(track)),
 
-    relations.trackLink =
-      relation('linkTrack', track);
-
-    if (!query.trackHasDuration) {
-      relations.missingDuration =
-        relation('generateAlbumTrackListMissingDuration');
-    }
-
-    return relations;
-  },
+    albumHasDuration:
+      album.tracks.some(track => track.duration),
+  }),
 
-  data(query, track, album) {
-    const data = {};
+  relations: (relation, query, track) => ({
+    item:
+      relation('generateTrackListItem',
+        track,
+        track.album.artistContribs),
+  }),
 
-    data.duration = query.duration;
-    data.trackHasDuration = query.trackHasDuration;
-    data.sectionHasDuration = query.sectionHasDuration;
-    data.albumHasDuration = query.albumHasDuration;
+  data: (query, track, album) => ({
+    trackHasDuration: query.trackHasDuration,
+    sectionHasDuration: query.sectionHasDuration,
+    albumHasDuration: query.albumHasDuration,
 
-    if (track.color !== album.color) {
-      data.color = track.color;
-    }
-
-    data.showArtists =
-      !empty(track.artistContribs) &&
-       (empty(album.artistContribs) ||
-        !compareArrays(
-          track.artistContribs.map(c => c.who),
-          album.artistContribs.map(c => c.who),
-          {checkOrder: false}));
-
-    return data;
-  },
+    colorize:
+      track.color !== album.color,
+  }),
 
   slots: {
     collapseDurationScope: {
@@ -80,52 +41,22 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {getColors, html, language}) {
-    let colorStyle;
-    if (data.color) {
-      const {primary} = getColors(data.color);
-      colorStyle = {style: `--primary-color: ${primary}`};
-    }
-
-    const parts = ['trackList.item'];
-    const options = {};
-
-    options.track =
-      relations.trackLink
-        .slot('color', false);
-
-    const collapseDuration =
-      (slots.collapseDurationScope === 'track'
-        ? !data.trackHasDuration
-     : slots.collapseDurationScope === 'section'
-        ? !data.sectionHasDuration
-     : slots.collapseDurationScope === 'album'
-        ? !data.albumHasDuration
-        : false);
-
-    if (!collapseDuration) {
-      parts.push('withDuration');
-
-      options.duration =
-        (data.trackHasDuration
-          ? language.$('trackList.item.withDuration.duration', {
-              duration:
-                language.formatDuration(data.duration),
-            })
-          : relations.missingDuration);
-    }
-
-    if (data.showArtists) {
-      parts.push('withArtists');
-      options.by =
-        html.tag('span', {class: 'by'},
-          language.$('trackList.item.withArtists.by', {
-            artists: language.formatConjunctionList(relations.contributionLinks),
-          }));
-    }
-
-    return html.tag('li',
-      colorStyle,
-      language.formatString(...parts, options));
-  },
+  generate: (data, relations, slots) =>
+    relations.item.slots({
+      showArtists: true,
+
+      showDuration:
+        (slots.collapseDurationScope === 'track'
+          ? data.trackHasDuration
+       : slots.collapseDurationScope === 'section'
+          ? data.sectionHasDuration
+       : slots.collapseDurationScope === 'album'
+          ? data.albumHasDuration
+          : true),
+
+      colorMode:
+        (data.colorize
+          ? 'line'
+          : 'none'),
+    }),
 };
diff --git a/src/content/dependencies/generateAlbumTrackListMissingDuration.js b/src/content/dependencies/generateAlbumTrackListMissingDuration.js
deleted file mode 100644
index 6d4a6ec8..00000000
--- a/src/content/dependencies/generateAlbumTrackListMissingDuration.js
+++ /dev/null
@@ -1,33 +0,0 @@
-export default {
-  contentDependencies: ['generateTextWithTooltip', 'generateTooltip'],
-  extraDependencies: ['html', 'language'],
-
-  relations: (relation) => ({
-    textWithTooltip:
-      relation('generateTextWithTooltip'),
-
-    tooltip:
-      relation('generateTooltip'),
-  }),
-
-  generate: (relations, {html, language}) =>
-    relations.textWithTooltip.slots({
-      attributes: {class: 'missing-duration'},
-      customInteractionCue: true,
-
-      text:
-        language.$('trackList.item.withDuration.duration', {
-          duration:
-            html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
-              language.$('trackList.item.withDuration.duration.missing')),
-        }),
-
-      tooltip:
-        relations.tooltip.slots({
-          attributes: {class: 'missing-duration-tooltip'},
-
-          content:
-            language.$('trackList.item.withDuration.duration.missing.info'),
-        }),
-    }),
-};
diff --git a/src/content/dependencies/generateAlbumWallpaperStyleTag.js b/src/content/dependencies/generateAlbumWallpaperStyleTag.js
new file mode 100644
index 00000000..47864a1d
--- /dev/null
+++ b/src/content/dependencies/generateAlbumWallpaperStyleTag.js
@@ -0,0 +1,38 @@
+export default {
+  contentDependencies: ['generateWallpaperStyleTag'],
+  extraDependencies: ['html'],
+
+  relations: (relation, album) => ({
+    wallpaperStyleTag:
+      (album.hasWallpaperArt
+        ? relation('generateWallpaperStyleTag')
+        : null),
+  }),
+
+  data: (album) => ({
+    singleWallpaperPath:
+      ['media.albumWallpaper', album.directory, album.wallpaperFileExtension],
+
+    singleWallpaperStyle:
+      album.wallpaperStyle,
+
+    wallpaperPartPaths:
+      album.wallpaperParts.map(part =>
+        (part.asset
+          ? ['media.albumWallpaperPart', album.directory, part.asset]
+          : null)),
+
+    wallpaperPartStyles:
+      album.wallpaperParts.map(part => part.style),
+  }),
+
+  generate: (data, relations, {html}) =>
+    (relations.wallpaperStyleTag
+      ? relations.wallpaperStyleTag.slots({
+          singleWallpaperPath: data.singleWallpaperPath,
+          singleWallpaperStyle: data.singleWallpaperStyle,
+          wallpaperPartPaths: data.wallpaperPartPaths,
+          wallpaperPartStyles: data.wallpaperPartStyles,
+        })
+      : html.blank()),
+};
diff --git a/src/content/dependencies/generateArtTagAncestorDescendantMapList.js b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
new file mode 100644
index 00000000..80d19b5a
--- /dev/null
+++ b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
@@ -0,0 +1,153 @@
+import {
+  filterMultipleArrays,
+  sortMultipleArrays,
+  stitchArrays,
+  unique,
+} from '#sugar';
+
+export default {
+  contentDependencies: ['linkArtTagDynamically'],
+  extraDependencies: ['html', 'language'],
+
+  // Recursion ain't too pretty!
+
+  query(ancestorArtTag, targetArtTag) {
+    const recursive = artTag => {
+      const artTags =
+        artTag.directDescendantArtTags.slice();
+
+      const displayBriefly =
+        !artTags.includes(targetArtTag) &&
+        artTags.length > 3;
+
+      const artTagsIncludeTargetArtTag =
+        artTags.map(artTag => artTag.allDescendantArtTags.includes(targetArtTag));
+
+      const numExemptArtTags =
+        (displayBriefly
+          ? artTagsIncludeTargetArtTag
+              .filter(includesTargetArtTag => !includesTargetArtTag)
+              .length
+          : null);
+
+      const artTagsTimesFeaturedTotal =
+        artTags.map(artTag =>
+          unique([
+            ...artTag.directlyFeaturedInArtworks,
+            ...artTag.indirectlyFeaturedInArtworks,
+          ]).length);
+
+      const sublists =
+        stitchArrays({
+          artTag: artTags,
+          includesTargetArtTag: artTagsIncludeTargetArtTag,
+        }).map(({artTag, includesTargetArtTag}) =>
+            (includesTargetArtTag
+              ? recursive(artTag)
+              : null));
+
+      if (displayBriefly) {
+        filterMultipleArrays(artTags, sublists, artTagsTimesFeaturedTotal,
+          (artTag, sublist) =>
+            artTag === targetArtTag ||
+            sublist !== null);
+      } else {
+        sortMultipleArrays(artTags, sublists, artTagsTimesFeaturedTotal,
+          (artTagA, artTagB, sublistA, sublistB) =>
+            (sublistA && sublistB
+              ? 0
+           : !sublistA && !sublistB
+              ? 0
+           : sublistA
+              ? 1
+              : -1));
+      }
+
+      return {
+        displayBriefly,
+        numExemptArtTags,
+        artTags,
+        artTagsTimesFeaturedTotal,
+        sublists,
+      };
+    };
+
+    return {root: recursive(ancestorArtTag)};
+  },
+
+  relations(relation, query, _ancestorArtTag, _targetArtTag) {
+    const recursive = ({artTags, sublists}) => ({
+      artTagLinks:
+        artTags
+          .map(artTag => relation('linkArtTagDynamically', artTag)),
+
+      sublists:
+        sublists
+          .map(sublist => (sublist ? recursive(sublist) : null)),
+    });
+
+    return {root: recursive(query.root)};
+  },
+
+  data(query, _ancestorArtTag, targetArtTag) {
+    const recursive = ({
+      displayBriefly,
+      numExemptArtTags,
+      artTags,
+      artTagsTimesFeaturedTotal,
+      sublists,
+    }) => ({
+      displayBriefly,
+      numExemptArtTags,
+      artTagsTimesFeaturedTotal,
+
+      artTagsAreTargetTag:
+        artTags
+          .map(artTag => artTag === targetArtTag),
+
+      sublists:
+        sublists
+          .map(sublist => (sublist ? recursive(sublist) : null)),
+    });
+
+    return {root: recursive(query.root)};
+  },
+
+  generate(data, relations, {html, language}) {
+    const recursive = (dataNode, relationsNode) =>
+      html.tag('dl', {class: dataNode === data.root && 'tree-list'}, [
+        dataNode.displayBriefly &&
+          html.tag('dt',
+            language.$('artTagPage.sidebar.otherTagsExempt', {
+              tags:
+                language.countArtTags(dataNode.numExemptArtTags, {unit: true}),
+            })),
+
+        stitchArrays({
+          isTargetTag: dataNode.artTagsAreTargetTag,
+          timesFeaturedTotal: dataNode.artTagsTimesFeaturedTotal,
+          dataSublist: dataNode.sublists,
+
+          artTagLink: relationsNode.artTagLinks,
+          relationsSublist: relationsNode.sublists,
+        }).map(({
+            isTargetTag, timesFeaturedTotal, dataSublist,
+            artTagLink, relationsSublist,
+          }) => [
+            html.tag('dt',
+              {class: (dataSublist || isTargetTag) && 'current'},
+              [
+                artTagLink,
+                html.tag('span', {class: 'times-used'},
+                  language.countTimesFeatured(timesFeaturedTotal)),
+              ]),
+
+            dataSublist &&
+              html.tag('dd',
+                recursive(dataSublist, relationsSublist)),
+          ]),
+      ]);
+
+    return recursive(data.root, relations.root);
+  },
+};
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
index 962f1b7f..cfd6d03e 100644
--- a/src/content/dependencies/generateArtTagGalleryPage.js
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -1,14 +1,19 @@
-import {sortAlbumsTracksChronologically} from '#sort';
-import {stitchArrays} from '#sugar';
+import {sortArtworksChronologically} from '#sort';
+import {empty, stitchArrays, unique} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateArtTagGalleryPageFeaturedLine',
+    'generateArtTagGalleryPageShowingLine',
+    'generateArtTagNavLinks',
     'generateCoverGrid',
     'generatePageLayout',
+    'generateQuickDescription',
     'image',
-    'linkAlbum',
-    'linkArtTag',
-    'linkTrack',
+    'linkAnythingMan',
+    'linkArtTagGallery',
+    'linkExternal',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
@@ -19,128 +24,215 @@ export default {
     };
   },
 
-  query(sprawl, tag) {
-    const things = tag.taggedInThings.slice();
+  query(sprawl, artTag) {
+    const directArtworks = artTag.directlyFeaturedInArtworks;
+    const indirectArtworks = artTag.indirectlyFeaturedInArtworks;
+    const allArtworks = unique([...directArtworks, ...indirectArtworks]);
 
-    sortAlbumsTracksChronologically(things, {
-      getDate: thing => thing.coverArtDate ?? thing.date,
-      latestFirst: true,
-    });
+    sortArtworksChronologically(allArtworks, {latestFirst: true});
 
-    return {things};
+    return {directArtworks, indirectArtworks, allArtworks};
   },
 
-  relations(relation, query, sprawl, tag) {
+  relations(relation, query, sprawl, artTag) {
     const relations = {};
 
     relations.layout =
       relation('generatePageLayout');
 
-    relations.artTagMainLink =
-      relation('linkArtTag', tag);
+    relations.navLinks =
+      relation('generateArtTagNavLinks', artTag);
+
+    relations.additionalNamesBox =
+      relation('generateAdditionalNamesBox', artTag.additionalNames);
+
+    relations.quickDescription =
+      relation('generateQuickDescription', artTag);
+
+    relations.featuredLine =
+      relation('generateArtTagGalleryPageFeaturedLine');
+
+    relations.showingLine =
+      relation('generateArtTagGalleryPageShowingLine');
+
+    if (!empty(artTag.extraReadingURLs)) {
+      relations.extraReadingLinks =
+        artTag.extraReadingURLs
+          .map(url => relation('linkExternal', url));
+    }
+
+    if (!empty(artTag.directAncestorArtTags)) {
+      relations.ancestorLinks =
+        artTag.directAncestorArtTags
+          .map(artTag => relation('linkArtTagGallery', artTag));
+    }
+
+    if (!empty(artTag.directDescendantArtTags)) {
+      relations.descendantLinks =
+        artTag.directDescendantArtTags
+          .map(artTag => relation('linkArtTagGallery', artTag));
+    }
 
     relations.coverGrid =
       relation('generateCoverGrid');
 
     relations.links =
-      query.things.map(thing =>
-        (thing.album
-          ? relation('linkTrack', thing)
-          : relation('linkAlbum', thing)));
+      query.allArtworks
+        .map(artwork => relation('linkAnythingMan', artwork.thing));
 
     relations.images =
-      query.things.map(thing =>
-        relation('image', thing.artTags));
+      query.allArtworks
+        .map(artwork => relation('image', artwork));
 
     return relations;
   },
 
-  data(query, sprawl, tag) {
+  data(query, sprawl, artTag) {
     const data = {};
 
     data.enableListings = sprawl.enableListings;
 
-    data.name = tag.name;
-    data.color = tag.color;
+    data.name = artTag.name;
+    data.color = artTag.color;
 
-    data.numArtworks = query.things.length;
+    data.numArtworksIndirectly = query.indirectArtworks.length;
+    data.numArtworksDirectly = query.directArtworks.length;
+    data.numArtworksTotal = query.allArtworks.length;
 
     data.names =
-      query.things.map(thing => thing.name);
+      query.allArtworks
+        .map(artwork => artwork.thing.name);
 
-    data.paths =
-      query.things.map(thing =>
-        (thing.album
-          ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
-          : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
+    data.artworkArtists =
+      query.allArtworks
+        .map(artwork => artwork.artistContribs
+          .map(contrib => contrib.artist.name));
 
-    data.coverArtists =
-      query.things.map(thing =>
-        thing.coverArtistContribs
-          .map(({who: artist}) => artist.name));
+    data.artworkLabels =
+      query.allArtworks
+        .map(artwork => artwork.label)
+
+    data.onlyFeaturedIndirectly =
+      query.allArtworks.map(artwork =>
+        !query.directArtworks.includes(artwork));
+
+    data.hasMixedDirectIndirect =
+      data.onlyFeaturedIndirectly.includes(true) &&
+      data.onlyFeaturedIndirectly.includes(false);
 
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artTagGalleryPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('tagPage.title', {
+          language.$(pageCapsule, 'title', {
             tag: data.name,
           }),
 
         headingMode: 'static',
-
         color: data.color,
 
+        additionalNames: relations.additionalNamesBox,
+
         mainClasses: ['top-index'],
         mainContent: [
-          html.tag('p', {class: 'quick-info'},
-            language.$('tagPage.infoLine', {
-              coverArts: language.countCoverArts(data.numArtworks, {
-                unit: true,
+          relations.quickDescription.slots({
+            extraReadingLinks: relations.extraReadingLinks ?? null,
+          }),
+
+          data.numArtworksTotal === 0 &&
+            html.tag('p', {class: 'quick-info'},
+              language.encapsulate(pageCapsule, 'featuredLine.notFeatured', capsule => [
+                language.$(capsule),
+                html.tag('br'),
+                language.$(capsule, 'callToAction'),
+              ])),
+
+          data.numArtworksTotal >= 1 &&
+            relations.featuredLine.clone()
+              .slots({
+                showing: 'all',
+                count: data.numArtworksTotal,
+              }),
+
+          data.hasMixedDirectIndirect && [
+            relations.featuredLine.clone()
+              .slots({
+                showing: 'direct',
+                count: data.numArtworksDirectly,
+              }),
+
+            relations.featuredLine.clone()
+              .slots({
+                showing: 'indirect',
+                count: data.numArtworksIndirectly,
               }),
-            })),
+          ],
+
+          relations.ancestorLinks &&
+            html.tag('p', {id: 'descends-from-line'},
+              {class: 'quick-info'},
+              language.$(pageCapsule, 'descendsFrom', {
+                tags: language.formatUnitList(relations.ancestorLinks),
+              })),
+
+          relations.descendantLinks &&
+            html.tag('p', {id: 'descendants-line'},
+              {class: 'quick-info'},
+              language.$(pageCapsule, 'descendants', {
+                tags: language.formatUnitList(relations.descendantLinks),
+              })),
+
+          data.hasMixedDirectIndirect && [
+            relations.showingLine.clone()
+              .slot('showing', 'all'),
+
+            relations.showingLine.clone()
+              .slot('showing', 'direct'),
+
+            relations.showingLine.clone()
+              .slot('showing', 'indirect'),
+          ],
 
           relations.coverGrid
             .slots({
               links: relations.links,
+              images: relations.images,
               names: data.names,
-              images:
-                stitchArrays({
-                  image: relations.images,
-                  path: data.paths,
-                }).map(({image, path}) =>
-                    image.slot('path', path)),
+              lazy: 12,
+
+              classes:
+                data.onlyFeaturedIndirectly.map(onlyFeaturedIndirectly =>
+                  (onlyFeaturedIndirectly ? 'featured-indirectly' : '')),
 
               info:
-                data.coverArtists.map(names =>
-                  (names === null
-                    ? null
-                    : language.$('misc.albumGrid.details.coverArtists', {
-                        artists: language.formatUnitList(names),
-                      }))),
+                stitchArrays({
+                  artists: data.artworkArtists,
+                  label: data.artworkLabels,
+                }).map(({artists, label}) =>
+                    language.encapsulate('misc.coverGrid.details.coverArtists', workingCapsule => {
+                      const workingOptions = {};
+
+                      workingOptions[language.onlyIfOptions] = ['artists'];
+                      workingOptions.artists =
+                        language.formatUnitList(artists);
+
+                      if (label) {
+                        workingCapsule += '.customLabel';
+                        workingOptions.label = label;
+                      }
+
+                      return language.$(workingCapsule, workingOptions);
+                    })),
             }),
         ],
 
         navLinkStyle: 'hierarchical',
-        navLinks: [
-          {auto: 'home'},
-
-          data.enableListings &&
-            {
-              path: ['localized.listingIndex'],
-              title: language.$('listingIndex.title'),
-            },
-
-          {
-            html:
-              language.$('tagPage.nav.tag', {
-                tag: relations.artTagMainLink,
-              }),
-          },
-        ],
-      });
-  },
+        navLinks:
+          html.resolve(
+            relations.navLinks
+              .slot('currentExtra', 'gallery')),
+      })),
 };
diff --git a/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js
new file mode 100644
index 00000000..b4620fa4
--- /dev/null
+++ b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js
@@ -0,0 +1,23 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    showing: {
+      validate: v => v.is('all', 'direct', 'indirect'),
+    },
+
+    count: {type: 'number'},
+  },
+
+  generate: (slots, {html, language}) =>
+    language.encapsulate('artTagGalleryPage', pageCapsule =>
+      html.tag('p', {class: 'quick-info'},
+        {id: `featured-${slots.showing}-line`},
+
+        language.$(pageCapsule, 'featuredLine', slots.showing, {
+          coverArts:
+            language.countArtworks(slots.count, {
+              unit: true,
+            }),
+        }))),
+};
diff --git a/src/content/dependencies/generateArtTagGalleryPageShowingLine.js b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js
new file mode 100644
index 00000000..6df4d0e5
--- /dev/null
+++ b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js
@@ -0,0 +1,22 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    showing: {
+      validate: v => v.is('all', 'direct', 'indirect'),
+    },
+
+    count: {type: 'number'},
+  },
+
+  generate: (slots, {html, language}) =>
+    language.encapsulate('artTagGalleryPage', pageCapsule =>
+      html.tag('p', {class: 'quick-info'},
+        {id: `showing-${slots.showing}-line`},
+
+        language.$(pageCapsule, 'showingLine', {
+          showing:
+            html.tag('a', {href: '#'},
+              language.$(pageCapsule, 'showingLine', slots.showing)),
+        }))),
+};
diff --git a/src/content/dependencies/generateArtTagInfoPage.js b/src/content/dependencies/generateArtTagInfoPage.js
new file mode 100644
index 00000000..9df51b77
--- /dev/null
+++ b/src/content/dependencies/generateArtTagInfoPage.js
@@ -0,0 +1,281 @@
+import {empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateArtTagNavLinks',
+    'generateArtTagSidebar',
+    'generateContentHeading',
+    'generatePageLayout',
+    'linkArtTagGallery',
+    'linkArtTagInfo',
+    'linkExternal',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    enableListings: wikiInfo.enableListings,
+  }),
+
+  query(sprawl, artTag) {
+    const query = {};
+
+    query.directThings =
+      artTag.directlyFeaturedInArtworks;
+
+    query.indirectThings =
+      artTag.indirectlyFeaturedInArtworks;
+
+    query.allThings =
+      unique([...query.directThings, ...query.indirectThings]);
+
+    query.allDescendantsHaveMoreDescendants =
+      artTag.directDescendantArtTags
+        .every(descendant => !empty(descendant.directDescendantArtTags));
+
+    return query;
+  },
+
+  relations: (relation, query, sprawl, artTag) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    navLinks:
+      relation('generateArtTagNavLinks', artTag),
+
+    sidebar:
+      relation('generateArtTagSidebar', artTag),
+
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', artTag.additionalNames),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    description:
+      relation('transformContent', artTag.description),
+
+    galleryLink:
+      (empty(query.allThings)
+        ? null
+        : relation('linkArtTagGallery', artTag)),
+
+    extraReadingLinks:
+      artTag.extraReadingURLs
+        .map(url => relation('linkExternal', url)),
+
+    relatedArtTagLinks:
+      artTag.relatedArtTags
+        .map(({artTag}) => relation('linkArtTagInfo', artTag)),
+
+    directAncestorLinks:
+      artTag.directAncestorArtTags
+        .map(artTag => relation('linkArtTagInfo', artTag)),
+
+    directDescendantInfoLinks:
+      artTag.directDescendantArtTags
+        .map(artTag => relation('linkArtTagInfo', artTag)),
+
+    directDescendantGalleryLinks:
+      artTag.directDescendantArtTags.map(artTag =>
+        (query.allDescendantsHaveMoreDescendants
+          ? null
+          : relation('linkArtTagGallery', artTag))),
+  }),
+
+  data: (query, sprawl, artTag) => ({
+    enableListings:
+      sprawl.enableListings,
+
+    name:
+      artTag.name,
+
+    color:
+      artTag.color,
+
+    numArtworksIndirectly:
+      query.indirectThings.length,
+
+    numArtworksDirectly:
+      query.directThings.length,
+
+    numArtworksTotal:
+      query.allThings.length,
+
+    relatedArtTagAnnotations:
+      artTag.relatedArtTags
+        .map(({annotation}) => annotation),
+
+    directDescendantTimesFeaturedTotal:
+      artTag.directDescendantArtTags.map(artTag =>
+        unique([
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
+        ]).length),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artTagInfoPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            tag: language.sanitize(data.name),
+          }),
+
+        headingMode: 'sticky',
+        color: data.color,
+
+        additionalNames: relations.additionalNamesBox,
+
+        mainContent: [
+          html.tag('p',
+            language.encapsulate(pageCapsule, 'featuredIn', capsule =>
+              (data.numArtworksTotal === 0
+                ? language.$(capsule, 'notFeatured')
+
+             : data.numArtworksDirectly === 0
+                ? language.$(capsule, 'indirectlyOnly', {
+                    artworks:
+                      language.countArtworks(data.numArtworksIndirectly, {unit: true}),
+                  })
+
+             : data.numArtworksIndirectly === 0
+                ? language.$(capsule, 'directlyOnly', {
+                    artworks:
+                      language.countArtworks(data.numArtworksDirectly, {unit: true}),
+                  })
+
+                : language.$(capsule, 'directlyAndIndirectly', {
+                    artworksDirectly:
+                      language.countArtworks(data.numArtworksDirectly, {unit: true}),
+
+                    artworksIndirectly:
+                      language.countArtworks(data.numArtworksIndirectly, {unit: false}),
+
+                    artworksTotal:
+                      language.countArtworks(data.numArtworksTotal, {unit: false}),
+                  })))),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$(pageCapsule, 'viewArtGallery', {
+              [language.onlyIfOptions]: ['link'],
+
+              link:
+                relations.galleryLink
+                  ?.slot('content', language.$(pageCapsule, 'viewArtGallery.link')),
+            })),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.encapsulate(pageCapsule, 'seeAlso', capsule =>
+              language.$(capsule, {
+                [language.onlyIfOptions]: ['tags'],
+
+                tags:
+                  language.formatUnitList(
+                    stitchArrays({
+                      artTagLink: relations.relatedArtTagLinks,
+                      annotation: data.relatedArtTagAnnotations,
+                    }).map(({artTagLink, annotation}) =>
+                        (html.isBlank(annotation)
+                          ? artTagLink
+                          : language.$(capsule, 'tagWithAnnotation', {
+                              tag: artTagLink,
+                              annotation,
+                            })))),
+              }))),
+
+          html.tag('blockquote',
+            {[html.onlyIfContent]: true},
+
+            relations.description
+              .slot('mode', 'multiline')),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$(pageCapsule, 'readMoreOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              tag: language.sanitize(data.name),
+              links: language.formatDisjunctionList(relations.extraReadingLinks),
+            })),
+
+          language.encapsulate(pageCapsule, 'descendsFromTags', listCapsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  title:
+                    language.$(listCapsule, {
+                      tag: language.sanitize(data.name),
+                    }),
+                }),
+
+              html.tag('ul',
+                {[html.onlyIfContent]: true},
+
+                relations.directAncestorLinks
+                  .map(link =>
+                    html.tag('li',
+                      language.$(listCapsule, 'item', {
+                        tag: link,
+                      })))),
+            ])),
+
+          language.encapsulate(pageCapsule, 'descendantTags', listCapsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  title:
+                    language.$(listCapsule, {
+                      tag: language.sanitize(data.name),
+                    }),
+                }),
+
+              html.tag('ul',
+                {[html.onlyIfContent]: true},
+
+                stitchArrays({
+                  infoLink: relations.directDescendantInfoLinks,
+                  galleryLink: relations.directDescendantGalleryLinks,
+                  timesFeaturedTotal: data.directDescendantTimesFeaturedTotal,
+                }).map(({infoLink, galleryLink, timesFeaturedTotal}) =>
+                    html.tag('li',
+                      language.encapsulate(listCapsule, 'item', itemCapsule =>
+                        language.encapsulate(itemCapsule, workingCapsule => {
+                          const workingOptions = {};
+
+                          workingOptions.tag = infoLink;
+
+                          if (!html.isBlank(galleryLink ?? html.blank())) {
+                            workingCapsule += '.withGallery';
+                            workingOptions.gallery =
+                              galleryLink.slot('content',
+                                language.$(itemCapsule, 'withGallery.gallery'));
+                          }
+
+                          if (timesFeaturedTotal >= 1) {
+                            workingCapsule += `.withTimesUsed`;
+                            workingOptions.timesUsed =
+                              language.countTimesFeatured(timesFeaturedTotal, {
+                                unit: true,
+                              });
+                          }
+
+                          return language.$(workingCapsule, workingOptions);
+                        }))))),
+            ])),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: relations.navLinks.content,
+
+        leftSidebar:
+          relations.sidebar,
+      })),
+};
diff --git a/src/content/dependencies/generateArtTagNavLinks.js b/src/content/dependencies/generateArtTagNavLinks.js
new file mode 100644
index 00000000..9061a09f
--- /dev/null
+++ b/src/content/dependencies/generateArtTagNavLinks.js
@@ -0,0 +1,81 @@
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'linkArtTagInfo',
+    'linkArtTagGallery',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) =>
+    ({enableListings: wikiInfo.enableListings}),
+
+  relations: (relation, sprawl, tag) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    mainLink:
+      relation('linkArtTagInfo', tag),
+
+    infoLink:
+      relation('linkArtTagInfo', tag),
+
+    galleryLink:
+      relation('linkArtTagGallery', tag),
+  }),
+
+  data: (sprawl) =>
+    ({enableListings: sprawl.enableListings}),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(data, relations, slots, {language}) {
+    if (!data.enableListings) {
+      return [
+        {auto: 'home'},
+        {auto: 'current'},
+      ];
+    }
+
+    const infoLink =
+      relations.infoLink.slots({
+        attributes: {class: slots.currentExtra === null && 'current'},
+        content: language.$('misc.nav.info'),
+      });
+
+    const galleryLink =
+      relations.galleryLink.slots({
+        attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+        content: language.$('misc.nav.gallery'),
+      });
+
+    return [
+      {auto: 'home'},
+
+      data.enableListings &&
+        {
+          path: ['localized.listingIndex'],
+          title: language.$('listingIndex.title'),
+        },
+
+      {
+        html:
+          language.$('artTagPage.nav.tag', {
+            tag: relations.mainLink,
+          }),
+
+        accent:
+          relations.switcher.slots({
+            links: [
+              infoLink,
+              galleryLink,
+            ],
+          }),
+      },
+    ].filter(Boolean);
+  },
+};
diff --git a/src/content/dependencies/generateArtTagSidebar.js b/src/content/dependencies/generateArtTagSidebar.js
new file mode 100644
index 00000000..9e2f813c
--- /dev/null
+++ b/src/content/dependencies/generateArtTagSidebar.js
@@ -0,0 +1,124 @@
+import {collectTreeLeaves, empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'generateArtTagAncestorDescendantMapList',
+    'linkArtTagDynamically',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({artTagData}) =>
+    ({artTagData}),
+
+  query(sprawl, artTag) {
+    const baobab = artTag.ancestorArtTagBaobabTree;
+    const uniqueLeaves = new Set(collectTreeLeaves(baobab));
+
+    // Just match the order in tag data.
+    const furthestAncestorArtTags =
+      sprawl.artTagData
+        .filter(artTag => uniqueLeaves.has(artTag));
+
+    return {furthestAncestorArtTags};
+  },
+
+  relations: (relation, query, sprawl, artTag) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    artTagLink:
+      relation('linkArtTagDynamically', artTag),
+
+    directDescendantArtTagLinks:
+      artTag.directDescendantArtTags
+        .map(descendantArtTag =>
+          relation('linkArtTagDynamically', descendantArtTag)),
+
+    furthestAncestorArtTagMapLists:
+      query.furthestAncestorArtTags
+        .map(ancestorArtTag =>
+          relation('generateArtTagAncestorDescendantMapList',
+            ancestorArtTag,
+            artTag)),
+  }),
+
+  data: (query, sprawl, artTag) => ({
+    name: artTag.name,
+
+    directDescendantTimesFeaturedTotal:
+      artTag.directDescendantArtTags.map(artTag =>
+        unique([
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
+        ]).length),
+
+    furthestAncestorArtTagNames:
+      query.furthestAncestorArtTags
+        .map(ancestorArtTag => ancestorArtTag.name),
+  }),
+
+  generate(data, relations, {html, language}) {
+    if (
+      empty(relations.directDescendantArtTagLinks) &&
+      empty(relations.furthestAncestorArtTagMapLists)
+    ) {
+      return relations.sidebar;
+    }
+
+    return relations.sidebar.slots({
+      boxes: [
+        relations.sidebarBox.slots({
+          content: [
+            html.tag('h1',
+              relations.artTagLink),
+
+            !empty(relations.directDescendantArtTagLinks) &&
+              html.tag('details', {class: 'current', open: true}, [
+                html.tag('summary',
+                  html.tag('span',
+                    html.tag('b',
+                      language.sanitize(data.name)))),
+
+                html.tag('ul',
+                  stitchArrays({
+                    link: relations.directDescendantArtTagLinks,
+                    timesFeaturedTotal: data.directDescendantTimesFeaturedTotal,
+                  }).map(({link, timesFeaturedTotal}) =>
+                      html.tag('li', [
+                        link,
+                        html.tag('span', {class: 'times-used'},
+                          language.countTimesFeatured(timesFeaturedTotal)),
+                      ]))),
+              ]),
+
+            stitchArrays({
+              name: data.furthestAncestorArtTagNames,
+              list: relations.furthestAncestorArtTagMapLists,
+            }).map(({name, list}) =>
+                html.tag('details',
+                  {
+                    class: 'has-tree-list',
+                    open:
+                      empty(relations.directDescendantArtTagLinks) &&
+                      relations.furthestAncestorArtTagMapLists.length === 1,
+                  },
+                  [
+                    html.tag('summary',
+                      html.tag('span',
+                        html.tag('b',
+                          language.sanitize(name)))),
+
+                      list,
+                    ])),
+          ],
+        }),
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateArtistArtworkColumn.js b/src/content/dependencies/generateArtistArtworkColumn.js
new file mode 100644
index 00000000..a4135489
--- /dev/null
+++ b/src/content/dependencies/generateArtistArtworkColumn.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations: (relation, artist) => ({
+    coverArtwork:
+      (artist.hasAvatar
+        ? relation('generateCoverArtwork', artist.avatarArtwork)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.coverArtwork,
+};
diff --git a/src/content/dependencies/generateArtistCredit.js b/src/content/dependencies/generateArtistCredit.js
new file mode 100644
index 00000000..bab32f7d
--- /dev/null
+++ b/src/content/dependencies/generateArtistCredit.js
@@ -0,0 +1,194 @@
+import {compareArrays, empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistCreditWikiEditsPart',
+    'linkContribution',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (creditContributions, contextContributions) => {
+    const query = {};
+
+    const featuringFilter = contribution =>
+      contribution.annotation === 'featuring';
+
+    const wikiEditFilter = contribution =>
+      contribution.annotation?.startsWith('edits for wiki');
+
+    const normalFilter = contribution =>
+      !featuringFilter(contribution) &&
+      !wikiEditFilter(contribution);
+
+    query.normalContributions =
+      creditContributions.filter(normalFilter);
+
+    query.featuringContributions =
+      creditContributions.filter(featuringFilter);
+
+    query.wikiEditContributions =
+      creditContributions.filter(wikiEditFilter);
+
+    const contextNormalContributions =
+      contextContributions.filter(normalFilter);
+
+    // Note that the normal contributions will implicitly *always*
+    // "differ from context" if no context contributions are given,
+    // as in release info lines.
+
+    query.normalContributionArtistsDifferFromContext =
+      !compareArrays(
+        query.normalContributions.map(({artist}) => artist),
+        contextNormalContributions.map(({artist}) => artist),
+        {checkOrder: true});
+
+    query.normalContributionAnnotationsDifferFromContext =
+      !compareArrays(
+        query.normalContributions.map(({annotation}) => annotation),
+        contextNormalContributions.map(({annotation}) => annotation),
+        {checkOrder: true});
+
+    return query;
+  },
+
+  relations: (relation, query, _creditContributions, _contextContributions) => ({
+    normalContributionLinks:
+      query.normalContributions
+        .map(contrib => relation('linkContribution', contrib)),
+
+    featuringContributionLinks:
+      query.featuringContributions
+        .map(contrib => relation('linkContribution', contrib)),
+
+    wikiEditsPart:
+      relation('generateArtistCreditWikiEditsPart',
+        query.wikiEditContributions),
+  }),
+
+  data: (query, _creditContributions, _contextContributions) => ({
+    normalContributionArtistsDifferFromContext:
+      query.normalContributionArtistsDifferFromContext,
+
+    normalContributionAnnotationsDifferFromContext:
+      query.normalContributionAnnotationsDifferFromContext,
+
+    hasWikiEdits:
+      !empty(query.wikiEditContributions),
+  }),
+
+  slots: {
+    // This string is mandatory.
+    normalStringKey: {type: 'string'},
+
+    // This string is optional.
+    // Without it, there's no special behavior for "featuring" credits.
+    normalFeaturingStringKey: {type: 'string'},
+
+    // This string is optional.
+    // Without it, "featuring" credits will always be alongside main credits.
+    // It won't be used if contextContributions isn't provided.
+    featuringStringKey: {type: 'string'},
+
+    additionalStringOptions: {validate: v => v.isObject},
+
+    showAnnotation: {type: 'boolean', default: false},
+    showExternalLinks: {type: 'boolean', default: false},
+    showChronology: {type: 'boolean', default: false},
+    showWikiEdits: {type: 'boolean', default: false},
+
+    trimAnnotation: {type: 'boolean', default: false},
+
+    chronologyKind: {type: 'string'},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    if (!slots.normalStringKey) return html.blank();
+
+    for (const link of [
+      ...relations.normalContributionLinks,
+      ...relations.featuringContributionLinks,
+    ]) {
+      link.setSlots({
+        showExternalLinks: slots.showExternalLinks,
+        showChronology: slots.showChronology,
+        trimAnnotation: slots.trimAnnotation,
+        chronologyKind: slots.chronologyKind,
+      });
+    }
+
+    for (const link of relations.normalContributionLinks) {
+      link.setSlots({
+        showAnnotation: slots.showAnnotation,
+      });
+    }
+
+    for (const link of relations.featuringContributionLinks) {
+      link.setSlots({
+        showAnnotation:
+          (slots.featuringStringKey || slots.normalFeaturingStringKey
+            ? false
+            : slots.showAnnotation),
+      });
+    }
+
+    if (empty(relations.normalContributionLinks)) {
+      return html.blank();
+    }
+
+    const artistsList =
+      (data.hasWikiEdits && slots.showWikiEdits
+        ? language.$('misc.artistLink.withEditsForWiki', {
+            artists:
+              language.formatConjunctionList(relations.normalContributionLinks),
+
+            edits:
+              relations.wikiEditsPart.slots({
+                showAnnotation: slots.showAnnotation,
+              }),
+          })
+        : language.formatConjunctionList(relations.normalContributionLinks));
+
+    const featuringList =
+      language.formatConjunctionList(relations.featuringContributionLinks);
+
+    const everyoneList =
+      language.formatConjunctionList([
+        ...relations.normalContributionLinks,
+        ...relations.featuringContributionLinks,
+      ]);
+
+    const effectivelyDiffers =
+      (slots.showAnnotation && data.normalContributionAnnotationsDifferFromContext) ||
+      (data.normalContributionArtistsDifferFromContext);
+
+    if (empty(relations.featuringContributionLinks)) {
+      if (effectivelyDiffers) {
+        return language.$(slots.normalStringKey, {
+          ...slots.additionalStringOptions,
+          artists: artistsList,
+        });
+      } else {
+        return html.blank();
+      }
+    }
+
+    if (effectivelyDiffers && slots.normalFeaturingStringKey) {
+      return language.$(slots.normalFeaturingStringKey, {
+        ...slots.additionalStringOptions,
+        artists: artistsList,
+        featuring: featuringList,
+      });
+    } else if (slots.featuringStringKey) {
+      return language.$(slots.featuringStringKey, {
+        ...slots.additionalStringOptions,
+        artists: featuringList,
+      });
+    } else {
+      return language.$(slots.normalStringKey, {
+        ...slots.additionalStringOptions,
+        artists: everyoneList,
+      });
+    }
+  },
+};
diff --git a/src/content/dependencies/generateArtistCreditWikiEditsPart.js b/src/content/dependencies/generateArtistCreditWikiEditsPart.js
new file mode 100644
index 00000000..70296e39
--- /dev/null
+++ b/src/content/dependencies/generateArtistCreditWikiEditsPart.js
@@ -0,0 +1,55 @@
+export default {
+  contentDependencies: [
+    'generateTextWithTooltip',
+    'generateTooltip',
+    'linkContribution',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, contributions) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateTooltip'),
+
+    contributionLinks:
+      contributions
+        .map(contrib => relation('linkContribution', contrib)),
+  }),
+
+  slots: {
+    showAnnotation: {type: 'boolean', default: true},
+  },
+
+  generate: (relations, slots, {language}) =>
+    language.encapsulate('misc.artistLink.withEditsForWiki', capsule =>
+      relations.textWithTooltip.slots({
+        attributes:
+          {class: 'wiki-edits'},
+
+        text:
+          language.$(capsule, 'edits'),
+
+        tooltip:
+          relations.tooltip.slots({
+            attributes:
+              {class: 'wiki-edits-tooltip'},
+
+            content:
+              language.$(capsule, 'editsLine', {
+                [language.onlyIfOptions]: ['artists'],
+
+                artists:
+                  language.formatConjunctionList(
+                    relations.contributionLinks.map(link =>
+                      link.slots({
+                        showAnnotation: slots.showAnnotation,
+                        trimAnnotation: true,
+                        preventTooltip: true,
+                      }))),
+                }),
+          }),
+      })),
+};
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
index 13779159..6a24275e 100644
--- a/src/content/dependencies/generateArtistGalleryPage.js
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -1,5 +1,4 @@
-import {sortAlbumsTracksChronologically} from '#sort';
-import {stitchArrays} from '#sugar';
+import {sortArtworksChronologically} from '#sort';
 
 export default {
   contentDependencies: [
@@ -7,83 +6,65 @@ export default {
     'generateCoverGrid',
     'generatePageLayout',
     'image',
-    'linkAlbum',
-    'linkTrack',
+    'linkAnythingMan',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  query(artist) {
-    const things = [
-      ...artist.albumsAsCoverArtist,
-      ...artist.tracksAsCoverArtist,
-    ];
-
-    sortAlbumsTracksChronologically(things, {
-      latestFirst: true,
-      getDate: thing => thing.coverArtDate ?? thing.date,
-    });
-
-    return {things};
-  },
-
-  relations(relation, query, artist) {
-    const relations = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.artistNavLinks =
-      relation('generateArtistNavLinks', artist);
-
-    relations.coverGrid =
-      relation('generateCoverGrid');
-
-    relations.links =
-      query.things.map(thing =>
-        (thing.album
-          ? relation('linkTrack', thing)
-          : relation('linkAlbum', thing)));
-
-    relations.images =
-      query.things.map(thing =>
-        relation('image', thing.artTags));
-
-    return relations;
-  },
-
-  data(query, artist) {
-    const data = {};
-
-    data.name = artist.name;
-
-    data.numArtworks = query.things.length;
-
-    data.names =
-      query.things.map(thing => thing.name);
-
-    data.paths =
-      query.things.map(thing =>
-        (thing.album
-          ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
-          : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
-
-    data.otherCoverArtists =
-      query.things.map(thing =>
-        (thing.coverArtistContribs.length > 1
-          ? thing.coverArtistContribs
-              .filter(({who}) => who !== artist)
-              .map(({who}) => who.name)
-          : null));
-
-    return data;
-  },
-
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
+  query: (artist) => ({
+    artworks:
+      sortArtworksChronologically(
+        ([
+          artist.albumCoverArtistContributions,
+          artist.trackCoverArtistContributions,
+        ]).flat()
+          .filter(contrib => !contrib.annotation?.startsWith(`edits for wiki`))
+          .map(contrib => contrib.thing),
+        {latestFirst: true}),
+  }),
+
+  relations: (relation, query, artist) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    artistNavLinks:
+      relation('generateArtistNavLinks', artist),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      query.artworks
+        .map(artwork => relation('linkAnythingMan', artwork.thing)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, artist) => ({
+    name:
+      artist.name,
+
+    numArtworks:
+      query.artworks.length,
+
+    names:
+      query.artworks
+        .map(artwork => artwork.thing.name),
+
+    otherCoverArtists:
+      query.artworks
+        .map(artwork => artwork.artistContribs
+          .filter(contrib => contrib.artist !== artist)
+          .map(contrib => contrib.artist.name)),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistGalleryPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('artistGalleryPage.title', {
+          language.$(pageCapsule, 'title', {
             artist: data.name,
           }),
 
@@ -92,31 +73,26 @@ export default {
         mainClasses: ['top-index'],
         mainContent: [
           html.tag('p', {class: 'quick-info'},
-            language.$('artistGalleryPage.infoLine', {
-              coverArts: language.countCoverArts(data.numArtworks, {
-                unit: true,
-              }),
+            language.$(pageCapsule, 'infoLine', {
+              coverArts:
+                language.countArtworks(data.numArtworks, {
+                  unit: true,
+                }),
             })),
 
           relations.coverGrid
             .slots({
               links: relations.links,
+              images: relations.images,
               names: data.names,
 
-              images:
-                stitchArrays({
-                  image: relations.images,
-                  path: data.paths,
-                }).map(({image, path}) =>
-                    image.slot('path', path)),
-
               info:
                 data.otherCoverArtists.map(names =>
-                  (names === null
-                    ? null
-                    : language.$('misc.albumGrid.details.otherCoverArtists', {
-                        artists: language.formatUnitList(names),
-                      }))),
+                  language.$('misc.coverGrid.details.otherCoverArtists', {
+                    [language.onlyIfOptions]: ['artists'],
+
+                    artists: language.formatUnitList(names),
+                  })),
             }),
         ],
 
@@ -128,6 +104,5 @@ export default {
               currentExtra: 'gallery',
             })
             .content,
-      })
-  },
+      })),
 }
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index a51f516b..e1fa7a0b 100644
--- a/src/content/dependencies/generateArtistGroupContributionsInfo.js
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -1,82 +1,90 @@
-import {empty, filterProperties, stitchArrays, unique} from '#sugar';
+import {accumulateSum, empty, stitchArrays, withEntries} from '#sugar';
 
 export default {
   contentDependencies: ['linkGroup'],
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupCategoryData}) {
-    return {
-      groupOrder: groupCategoryData.flatMap(category => category.groups),
-    }
-  },
+  sprawl: ({groupCategoryData}) => ({
+    groupOrder:
+      groupCategoryData.flatMap(category => category.groups),
+  }),
 
-  query(sprawl, tracksAndAlbums) {
-    const filteredAlbums = tracksAndAlbums.filter(thing => !thing.album);
-    const filteredTracks = tracksAndAlbums.filter(thing => thing.album);
+  query(sprawl, contributions) {
+    const allGroupsUnordered =
+      new Set(contributions.flatMap(contrib => contrib.groups));
 
-    const allAlbums = unique([
-      ...filteredAlbums,
-      ...filteredTracks.map(track => track.album),
-    ]);
+    const allGroupsOrdered =
+      sprawl.groupOrder.filter(group => allGroupsUnordered.has(group));
 
-    const allGroupsUnordered = new Set(Array.from(allAlbums).flatMap(album => album.groups));
-    const allGroupsOrdered = sprawl.groupOrder.filter(group => allGroupsUnordered.has(group));
+    const groupToThingsCountedForContributions =
+      new Map(allGroupsOrdered.map(group => [group, new Set]));
 
-    const mapTemplate = allGroupsOrdered.map(group => [group, 0]);
-    const groupToCountMap = new Map(mapTemplate);
-    const groupToDurationMap = new Map(mapTemplate);
-    const groupToDurationCountMap = new Map(mapTemplate);
+    const groupToThingsCountedForDuration =
+      new Map(allGroupsOrdered.map(group => [group, new Set]));
 
-    for (const album of filteredAlbums) {
-      for (const group of album.groups) {
-        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
-      }
-    }
+    for (const contrib of contributions) {
+      for (const group of contrib.groups) {
+        if (contrib.countInContributionTotals) {
+          groupToThingsCountedForContributions.get(group).add(contrib.thing);
+        }
 
-    for (const track of filteredTracks) {
-      for (const group of track.album.groups) {
-        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
-        if (track.duration && track.originalReleaseTrack === null) {
-          groupToDurationMap.set(group, groupToDurationMap.get(group) + track.duration);
-          groupToDurationCountMap.set(group, groupToDurationCountMap.get(group) + 1);
+        if (contrib.countInDurationTotals) {
+          groupToThingsCountedForDuration.get(group).add(contrib.thing);
         }
       }
     }
 
+    const groupToTotalContributions =
+      withEntries(
+        groupToThingsCountedForContributions,
+        entries => entries.map(
+          ([group, things]) =>
+          ([group, things.size])));
+
+    const groupToTotalDuration =
+      withEntries(
+        groupToThingsCountedForDuration,
+        entries => entries.map(
+          ([group, things]) =>
+          ([group, accumulateSum(things, thing => thing.duration)])))
+
     const groupsSortedByCount =
       allGroupsOrdered
-        .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.get(a));
+        .filter(group => groupToTotalContributions.get(group) > 0)
+        .sort((a, b) =>
+          (groupToTotalContributions.get(b)
+         - groupToTotalContributions.get(a)));
 
-    // The filter here ensures all displayed groups have at least some duration
-    // when sorting by duration.
     const groupsSortedByDuration =
       allGroupsOrdered
-        .filter(group => groupToDurationMap.get(group) > 0)
-        .sort((a, b) => groupToDurationMap.get(b) - groupToDurationMap.get(a));
+        .filter(group => groupToTotalDuration.get(group) > 0)
+        .sort((a, b) =>
+          (groupToTotalDuration.get(b)
+         - groupToTotalDuration.get(a)));
 
     const groupCountsSortedByCount =
       groupsSortedByCount
-        .map(group => groupToCountMap.get(group));
+        .map(group => groupToTotalContributions.get(group));
 
     const groupDurationsSortedByCount =
       groupsSortedByCount
-        .map(group => groupToDurationMap.get(group));
+        .map(group => groupToTotalDuration.get(group));
 
     const groupDurationsApproximateSortedByCount =
       groupsSortedByCount
-        .map(group => groupToDurationCountMap.get(group) > 1);
+        .map(group => groupToThingsCountedForDuration.get(group).size > 1);
 
     const groupCountsSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToCountMap.get(group));
+        .map(group => groupToTotalContributions.get(group));
 
     const groupDurationsSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToDurationMap.get(group));
+        .map(group => groupToTotalDuration.get(group));
 
     const groupDurationsApproximateSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToDurationCountMap.get(group) > 1);
+        .map(group => groupToThingsCountedForDuration.get(group).size > 1);
 
     return {
       groupsSortedByCount,
@@ -92,29 +100,35 @@ export default {
     };
   },
 
-  relations(relation, query) {
-    return {
-      groupLinksSortedByCount:
-        query.groupsSortedByCount
-          .map(group => relation('linkGroup', group)),
+  relations: (relation, query) => ({
+    groupLinksSortedByCount:
+      query.groupsSortedByCount
+        .map(group => relation('linkGroup', group)),
 
-      groupLinksSortedByDuration:
-        query.groupsSortedByDuration
-          .map(group => relation('linkGroup', group)),
-    };
-  },
+    groupLinksSortedByDuration:
+      query.groupsSortedByDuration
+        .map(group => relation('linkGroup', group)),
+  }),
 
-  data(query) {
-    return filterProperties(query, [
-      'groupCountsSortedByCount',
-      'groupDurationsSortedByCount',
-      'groupDurationsApproximateSortedByCount',
+  data: (query) => ({
+    groupCountsSortedByCount:
+      query.groupCountsSortedByCount,
 
-      'groupCountsSortedByDuration',
-      'groupDurationsSortedByDuration',
-      'groupDurationsApproximateSortedByDuration',
-    ]);
-  },
+    groupDurationsSortedByCount:
+      query.groupDurationsSortedByCount,
+
+    groupDurationsApproximateSortedByCount:
+      query.groupDurationsApproximateSortedByCount,
+
+    groupCountsSortedByDuration:
+      query.groupCountsSortedByDuration,
+
+    groupDurationsSortedByDuration:
+      query.groupDurationsSortedByDuration,
+
+    groupDurationsApproximateSortedByDuration:
+      query.groupDurationsApproximateSortedByDuration,
+  }),
 
   slots: {
     title: {
@@ -130,94 +144,104 @@ export default {
     countUnit: {validate: v => v.is('tracks', 'artworks')},
   },
 
-  generate(data, relations, slots, {html, language}) {
-    if (slots.sort === 'count' && empty(relations.groupLinksSortedByCount)) {
-      return html.blank();
-    } else if (slots.sort === 'duration' && empty(relations.groupLinksSortedByDuration)) {
-      return html.blank();
-    }
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('artistPage.groupContributions', capsule => {
+      if (slots.sort === 'count' && empty(relations.groupLinksSortedByCount)) {
+        return html.blank();
+      } else if (slots.sort === 'duration' && empty(relations.groupLinksSortedByDuration)) {
+        return html.blank();
+      }
 
-    const getCounts = counts =>
-      counts.map(count => {
-        switch (slots.countUnit) {
-          case 'tracks': return language.countTracks(count, {unit: true});
-          case 'artworks': return language.countArtworks(count, {unit: true});
-        }
-      });
-
-    // We aren't displaying the "~" approximate symbol here for now.
-    // The general notion that these sums aren't going to be 100% accurate
-    // is made clear by the "XYZ has contributed ~1:23:45 hours of music..."
-    // line that's always displayed above this table.
-    const getDurations = (durations, approximate) =>
-      stitchArrays({
-        duration: durations,
-        approximate: approximate,
-      }).map(({duration}) => language.formatDuration(duration));
-
-    const topLevelClasses = [
-      'group-contributions-sorted-by-' + slots.sort,
-      slots.visible && 'visible',
-    ];
-
-    return html.tags([
-      html.tag('dt', {class: topLevelClasses},
-        (slots.showSortButton
-          ? language.$('artistPage.groupContributions.title.withSortButton', {
-              title: slots.title,
-              sort:
-                html.tag('a', {class: 'group-contributions-sort-button'},
-                  {href: '#'},
-
-                  (slots.sort === 'count'
-                    ? language.$('artistPage.groupContributions.title.sorting.count')
-                    : language.$('artistPage.groupContributions.title.sorting.duration'))),
-            })
-          : slots.title)),
-
-      html.tag('dd', {class: topLevelClasses},
-        html.tag('ul', {class: 'group-contributions-table'},
-          {role: 'list'},
-
-          (slots.sort === 'count'
-            ? stitchArrays({
-                group: relations.groupLinksSortedByCount,
-                count: getCounts(data.groupCountsSortedByCount),
-                duration:
-                  getDurations(
-                    data.groupDurationsSortedByCount,
-                    data.groupDurationsApproximateSortedByCount),
-              }).map(({group, count, duration}) =>
-                html.tag('li',
-                  html.tag('div', {class: 'group-contributions-row'}, [
-                    group,
-                    html.tag('span', {class: 'group-contributions-metrics'},
-                      // When sorting by count, duration details aren't necessarily
-                      // available for all items.
-                      (slots.showBothColumns && duration
-                        ? language.$('artistPage.groupContributions.item.countDurationAccent', {count, duration})
-                        : language.$('artistPage.groupContributions.item.countAccent', {count}))),
-                  ])))
-
-            : stitchArrays({
-                group: relations.groupLinksSortedByDuration,
-                count: getCounts(data.groupCountsSortedByDuration),
-                duration:
-                  getDurations(
-                    data.groupDurationsSortedByDuration,
-                    data.groupDurationsApproximateSortedByCount),
-              }).map(({group, count, duration}) =>
-                html.tag('li',
-                  html.tag('div', {class: 'group-contributions-row'}, [
-                    group,
-                    html.tag('span', {class: 'group-contributions-metrics'},
-                      // Count details are always available, since they're just the
-                      // number of contributions directly. And duration details are
-                      // guaranteed for every item when sorting by duration.
-                      (slots.showBothColumns
-                        ? language.$('artistPage.groupContributions.item.durationCountAccent', {duration, count})
-                        : language.$('artistPage.groupContributions.item.durationAccent', {duration}))),
-                  ])))))),
-    ]);
-  },
+      const getCounts = counts =>
+        counts.map(count => {
+          switch (slots.countUnit) {
+            case 'tracks': return language.countTracks(count, {unit: true});
+            case 'artworks': return language.countArtworks(count, {unit: true});
+          }
+        });
+
+      // We aren't displaying the "~" approximate symbol here for now.
+      // The general notion that these sums aren't going to be 100% accurate
+      // is made clear by the "XYZ has contributed ~1:23:45 hours of music..."
+      // line that's always displayed above this table.
+      const getDurations = (durations, approximate) =>
+        stitchArrays({
+          duration: durations,
+          approximate: approximate,
+        }).map(({duration}) => language.formatDuration(duration));
+
+      const topLevelClasses = [
+        'group-contributions-sorted-by-' + slots.sort,
+        slots.visible && 'visible',
+      ];
+
+      // TODO: It feels pretty awkward that this component is the only one that
+      // has enough knowledge to decide if the sort button is even applicable...
+      const switchingSortPossible =
+        !empty(relations.groupLinksSortedByCount) &&
+        !empty(relations.groupLinksSortedByDuration);
+
+      return html.tags([
+        html.tag('dt', {class: topLevelClasses},
+          language.encapsulate(capsule, 'title', capsule =>
+            (switchingSortPossible && slots.showSortButton
+              ? language.$(capsule, 'withSortButton', {
+                  title: slots.title,
+                  sort:
+                    html.tag('a', {class: 'group-contributions-sort-button'},
+                      {href: '#'},
+
+                      (slots.sort === 'count'
+                        ? language.$(capsule, 'sorting.count')
+                        : language.$(capsule, 'sorting.duration'))),
+                })
+              : slots.title))),
+
+        html.tag('dd', {class: topLevelClasses},
+          html.tag('ul', {class: 'group-contributions-table'},
+            {role: 'list'},
+
+            (slots.sort === 'count'
+              ? stitchArrays({
+                  group: relations.groupLinksSortedByCount,
+                  count: getCounts(data.groupCountsSortedByCount),
+                  duration:
+                    getDurations(
+                      data.groupDurationsSortedByCount,
+                      data.groupDurationsApproximateSortedByCount),
+                }).map(({group, count, duration}) =>
+                    language.encapsulate(capsule, 'item', capsule =>
+                      html.tag('li',
+                        html.tag('div', {class: 'group-contributions-row'}, [
+                          group,
+                          html.tag('span', {class: 'group-contributions-metrics'},
+                            // When sorting by count, duration details aren't necessarily
+                            // available for all items.
+                            (slots.showBothColumns && duration
+                              ? language.$(capsule, 'countDurationAccent', {count, duration})
+                              : language.$(capsule, 'countAccent', {count}))),
+                        ]))))
+
+              : stitchArrays({
+                  group: relations.groupLinksSortedByDuration,
+                  count: getCounts(data.groupCountsSortedByDuration),
+                  duration:
+                    getDurations(
+                      data.groupDurationsSortedByDuration,
+                      data.groupDurationsApproximateSortedByDuration),
+                }).map(({group, count, duration}) =>
+                    language.encapsulate(capsule, 'item', capsule =>
+                      html.tag('li',
+                        html.tag('div', {class: 'group-contributions-row'}, [
+                          group,
+                          html.tag('span', {class: 'group-contributions-metrics'},
+                            // Count details are always available, since they're just the
+                            // number of contributions directly. And duration details are
+                            // guaranteed for every item when sorting by duration.
+                            (slots.showBothColumns
+                              ? language.$(capsule, 'durationCountAccent', {duration, count})
+                              : language.$(capsule, 'durationAccent', {duration}))),
+                        ]))))))),
+      ]);
+    }),
 };
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index 1b85680f..1f738de4 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -1,8 +1,8 @@
-import {empty, unique} from '#sugar';
-import {getTotalDuration} from '#wiki-data';
+import {empty, stitchArrays, unique} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateArtistArtworkColumn',
     'generateArtistGroupContributionsInfo',
     'generateArtistInfoPageArtworksChunkedList',
     'generateArtistInfoPageCommentaryChunkedList',
@@ -10,212 +10,256 @@ export default {
     'generateArtistInfoPageTracksChunkedList',
     'generateArtistNavLinks',
     'generateContentHeading',
-    'generateCoverArtwork',
     'generatePageLayout',
-    'linkAlbum',
     'linkArtistGallery',
     'linkExternal',
     'linkGroup',
-    'linkTrack',
     'transformContent',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
-
-  sprawl({wikiInfo}) {
-    return {
-      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
-    };
-  },
-
-  query(sprawl, artist) {
-    return {
-      // Even if an artist has served as both "artist" (compositional) and
-      // "contributor" (instruments, production, etc) on the same track, that
-      // track only counts as one unique contribution.
-      allTracks:
-        unique([...artist.tracksAsArtist, ...artist.tracksAsContributor]),
-
-      // Artworks are different, though. We intentionally duplicate album data
-      // objects when the artist has contributed some combination of cover art,
-      // wallpaper, and banner - these each count as a unique contribution.
-      allArtworks: [
-        ...artist.albumsAsCoverArtist,
-        ...artist.albumsAsWallpaperArtist,
-        ...artist.albumsAsBannerArtist,
-        ...artist.tracksAsCoverArtist,
-      ],
-
-      // Banners and wallpapers don't show up in the artist gallery page, only
-      // cover art.
-      hasGallery:
-        !empty(artist.albumsAsCoverArtist) ||
-        !empty(artist.tracksAsCoverArtist),
-    };
-  },
-
-  relations(relation, query, sprawl, artist) {
-    const relations = {};
-    const sections = relations.sections = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.artistNavLinks =
-      relation('generateArtistNavLinks', artist);
-
-    if (artist.hasAvatar) {
-      relations.cover =
-        relation('generateCoverArtwork', []);
-    }
-
-    if (artist.contextNotes) {
-      const contextNotes = sections.contextNotes = {};
-      contextNotes.content = relation('transformContent', artist.contextNotes);
-    }
-
-    if (!empty(artist.urls)) {
-      const visit = sections.visit = {};
-      visit.externalLinks =
-        artist.urls.map(url =>
-          relation('linkExternal', url));
-    }
-
-    if (!empty(query.allTracks)) {
-      const tracks = sections.tracks = {};
-      tracks.heading = relation('generateContentHeading');
-      tracks.list = relation('generateArtistInfoPageTracksChunkedList', artist);
-      tracks.groupInfo = relation('generateArtistGroupContributionsInfo', query.allTracks);
-    }
-
-    if (!empty(query.allArtworks)) {
-      const artworks = sections.artworks = {};
-      artworks.heading = relation('generateContentHeading');
-      artworks.list = relation('generateArtistInfoPageArtworksChunkedList', artist);
-      artworks.groupInfo =
-        relation('generateArtistGroupContributionsInfo', query.allArtworks);
-
-      if (query.hasGallery) {
-        artworks.artistGalleryLink =
-          relation('linkArtistGallery', artist);
-      }
-    }
-
-    if (sprawl.enableFlashesAndGames && !empty(artist.flashesAsContributor)) {
-      const flashes = sections.flashes = {};
-      flashes.heading = relation('generateContentHeading');
-      flashes.list = relation('generateArtistInfoPageFlashesChunkedList', artist);
-    }
-
-    if (!empty(artist.albumsAsCommentator) || !empty(artist.tracksAsCommentator)) {
-      const commentary = sections.commentary = {};
-      commentary.heading = relation('generateContentHeading');
-      commentary.list = relation('generateArtistInfoPageCommentaryChunkedList', artist);
-    }
-
-    return relations;
-  },
-
-  data(query, sprawl, artist) {
-    const data = {};
-
-    data.name = artist.name;
-    data.directory = artist.directory;
-
-    if (artist.hasAvatar) {
-      data.avatarFileExtension = artist.avatarFileExtension;
-    }
-
-    data.totalTrackCount = query.allTracks.length;
-    data.totalDuration = getTotalDuration(query.allTracks, {originalReleasesOnly: true});
-
-    return data;
-  },
-
-  generate(data, relations, {html, language}) {
-    const {sections: sec} = relations;
-
-    return relations.layout
-      .slots({
+  extraDependencies: ['html', 'language'],
+
+  query: (artist) => ({
+    trackContributions: [
+      ...artist.trackArtistContributions,
+      ...artist.trackContributorContributions,
+    ],
+
+    artworkContributions: [
+      ...artist.albumCoverArtistContributions,
+      ...artist.albumWallpaperArtistContributions,
+      ...artist.albumBannerArtistContributions,
+      ...artist.trackCoverArtistContributions,
+    ],
+
+    // Banners and wallpapers don't show up in the artist gallery page, only
+    // cover art.
+    hasGallery:
+      !empty(artist.albumCoverArtistContributions) ||
+      !empty(artist.trackCoverArtistContributions),
+
+    aliasLinkedGroups:
+      artist.closelyLinkedGroups
+        .filter(({annotation}) =>
+          annotation === 'alias'),
+
+    generalLinkedGroups:
+      artist.closelyLinkedGroups
+        .filter(({annotation}) =>
+          annotation !== 'alias'),
+  }),
+
+  relations: (relation, query, artist) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    artistNavLinks:
+      relation('generateArtistNavLinks', artist),
+
+    artworkColumn:
+      relation('generateArtistArtworkColumn', artist),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    contextNotes:
+      relation('transformContent', artist.contextNotes),
+
+    closeGroupLinks:
+      query.generalLinkedGroups
+        .map(({group}) => relation('linkGroup', group)),
+
+    aliasGroupLinks:
+      query.aliasLinkedGroups
+        .map(({group}) => relation('linkGroup', group)),
+
+    visitLinks:
+      artist.urls
+        .map(url => relation('linkExternal', url)),
+
+    tracksChunkedList:
+      relation('generateArtistInfoPageTracksChunkedList', artist),
+
+    tracksGroupInfo:
+      relation('generateArtistGroupContributionsInfo', query.trackContributions),
+
+    artworksChunkedList:
+      relation('generateArtistInfoPageArtworksChunkedList', artist, false),
+
+    editsForWikiArtworksChunkedList:
+      relation('generateArtistInfoPageArtworksChunkedList', artist, true),
+
+    artworksGroupInfo:
+      relation('generateArtistGroupContributionsInfo', query.artworkContributions),
+
+    artistGalleryLink:
+      (query.hasGallery
+        ? relation('linkArtistGallery', artist)
+        : null),
+
+    flashesChunkedList:
+      relation('generateArtistInfoPageFlashesChunkedList', artist),
+
+    commentaryChunkedList:
+      relation('generateArtistInfoPageCommentaryChunkedList', artist, false),
+
+    wikiEditorCommentaryChunkedList:
+      relation('generateArtistInfoPageCommentaryChunkedList', artist, true),
+  }),
+
+  data: (query, artist) => ({
+    name:
+      artist.name,
+
+    closeGroupAnnotations:
+      query.generalLinkedGroups
+        .map(({annotation}) => annotation),
+
+    totalTrackCount:
+      unique(
+        query.trackContributions
+          .filter(contrib => contrib.countInContributionTotals)
+          .map(contrib => contrib.thing))
+        .length,
+
+    totalDuration:
+      artist.totalDuration,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistPage', pageCapsule =>
+      relations.layout.slots({
         title: data.name,
         headingMode: 'sticky',
 
-        cover:
-          (relations.cover
-            ? relations.cover.slots({
-                path: [
-                  'media.artistAvatar',
-                  data.directory,
-                  data.avatarFileExtension,
-                ],
-              })
-            : null),
+        artworkColumnContent:
+          relations.artworkColumn,
 
         mainContent: [
-          sec.contextNotes && [
-            html.tag('p', language.$('releaseInfo.note')),
+          html.tags([
+            html.tag('p',
+              {[html.onlyIfSiblings]: true},
+              language.$('releaseInfo.note')),
+
             html.tag('blockquote',
-              sec.contextNotes.content),
-          ],
+              {[html.onlyIfContent]: true},
+              relations.contextNotes),
+          ]),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate(pageCapsule, 'closelyLinkedGroups', capsule => [
+              language.encapsulate(capsule, capsule => {
+                const [workingCapsule, option] =
+                  (relations.closeGroupLinks.length === 0
+                    ? [null, null]
+                 : relations.closeGroupLinks.length === 1
+                    ? [language.encapsulate(capsule, 'one'), 'group']
+                    : [language.encapsulate(capsule, 'multiple'), 'groups']);
+
+                if (!workingCapsule) return html.blank();
+
+                return language.$(workingCapsule, {
+                  [option]:
+                    language.formatUnitList(
+                      stitchArrays({
+                        link: relations.closeGroupLinks,
+                        annotation: data.closeGroupAnnotations,
+                      }).map(({link, annotation}) =>
+                          language.encapsulate(capsule, 'group', workingCapsule => {
+                            const workingOptions = {group: link};
+
+                            if (annotation) {
+                              workingCapsule += '.withAnnotation';
+                              workingOptions.annotation = annotation;
+                            }
+
+                            return language.$(workingCapsule, workingOptions);
+                          }))),
+                });
+              }),
 
-          sec.visit &&
-            html.tag('p',
-              language.$('releaseInfo.visitOn', {
-                links:
-                  language.formatDisjunctionList(
-                    sec.visit.externalLinks.map(link =>
-                      link.slots({
-                        context: 'artist',
-                        style: 'platform',
-                      }))),
-              })),
-
-          sec.artworks?.artistGalleryLink &&
-            html.tag('p',
-              language.$('artistPage.viewArtGallery', {
-                link: sec.artworks.artistGalleryLink.slots({
-                  content: language.$('artistPage.viewArtGallery.link'),
-                }),
-              })),
+              language.$(capsule, 'alias', {
+                [language.onlyIfOptions]: ['groups'],
 
-          (sec.tracks || sec.artworsk || sec.flashes || sec.commentary) &&
-            html.tag('p',
-              language.$('misc.jumpTo.withLinks', {
-                links: language.formatUnitList(
-                  [
-                    sec.tracks &&
-                      html.tag('a',
-                        {href: '#tracks'},
-                        language.$('artistPage.trackList.title')),
+                groups:
+                  language.formatConjunctionList(relations.aliasGroupLinks),
+              }),
+            ])),
 
-                    sec.artworks &&
-                      html.tag('a',
-                        {href: '#art'},
-                        language.$('artistPage.artList.title')),
+          html.tag('p',
+            {[html.onlyIfContent]: true},
 
-                    sec.flashes &&
-                      html.tag('a',
-                        {href: '#flashes'},
-                        language.$('artistPage.flashList.title')),
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
 
-                    sec.commentary &&
-                      html.tag('a',
-                        {href: '#commentary'},
-                        language.$('artistPage.commentaryList.title')),
-                  ].filter(Boolean)),
-              })),
+              links:
+                language.formatDisjunctionList(
+                  relations.visitLinks
+                    .map(link => link.slot('context', 'artist'))),
+            })),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.encapsulate(pageCapsule, 'viewArtGallery', capsule =>
+              language.$(capsule, {
+                [language.onlyIfOptions]: ['link'],
+
+                link:
+                  relations.artistGalleryLink?.slots({
+                    content:
+                      language.$(capsule, 'link'),
+                  }),
+              }))),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$('misc.jumpTo.withLinks', {
+              [language.onlyIfOptions]: ['links'],
 
-          sec.tracks && [
-            sec.tracks.heading
+              links:
+                language.formatUnitList([
+                  !html.isBlank(relations.tracksChunkedList) &&
+                    html.tag('a',
+                      {href: '#tracks'},
+                      language.$(pageCapsule, 'trackList.title')),
+
+                  (!html.isBlank(relations.artworksChunkedList) ||
+                   !html.isBlank(relations.editsForWikiArtworksChunkedList)) &&
+                      html.tag('a',
+                        {href: '#art'},
+                        language.$(pageCapsule, 'artList.title')),
+
+                  !html.isBlank(relations.flashesChunkedList) &&
+                    html.tag('a',
+                      {href: '#flashes'},
+                      language.$(pageCapsule, 'flashList.title')),
+
+                  (!html.isBlank(relations.commentaryChunkedList) ||
+                   !html.isBlank(relations.wikiEditorCommentaryChunkedList)) &&
+                    html.tag('a',
+                      {href: '#commentary'},
+                      language.$(pageCapsule, 'commentaryList.title')),
+                ].filter(Boolean)),
+            })),
+
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
-                id: 'tracks',
-                title: language.$('artistPage.trackList.title'),
+                attributes: {id: 'tracks'},
+                title: language.$(pageCapsule, 'trackList.title'),
               }),
 
             data.totalDuration > 0 &&
               html.tag('p',
-                language.$('artistPage.contributedDurationLine', {
+                {[html.onlyIfSiblings]: true},
+
+                language.$(pageCapsule, 'contributedDurationLine', {
                   artist: data.name,
                   duration:
                     language.formatDuration(data.totalDuration, {
@@ -224,82 +268,118 @@ export default {
                     }),
                 })),
 
-            sec.tracks.list
-              .slots({
-                groupInfo: [
-                  sec.tracks.groupInfo
-                    .clone()
+            relations.tracksChunkedList.slots({
+              groupInfo:
+                language.encapsulate(pageCapsule, 'groupContributions', capsule => [
+                  relations.tracksGroupInfo.clone()
                     .slots({
-                      title: language.$('artistPage.groupContributions.title.music'),
+                      title: language.$(capsule, 'title.music'),
                       showSortButton: true,
                       sort: 'count',
                       countUnit: 'tracks',
                       visible: true,
                     }),
 
-                  sec.tracks.groupInfo
-                    .clone()
+                  relations.tracksGroupInfo.clone()
                     .slots({
-                      title: language.$('artistPage.groupContributions.title.music'),
+                      title: language.$(capsule, 'title.music'),
                       showSortButton: true,
                       sort: 'duration',
                       countUnit: 'tracks',
                       visible: false,
                     }),
-                ],
-              }),
-          ],
+                ]),
+            }),
+          ]),
 
-          sec.artworks && [
-            sec.artworks.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
-                id: 'art',
-                title: language.$('artistPage.artList.title'),
+                attributes: {id: 'art'},
+                title: language.$(pageCapsule, 'artList.title'),
               }),
 
-            sec.artworks.artistGalleryLink &&
-              html.tag('p',
-                language.$('artistPage.viewArtGallery.orBrowseList', {
-                  link: sec.artworks.artistGalleryLink.slots({
-                    content: language.$('artistPage.viewArtGallery.link'),
-                  }),
-                })),
+            html.tag('p',
+              {[html.onlyIfContent]: true},
+
+              language.encapsulate(pageCapsule, 'viewArtGallery', capsule =>
+                language.$(capsule, 'orBrowseList', {
+                  [language.onlyIfOptions]: ['link'],
+
+                  link:
+                    relations.artistGalleryLink?.slots({
+                      content: language.$(capsule, 'link'),
+                    }),
+                }))),
 
-            sec.artworks.list
+            relations.artworksChunkedList
               .slots({
                 groupInfo:
-                  sec.artworks.groupInfo
-                    .slots({
-                      title: language.$('artistPage.groupContributions.title.artworks'),
-                      showBothColumns: false,
-                      sort: 'count',
-                      countUnit: 'artworks',
-                    }),
+                  language.encapsulate(pageCapsule, 'groupContributions', capsule =>
+                    relations.artworksGroupInfo
+                      .slots({
+                        title: language.$(capsule, 'title.artworks'),
+                        showBothColumns: false,
+                        sort: 'count',
+                        countUnit: 'artworks',
+                      })),
               }),
-          ],
 
-          sec.flashes && [
-            sec.flashes.heading
+            html.tags([
+              language.encapsulate(pageCapsule, 'wikiEditArtworks', capsule =>
+                relations.contentHeading.clone()
+                  .slots({
+                    tag: 'p',
+
+                    title:
+                      language.$(capsule, {artist: data.name}),
+
+                    stickyTitle:
+                      language.$(capsule, 'sticky'),
+                  })),
+
+              relations.editsForWikiArtworksChunkedList,
+            ]),
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
-                id: 'flashes',
-                title: language.$('artistPage.flashList.title'),
+                attributes: {id: 'flashes'},
+                title: language.$(pageCapsule, 'flashList.title'),
               }),
 
-            sec.flashes.list,
-          ],
+            relations.flashesChunkedList,
+          ]),
 
-          sec.commentary && [
-            sec.commentary.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
-                id: 'commentary',
-                title: language.$('artistPage.commentaryList.title'),
+                attributes: {id: 'commentary'},
+                title: language.$(pageCapsule, 'commentaryList.title'),
               }),
 
-            sec.commentary.list,
-          ],
+            relations.commentaryChunkedList,
+
+            html.tags([
+              language.encapsulate(pageCapsule, 'wikiEditorCommentary', capsule =>
+                relations.contentHeading.clone()
+                  .slots({
+                    tag: 'p',
+
+                    title:
+                      language.$(capsule, {artist: data.name}),
+
+                    stickyTitle:
+                      language.$(capsule, 'sticky'),
+                  })),
+
+              relations.wikiEditorCommentaryChunkedList,
+            ]),
+          ]),
         ],
 
         navLinkStyle: 'hierarchical',
@@ -309,6 +389,5 @@ export default {
               showExtraLinks: true,
             })
             .content,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunk.js b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js
new file mode 100644
index 00000000..66e4204a
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js
@@ -0,0 +1,50 @@
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageArtworksChunkItem',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album, contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunk'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    items:
+      contribs
+        .map(contrib =>
+          relation('generateArtistInfoPageArtworksChunkItem', contrib)),
+  }),
+
+  data: (_album, contribs) => ({
+    dates:
+      contribs
+        .map(contrib => contrib.date),
+  }),
+
+  slots: {
+    filterEditsForWiki: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots) =>
+    relations.template.slots({
+      mode: 'album',
+      albumLink: relations.albumLink,
+
+      dates:
+        (slots.filterEditsForWiki
+          ? Array.from({length: data.dates}, () => null)
+          : data.dates),
+
+      items:
+        relations.items.map(item =>
+          item.slot('filterEditsForWiki', slots.filterEditsForWiki)),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
new file mode 100644
index 00000000..cb436b0f
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
@@ -0,0 +1,111 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageOtherArtistLinks',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (contrib) => ({
+    kind:
+      (contrib.isBannerArtistContribution
+        ? 'banner'
+     : contrib.isWallpaperArtistContribution
+        ? 'wallpaper'
+     : contrib.isForAlbum
+        ? 'album-cover'
+        : 'track-cover'),
+  }),
+
+  relations: (relation, query, contrib) => ({
+    template:
+      relation('generateArtistInfoPageChunkItem'),
+
+    trackLink:
+      (query.kind === 'track-cover'
+        ? relation('linkTrack', contrib.thing.thing)
+        : null),
+
+    otherArtistLinks:
+      relation('generateArtistInfoPageOtherArtistLinks', [contrib]),
+
+    originDetails:
+      relation('transformContent', contrib.thing.originDetails),
+  }),
+
+  data: (query, contrib) => ({
+    kind:
+      query.kind,
+
+    annotation:
+      contrib.annotation,
+
+    label:
+      contrib.thing.label,
+  }),
+
+  slots: {
+    filterEditsForWiki: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.template.slots({
+      otherArtistLinks: relations.otherArtistLinks,
+
+      annotation:
+        language.encapsulate('artistPage.creditList.entry.artwork.accent', workingCapsule => {
+          const workingOptions = {};
+
+          const artworkLabel = data.label;
+
+          if (artworkLabel) {
+            workingCapsule += '.withLabel';
+            workingOptions.label =
+              language.typicallyLowerCase(artworkLabel);
+          }
+
+          const contribAnnotation =
+            (slots.filterEditsForWiki
+              ? data.annotation?.replace(/^edits for wiki(: )?/, '')
+              : data.annotation);
+
+          if (contribAnnotation) {
+            workingCapsule += '.withAnnotation';
+            workingOptions.annotation = contribAnnotation;
+          }
+
+          if (empty(Object.keys(workingOptions))) {
+            return html.blank();
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }),
+
+      content:
+        language.encapsulate('artistPage.creditList.entry', capsule =>
+          (data.kind === 'track-cover'
+            ? language.$(capsule, 'track', {
+                track: relations.trackLink,
+              })
+            : html.tag('i',
+                language.encapsulate(capsule, 'album', capsule =>
+                  (data.kind === 'wallpaper'
+                    ? language.$(capsule, 'wallpaperArt')
+                 : data.kind === 'banner'
+                    ? language.$(capsule, 'bannerArt')
+                    : language.$(capsule, 'coverArt')))))),
+
+      originDetails:
+        relations.originDetails.slots({
+          mode: 'inline',
+          absorbPunctuationFollowingExternalLinks: false,
+        }),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
index 0beeb271..75a4aa5a 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -1,241 +1,72 @@
-import {sortAlbumsTracksChronologically, sortEntryThingPairs} from '#sort';
-import {chunkByProperties, stitchArrays} from '#sugar';
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {chunkByConditions, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateArtistInfoPageChunk',
     'generateArtistInfoPageChunkedList',
-    'generateArtistInfoPageChunkItem',
-    'generateArtistInfoPageOtherArtistLinks',
-    'linkAlbum',
-    'linkTrack',
+    'generateArtistInfoPageArtworksChunk',
   ],
 
-  extraDependencies: ['html', 'language'],
+  query(artist, filterEditsForWiki) {
+    const query = {};
 
-  query(artist) {
-    // TODO: Add and integrate wallpaper and banner date fields (#90)
-    // This will probably only happen once all artworks follow a standard
-    // shape (#70) and get their own sorting function. Read for more info:
-    // https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607422961
-
-    const processEntry = ({thing, type, track, album, contribs}) => ({
-      thing: thing,
-      entry: {
-        type: type,
-        track: track,
-        album: album,
-        contribs: contribs,
-        date: thing.coverArtDate ?? thing.date,
-      },
-    });
-
-    const processAlbumEntry = ({type, album, contribs}) =>
-      processEntry({
-        thing: album,
-        type: type,
-        track: null,
-        album: album,
-        contribs: contribs,
-      });
-
-    const processTrackEntry = ({type, track, contribs}) =>
-      processEntry({
-        thing: track,
-        type: type,
-        track: track,
-        album: track.album,
-        contribs: contribs,
-      });
-
-    const processAlbumEntries = ({type, albums, contribs}) =>
-      stitchArrays({
-        album: albums,
-        contribs: contribs,
-      }).map(entry =>
-          processAlbumEntry({type, ...entry}));
-
-    const processTrackEntries = ({type, tracks, contribs}) =>
-      stitchArrays({
-        track: tracks,
-        contribs: contribs,
-      }).map(entry =>
-          processTrackEntry({type, ...entry}));
-
-    const {
-      albumsAsCoverArtist,
-      albumsAsWallpaperArtist,
-      albumsAsBannerArtist,
-      tracksAsCoverArtist,
-    } = artist;
-
-    const albumsAsCoverArtistContribs =
-      albumsAsCoverArtist
-        .map(album => album.coverArtistContribs);
-
-    const albumsAsWallpaperArtistContribs =
-      albumsAsWallpaperArtist
-        .map(album => album.wallpaperArtistContribs);
-
-    const albumsAsBannerArtistContribs =
-      albumsAsBannerArtist
-        .map(album => album.bannerArtistContribs);
-
-    const tracksAsCoverArtistContribs =
-      tracksAsCoverArtist
-        .map(track => track.coverArtistContribs);
-
-    const albumsAsCoverArtistEntries =
-      processAlbumEntries({
-        type: 'albumCover',
-        albums: albumsAsCoverArtist,
-        contribs: albumsAsCoverArtistContribs,
-      });
-
-    const albumsAsWallpaperArtistEntries =
-      processAlbumEntries({
-        type: 'albumWallpaper',
-        albums: albumsAsWallpaperArtist,
-        contribs: albumsAsWallpaperArtistContribs,
-      });
-
-    const albumsAsBannerArtistEntries =
-      processAlbumEntries({
-        type: 'albumBanner',
-        albums: albumsAsBannerArtist,
-        contribs: albumsAsBannerArtistContribs,
-      });
-
-    const tracksAsCoverArtistEntries =
-      processTrackEntries({
-        type: 'trackCover',
-        tracks: tracksAsCoverArtist,
-        contribs: tracksAsCoverArtistContribs,
-      });
-
-    const entries = [
-      ...albumsAsCoverArtistEntries,
-      ...albumsAsWallpaperArtistEntries,
-      ...albumsAsBannerArtistEntries,
-      ...tracksAsCoverArtistEntries,
+    const allContributions = [
+      ...artist.albumCoverArtistContributions,
+      ...artist.albumWallpaperArtistContributions,
+      ...artist.albumBannerArtistContributions,
+      ...artist.trackCoverArtistContributions,
     ];
 
-    sortEntryThingPairs(entries,
-      things => sortAlbumsTracksChronologically(things, {
-        getDate: thing => thing.coverArtDate ?? thing.date,
-      }));
-
-    const chunks =
-      chunkByProperties(
-        entries.map(({entry}) => entry),
-        ['album', 'date']);
-
-    return {chunks};
-  },
-
-  relations(relation, query, artist) {
-    return {
-      chunkedList:
-        relation('generateArtistInfoPageChunkedList'),
-
-      chunks:
-        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
-
-      albumLinks:
-        query.chunks.map(({album}) => relation('linkAlbum', album)),
-
-      items:
-        query.chunks.map(({chunk}) =>
-          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
-
-      itemTrackLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) => track ? relation('linkTrack', track) : null)),
-
-      itemOtherArtistLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))),
-    };
+    const filteredContributions =
+      allContributions
+        .filter(({annotation}) =>
+          (filterEditsForWiki
+            ? annotation?.startsWith(`edits for wiki`)
+            : !annotation?.startsWith(`edits for wiki`)));
+
+    sortContributionsChronologically(
+      filteredContributions,
+      sortAlbumsTracksChronologically,
+      {getThing: contrib => contrib.thing.thing});
+
+    query.contribs =
+      chunkByConditions(filteredContributions, [
+        ({date: date1}, {date: date2}) =>
+          +date1 !== +date2,
+        ({thing: {thing: thing1}}, {thing: {thing: thing2}}) =>
+          (thing1.album ?? thing1) !==
+          (thing2.album ?? thing2),
+      ]);
+
+    query.albums =
+      query.contribs
+        .map(contribs => contribs[0].thing.thing)
+        .map(thing => thing.album ?? thing);
+
+    return query;
   },
 
-  data(query, artist) {
-    return {
-      chunkDates:
-        query.chunks.map(({date}) => date),
+  relations: (relation, query, _artist, _filterEditsForWiki) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
 
-      itemTypes:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({type}) => type)),
-
-      itemContributions:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({contribs}) =>
-            contribs
-              .find(({who}) => who === artist)
-              .what)),
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    return relations.chunkedList.slots({
+    chunks:
+      stitchArrays({
+        album: query.albums,
+        contribs: query.contribs,
+      }).map(({album, contribs}) =>
+          relation('generateArtistInfoPageArtworksChunk', album, contribs)),
+  }),
+
+  data: (_query, _artist, filterEditsForWiki) => ({
+    filterEditsForWiki,
+  }),
+
+  generate: (data, relations) =>
+    relations.chunkedList.slots({
       chunks:
-        stitchArrays({
-          chunk: relations.chunks,
-          albumLink: relations.albumLinks,
-          date: data.chunkDates,
-
-          items: relations.items,
-          itemTrackLinks: relations.itemTrackLinks,
-          itemOtherArtistLinks: relations.itemOtherArtistLinks,
-          itemTypes: data.itemTypes,
-          itemContributions: data.itemContributions,
-        }).map(({
-            chunk,
-            albumLink,
-            date,
-
-            items,
-            itemTrackLinks,
-            itemOtherArtistLinks,
-            itemTypes,
-            itemContributions,
-          }) =>
-            chunk.slots({
-              mode: 'album',
-              albumLink,
-              date,
-
-              items:
-                stitchArrays({
-                  item: items,
-                  trackLink: itemTrackLinks,
-                  otherArtistLinks: itemOtherArtistLinks,
-                  type: itemTypes,
-                  contribution: itemContributions,
-                }).map(({
-                    item,
-                    trackLink,
-                    otherArtistLinks,
-                    type,
-                    contribution,
-                  }) =>
-                    item.slots({
-                      otherArtistLinks,
-                      annotation: contribution,
-
-                      content:
-                        (type === 'trackCover'
-                          ? language.$('artistPage.creditList.entry.track', {
-                              track: trackLink,
-                            })
-                          : html.tag('i',
-                              language.$('artistPage.creditList.entry.album.' + {
-                                albumWallpaper: 'wallpaperArt',
-                                albumBanner: 'bannerArt',
-                                albumCover: 'coverArt',
-                              }[type]))),
-                    })),
-            })),
-    });
-  },
+        relations.chunks.map(chunk =>
+          chunk.slot('filterEditsForWiki', data.filterEditsForWiki)),
+    }),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js
index 40943914..fce68a7d 100644
--- a/src/content/dependencies/generateArtistInfoPageChunk.js
+++ b/src/content/dependencies/generateArtistInfoPageChunk.js
@@ -1,3 +1,5 @@
+import {empty} from '#sugar';
+
 export default {
   extraDependencies: ['html', 'language'],
 
@@ -6,6 +8,8 @@ export default {
       validate: v => v.is('flash', 'album'),
     },
 
+    id: {type: 'string'},
+
     albumLink: {
       type: 'html',
       mutable: false,
@@ -21,15 +25,33 @@ export default {
       mutable: false,
     },
 
-    date: {validate: v => v.isDate},
-    dateRangeStart: {validate: v => v.isDate},
-    dateRangeEnd: {validate: v => v.isDate},
+    dates: {
+      validate: v => v.sparseArrayOf(v.isDate),
+    },
 
     duration: {validate: v => v.isDuration},
     durationApproximate: {type: 'boolean'},
   },
 
   generate(slots, {html, language}) {
+    let earliestDate = null;
+    let latestDate = null;
+    let onlyDate = null;
+
+    if (!empty(slots.dates)) {
+      earliestDate =
+        slots.dates
+          .reduce((a, b) => a <= b ? a : b);
+
+      latestDate =
+        slots.dates
+          .reduce((a, b) => a <= b ? b : a);
+
+      if (+earliestDate === +latestDate) {
+        onlyDate = earliestDate;
+      }
+    }
+
     let accentedLink;
 
     accent: {
@@ -40,9 +62,9 @@ export default {
           const options = {album: accentedLink};
           const parts = ['artistPage.creditList.album'];
 
-          if (slots.date) {
+          if (onlyDate) {
             parts.push('withDate');
-            options.date = language.formatDate(slots.date);
+            options.date = language.formatDate(onlyDate);
           }
 
           if (slots.duration) {
@@ -63,16 +85,13 @@ export default {
           const options = {act: accentedLink};
           const parts = ['artistPage.creditList.flashAct'];
 
-          if (
-            slots.dateRangeStart &&
-            slots.dateRangeEnd &&
-            slots.dateRangeStart !== slots.dateRangeEnd
-          ) {
-            parts.push('withDateRange');
-            options.dateRange = language.formatDateRange(slots.dateRangeStart, slots.dateRangeEnd);
-          } else if (slots.dateRangeStart || slots.date) {
+          if (onlyDate) {
             parts.push('withDate');
-            options.date = language.formatDate(slots.dateRangeStart ?? slots.date);
+            options.date = language.formatDate(onlyDate);
+          } else if (earliestDate && latestDate) {
+            parts.push('withDateRange');
+            options.dateRange =
+              language.formatDateRange(earliestDate, latestDate);
           }
 
           accentedLink = language.formatString(...parts, options);
@@ -82,9 +101,13 @@ export default {
     }
 
     return html.tags([
-      html.tag('dt', accentedLink),
+      html.tag('dt',
+        slots.id && {id: slots.id},
+        accentedLink),
+
       html.tag('dd',
         html.tag('ul',
+          {class: 'offset-tooltips'},
           slots.items)),
     ]);
   },
diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js
index b6f40727..c80aeab7 100644
--- a/src/content/dependencies/generateArtistInfoPageChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js
@@ -1,6 +1,14 @@
+import {empty} from '#sugar';
+
 export default {
+  contentDependencies: ['generateTextWithTooltip'],
   extraDependencies: ['html', 'language'],
 
+  relations: (relation) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+  }),
+
   slots: {
     content: {
       type: 'html',
@@ -16,45 +24,80 @@ export default {
       validate: v => v.strictArrayOf(v.isHTML),
     },
 
-    rerelease: {type: 'boolean'},
+    rereleaseTooltip: {
+      type: 'html',
+      mutable: false,
+    },
+
+    firstReleaseTooltip: {
+      type: 'html',
+      mutable: false,
+    },
+
+    originDetails: {
+      type: 'html',
+      mutable: false,
+    },
   },
 
-  generate(slots, {html, language}) {
-    let accentedContent = slots.content;
+  generate: (relations, slots, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry', entryCapsule =>
+      html.tag('li',
+        slots.rerelease && {class: 'rerelease'},
 
-    accent: {
-      if (slots.rerelease) {
-        accentedContent =
-          language.$('artistPage.creditList.entry.rerelease', {
-            entry: accentedContent,
-          });
+        html.tags([
+          language.encapsulate(entryCapsule, workingCapsule => {
+            const workingOptions = {entry: slots.content};
 
-        break accent;
-      }
+            if (!html.isBlank(slots.rereleaseTooltip)) {
+              workingCapsule += '.rerelease';
+              workingOptions.rerelease =
+                relations.textWithTooltip.slots({
+                  attributes: {class: 'rerelease'},
+                  text: language.$(entryCapsule, 'rerelease.term'),
+                  tooltip: slots.rereleaseTooltip,
+                });
 
-      const parts = ['artistPage.creditList.entry'];
-      const options = {entry: accentedContent};
+              return language.$(workingCapsule, workingOptions);
+            }
 
-      if (slots.otherArtistLinks) {
-        parts.push('withArtists');
-        options.artists = language.formatConjunctionList(slots.otherArtistLinks);
-      }
+            if (!html.isBlank(slots.firstReleaseTooltip)) {
+              workingCapsule += '.firstRelease';
+              workingOptions.firstRelease =
+                relations.textWithTooltip.slots({
+                  attributes: {class: 'first-release'},
+                  text: language.$(entryCapsule, 'firstRelease.term'),
+                  tooltip: slots.firstReleaseTooltip,
+                });
 
-      if (!html.isBlank(slots.annotation)) {
-        parts.push('withAnnotation');
-        options.annotation = slots.annotation;
-      }
+              return language.$(workingCapsule, workingOptions);
+            }
 
-      if (parts.length === 1) {
-        break accent;
-      }
+            let anyAccent = false;
 
-      accentedContent = language.formatString(...parts, options);
-    }
+            if (!empty(slots.otherArtistLinks)) {
+              anyAccent = true;
+              workingCapsule += '.withArtists';
+              workingOptions.artists =
+                language.formatConjunctionList(slots.otherArtistLinks);
+            }
 
-    return (
-      html.tag('li',
-        slots.rerelease && {class: 'rerelease'},
-        accentedContent));
-  },
+            if (!html.isBlank(slots.annotation)) {
+              anyAccent = true;
+              workingCapsule += '.withAnnotation';
+              workingOptions.annotation = slots.annotation;
+            }
+
+            if (anyAccent) {
+              return language.$(workingCapsule, workingOptions);
+            } else {
+              return slots.content;
+            }
+          }),
+
+          html.tag('span', {class: 'origin-details'},
+            {[html.onlyIfContent]: true},
+
+            slots.originDetails),
+        ]))),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageChunkedList.js b/src/content/dependencies/generateArtistInfoPageChunkedList.js
index 8503d014..e7915ab7 100644
--- a/src/content/dependencies/generateArtistInfoPageChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageChunkedList.js
@@ -13,11 +13,8 @@ export default {
     },
   },
 
-  generate(slots, {html}) {
-    return (
-      html.tag('dl', [
-        slots.groupInfo,
-        slots.chunks,
-      ]));
-  },
+  generate: (slots, {html}) =>
+    html.tag('dl',
+      {[html.onlyIfContent]: true},
+      [slots.groupInfo, slots.chunks]),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
index 0bcadc7c..88c5ed54 100644
--- a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
@@ -1,184 +1,283 @@
-import {sortAlbumsTracksChronologically, sortEntryThingPairs} from '#sort';
 import {chunkByProperties, stitchArrays} from '#sugar';
 
+import {
+  sortAlbumsTracksChronologically,
+  sortByDate,
+  sortEntryThingPairs,
+} from '#sort';
+
 export default {
   contentDependencies: [
     'generateArtistInfoPageChunk',
     'generateArtistInfoPageChunkItem',
-    'generateArtistInfoPageOtherArtistLinks',
     'linkAlbum',
+    'linkFlash',
+    'linkFlashAct',
     'linkTrack',
     'transformContent',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  query(artist) {
-    const processEntry = ({thing, entry, type, track, album}) => ({
+  query(artist, filterWikiEditorCommentary) {
+    const processEntry = ({
+      thing,
+      entry,
+
+      chunkType,
+      itemType,
+
+      album = null,
+      track = null,
+      flashAct = null,
+      flash = null,
+    }) => ({
       thing: thing,
       entry: {
-        type: type,
-        track: track,
-        album: album,
+        chunkType,
+        itemType,
+
+        album,
+        track,
+        flashAct,
+        flash,
+
         annotation: entry.annotation,
+        annotationParts: entry.annotationParts,
       },
     });
 
-    const processAlbumEntry = ({type, album, entry}) =>
+    const processAlbumEntry = ({thing: album, entry}) =>
       processEntry({
         thing: album,
         entry: entry,
-        type: type,
+
+        chunkType: 'album',
+        itemType: 'album',
+
         album: album,
         track: null,
       });
 
-    const processTrackEntry = ({type, track, entry}) =>
+    const processTrackEntry = ({thing: track, entry}) =>
       processEntry({
         thing: track,
         entry: entry,
-        type: type,
+
+        chunkType: 'album',
+        itemType: 'track',
+
         album: track.album,
         track: track,
       });
 
+    const processFlashEntry = ({thing: flash, entry}) =>
+      processEntry({
+        thing: flash,
+        entry: entry,
+
+        chunkType: 'flash-act',
+        itemType: 'flash',
+
+        flashAct: flash.act,
+        flash: flash,
+      });
+
     const processEntries = ({things, processEntry}) =>
       things
         .flatMap(thing =>
           thing.commentary
             .filter(entry => entry.artists.includes(artist))
+
+            .filter(entry =>
+              (filterWikiEditorCommentary
+                ? entry.isWikiEditorCommentary
+                : !entry.isWikiEditorCommentary))
+
             .map(entry => processEntry({thing, entry})));
 
-    const processAlbumEntries = ({type, albums}) =>
+    const processAlbumEntries = ({albums}) =>
       processEntries({
         things: albums,
-        processEntry: ({thing, entry}) =>
-          processAlbumEntry({
-            type: type,
-            album: thing,
-            entry: entry,
-          }),
+        processEntry: processAlbumEntry,
       });
 
-    const processTrackEntries = ({type, tracks}) =>
+    const processTrackEntries = ({tracks}) =>
       processEntries({
         things: tracks,
-        processEntry: ({thing, entry}) =>
-          processTrackEntry({
-            type: type,
-            track: thing,
-            entry: entry,
-          }),
+        processEntry: processTrackEntry,
       });
 
-    const {albumsAsCommentator, tracksAsCommentator} = artist;
-
-    const trackEntries =
-      processTrackEntries({
-        type: 'track',
-        tracks: tracksAsCommentator,
+    const processFlashEntries = ({flashes}) =>
+      processEntries({
+        things: flashes,
+        processEntry: processFlashEntry,
       });
 
+    const {
+      albumsAsCommentator,
+      tracksAsCommentator,
+      flashesAsCommentator,
+    } = artist;
+
     const albumEntries =
       processAlbumEntries({
-        type: 'album',
         albums: albumsAsCommentator,
       });
 
-    const entries = [
-      ...albumEntries,
-      ...trackEntries,
-    ];
+    const trackEntries =
+      processTrackEntries({
+        tracks: tracksAsCommentator,
+      });
+
+    const flashEntries =
+      processFlashEntries({
+        flashes: flashesAsCommentator,
+      })
 
-    sortEntryThingPairs(entries, sortAlbumsTracksChronologically);
+    const albumTrackEntries =
+      sortEntryThingPairs(
+        [...albumEntries, ...trackEntries],
+        sortAlbumsTracksChronologically);
+
+    const allEntries =
+      sortEntryThingPairs(
+        [...albumTrackEntries, ...flashEntries],
+        sortByDate);
 
     const chunks =
       chunkByProperties(
-        entries.map(({entry}) => entry),
-        ['album']);
+        allEntries.map(({entry}) => entry),
+        ['chunkType', 'album', 'flashAct']);
 
     return {chunks};
   },
 
-  relations(relation, query) {
-    return {
-      chunks:
-        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
+  relations: (relation, query, _artist, filterWikiEditorCommentary) => ({
+    chunks:
+      query.chunks
+        .map(() => relation('generateArtistInfoPageChunk')),
 
-      albumLinks:
-        query.chunks.map(({album}) => relation('linkAlbum', album)),
+    chunkLinks:
+      query.chunks
+        .map(({chunkType, album, flashAct}) =>
+          (chunkType === 'album'
+            ? relation('linkAlbum', album)
+         : chunkType === 'flash-act'
+            ? relation('linkFlashAct', flashAct)
+            : null)),
 
-      items:
-        query.chunks.map(({chunk}) =>
-          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+    items:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(() => relation('generateArtistInfoPageChunkItem'))),
 
-      itemTrackLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) =>
+    itemLinks:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(({track, flash}) =>
             (track
               ? relation('linkTrack', track)
+           : flash
+              ? relation('linkFlash', flash)
               : null))),
 
-      itemAnnotations:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({annotation}) =>
-            (annotation
-              ? relation('transformContent', annotation)
-              : null))),
-    };
-  },
+    itemAnnotations:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(entry =>
+            relation('transformContent',
+              (filterWikiEditorCommentary
+                ? entry.annotationParts
+                    .filter(part => part !== 'wiki editor')
+                    .join(', ')
+                : entry.annotation)))),
+  }),
 
-  data(query) {
-    return {
-      itemTypes:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({type}) => type)),
-    };
-  },
+  data: (query, _artist, _filterWikiEditorCommentary) => ({
+    chunkTypes:
+      query.chunks
+        .map(({chunkType}) => chunkType),
+
+    itemTypes:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(({itemType}) => itemType)),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    html.tag('dl',
+      {[html.onlyIfContent]: true},
 
-  generate(data, relations, {html, language}) {
-    return html.tag('dl',
       stitchArrays({
         chunk: relations.chunks,
-        albumLink: relations.albumLinks,
+        chunkLink: relations.chunkLinks,
+        chunkType: data.chunkTypes,
 
         items: relations.items,
-        itemTrackLinks: relations.itemTrackLinks,
+        itemLinks: relations.itemLinks,
         itemAnnotations: relations.itemAnnotations,
         itemTypes: data.itemTypes,
       }).map(({
           chunk,
-          albumLink,
+          chunkLink,
+          chunkType,
 
           items,
-          itemTrackLinks,
+          itemLinks,
           itemAnnotations,
           itemTypes,
         }) =>
-          chunk.slots({
-            mode: 'album',
-            albumLink,
-            items:
-              stitchArrays({
-                item: items,
-                trackLink: itemTrackLinks,
-                annotation: itemAnnotations,
-                type: itemTypes,
-              }).map(({item, trackLink, annotation, type}) =>
-                item.slots({
-                  annotation:
-                    (annotation
-                      ? annotation.slot('mode', 'inline')
-                      : null),
-
-                  content:
-                    (type === 'album'
-                      ? html.tag('i',
-                          language.$('artistPage.creditList.entry.album.commentary'))
-                      : language.$('artistPage.creditList.entry.track', {
-                          track: trackLink,
-                        })),
-                })),
-          })));
-  },
+          language.encapsulate('artistPage.creditList.entry', capsule =>
+            (chunkType === 'album'
+              ? chunk.slots({
+                  mode: 'album',
+                  albumLink: chunkLink,
+                  items:
+                    stitchArrays({
+                      item: items,
+                      link: itemLinks,
+                      annotation: itemAnnotations,
+                      type: itemTypes,
+                    }).map(({item, link, annotation, type}) =>
+                      item.slots({
+                        annotation:
+                          annotation.slots({
+                            mode: 'inline',
+                            absorbPunctuationFollowingExternalLinks: false,
+                          }),
+
+                        content:
+                          (type === 'album'
+                            ? html.tag('i',
+                                language.$(capsule, 'album.commentary'))
+                            : language.$(capsule, 'track', {track: link})),
+                      })),
+                })
+           : chunkType === 'flash-act'
+              ? chunk.slots({
+                  mode: 'flash',
+                  flashActLink: chunkLink,
+                  items:
+                    stitchArrays({
+                      item: items,
+                      link: itemLinks,
+                      annotation: itemAnnotations,
+                    }).map(({item, link, annotation}) =>
+                      item.slots({
+                        annotation:
+                          (annotation
+                            ? annotation.slots({
+                                mode: 'inline',
+                                absorbPunctuationFollowingExternalLinks: false,
+                              })
+                            : null),
+
+                        content:
+                          language.$(capsule, 'flash', {
+                            flash: link,
+                          }),
+                      })),
+                })
+              : null)))),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
new file mode 100644
index 00000000..f86dead7
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
@@ -0,0 +1,75 @@
+import {sortChronologically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateTooltip',
+    'linkOtherReleaseOnArtistInfoPage',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    rereleases:
+      sortChronologically(track.allReleases).slice(1),
+  }),
+
+  relations: (relation, query, track, artist) => ({
+    tooltip:
+      relation('generateTooltip'),
+
+    firstReleaseColorStyle:
+      relation('generateColorStyleAttribute', track.color),
+
+    rereleaseLinks:
+      query.rereleases
+        .map(rerelease =>
+          relation('linkOtherReleaseOnArtistInfoPage', rerelease, artist)),
+  }),
+
+  data: (query, track) => ({
+    firstReleaseDate:
+      track.dateFirstReleased ??
+      track.album.date,
+
+    rereleaseDates:
+      query.rereleases
+        .map(rerelease =>
+          rerelease.dateFirstReleased ??
+          rerelease.album.date),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry.firstRelease', capsule =>
+      relations.tooltip.slots({
+        attributes: [
+          {class: 'first-release-tooltip'},
+          relations.firstReleaseColorStyle,
+        ],
+
+        contentAttributes: [
+          {[html.joinChildren]: html.tag('hr', {class: 'cute'})},
+        ],
+
+        content:
+          stitchArrays({
+            rereleaseLink: relations.rereleaseLinks,
+            rereleaseDate: data.rereleaseDates,
+          }).map(({rereleaseLink, rereleaseDate}) =>
+              html.tags([
+                language.$(capsule, 'rerelease', {
+                  album:
+                    html.metatag('blockwrap', rereleaseLink),
+                }),
+
+                html.tag('br'),
+
+                language.formatRelativeDate(rereleaseDate, data.firstReleaseDate, {
+                  considerRoundingDays: true,
+                  approximate: true,
+                  absolute: true,
+                }),
+              ])),
+      })),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunk.js b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js
new file mode 100644
index 00000000..8aa7223a
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js
@@ -0,0 +1,34 @@
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageFlashesChunkItem',
+    'linkFlashAct',
+  ],
+
+  relations: (relation, flashAct, contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunk'),
+
+    flashActLink:
+      relation('linkFlashAct', flashAct),
+
+    items:
+      contribs
+        .map(contrib =>
+          relation('generateArtistInfoPageFlashesChunkItem', contrib)),
+  }),
+
+  data: (_flashAct, contribs) => ({
+    dates:
+      contribs
+        .map(contrib => contrib.date),
+  }),
+
+  generate: (data, relations) =>
+    relations.template.slots({
+      mode: 'flash',
+      flashActLink: relations.flashActLink,
+      dates: data.dates,
+      items: relations.items,
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js
new file mode 100644
index 00000000..e4908bf9
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js
@@ -0,0 +1,34 @@
+export default {
+  contentDependencies: ['generateArtistInfoPageChunkItem', 'linkFlash'],
+
+  extraDependencies: ['language'],
+
+  relations: (relation, contrib) => ({
+    // Flashes and games can list multiple contributors as collaborative
+    // credits, but we don't display these on the artist page, since they
+    // usually involve many artists crediting a larger team where collaboration
+    // isn't as relevant (without more particular details that aren't tracked
+    // on the wiki).
+
+    template:
+      relation('generateArtistInfoPageChunkItem'),
+
+    flashLink:
+      relation('linkFlash', contrib.thing),
+  }),
+
+  data: (contrib) => ({
+    annotation:
+      contrib.annotation,
+  }),
+
+  generate: (data, relations, {language}) =>
+    relations.template.slots({
+      annotation: data.annotation,
+
+      content:
+        language.$('artistPage.creditList.entry.flash', {
+          flash: relations.flashLink,
+        }),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
index 88a97af2..b347faf5 100644
--- a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
@@ -1,149 +1,62 @@
-import {sortEntryThingPairs, sortFlashesChronologically} from '#sort';
-import {chunkByProperties, stitchArrays} from '#sugar';
+import {sortContributionsChronologically, sortFlashesChronologically}
+  from '#sort';
+import {chunkByConditions, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateArtistInfoPageChunk',
-    'generateArtistInfoPageChunkItem',
-    'linkFlash',
+    'generateArtistInfoPageChunkedList',
+    'generateArtistInfoPageFlashesChunk',
   ],
 
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['wikiData'],
 
-  query(artist) {
-    const processFlashEntry = ({flash, contribs}) => ({
-      thing: flash,
-      entry: {
-        flash: flash,
-        act: flash.act,
-        contribs: contribs,
-      },
-    });
+  sprawl: ({wikiInfo}) => ({
+    enableFlashesAndGames:
+      wikiInfo.enableFlashesAndGames,
+  }),
 
-    const processFlashEntries = ({flashes, contribs}) =>
-      stitchArrays({
-        flash: flashes,
-        contribs: contribs,
-      }).map(processFlashEntry);
-
-    const {flashesAsContributor} = artist;
-
-    const flashesAsContributorContribs =
-      flashesAsContributor
-        .map(flash => flash.contributorContribs);
-
-    const flashesAsContributorEntries =
-      processFlashEntries({
-        flashes: flashesAsContributor,
-        contribs: flashesAsContributorContribs,
-      });
-
-    const entries = [
-      ...flashesAsContributorEntries,
-    ];
-
-    sortEntryThingPairs(entries, sortFlashesChronologically);
-
-    const chunks =
-      chunkByProperties(
-        entries.map(({entry}) => entry),
-        ['act']);
-
-    return {chunks};
-  },
+  query(sprawl, artist) {
+    const query = {};
 
-  relations(relation, query) {
-    // Flashes and games can list multiple contributors as collaborative
-    // credits, but we don't display these on the artist page, since they
-    // usually involve many artists crediting a larger team where collaboration
-    // isn't as relevant (without more particular details that aren't tracked
-    // on the wiki).
+    const allContributions =
+      (sprawl.enableFlashesAndGames
+        ? [
+            ...artist.flashContributorContributions,
+          ]
+      : []);
 
-    return {
-      chunks:
-        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
+    sortContributionsChronologically(
+      allContributions,
+      sortFlashesChronologically);
 
-      actLinks:
-        query.chunks.map(({chunk}) =>
-          relation('linkFlash', chunk[0].flash)),
+    query.contribs =
+      chunkByConditions(allContributions, [
+        ({thing: flash1}, {thing: flash2}) =>
+          flash1.act !== flash2.act,
+      ]);
 
-      items:
-        query.chunks.map(({chunk}) =>
-          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+    query.flashActs =
+      query.contribs
+        .map(contribs => contribs[0].thing)
+        .map(thing => thing.act);
 
-      itemFlashLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({flash}) => relation('linkFlash', flash))),
-    };
+    return query;
   },
 
-  data(query, artist) {
-    return {
-      actNames:
-        query.chunks.map(({act}) => act.name),
+  relations: (relation, query, _sprawl, _artist) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
 
-      firstDates:
-        query.chunks.map(({chunk}) => chunk[0].flash.date ?? null),
-
-      lastDates:
-        query.chunks.map(({chunk}) => chunk.at(-1).flash.date ?? null),
-
-      itemContributions:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({contribs}) =>
-            contribs
-              .find(({who}) => who === artist)
-              .what)),
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    return html.tag('dl',
+    chunks:
       stitchArrays({
-        chunk: relations.chunks,
-        actLink: relations.actLinks,
-        actName: data.actNames,
-        firstDate: data.firstDates,
-        lastDate: data.lastDates,
-
-        items: relations.items,
-        itemFlashLinks: relations.itemFlashLinks,
-        itemContributions: data.itemContributions,
-      }).map(({
-          chunk,
-          actLink,
-          actName,
-          firstDate,
-          lastDate,
-
-          items,
-          itemFlashLinks,
-          itemContributions,
-        }) =>
-          chunk.slots({
-            mode: 'flash',
-            flashActLink: actLink.slot('content', actName),
-            dateRangeStart: firstDate,
-            dateRangeEnd: lastDate,
-
-            items:
-              stitchArrays({
-                item: items,
-                flashLink: itemFlashLinks,
-                contribution: itemContributions,
-              }).map(({
-                  item,
-                  flashLink,
-                  contribution,
-                }) =>
-                  item.slots({
-                    annotation: contribution,
-
-                    content:
-                      language.$('artistPage.creditList.entry.flash', {
-                        flash: flashLink,
-                      }),
-                  })),
-          })));
-  },
+        flashAct: query.flashActs,
+        contribs: query.contribs,
+      }).map(({flashAct, contribs}) =>
+          relation('generateArtistInfoPageFlashesChunk', flashAct, contribs)),
+  }),
+
+  generate: (relations) =>
+    relations.chunkedList.slots({
+      chunks: relations.chunks,
+    }),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
index dea7742a..dcee9c00 100644
--- a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
+++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
@@ -1,23 +1,30 @@
-import {empty} from '#sugar';
+import {unique} from '#sugar';
 
 export default {
   contentDependencies: ['linkArtist'],
 
-  relations(relation, contribs, artist) {
-    const otherArtistContribs = contribs.filter(({who}) => who !== artist);
+  query(contribs) {
+    const associatedContributionsByOtherArtists =
+      contribs
+        .flatMap(ownContrib =>
+          ownContrib.associatedContributions
+            .filter(associatedContrib =>
+              associatedContrib.artist !== ownContrib.artist));
 
-    if (empty(otherArtistContribs)) {
-      return {};
-    }
+    const otherArtists =
+      unique(
+        associatedContributionsByOtherArtists
+          .map(contrib => contrib.artist));
 
-    const otherArtistLinks =
-      otherArtistContribs
-        .map(({who}) => relation('linkArtist', who));
-
-    return {otherArtistLinks};
+    return {otherArtists};
   },
 
-  generate(relations) {
-    return relations.otherArtistLinks ?? null;
-  },
+  relations: (relation, query) => ({
+    artistLinks:
+      query.otherArtists
+        .map(artist => relation('linkArtist', artist)),
+  }),
+
+  generate: (relations) =>
+    relations.artistLinks,
 };
diff --git a/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
new file mode 100644
index 00000000..1d849919
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
@@ -0,0 +1,61 @@
+import {sortChronologically} from '#sort';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateTooltip',
+    'linkOtherReleaseOnArtistInfoPage'
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    firstRelease:
+      sortChronologically(track.allReleases)[0],
+  }),
+
+  relations: (relation, query, track, artist) => ({
+    tooltip:
+      relation('generateTooltip'),
+
+    rereleaseColorStyle:
+      relation('generateColorStyleAttribute', track.color),
+
+    firstReleaseLink:
+      relation('linkOtherReleaseOnArtistInfoPage', query.firstRelease, artist),
+  }),
+
+  data: (query, track) => ({
+    rereleaseDate:
+      track.dateFirstReleased ??
+      track.album.date,
+
+    firstReleaseDate:
+      query.firstRelease.dateFirstReleased ??
+      query.firstRelease.album.date,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry.rerelease', capsule =>
+      relations.tooltip.slots({
+        attributes: [
+          {class: 'rerelease-tooltip'},
+          relations.rereleaseColorStyle,
+        ],
+
+        content: [
+          language.$(capsule, 'firstRelease', {
+            album:
+              html.metatag('blockwrap', relations.firstReleaseLink),
+          }),
+
+          html.tag('br'),
+
+          language.formatRelativeDate(data.firstReleaseDate, data.rereleaseDate, {
+            considerRoundingDays: true,
+            approximate: true,
+            absolute: true,
+          }),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunk.js b/src/content/dependencies/generateArtistInfoPageTracksChunk.js
new file mode 100644
index 00000000..f6d70901
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunk.js
@@ -0,0 +1,67 @@
+import {unique} from '#sugar';
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageTracksChunkItem',
+    'linkAlbum',
+  ],
+
+  relations: (relation, artist, album, trackContribLists) => ({
+    template:
+      relation('generateArtistInfoPageChunk'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    // Intentional mapping here: each item may be associated with
+    // more than one contribution.
+    items:
+      trackContribLists.map(trackContribs =>
+        relation('generateArtistInfoPageTracksChunkItem',
+          artist,
+          trackContribs)),
+  }),
+
+  data(_artist, album, trackContribLists) {
+    const data = {};
+
+    const contribs =
+      trackContribLists.flat();
+
+    data.dates =
+      contribs
+        .map(contrib => contrib.date);
+
+    // TODO: Duration stuff should *maybe* be in proper data logic? Maaaybe?
+    const durationTerms =
+      unique(
+        contribs
+          .filter(contrib => contrib.countInDurationTotals)
+          .map(contrib => contrib.thing)
+          .filter(track => track.isMainRelease)
+          .filter(track => track.duration > 0));
+
+    data.duration =
+      getTotalDuration(durationTerms);
+
+    data.durationApproximate =
+      durationTerms.length > 1;
+
+    return data;
+  },
+
+  generate: (data, relations) =>
+    relations.template.slots({
+      mode: 'album',
+
+      albumLink: relations.albumLink,
+
+      dates: data.dates,
+      duration: data.duration,
+      durationApproximate: data.durationApproximate,
+
+      items: relations.items,
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
new file mode 100644
index 00000000..a42d6fee
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
@@ -0,0 +1,146 @@
+import {sortChronologically} from '#sort';
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageFirstReleaseTooltip',
+    'generateArtistInfoPageOtherArtistLinks',
+    'generateArtistInfoPageRereleaseTooltip',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query (_artist, contribs) {
+    const query = {};
+
+    // TODO: Very mysterious what to do if the set of contributions is,
+    // in total, associated with more than one thing. No design yet.
+    query.track =
+      contribs[0].thing;
+
+    const creditedAsArtist =
+      contribs
+        .some(contrib => contrib.isArtistContribution);
+
+    const creditedAsContributor =
+      contribs
+        .some(contrib => contrib.isContributorContribution);
+
+    const annotatedContribs =
+      contribs
+        .filter(contrib => contrib.annotation);
+
+    const annotatedArtistContribs =
+      annotatedContribs
+        .filter(contrib => contrib.isArtistContribution);
+
+    const annotatedContributorContribs =
+      annotatedContribs
+        .filter(contrib => contrib.isContributorContribution);
+
+    // Don't display annotations associated with crediting in the
+    // Contributors field if the artist is also credited as an Artist
+    // *and* the Artist-field contribution is non-annotated. This is
+    // so that we don't misrepresent the artist - the contributor
+    // annotation tends to be for "secondary" and performance roles.
+    // For example, this avoids crediting Marcy Nabors on Renewed
+    // Return seemingly only for "bass clarinet" when they're also
+    // the one who composed and arranged Renewed Return!
+    if (
+      creditedAsArtist &&
+      creditedAsContributor &&
+      empty(annotatedArtistContribs)
+    ) {
+      query.displayedContributions = null;
+    } else if (
+      !empty(annotatedArtistContribs) ||
+      !empty(annotatedContributorContribs)
+    ) {
+      query.displayedContributions = [
+        ...annotatedArtistContribs,
+        ...annotatedContributorContribs,
+      ];
+    }
+
+    // It's kinda awkward to perform this chronological sort here,
+    // per track, rather than just reusing the one that's done to
+    // sort all the items on the page altogether... but then, the
+    // sort for the page is actually *a different* sort, on purpsoe.
+    // That sort is according to the dates of the contributions;
+    // this is according to the dates of the tracks. Those can be
+    // different - and it's the latter that determines whether the
+    // track is a rerelease!
+    const allReleasesChronologically =
+      sortChronologically(query.track.allReleases);
+
+    query.isFirstRelease =
+      allReleasesChronologically[0] === query.track;
+
+    query.isRerelease =
+      allReleasesChronologically[0] !== query.track;
+
+    query.hasOtherReleases =
+      !empty(query.track.otherReleases);
+
+    return query;
+  },
+
+  relations: (relation, query, artist, contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunkItem'),
+
+    trackLink:
+      relation('linkTrack', query.track),
+
+    otherArtistLinks:
+      relation('generateArtistInfoPageOtherArtistLinks', contribs),
+
+    rereleaseTooltip:
+      (query.isRerelease
+        ? relation('generateArtistInfoPageRereleaseTooltip', query.track, artist)
+        : null),
+
+    firstReleaseTooltip:
+      (query.isFirstRelease && query.hasOtherReleases
+        ? relation('generateArtistInfoPageFirstReleaseTooltip', query.track, artist)
+        : null),
+  }),
+
+  data: (query) => ({
+    duration:
+      query.track.duration,
+
+    contribAnnotations:
+      (query.displayedContributions
+        ? query.displayedContributions
+            .map(contrib => contrib.annotation)
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.template.slots({
+      otherArtistLinks: relations.otherArtistLinks,
+      rereleaseTooltip: relations.rereleaseTooltip,
+      firstReleaseTooltip: relations.firstReleaseTooltip,
+
+      annotation:
+        (data.contribAnnotations
+          ? language.formatUnitList(data.contribAnnotations)
+          : html.blank()),
+
+      content:
+        language.encapsulate('artistPage.creditList.entry.track', workingCapsule => {
+          const workingOptions = {track: relations.trackLink};
+
+          if (data.duration) {
+            workingCapsule += '.withDuration';
+            workingOptions.duration =
+              language.formatDuration(data.duration);
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
index f003779d..84eb29ac 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -1,293 +1,81 @@
-import {sortAlbumsTracksChronologically, sortEntryThingPairs} from '#sort';
-import {accumulateSum, chunkByProperties, empty, stitchArrays} from '#sugar';
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {stitchArrays} from '#sugar';
+import {chunkArtistTrackContributions} from '#wiki-data';
 
 export default {
   contentDependencies: [
-    'generateArtistInfoPageChunk',
     'generateArtistInfoPageChunkedList',
-    'generateArtistInfoPageChunkItem',
-    'generateArtistInfoPageOtherArtistLinks',
-    'linkAlbum',
-    'linkTrack',
+    'generateArtistInfoPageTracksChunk',
   ],
 
-  extraDependencies: ['html', 'language'],
-
   query(artist) {
-    const processTrackEntry = ({track, contribs}) => ({
-      thing: track,
-      entry: {
-        track: track,
-        album: track.album,
-        date: track.date,
-        contribs: contribs,
-      },
-    });
-
-    const processTrackEntries = ({tracks, contribs}) =>
-      stitchArrays({
-        track: tracks,
-        contribs: contribs,
-      }).map(processTrackEntry);
-
-    const {tracksAsArtist, tracksAsContributor} = artist;
-
-    const tracksAsArtistAndContributor =
-      tracksAsArtist
-        .filter(track => tracksAsContributor.includes(track));
-
-    const tracksAsArtistOnly =
-      tracksAsArtist
-        .filter(track => !tracksAsContributor.includes(track));
-
-    const tracksAsContributorOnly =
-      tracksAsContributor
-        .filter(track => !tracksAsArtist.includes(track));
-
-    const tracksAsArtistAndContributorContribs =
-      tracksAsArtistAndContributor
-        .map(track => [
-          ...
-            track.artistContribs
-              .map(contrib => ({...contrib, kind: 'artist'})),
-          ...
-            track.contributorContribs
-              .map(contrib => ({...contrib, kind: 'contributor'})),
-        ]);
-
-    const tracksAsArtistOnlyContribs =
-      tracksAsArtistOnly
-        .map(track => track.artistContribs
-          .map(contrib => ({...contrib, kind: 'artist'})));
-
-    const tracksAsContributorOnlyContribs =
-      tracksAsContributorOnly
-        .map(track => track.contributorContribs
-          .map(contrib => ({...contrib, kind: 'contributor'})));
+    const query = {};
 
-    const tracksAsArtistAndContributorEntries =
-      processTrackEntries({
-        tracks: tracksAsArtistAndContributor,
-        contribs: tracksAsArtistAndContributorContribs,
-      });
-
-    const tracksAsArtistOnlyEntries =
-      processTrackEntries({
-        tracks: tracksAsArtistOnly,
-        contribs: tracksAsArtistOnlyContribs,
-      });
-
-    const tracksAsContributorOnlyEntries =
-      processTrackEntries({
-        tracks: tracksAsContributorOnly,
-        contribs: tracksAsContributorOnlyContribs,
-      });
-
-    const entries = [
-      ...tracksAsArtistAndContributorEntries,
-      ...tracksAsArtistOnlyEntries,
-      ...tracksAsContributorOnlyEntries,
+    const allContributions = [
+      ...artist.trackArtistContributions,
+      ...artist.trackContributorContributions,
     ];
 
-    sortEntryThingPairs(entries, sortAlbumsTracksChronologically);
-
-    const chunks =
-      chunkByProperties(
-        entries.map(({entry}) => entry),
-        ['album', 'date']);
-
-    return {chunks};
-  },
-
-  relations(relation, query, artist) {
-    return {
-      chunkedList:
-        relation('generateArtistInfoPageChunkedList'),
-
-      chunks:
-        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
-
-      albumLinks:
-        query.chunks.map(({album}) => relation('linkAlbum', album)),
+    sortContributionsChronologically(
+      allContributions,
+      sortAlbumsTracksChronologically);
 
-      items:
-        query.chunks.map(({chunk}) =>
-          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+    query.contribs =
+      chunkArtistTrackContributions(allContributions);
 
-      trackLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) => relation('linkTrack', track))),
+    query.albums =
+      query.contribs
+        .map(contribs =>
+          contribs[0][0].thing.album);
 
-      trackOtherArtistLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))),
-    };
+    return query;
   },
 
-  data(query, artist) {
-    return {
-      chunkDates:
-        query.chunks.map(({date}) => date),
-
-      chunkDurations:
-        query.chunks.map(({chunk}) =>
-          accumulateSum(
-            chunk
-              .filter(({track}) => track.duration && track.originalReleaseTrack === null)
-              .map(({track}) => track.duration))),
-
-      chunkDurationsApproximate:
-        query.chunks.map(({chunk}) =>
-          chunk
-            .filter(({track}) => track.duration && track.originalReleaseTrack === null)
-            .length > 1),
-
-      trackDurations:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) => track.duration)),
-
-      trackContributions:
-        query.chunks.map(({chunk}) =>
-          chunk
-            .map(({contribs}) =>
-              contribs.filter(({who}) => who === artist))
-            .map(ownContribs => ({
-              creditedAsArtist:
-                ownContribs
-                  .some(({kind}) => kind === 'artist'),
-
-              creditedAsContributor:
-                ownContribs
-                  .some(({kind}) => kind === 'contributor'),
-
-              annotatedContribs:
-                ownContribs
-                  .filter(({what}) => what),
-            }))
-            .map(({annotatedContribs, ...rest}) => ({
-              ...rest,
-
-              annotatedArtistContribs:
-                annotatedContribs
-                  .filter(({kind}) => kind === 'artist'),
-
-              annotatedContributorContribs:
-                annotatedContribs
-                  .filter(({kind}) => kind === 'contributor'),
-            }))
-            .map(({
-              creditedAsArtist,
-              creditedAsContributor,
-              annotatedArtistContribs,
-              annotatedContributorContribs,
-            }) => {
-              // Don't display annotations associated with crediting in the
-              // Contributors field if the artist is also credited as an Artist
-              // *and* the Artist-field contribution is non-annotated. This is
-              // so that we don't misrepresent the artist - the contributor
-              // annotation tends to be for "secondary" and performance roles.
-              // For example, this avoids crediting Marcy Nabors on Renewed
-              // Return seemingly only for "bass clarinet" when they're also
-              // the one who composed and arranged Renewed Return!
-              if (
-                creditedAsArtist &&
-                creditedAsContributor &&
-                empty(annotatedArtistContribs)
-              ) {
-                return [];
-              }
-
-              return [
-                ...annotatedArtistContribs,
-                ...annotatedContributorContribs,
-              ];
-            })
-            .map(contribs =>
-              contribs.map(({what}) => what))
-            .map(contributions =>
-              (empty(contributions)
-                ? null
-                : contributions))),
+  relations: (relation, query, artist) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
 
-      trackRereleases:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) => track.originalReleaseTrack !== null)),
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    return relations.chunkedList.slots({
+    chunks:
+      stitchArrays({
+        album: query.albums,
+        contribs: query.contribs,
+      }).map(({album, contribs}) =>
+          relation('generateArtistInfoPageTracksChunk',
+            artist,
+            album,
+            contribs)),
+  }),
+
+  data: (query, _artist) => ({
+    albumDirectories:
+      query.albums
+        .map(album => album.directory),
+
+    albumChunkIndices:
+      query.albums
+        .reduce(([indices, map], album) => {
+          if (map.has(album)) {
+            const n = map.get(album);
+            indices.push(n);
+            map.set(album, n + 1);
+          } else {
+            indices.push(0);
+            map.set(album, 1);
+          }
+          return [indices, map];
+        }, [[], new Map()])
+        [0],
+  }),
+
+  generate: (data, relations) =>
+    relations.chunkedList.slots({
       chunks:
         stitchArrays({
           chunk: relations.chunks,
-          albumLink: relations.albumLinks,
-          date: data.chunkDates,
-          duration: data.chunkDurations,
-          durationApproximate: data.chunkDurationsApproximate,
-
-          items: relations.items,
-          trackLinks: relations.trackLinks,
-          trackOtherArtistLinks: relations.trackOtherArtistLinks,
-          trackDurations: data.trackDurations,
-          trackContributions: data.trackContributions,
-          trackRereleases: data.trackRereleases,
-        }).map(({
-            chunk,
-            albumLink,
-            date,
-            duration,
-            durationApproximate,
-
-            items,
-            trackLinks,
-            trackOtherArtistLinks,
-            trackDurations,
-            trackContributions,
-            trackRereleases,
-          }) =>
-            chunk.slots({
-              mode: 'album',
-              albumLink,
-              date,
-              duration,
-              durationApproximate,
-
-              items:
-                stitchArrays({
-                  item: items,
-                  trackLink: trackLinks,
-                  otherArtistLinks: trackOtherArtistLinks,
-                  duration: trackDurations,
-                  contribution: trackContributions,
-                  rerelease: trackRereleases,
-                }).map(({
-                    item,
-                    trackLink,
-                    otherArtistLinks,
-                    duration,
-                    contribution,
-                    rerelease,
-                  }) =>
-                    item.slots({
-                      otherArtistLinks,
-                      rerelease,
-
-                      annotation:
-                        (contribution
-                          ? language.formatUnitList(contribution)
-                          : html.blank()),
-
-                      content:
-                        (duration
-                          ? language.$('artistPage.creditList.entry.track.withDuration', {
-                              track: trackLink,
-                              duration: language.formatDuration(duration),
-                            })
-                          : language.$('artistPage.creditList.entry.track', {
-                              track: trackLink,
-                            })),
-                    })),
-            })),
-    });
-  },
+          albumDirectory: data.albumDirectories,
+          albumChunkIndex: data.albumChunkIndices,
+        }).map(({chunk, albumDirectory, albumChunkIndex}) =>
+            chunk.slot('id', `tracks-${albumDirectory}-${albumChunkIndex}`)),
+    }),
 };
diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js
index aa95dba2..1b4b6eca 100644
--- a/src/content/dependencies/generateArtistNavLinks.js
+++ b/src/content/dependencies/generateArtistNavLinks.js
@@ -2,43 +2,44 @@ import {empty} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateInterpageDotSwitcher',
     'linkArtist',
     'linkArtistGallery',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({wikiInfo}) {
-    return {
-      enableListings: wikiInfo.enableListings,
-    };
-  },
+  sprawl: ({wikiInfo}) => ({
+    enableListings:
+      wikiInfo.enableListings,
+  }),
 
-  relations(relation, sprawl, artist) {
-    const relations = {};
+  query: (_sprawl, artist) => ({
+    hasGallery:
+      !empty(artist.albumCoverArtistContributions) ||
+      !empty(artist.trackCoverArtistContributions),
+  }),
 
-    relations.artistMainLink =
-      relation('linkArtist', artist);
+  relations: (relation, query, _sprawl, artist) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
 
-    relations.artistInfoLink =
-      relation('linkArtist', artist);
+    artistMainLink:
+      relation('linkArtist', artist),
 
-    if (
-      !empty(artist.albumsAsCoverArtist) ||
-      !empty(artist.tracksAsCoverArtist)
-    ) {
-      relations.artistGalleryLink =
-        relation('linkArtistGallery', artist);
-    }
+    artistInfoLink:
+      relation('linkArtist', artist),
 
-    return relations;
-  },
+    artistGalleryLink:
+      (query.hasGallery
+        ? relation('linkArtistGallery', artist)
+        : null),
+  }),
 
-  data(sprawl) {
-    return {
-      enableListings: sprawl.enableListings,
-    };
-  },
+  data: (_query, sprawl) => ({
+    enableListings:
+      sprawl.enableListings,
+  }),
 
   slots: {
     showExtraLinks: {type: 'boolean', default: false},
@@ -48,53 +49,46 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {html, language}) {
-    const infoLink =
-      relations.artistInfoLink?.slots({
-        attributes: {class: slots.currentExtra === null && 'current'},
-        content: language.$('misc.nav.info'),
-      });
-
-    const {content: extraLinks = []} =
-      slots.showExtraLinks &&
-        {content: [
-          relations.artistGalleryLink?.slots({
-            attributes: {class: slots.currentExtra === 'gallery' && 'current'},
-            content: language.$('misc.nav.gallery'),
-          }),
-        ]};
-
-    const mostAccentLinks = [
-      ...extraLinks,
-    ].filter(Boolean);
-
-    // Don't show the info accent link all on its own.
-    const allAccentLinks =
-      (empty(mostAccentLinks)
-        ? []
-        : [infoLink, ...mostAccentLinks]);
-
-    const accent =
-      (empty(allAccentLinks)
-        ? html.blank()
-        : `(${language.formatUnitList(allAccentLinks)})`);
-
-    return [
-      {auto: 'home'},
-
-      data.enableListings &&
-        {
-          path: ['localized.listingIndex'],
-          title: language.$('listingIndex.title'),
-        },
+  generate: (data, relations, slots, {html, language}) => [
+    {auto: 'home'},
 
+    data.enableListings &&
       {
-        accent,
-        html:
-          language.$('artistPage.nav.artist', {
-            artist: relations.artistMainLink,
-          }),
+        path: ['localized.listingIndex'],
+        title: language.$('listingIndex.title'),
       },
-    ];
-  },
+
+    {
+      html:
+        language.$('artistPage.nav.artist', {
+          artist: relations.artistMainLink,
+        }),
+
+      accent:
+        relations.switcher.slots({
+          links: [
+            relations.artistInfoLink.slots({
+              attributes: [
+                slots.currentExtra === null &&
+                  {class: 'current'},
+
+                {[html.onlyIfSiblings]: true},
+              ],
+
+              content: language.$('misc.nav.info'),
+            }),
+
+            slots.showExtraLinks &&
+              relations.artistGalleryLink?.slots({
+                attributes: [
+                  slots.currentExtra === 'gallery' &&
+                    {class: 'current'},
+                ],
+
+                content: language.$('misc.nav.gallery'),
+              }),
+          ],
+        }),
+    },
+  ],
 };
diff --git a/src/content/dependencies/generateBackToAlbumLink.js b/src/content/dependencies/generateBackToAlbumLink.js
new file mode 100644
index 00000000..6648b463
--- /dev/null
+++ b/src/content/dependencies/generateBackToAlbumLink.js
@@ -0,0 +1,15 @@
+export default {
+  contentDependencies: ['linkAlbum'],
+  extraDependencies: ['language'],
+
+  relations: (relation, track) => ({
+    trackLink:
+      relation('linkAlbum', track),
+  }),
+
+  generate: (relations, {language}) =>
+    relations.trackLink.slots({
+      content: language.$('albumPage.nav.backToAlbum'),
+      color: false,
+    }),
+};
diff --git a/src/content/dependencies/generateBackToTrackLink.js b/src/content/dependencies/generateBackToTrackLink.js
new file mode 100644
index 00000000..8677d811
--- /dev/null
+++ b/src/content/dependencies/generateBackToTrackLink.js
@@ -0,0 +1,15 @@
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['language'],
+
+  relations: (relation, track) => ({
+    trackLink:
+      relation('linkTrack', track),
+  }),
+
+  generate: (relations, {language}) =>
+    relations.trackLink.slots({
+      content: language.$('trackPage.nav.backToTrack'),
+      color: false,
+    }),
+};
diff --git a/src/content/dependencies/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js
deleted file mode 100644
index 8ec6ee0a..00000000
--- a/src/content/dependencies/generateChronologyLinks.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import {accumulateSum, empty} from '#sugar';
-
-export default {
-  extraDependencies: ['html', 'language'],
-
-  slots: {
-    chronologyInfoSets: {
-      validate: v =>
-        v.strictArrayOf(
-          v.validateProperties({
-            headingString: v.isString,
-            contributions: v.strictArrayOf(v.validateProperties({
-              index: v.isCountingNumber,
-              artistLink: v.isHTML,
-              previousLink: v.isHTML,
-              nextLink: v.isHTML,
-            })),
-          })),
-    }
-  },
-
-  generate(slots, {html, language}) {
-    if (empty(slots.chronologyInfoSets)) {
-      return html.blank();
-    }
-
-    const totalContributionCount =
-      accumulateSum(
-        slots.chronologyInfoSets,
-        ({contributions}) => contributions.length);
-
-    if (totalContributionCount === 0) {
-      return html.blank();
-    }
-
-    if (totalContributionCount > 8) {
-      return html.tag('div', {class: 'chronology'},
-        language.$('misc.chronology.seeArtistPages'));
-    }
-
-    return html.tags(
-      slots.chronologyInfoSets.map(({
-        headingString,
-        contributions,
-      }) =>
-        contributions.map(({
-          index,
-          artistLink,
-          previousLink,
-          nextLink,
-        }) => {
-          const heading =
-            html.tag('span', {class: 'heading'},
-              language.$(headingString, {
-                index: language.formatIndex(index),
-                artist: artistLink,
-              }));
-
-          const navigation =
-            (previousLink || nextLink) &&
-              html.tag('span', {class: 'buttons'},
-                language.formatUnitList([
-                  previousLink?.slots({
-                    tooltipStyle: 'browser',
-                    color: false,
-                    content: language.$('misc.nav.previous'),
-                  }),
-
-                  nextLink?.slots({
-                    tooltipStyle: 'browser',
-                    color: false,
-                    content: language.$('misc.nav.next'),
-                  }),
-                ].filter(Boolean)));
-
-          return html.tag('div', {class: 'chronology'},
-            (navigation
-              ? language.$('misc.chronology.withNavigation', {heading, navigation})
-              : heading));
-        })));
-  },
-};
diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js
deleted file mode 100644
index c412b8f2..00000000
--- a/src/content/dependencies/generateColorStyleRules.js
+++ /dev/null
@@ -1,42 +0,0 @@
-export default {
-  contentDependencies: ['generateColorStyleVariables'],
-  extraDependencies: ['html'],
-
-  relations: (relation) => ({
-    variables:
-      relation('generateColorStyleVariables'),
-  }),
-
-  data: (color) => ({
-    color:
-      color ?? null,
-  }),
-
-  slots: {
-    color: {
-      validate: v => v.isColor,
-    },
-  },
-
-  generate(data, relations, slots) {
-    const color = data.color ?? slots.color;
-
-    if (!color) {
-      return '';
-    }
-
-    return [
-      `:root {`,
-      ...(
-        relations.variables
-          .slots({
-            color,
-            context: 'page-root',
-            mode: 'property-list',
-          })
-          .content
-          .map(line => line + ';')),
-      `}`,
-    ].join('\n');
-  },
-};
diff --git a/src/content/dependencies/generateColorStyleTag.js b/src/content/dependencies/generateColorStyleTag.js
new file mode 100644
index 00000000..2b1a21dd
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleTag.js
@@ -0,0 +1,51 @@
+export default {
+  contentDependencies: ['generateColorStyleVariables', 'generateStyleTag'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    styleTag:
+      relation('generateStyleTag'),
+
+    variables:
+      relation('generateColorStyleVariables'),
+  }),
+
+  data: (color) => ({
+    color:
+      color ?? null,
+  }),
+
+  slots: {
+    color: {
+      validate: v => v.isColor,
+    },
+  },
+
+  generate(data, relations, slots, {html}) {
+    const color =
+      data.color ?? slots.color;
+
+    if (!color) {
+      return html.blank();
+    }
+
+    return relations.styleTag.slots({
+      attributes: [
+        {class: 'color-style'},
+        {'data-color': color},
+      ],
+
+      rules: [
+        {
+          select: ':root',
+          declare:
+            relations.variables.slots({
+              color,
+              context: 'page-root',
+              mode: 'declarations',
+            }).content,
+        },
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js
index 069d85dd..c872d0b6 100644
--- a/src/content/dependencies/generateColorStyleVariables.js
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -18,7 +18,7 @@ export default {
     },
 
     mode: {
-      validate: v => v.is('style', 'property-list'),
+      validate: v => v.is('style', 'declarations'),
       default: 'style',
     },
   },
@@ -32,6 +32,7 @@ export default {
       dim,
       deep,
       deepGhost,
+      lightGhost,
       bg,
       bgBlack,
       shadow,
@@ -43,20 +44,21 @@ export default {
       `--dim-color: ${dim}`,
       `--deep-color: ${deep}`,
       `--deep-ghost-color: ${deepGhost}`,
+      `--light-ghost-color: ${lightGhost}`,
       `--bg-color: ${bg}`,
       `--bg-black-color: ${bgBlack}`,
       `--shadow-color: ${shadow}`,
     ];
 
-    let selectedProperties;
+    let selectedDeclarations;
 
     switch (slots.context) {
       case 'any-content':
-        selectedProperties = anyContent;
+        selectedDeclarations = anyContent;
         break;
 
       case 'image-box':
-        selectedProperties = [
+        selectedDeclarations = [
           `--primary-color: ${primary}`,
           `--dim-color: ${dim}`,
           `--deep-color: ${deep}`,
@@ -65,14 +67,14 @@ export default {
         break;
 
       case 'page-root':
-        selectedProperties = [
+        selectedDeclarations = [
           ...anyContent,
           `--page-primary-color: ${primary}`,
         ];
         break;
 
       case 'primary-only':
-        selectedProperties = [
+        selectedDeclarations = [
           `--primary-color: ${primary}`,
         ];
         break;
@@ -80,10 +82,10 @@ export default {
 
     switch (slots.mode) {
       case 'style':
-        return selectedProperties.join('; ');
+        return selectedDeclarations.join('; ');
 
-      case 'property-list':
-        return selectedProperties;
+      case 'declarations':
+        return selectedDeclarations.map(declaration => declaration + ';');
     }
   },
 };
diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
index 522a0284..367de506 100644
--- a/src/content/dependencies/generateCommentaryEntry.js
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -2,6 +2,7 @@ import {empty} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateCommentaryEntryDate',
     'generateColorStyleAttribute',
     'linkArtist',
     'transformContent',
@@ -11,14 +12,14 @@ export default {
 
   relations: (relation, entry) => ({
     artistLinks:
-      (!empty(entry.artists) && !entry.artistDisplayText
+      (!empty(entry.artists) && !entry.artistText
         ? entry.artists
             .map(artist => relation('linkArtist', artist))
         : null),
 
     artistsContent:
-      (entry.artistDisplayText
-        ? relation('transformContent', entry.artistDisplayText)
+      (entry.artistText
+        ? relation('transformContent', entry.artistText)
         : null),
 
     annotationContent:
@@ -33,66 +34,79 @@ export default {
 
     colorStyle:
       relation('generateColorStyleAttribute'),
-  }),
 
-  data: (entry) => ({
-    date: entry.date,
+    date:
+      relation('generateCommentaryEntryDate', entry),
   }),
 
   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.colorStyle.slot('color', slots.color);
-
-    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')),
-    ]);
-  },
+  generate: (relations, slots, {html, language}) =>
+    language.encapsulate('misc.artistCommentary.entry', entryCapsule =>
+      html.tags([
+        html.tag('p', {class: 'commentary-entry-heading'},
+          slots.color &&
+            relations.colorStyle.clone()
+              .slot('color', slots.color),
+
+          !html.isBlank(relations.date) &&
+            {class: 'dated'},
+
+          language.encapsulate(entryCapsule, 'title', titleCapsule => [
+            html.tag('span', {class: 'commentary-entry-heading-text'},
+              language.encapsulate(titleCapsule, workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.artists =
+                  html.tag('span', {class: 'commentary-entry-artists'},
+                    (relations.artistsContent
+                      ? relations.artistsContent.slot('mode', 'inline')
+                   : relations.artistLinks
+                      ? language.formatConjunctionList(relations.artistLinks)
+                      : language.$(titleCapsule, 'noArtists')));
+
+                const accent =
+                  html.tag('span', {class: 'commentary-entry-accent'},
+                    {[html.onlyIfContent]: true},
+
+                    language.encapsulate(titleCapsule, 'accent', accentCapsule =>
+                      language.encapsulate(accentCapsule, workingCapsule => {
+                        const workingOptions = {};
+
+                        if (relations.annotationContent) {
+                          workingCapsule += '.withAnnotation';
+                          workingOptions.annotation =
+                            relations.annotationContent.slots({
+                              mode: 'inline',
+                              absorbPunctuationFollowingExternalLinks: false,
+                            });
+                        }
+
+                        if (workingCapsule === accentCapsule) {
+                          return html.blank();
+                        } else {
+                          return language.$(workingCapsule, workingOptions);
+                        }
+                      })));
+
+                if (!html.isBlank(accent)) {
+                  workingCapsule += '.withAccent';
+                  workingOptions.accent = accent;
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })),
+
+            relations.date,
+          ])),
+
+        html.tag('blockquote', {class: 'commentary-entry-body'},
+          slots.color &&
+            relations.colorStyle.clone()
+              .slot('color', slots.color),
+
+          relations.bodyContent.slot('mode', 'multiline')),
+      ])),
 };
diff --git a/src/content/dependencies/generateCommentaryEntryDate.js b/src/content/dependencies/generateCommentaryEntryDate.js
new file mode 100644
index 00000000..f1cf5cb3
--- /dev/null
+++ b/src/content/dependencies/generateCommentaryEntryDate.js
@@ -0,0 +1,93 @@
+export default {
+  contentDependencies: ['generateTextWithTooltip', 'generateTooltip'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _entry) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateTooltip'),
+  }),
+
+  data: (entry) => ({
+    date: entry.date,
+    secondDate: entry.secondDate,
+    dateKind: entry.dateKind,
+
+    accessDate: entry.accessDate,
+    accessKind: entry.accessKind,
+  }),
+
+  generate(data, relations, {html, language}) {
+    const titleCapsule = language.encapsulate('misc.artistCommentary.entry.title');
+
+    const willDisplayTooltip =
+      !!(data.accessKind && data.accessDate);
+
+    const topAttributes =
+      {class: 'commentary-date'};
+
+    const time =
+      html.tag('time',
+        {[html.onlyIfContent]: true},
+
+        (willDisplayTooltip
+          ? {class: 'text-with-tooltip-interaction-cue'}
+          : topAttributes),
+
+        language.encapsulate(titleCapsule, 'date', workingCapsule => {
+          const workingOptions = {};
+
+          if (!data.date) {
+            return html.blank();
+          }
+
+          const rangeNeeded =
+            data.dateKind === 'sometime' ||
+            data.dateKind === 'throughout';
+
+          if (rangeNeeded && !data.secondDate) {
+            workingOptions.date = language.formatDate(data.date);
+            return language.$(workingCapsule, workingOptions);
+          }
+
+          if (data.dateKind) {
+            workingCapsule += '.' + data.dateKind;
+          }
+
+          if (data.secondDate) {
+            workingCapsule += '.range';
+            workingOptions.dateRange =
+              language.formatDateRange(data.date, data.secondDate);
+          } else {
+            workingOptions.date =
+              language.formatDate(data.date);
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }));
+
+    if (willDisplayTooltip) {
+      return relations.textWithTooltip.slots({
+        customInteractionCue: true,
+
+        attributes: topAttributes,
+        text: time,
+
+        tooltip:
+          relations.tooltip.slots({
+            attributes: {class: 'commentary-date-tooltip'},
+
+            content:
+              language.$(titleCapsule, 'date', data.accessKind, {
+                date:
+                  language.formatDate(data.accessDate),
+              }),
+          }),
+      });
+    } else {
+      return time;
+    }
+  },
+}
diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js
index 3c3504d2..d68ba42e 100644
--- a/src/content/dependencies/generateCommentaryIndexPage.js
+++ b/src/content/dependencies/generateCommentaryIndexPage.js
@@ -57,46 +57,48 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title: language.$('commentaryIndex.title'),
-
-      headingMode: 'static',
-
-      mainClasses: ['long-content'],
-      mainContent: [
-        html.tag('p', language.$('commentaryIndex.infoLine', {
-          words:
-            html.tag('b',
-              language.formatWordCount(data.totalWordCount, {unit: true})),
-
-          entries:
-            html.tag('b',
-                language.countCommentaryEntries(data.totalEntryCount, {unit: true})),
-        })),
-
-        html.tag('p',
-          language.$('commentaryIndex.albumList.title')),
-
-        html.tag('ul',
-          stitchArrays({
-            albumLink: relations.albumLinks,
-            wordCount: data.wordCounts,
-            entryCount: data.entryCounts,
-          }).map(({albumLink, wordCount, entryCount}) =>
-            html.tag('li',
-              language.$('commentaryIndex.albumList.item', {
-                album: albumLink,
-                words: language.formatWordCount(wordCount, {unit: true}),
-                entries: language.countCommentaryEntries(entryCount, {unit: true}),
-              })))),
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {auto: 'current'},
-      ],
-    });
-  },
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('commentaryIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+
+        headingMode: 'static',
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          html.tag('p', language.$(pageCapsule, 'infoLine', {
+            words:
+              html.tag('b',
+                language.formatWordCount(data.totalWordCount, {unit: true})),
+
+            entries:
+              html.tag('b',
+                  language.countCommentaryEntries(data.totalEntryCount, {unit: true})),
+          })),
+
+          language.encapsulate(pageCapsule, 'albumList', listCapsule => [
+            html.tag('p',
+              language.$(listCapsule, 'title')),
+
+            html.tag('ul',
+              stitchArrays({
+                albumLink: relations.albumLinks,
+                wordCount: data.wordCounts,
+                entryCount: data.entryCounts,
+              }).map(({albumLink, wordCount, entryCount}) =>
+                html.tag('li',
+                  language.$(listCapsule, 'item', {
+                    album: albumLink,
+                    words: language.formatWordCount(wordCount, {unit: true}),
+                    entries: language.countCommentaryEntries(entryCount, {unit: true}),
+                  })))),
+          ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js
deleted file mode 100644
index 8ae1b2d0..00000000
--- a/src/content/dependencies/generateCommentarySection.js
+++ /dev/null
@@ -1,29 +0,0 @@
-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/generateContentContentHeading.js b/src/content/dependencies/generateContentContentHeading.js
new file mode 100644
index 00000000..314ef197
--- /dev/null
+++ b/src/content/dependencies/generateContentContentHeading.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: ['generateContentHeading'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _thing) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+  }),
+
+  data: (thing) => ({
+    name:
+      thing.name,
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    string: {
+      type: 'string',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.contentHeading.slots({
+      attributes: slots.attributes,
+
+      title:
+        language.$(slots.string, {
+          thing:
+            html.tag('i', data.name),
+        }),
+
+      stickyTitle:
+        language.$(slots.string, 'sticky'),
+    }),
+};
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
index 469db876..f52bc043 100644
--- a/src/content/dependencies/generateContentHeading.js
+++ b/src/content/dependencies/generateContentHeading.js
@@ -12,23 +12,35 @@ export default {
       mutable: false,
     },
 
+    stickyTitle: {
+      type: 'html',
+      mutable: false,
+    },
+
     accent: {
       type: 'html',
       mutable: false,
     },
 
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
     color: {validate: v => v.isColor},
 
-    id: {type: 'string'},
-    tag: {type: 'string', default: 'p'},
+    tag: {
+      type: 'string',
+      default: 'p',
+    },
   },
 
   generate: (relations, slots, {html}) =>
     html.tag(slots.tag, {class: 'content-heading'},
       {tabindex: '0'},
+      {[html.onlyIfSiblings]: true},
 
-      slots.id &&
-        {id: slots.id},
+      slots.attributes,
 
       slots.color &&
         relations.colorStyle.slot('color', slots.color),
@@ -38,6 +50,10 @@ export default {
           {[html.onlyIfContent]: true},
           slots.title),
 
+        html.tag('template', {class: 'content-heading-sticky-title'},
+          {[html.onlyIfContent]: true},
+          slots.stickyTitle),
+
         html.tag('span', {class: 'content-heading-accent'},
           {[html.onlyIfContent]: true},
           slots.accent),
diff --git a/src/content/dependencies/generateContributionList.js b/src/content/dependencies/generateContributionList.js
index 6401e65e..d1c3de0f 100644
--- a/src/content/dependencies/generateContributionList.js
+++ b/src/content/dependencies/generateContributionList.js
@@ -2,20 +2,28 @@ export default {
   contentDependencies: ['linkContribution'],
   extraDependencies: ['html'],
 
-  relations: (relation, contributions) =>
-    ({contributionLinks:
-        contributions
-          .map(contrib => relation('linkContribution', contrib))}),
+  relations: (relation, contributions) => ({
+    contributionLinks:
+      contributions
+        .map(contrib => relation('linkContribution', contrib)),
+  }),
 
-  generate: (relations, {html}) =>
+  slots: {
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
     html.tag('ul',
-      relations.contributionLinks.map(contributionLink =>
-        html.tag('li',
-          contributionLink
-            .slots({
-              showIcons: true,
-              showContribution: true,
+      {[html.onlyIfContent]: true},
+
+      relations.contributionLinks
+        .map(contributionLink =>
+          html.tag('li',
+            contributionLink.slots({
+              showAnnotation: true,
+              showExternalLinks: true,
+              showChronology: true,
               preventWrapping: false,
-              iconMode: 'tooltip',
+              chronologyKind: slots.chronologyKind,
             })))),
 };
diff --git a/src/content/dependencies/generateContributionTooltip.js b/src/content/dependencies/generateContributionTooltip.js
new file mode 100644
index 00000000..3a31014d
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltip.js
@@ -0,0 +1,48 @@
+export default {
+  contentDependencies: [
+    'generateContributionTooltipChronologySection',
+    'generateContributionTooltipExternalLinkSection',
+    'generateTooltip',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, contribution) => ({
+    tooltip:
+      relation('generateTooltip'),
+
+    externalLinkSection:
+      relation('generateContributionTooltipExternalLinkSection', contribution),
+
+    chronologySection:
+      relation('generateContributionTooltipChronologySection', contribution),
+  }),
+
+  slots: {
+    showExternalLinks: {type: 'boolean'},
+    showChronology: {type: 'boolean'},
+
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    relations.tooltip.slots({
+      attributes:
+        {class: 'contribution-tooltip'},
+
+      contentAttributes: {
+        [html.joinChildren]:
+          html.tag('span', {class: 'tooltip-divider'}),
+      },
+
+      content: [
+        slots.showExternalLinks &&
+          relations.externalLinkSection,
+
+        slots.showChronology &&
+          relations.chronologySection.slots({
+            kind: slots.chronologyKind,
+          }),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateContributionTooltipChronologySection.js b/src/content/dependencies/generateContributionTooltipChronologySection.js
new file mode 100644
index 00000000..378c0e1c
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltipChronologySection.js
@@ -0,0 +1,129 @@
+import Thing from '#thing';
+
+function getName(thing) {
+  if (!thing) {
+    return null;
+  }
+
+  const referenceType = thing.constructor[Thing.referenceType];
+
+  if (referenceType === 'artwork') {
+    return thing.thing.name;
+  }
+
+  return thing.name;
+}
+
+export default {
+  contentDependencies: ['linkAnythingMan'],
+  extraDependencies: ['html', 'language'],
+
+  query(contribution) {
+    let previous = contribution;
+    while (previous && previous.thing === contribution.thing) {
+      previous = previous.previousBySameArtist;
+    }
+
+    let next = contribution;
+    while (next && next.thing === contribution.thing) {
+      next = next.nextBySameArtist;
+    }
+
+    return {previous, next};
+  },
+
+  relations: (relation, query, _contribution) => ({
+    previousLink:
+      (query.previous
+        ? relation('linkAnythingMan', query.previous.thing)
+        : null),
+
+    nextLink:
+      (query.next
+        ? relation('linkAnythingMan', query.next.thing)
+        : null),
+  }),
+
+  data: (query, _contribution) => ({
+    previousName:
+      getName(query.previous?.thing),
+
+    nextName:
+      getName(query.next?.thing),
+  }),
+
+  slots: {
+    kind: {
+      validate: v =>
+        v.is(
+          'album',
+          'bannerArt',
+          'coverArt',
+          'flash',
+          'track',
+          'trackArt',
+          'trackContribution',
+          'wallpaperArt'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('misc.artistLink.chronology', capsule =>
+      html.tags([
+        html.tags([
+          relations.previousLink?.slots({
+            attributes: {class: 'chronology-link'},
+            content: [
+              html.tag('span', {class: 'chronology-symbol'},
+                language.$(capsule, 'previous.symbol')),
+
+              html.tag('span', {class: 'chronology-text'},
+                language.sanitize(data.previousName)),
+            ],
+          }),
+
+          html.tag('span', {class: 'chronology-info'},
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(capsule, 'previous.info', workingCapsule => {
+              const workingOptions = {};
+
+              if (slots.kind) {
+                workingCapsule += '.withKind';
+                workingOptions.kind =
+                  language.$(capsule, 'kind', slots.kind);
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            })),
+        ]),
+
+        html.tags([
+          relations.nextLink?.slots({
+            attributes: {class: 'chronology-link'},
+            content: [
+              html.tag('span', {class: 'chronology-symbol'},
+                language.$(capsule, 'next.symbol')),
+
+              html.tag('span', {class: 'chronology-text'},
+                language.sanitize(data.nextName)),
+            ],
+          }),
+
+          html.tag('span', {class: 'chronology-info'},
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(capsule, 'next.info', workingCapsule => {
+              const workingOptions = {};
+
+              if (slots.kind) {
+                workingCapsule += '.withKind';
+                workingOptions.kind =
+                  language.$(capsule, 'kind', slots.kind);
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            }))
+        ]),
+      ])),
+};
diff --git a/src/content/dependencies/generateContributionTooltipExternalLinkSection.js b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js
new file mode 100644
index 00000000..4f9a23ed
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js
@@ -0,0 +1,70 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateExternalHandle',
+    'generateExternalIcon',
+    'generateExternalPlatform',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, contribution) => ({
+    icons:
+      contribution.artist.urls
+        .map(url => relation('generateExternalIcon', url)),
+
+    handles:
+      contribution.artist.urls
+        .map(url => relation('generateExternalHandle', url)),
+
+    platforms:
+      contribution.artist.urls
+        .map(url => relation('generateExternalPlatform', url)),
+  }),
+
+  data: (contribution) => ({
+    urls: contribution.artist.urls,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('misc.artistLink', capsule =>
+      html.tags(
+        stitchArrays({
+          icon: relations.icons,
+          handle: relations.handles,
+          platform: relations.platforms,
+          url: data.urls,
+        }).map(({icon, handle, platform, url}) => {
+            for (const template of [icon, handle, platform]) {
+              template.setSlot('context', 'artist');
+            }
+
+            return [
+              html.tag('a', {class: 'external-link'},
+                {href: url},
+
+                [
+                  icon,
+
+                  html.tag('span', {class: 'external-handle'},
+                    (html.isBlank(handle)
+                      ? platform
+                      : handle)),
+                ]),
+
+              html.tag('span', {class: 'external-platform'},
+                // This is a pretty ridiculous hack, but we currently
+                // don't have a way of telling formatExternalLink to *not*
+                // use the fallback string, which just formats the URL as
+                // its host/domain... so is technically detectable.
+                (((new URL(url))
+                    .host
+                    .endsWith(
+                      html.resolve(platform, {normalize: 'string'})))
+
+                  ? language.$(capsule, 'noExternalLinkPlatformName')
+                  : platform)),
+            ];
+          }))),
+};
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index d0941d2e..78a6103b 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -1,118 +1,157 @@
-import {empty, stitchArrays} from '#sugar';
-
 export default {
-  contentDependencies: ['image', 'linkArtTag'],
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateCoverArtworkArtTagDetails',
+    'generateCoverArtworkArtistDetails',
+    'generateCoverArtworkOriginDetails',
+    'generateCoverArtworkReferenceDetails',
+    'image',
+  ],
+
   extraDependencies: ['html'],
 
-  query: (artTags) => ({
-    linkableArtTags:
-      (artTags
-        ? artTags.filter(tag => !tag.isContentWarning)
-        : []),
-  }),
+  relations: (relation, artwork) => ({
+    colorStyleAttribute:
+      relation('generateColorStyleAttribute'),
 
-  relations: (relation, query, artTags) => ({
     image:
-      relation('image', artTags),
+      relation('image', artwork),
 
-    tagLinks:
-      query.linkableArtTags
-        .filter(tag => !tag.isContentWarning)
-        .map(tag => relation('linkArtTag', tag)),
-  }),
+    originDetails:
+      relation('generateCoverArtworkOriginDetails', artwork),
 
-  data: (query) => {
-    const data = {};
+    artTagDetails:
+      relation('generateCoverArtworkArtTagDetails', artwork),
 
-    const seenShortNames = new Set();
-    const duplicateShortNames = new Set();
+    artistDetails:
+      relation('generateCoverArtworkArtistDetails', artwork),
 
-    for (const {nameShort: shortName} of query.linkableArtTags) {
-      if (seenShortNames.has(shortName)) {
-        duplicateShortNames.add(shortName);
-      } else {
-        seenShortNames.add(shortName);
-      }
-    }
+    referenceDetails:
+      relation('generateCoverArtworkReferenceDetails', artwork),
+  }),
 
-    data.preferShortName =
-      query.linkableArtTags
-        .map(artTag => !duplicateShortNames.has(artTag.nameShort));
+  data: (artwork) => ({
+    attachAbove:
+      artwork.attachAbove,
 
-    return data;
-  },
+    attachedArtworkIsMainArtwork:
+      (artwork.attachAbove
+        ? artwork.attachedArtwork.isMainArtwork
+        : null),
 
-  slots: {
-    path: {
-      validate: v => v.validateArrayItems(v.isString),
-    },
+    color:
+      artwork.thing.color ?? null,
 
-    alt: {
-      type: 'string',
-    },
+    dimensions:
+      artwork.dimensions,
+  }),
+
+  slots: {
+    alt: {type: 'string'},
 
     color: {
-      validate: v => v.isColor,
+      validate: v => v.anyOf(v.isBoolean, v.isColor),
+      default: false,
     },
 
     mode: {
       validate: v => v.is('primary', 'thumbnail', 'commentary'),
       default: 'primary',
     },
+
+    showOriginDetails: {type: 'boolean', default: false},
+    showArtTagDetails: {type: 'boolean', default: false},
+    showArtistDetails: {type: 'boolean', default: false},
+    showReferenceDetails: {type: 'boolean', default: false},
+
+    details: {
+      type: 'html',
+      mutable: false,
+    },
   },
 
   generate(data, relations, slots, {html}) {
-    switch (slots.mode) {
-      case 'primary':
-        return html.tags([
-          relations.image.slots({
-            path: slots.path,
-            alt: slots.alt,
-            color: slots.color,
-            thumb: 'medium',
-            reveal: true,
-            link: true,
-            square: true,
-          }),
-
-          !empty(relations.tagLinks) &&
-            html.tag('ul', {class: 'image-details'},
-              stitchArrays({
-                tagLink: relations.tagLinks,
-                preferShortName: data.preferShortName,
-              }).map(({tagLink, preferShortName}) =>
-                  html.tag('li',
-                    tagLink.slot('preferShortName', preferShortName)))),
-        ]);
-
-      case 'thumbnail':
-        return relations.image.slots({
-          path: slots.path,
-          alt: slots.alt,
-          color: slots.color,
-          thumb: 'small',
-          reveal: false,
-          link: false,
-          square: true,
-        });
-
-      case 'commentary':
-        return relations.image.slots({
-          path: slots.path,
-          alt: slots.alt,
-          color: slots.color,
-          thumb: 'medium',
-          reveal: true,
-          link: true,
-          square: true,
-          lazy: true,
-
-          attributes:
-            {class: 'commentary-art'},
-        });
-
-      default:
-        return html.blank();
+    const {image} = relations;
+
+    image.setSlot('alt', slots.alt);
+
+    const square =
+      (data.dimensions
+        ? data.dimensions[0] === data.dimensions[1]
+        : true);
+
+    if (square) {
+      image.setSlot('square', true);
+    } else {
+      image.setSlot('dimensions', data.dimensions);
     }
+
+    const attributes = html.attributes();
+
+    let color = null;
+    if (typeof slots.color === 'boolean') {
+      if (slots.color) {
+        color = data.color;
+      }
+    } else if (slots.color) {
+      color = slots.color;
+    }
+
+    if (color) {
+      relations.colorStyleAttribute.setSlot('color', color);
+      attributes.add(relations.colorStyleAttribute);
+    }
+
+    return html.tags([
+      data.attachAbove &&
+        html.tag('div', {class: 'cover-artwork-joiner'}),
+
+      html.tag('div', {class: 'cover-artwork'},
+        slots.mode === 'commentary' &&
+          {class: 'commentary-art'},
+
+        data.attachAbove &&
+        data.attachedArtworkIsMainArtwork &&
+          {class: 'attached-artwork-is-main-artwork'},
+
+        attributes,
+
+        (slots.mode === 'primary'
+          ? [
+              relations.image.slots({
+                thumb: 'medium',
+                reveal: true,
+                link: true,
+              }),
+
+              slots.showOriginDetails &&
+                relations.originDetails,
+
+              slots.showArtTagDetails &&
+                relations.artTagDetails,
+
+              slots.showArtistDetails &&
+                relations.artistDetails,
+
+              slots.showReferenceDetails &&
+                relations.referenceDetails,
+
+              slots.details,
+            ]
+       : slots.mode === 'thumbnail'
+          ? relations.image.slots({
+              thumb: 'small',
+              reveal: false,
+              link: false,
+            })
+       : slots.mode === 'commentary'
+          ? relations.image.slots({
+              thumb: 'medium',
+              reveal: true,
+              link: true,
+              lazy: true,
+            })
+          : html.blank())),
+    ]);
   },
 };
diff --git a/src/content/dependencies/generateCoverArtworkArtTagDetails.js b/src/content/dependencies/generateCoverArtworkArtTagDetails.js
new file mode 100644
index 00000000..4d908665
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkArtTagDetails.js
@@ -0,0 +1,75 @@
+import {compareArrays, empty, stitchArrays} from '#sugar';
+
+function linkable(tag) {
+  return !tag.isContentWarning;
+}
+
+export default {
+  contentDependencies: ['linkArtTagGallery'],
+  extraDependencies: ['html', 'language'],
+
+  query: (artwork) => ({
+    linkableArtTags:
+      artwork.artTags.filter(linkable),
+
+    mainArtworkLinkableArtTags:
+      (artwork.mainArtwork
+        ? artwork.mainArtwork.artTags.filter(linkable)
+        : null),
+  }),
+
+  relations: (relation, query, _artwork) => ({
+    artTagLinks:
+      query.linkableArtTags
+        .map(tag => relation('linkArtTagGallery', tag)),
+  }),
+
+  data: (query, artwork) => {
+    const data = {};
+
+    data.attachAbove = artwork.attachAbove;
+
+    data.sameAsMainArtwork =
+      !artwork.isMainArtwork &&
+      query.mainArtworkLinkableArtTags &&
+      !empty(query.mainArtworkLinkableArtTags) &&
+      compareArrays(
+        query.mainArtworkLinkableArtTags,
+        query.linkableArtTags);
+
+    const seenShortNames = new Set();
+    const duplicateShortNames = new Set();
+
+    for (const {nameShort: shortName} of query.linkableArtTags) {
+      if (seenShortNames.has(shortName)) {
+        duplicateShortNames.add(shortName);
+      } else {
+        seenShortNames.add(shortName);
+      }
+    }
+
+    data.preferShortName =
+      query.linkableArtTags
+        .map(artTag => !duplicateShortNames.has(artTag.nameShort));
+
+    return data;
+  },
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('misc.coverArtwork', capsule =>
+      html.tag('ul', {class: 'image-details'},
+        {[html.onlyIfContent]: true},
+
+        {class: 'art-tag-details'},
+
+        (data.sameAsMainArtwork && data.attachAbove
+          ? html.blank()
+       : data.sameAsMainArtwork && relations.artTagLinks.length >= 3
+          ? language.$(capsule, 'sameTagsAsMainArtwork')
+          : stitchArrays({
+              artTagLink: relations.artTagLinks,
+              preferShortName: data.preferShortName,
+            }).map(({artTagLink, preferShortName}) =>
+                html.tag('li',
+                  artTagLink.slot('preferShortName', preferShortName)))))),
+};
diff --git a/src/content/dependencies/generateCoverArtworkArtistDetails.js b/src/content/dependencies/generateCoverArtworkArtistDetails.js
new file mode 100644
index 00000000..3ead80ab
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkArtistDetails.js
@@ -0,0 +1,25 @@
+export default {
+  contentDependencies: ['linkArtistGallery'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, artwork) => ({
+    artistLinks:
+      artwork.artistContribs
+        .map(contrib => contrib.artist)
+        .map(artist =>
+          relation('linkArtistGallery', artist)),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tag('p', {class: 'image-details'},
+      {[html.onlyIfContent]: true},
+
+      {class: 'illustrator-details'},
+
+      language.$('misc.coverGrid.details.coverArtists', {
+        [language.onlyIfOptions]: ['artists'],
+
+        artists:
+          language.formatConjunctionList(relations.artistLinks),
+      })),
+};
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
new file mode 100644
index 00000000..8628179e
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -0,0 +1,170 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'generateArtistCredit',
+    'generateAbsoluteDatetimestamp',
+    'linkAlbum',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'pagePath'],
+
+  query: (artwork) => ({
+    artworkThingType:
+      artwork.thing.constructor[Thing.referenceType],
+
+    attachedArtistContribs:
+      (artwork.attachedArtwork
+        ? artwork.attachedArtwork.artistContribs
+        : null)
+  }),
+
+  relations: (relation, query, artwork) => ({
+    credit:
+      relation('generateArtistCredit',
+        artwork.artistContribs,
+        query.attachedArtistContribs ?? []),
+
+    source:
+      relation('transformContent', artwork.source),
+
+    originDetails:
+      relation('transformContent', artwork.originDetails),
+
+    albumLink:
+      (query.artworkThingType === 'album'
+        ? relation('linkAlbum', artwork.thing)
+        : null),
+
+    datetimestamp:
+      (artwork.date && artwork.date !== artwork.thing.date
+        ? relation('generateAbsoluteDatetimestamp', artwork.date)
+        : null),
+  }),
+
+
+  data: (query, artwork) => ({
+    label:
+      artwork.label,
+
+    artworkThingType:
+      query.artworkThingType,
+  }),
+
+  generate: (data, relations, {html, language, pagePath}) =>
+    language.encapsulate('misc.coverArtwork', capsule =>
+      html.tag('p', {class: 'image-details'},
+        {[html.onlyIfContent]: true},
+        {[html.joinChildren]: html.tag('br')},
+
+        {class: 'origin-details'},
+
+        (() => {
+          relations.datetimestamp?.setSlots({
+            style: 'year',
+            tooltip: true,
+          });
+
+          const artworkBy =
+            language.encapsulate(capsule, 'artworkBy', workingCapsule => {
+              const workingOptions = {};
+
+              if (data.label) {
+                workingCapsule += '.customLabel';
+                workingOptions.label = data.label;
+              }
+
+              if (relations.datetimestamp) {
+                workingCapsule += '.withYear';
+                workingOptions.year = relations.datetimestamp;
+              }
+
+              return relations.credit.slots({
+                showAnnotation: true,
+                showExternalLinks: true,
+                showChronology: true,
+                showWikiEdits: true,
+
+                trimAnnotation: false,
+
+                chronologyKind: 'coverArt',
+
+                normalStringKey: workingCapsule,
+                additionalStringOptions: workingOptions,
+              });
+            });
+
+          const trackArtFromAlbum =
+            pagePath[0] === 'track' &&
+            data.artworkThingType === 'album' &&
+              language.$(capsule, 'trackArtFromAlbum', {
+                album:
+                  relations.albumLink.slot('color', false),
+              });
+
+          const source =
+            language.encapsulate(capsule, 'source', workingCapsule => {
+              const workingOptions = {
+                [language.onlyIfOptions]: ['source'],
+                source: relations.source.slot('mode', 'inline'),
+              };
+
+              if (html.isBlank(artworkBy) && data.label) {
+                workingCapsule += '.customLabel';
+                workingOptions.label = data.label;
+              }
+
+              if (html.isBlank(artworkBy) && relations.datetimestamp) {
+                workingCapsule += '.withYear';
+                workingOptions.year = relations.datetimestamp;
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            });
+
+          const label =
+            html.isBlank(artworkBy) &&
+            html.isBlank(source) &&
+            language.encapsulate(capsule, 'customLabel', workingCapsule => {
+              const workingOptions = {
+                [language.onlyIfOptions]: ['label'],
+                label: data.label,
+              };
+
+              if (relations.datetimestamp) {
+                workingCapsule += '.withYear';
+                workingOptions.year = relations.datetimestamp;
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            });
+
+          const year =
+            html.isBlank(artworkBy) &&
+            html.isBlank(source) &&
+            html.isBlank(label) &&
+            language.$(capsule, 'year', {
+              [language.onlyIfOptions]: ['year'],
+              year: relations.datetimestamp,
+            });
+
+          const originDetails =
+            html.tag('span', {class: 'origin-details'},
+              {[html.onlyIfContent]: true},
+
+              relations.originDetails.slots({
+                mode: 'inline',
+                absorbPunctuationFollowingExternalLinks: false,
+              }));
+
+          return [
+            artworkBy,
+            trackArtFromAlbum,
+            source,
+            label,
+            year,
+            originDetails,
+          ];
+        })())),
+};
diff --git a/src/content/dependencies/generateCoverArtworkReferenceDetails.js b/src/content/dependencies/generateCoverArtworkReferenceDetails.js
new file mode 100644
index 00000000..035ab586
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkReferenceDetails.js
@@ -0,0 +1,60 @@
+export default {
+  contentDependencies: ['linkReferencedArtworks', 'linkReferencingArtworks'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, artwork) => ({
+    referencedArtworksLink:
+      relation('linkReferencedArtworks', artwork),
+
+    referencingArtworksLink:
+      relation('linkReferencingArtworks', artwork),
+  }),
+
+  data: (artwork) => ({
+    referenced:
+      artwork.referencedArtworks.length,
+
+    referencedBy:
+      artwork.referencedByArtworks.length,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule => {
+      const referencedText =
+        language.$(capsule, 'referencesArtworks', {
+          [language.onlyIfOptions]: ['artworks'],
+
+          artworks:
+            language.countArtworks(data.referenced, {
+              blankIfZero: true,
+              unit: true,
+            }),
+        });
+
+      const referencingText =
+        language.$(capsule, 'referencedByArtworks', {
+          [language.onlyIfOptions]: ['artworks'],
+
+          artworks:
+            language.countArtworks(data.referencedBy, {
+              blankIfZero: true,
+              unit: true,
+            }),
+        });
+
+      return (
+        html.tag('p', {class: 'image-details'},
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          {class: 'reference-details'},
+
+          [
+            !html.isBlank(referencedText) &&
+              relations.referencedArtworksLink.slot('content', referencedText),
+
+            !html.isBlank(referencingText) &&
+              relations.referencingArtworksLink.slot('content', referencingText),
+          ]));
+    }),
+}
diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js
index 69220da6..0705d93e 100644
--- a/src/content/dependencies/generateCoverCarousel.js
+++ b/src/content/dependencies/generateCoverCarousel.js
@@ -2,24 +2,16 @@ import {empty, repeat, stitchArrays} from '#sugar';
 import {getCarouselLayoutForNumberOfItems} from '#wiki-data';
 
 export default {
-  contentDependencies: ['generateGridActionLinks'],
   extraDependencies: ['html'],
 
-  relations(relation) {
-    return {
-      actionLinks: relation('generateGridActionLinks'),
-    };
-  },
-
   slots: {
     images: {validate: v => v.strictArrayOf(v.isHTML)},
     links: {validate: v => v.strictArrayOf(v.isHTML)},
 
     lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)},
-    actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
   },
 
-  generate(relations, slots, {html}) {
+  generate(slots, {html}) {
     const stitched =
       stitchArrays({
         image: slots.images,
@@ -27,7 +19,7 @@ export default {
       });
 
     if (empty(stitched)) {
-      return;
+      return html.blank();
     }
 
     const layout = getCarouselLayoutForNumberOfItems(stitched.length);
@@ -58,9 +50,6 @@ export default {
                     }),
                 })))),
         ])),
-
-      relations.actionLinks
-        .slot('actionLinks', slots.actionLinks),
     ]);
   },
 };
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
index 0433aaf1..e4dfd905 100644
--- a/src/content/dependencies/generateCoverGrid.js
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -15,23 +15,59 @@ export default {
     links: {validate: v => v.strictArrayOf(v.isHTML)},
     names: {validate: v => v.strictArrayOf(v.isHTML)},
     info: {validate: v => v.strictArrayOf(v.isHTML)},
+    notFromThisGroup: {validate: v => v.strictArrayOf(v.isBoolean)},
+
+    // Differentiating from sparseArrayOf here - this list of classes should
+    // have the same length as the items above, i.e. nulls aren't going to be
+    // filtered out of it, but it is okay to *include* null (standing in for
+    // no classes for this grid item).
+    classes: {
+      validate: v =>
+        v.strictArrayOf(
+          v.optional(
+            v.anyOf(
+              v.isArray,
+              v.isString))),
+    },
 
     lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)},
     actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
   },
 
-  generate(relations, slots, {html, language}) {
-    return (
-      html.tag('div', {class: 'grid-listing'}, [
+  generate: (relations, slots, {html, language}) =>
+    html.tag('div', {class: 'grid-listing'},
+      {[html.onlyIfContent]: true},
+
+      [
         stitchArrays({
+          classes: slots.classes,
           image: slots.images,
           link: slots.links,
           name: slots.names,
           info: slots.info,
-        }).map(({image, link, name, info}, index) =>
+
+          notFromThisGroup:
+            slots.notFromThisGroup ??
+            Array.from(slots.links).fill(null)
+        }).map(({
+            classes,
+            image,
+            link,
+            name,
+            info,
+            notFromThisGroup,
+          }, index) =>
             link.slots({
-              attributes: {class: ['grid-item', 'box']},
+              attributes: [
+                {class: ['grid-item', 'box']},
+
+                (classes
+                  ? {class: classes}
+                  : null),
+              ],
+
               colorContext: 'image-box',
+
               content: [
                 image.slots({
                   thumb: 'medium',
@@ -44,16 +80,31 @@ export default {
                       : false),
                 }),
 
-                html.tag('span', {[html.onlyIfContent]: true},
-                  language.sanitize(name)),
+                html.tag('span',
+                  {[html.onlyIfContent]: true},
+
+                  (notFromThisGroup
+                    ? language.encapsulate('misc.coverGrid.details.notFromThisGroup', capsule =>
+                        language.$(capsule, {
+                          name,
+                          marker:
+                            html.tag('span', {class: 'grid-name-marker'},
+                              language.$(capsule, 'marker')),
+                        }))
+                    : language.sanitize(name))),
+
+                html.tag('span',
+                  {[html.onlyIfContent]: true},
 
-                html.tag('span', {[html.onlyIfContent]: true},
-                  language.sanitize(info)),
+                  language.$('misc.coverGrid.details.accent', {
+                    [language.onlyIfOptions]: ['details'],
+
+                    details: info,
+                  })),
               ],
             })),
 
         relations.actionLinks
           .slot('actionLinks', slots.actionLinks),
-      ]));
-  },
+      ]),
 };
diff --git a/src/content/dependencies/generateDatetimestampTemplate.js b/src/content/dependencies/generateDatetimestampTemplate.js
index d9ed036a..a92d15fc 100644
--- a/src/content/dependencies/generateDatetimestampTemplate.js
+++ b/src/content/dependencies/generateDatetimestampTemplate.js
@@ -31,8 +31,10 @@ export default {
           slots.mainContent),
 
       tooltip:
-        slots.tooltip?.slots({
-          attributes: [{class: 'datetimestamp-tooltip'}],
-        }),
+        (html.isBlank(slots.tooltip)
+          ? null
+          : slots.tooltip.slots({
+              attributes: [{class: 'datetimestamp-tooltip'}],
+            })),
     }),
 };
diff --git a/src/content/dependencies/generateDotSwitcherTemplate.js b/src/content/dependencies/generateDotSwitcherTemplate.js
new file mode 100644
index 00000000..22205922
--- /dev/null
+++ b/src/content/dependencies/generateDotSwitcherTemplate.js
@@ -0,0 +1,41 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    options: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    initialOptionIndex: {type: 'number'},
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('span', {class: 'dot-switcher'},
+      {[html.onlyIfContent]: true},
+      {[html.noEdgeWhitespace]: true},
+      {[html.joinChildren]: ''},
+
+      slots.attributes,
+
+      slots.options
+        .map((option, index) =>
+          html.tag('span',
+            {[html.onlyIfContent]: true},
+
+            html.resolve(option, {normalize: 'tag'})
+              .onlyIfSiblings &&
+                {[html.onlyIfSiblings]: true},
+
+            index === slots.initialOptionIndex &&
+              {class: 'current'},
+
+            [
+              html.metatag('imaginary-sibling'),
+              option,
+            ]))),
+};
diff --git a/src/content/dependencies/generateExpandableGallerySection.js b/src/content/dependencies/generateExpandableGallerySection.js
new file mode 100644
index 00000000..122ca4b1
--- /dev/null
+++ b/src/content/dependencies/generateExpandableGallerySection.js
@@ -0,0 +1,92 @@
+export default {
+  contentDependencies: ['generateContentHeading'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+  }),
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    contentAboveCut: {
+      type: 'html',
+      mutable: false,
+    },
+
+    contentBelowCut: {
+      type: 'html',
+      mutable: false,
+    },
+
+    caption: {
+      type: 'html',
+      mutable: false,
+    },
+
+    expandCue: {
+      type: 'html',
+      mutable: false,
+    },
+
+    collapseCue: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    html.tag('section', {class: 'expandable-gallery-section'}, [
+      relations.contentHeading.slots({
+        tag: 'h2',
+        title: slots.title,
+      }),
+
+      html.tag('div', {class: 'section-content-above-cut'},
+        {[html.onlyIfContent]: true},
+
+        slots.contentAboveCut),
+
+      html.tag('div', {class: 'section-content-below-cut'},
+        {[html.onlyIfContent]: true},
+
+        !html.isBlank(slots.contentBelowCut) &&
+          {style: 'display: none'},
+
+        slots.contentBelowCut),
+
+      html.tag('div', {class: 'section-expando'},
+        {[html.onlyIfSiblings]: true},
+
+        html.tag('div', {class: 'section-expando-content'},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            html.tag('span', {class: 'section-caption'},
+              slots.caption),
+
+            !html.isBlank(slots.contentBelowCut) &&
+              language.$('misc.coverGrid.expandCollapseCue', {
+                cue:
+                  html.tag('a', {class: 'section-expando-toggle'},
+                    {href: '#'},
+
+                    {[html.joinChildren]: ''},
+                    {[html.noEdgeWhitespace]: true},
+
+                    [
+                      html.tag('span', {class: 'section-expand-cue'},
+                        slots.expandCue),
+
+                      html.tag('span', {class: 'section-collapse-cue'},
+                        {style: 'display: none'},
+                        slots.collapseCue),
+                    ]),
+              }),
+          ])),
+    ]),
+};
diff --git a/src/content/dependencies/generateExternalHandle.js b/src/content/dependencies/generateExternalHandle.js
new file mode 100644
index 00000000..8c0368a4
--- /dev/null
+++ b/src/content/dependencies/generateExternalHandle.js
@@ -0,0 +1,20 @@
+import {isExternalLinkContext} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, slots, {language}) =>
+    language.formatExternalLink(data.url, {
+      style: 'handle',
+      context: slots.context,
+    }),
+};
diff --git a/src/content/dependencies/generateExternalIcon.js b/src/content/dependencies/generateExternalIcon.js
new file mode 100644
index 00000000..637af658
--- /dev/null
+++ b/src/content/dependencies/generateExternalIcon.js
@@ -0,0 +1,26 @@
+import {isExternalLinkContext} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language', 'to'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, slots, {html, language, to}) =>
+    html.tag('span', {class: 'external-icon'},
+      html.tag('svg',
+        html.tag('use', {
+          href:
+            to('staticMisc.icon',
+              language.formatExternalLink(data.url, {
+                style: 'icon-id',
+                context: slots.context,
+              })),
+        }))),
+};
diff --git a/src/content/dependencies/generateExternalPlatform.js b/src/content/dependencies/generateExternalPlatform.js
new file mode 100644
index 00000000..c4f63ecf
--- /dev/null
+++ b/src/content/dependencies/generateExternalPlatform.js
@@ -0,0 +1,20 @@
+import {isExternalLinkContext} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, slots, {language}) =>
+    language.formatExternalLink(data.url, {
+      style: 'platform',
+      context: slots.context,
+    }),
+};
diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js
index 8eea58bb..84ab549d 100644
--- a/src/content/dependencies/generateFlashActGalleryPage.js
+++ b/src/content/dependencies/generateFlashActGalleryPage.js
@@ -1,4 +1,4 @@
-import {stitchArrays} from '#sugar';
+import striptags from 'striptags';
 
 export default {
   contentDependencies: [
@@ -8,10 +8,11 @@ export default {
     'generatePageLayout',
     'image',
     'linkFlash',
+    'linkFlashAct',
     'linkFlashIndex',
   ],
 
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['language'],
 
   relations: (relation, act) => ({
     layout:
@@ -20,6 +21,9 @@ export default {
     flashIndexLink:
       relation('linkFlashIndex'),
 
+    flashActNavLink:
+      relation('linkFlashAct', act),
+
     flashActNavAccent:
       relation('generateFlashActNavAccent', act),
 
@@ -31,7 +35,7 @@ export default {
 
     coverGridImages:
       act.flashes
-        .map(_flash => relation('image')),
+        .map(flash => relation('image', flash.coverArtwork)),
 
     flashLinks:
       act.flashes
@@ -44,48 +48,38 @@ export default {
 
     flashNames:
       act.flashes.map(flash => flash.name),
-
-    flashCoverPaths:
-      act.flashes.map(flash =>
-        ['media.flashArt', flash.directory, flash.coverArtFileExtension])
   }),
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title:
-        language.$('flashPage.title', {
-          flash: new html.Tag(null, null, data.name),
-        }),
-
-      color: data.color,
-      headingMode: 'static',
-
-      mainClasses: ['flash-index'],
-      mainContent: [
-        relations.coverGrid.slots({
-          links: relations.flashLinks,
-          names: data.flashNames,
-          lazy: 6,
-
-          images:
-            stitchArrays({
-              image: relations.coverGridImages,
-              path: data.flashCoverPaths,
-            }).map(({image, path}) =>
-                image.slot('path', path)),
-        }),
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {html: relations.flashIndexLink},
-        {auto: 'current'},
-      ],
-
-      navBottomRowContent: relations.flashActNavAccent,
-
-      ...relations.sidebar,
-    });
-  },
+  generate: (data, relations, {language}) =>
+    language.encapsulate('flashPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            flash: striptags(data.name),
+          }),
+
+        color: data.color,
+        headingMode: 'static',
+
+        mainClasses: ['flash-index'],
+        mainContent: [
+          relations.coverGrid.slots({
+            links: relations.flashLinks,
+            images: relations.coverGridImages,
+            names: data.flashNames,
+            lazy: 6,
+          }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.flashIndexLink},
+          {html: relations.flashActNavLink},
+        ],
+
+        navBottomRowContent: relations.flashActNavAccent,
+
+        leftSidebar: relations.sidebar,
+      })),
 };
diff --git a/src/content/dependencies/generateFlashActNavAccent.js b/src/content/dependencies/generateFlashActNavAccent.js
index 424948f9..c4ec77b8 100644
--- a/src/content/dependencies/generateFlashActNavAccent.js
+++ b/src/content/dependencies/generateFlashActNavAccent.js
@@ -1,16 +1,17 @@
-import {atOffset, empty} from '#sugar';
+import {atOffset} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generatePreviousNextLinks',
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
     'linkFlashAct',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
+  extraDependencies: ['wikiData'],
 
-  sprawl({flashActData}) {
-    return {flashActData};
-  },
+  sprawl: ({flashActData}) =>
+    ({flashActData}),
 
   query(sprawl, flashAct) {
     // Like with generateFlashNavAccent, don't sort chronologically here.
@@ -29,43 +30,35 @@ export default {
     return {previousFlashAct, nextFlashAct};
   },
 
-  relations(relation, query) {
-    const relations = {};
-
-    if (query.previousFlashAct || query.nextFlashAct) {
-      relations.previousNextLinks =
-        relation('generatePreviousNextLinks');
-
-      relations.previousFlashActLink =
-        (query.previousFlashAct
-          ? relation('linkFlashAct', query.previousFlashAct)
-          : null);
-
-      relations.nextFlashActLink =
-        (query.nextFlashAct
-          ? relation('linkFlashAct', query.nextFlashAct)
-          : null);
-    }
-
-    return relations;
-  },
-
-  generate(relations, {html, language}) {
-    const {content: previousNextLinks = []} =
-      relations.previousNextLinks &&
-        relations.previousNextLinks.slots({
-          previousLink: relations.previousFlashActLink,
-          nextLink: relations.nextFlashActLink,
-        });
-
-    const allLinks = [
-      ...previousNextLinks,
-    ].filter(Boolean);
-
-    if (empty(allLinks)) {
-      return html.blank();
-    }
-
-    return `(${language.formatUnitList(allLinks)})`;
-  },
+  relations: (relation, query) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+
+    previousFlashActLink:
+      (query.previousFlashAct
+        ? relation('linkFlashAct', query.previousFlashAct)
+        : null),
+
+    nextFlashActLink:
+      (query.nextFlashAct
+        ? relation('linkFlashAct', query.nextFlashAct)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.switcher.slots({
+      links: [
+        relations.previousLink
+          .slot('link', relations.previousFlashActLink),
+
+        relations.nextLink
+          .slot('link', relations.nextFlashActLink),
+      ],
+    }),
 };
diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js
index 0bbfa1f8..1421dde9 100644
--- a/src/content/dependencies/generateFlashActSidebar.js
+++ b/src/content/dependencies/generateFlashActSidebar.js
@@ -1,216 +1,30 @@
-import find from '#find';
-import {filterMultipleArrays, stitchArrays} from '#sugar';
-
 export default {
-  contentDependencies: ['linkFlash', 'linkFlashAct', 'linkFlashIndex'],
-  extraDependencies: ['getColors', 'html', 'language', 'wikiData'],
-
-  // So help me Gog, the flash sidebar is heavily hard-coded.
-
-  sprawl: ({flashActData}) => ({flashActData}),
-
-  query(sprawl, act, flash) {
-    const findFlashAct = directory =>
-      find.flashAct(directory, sprawl.flashActData, {mode: 'quiet'});
-
-    const homestuckSide1 = findFlashAct('flash-act:a1');
-
-    const sideFirstActs = [
-      sprawl.flashActData[0],
-      findFlashAct('flash-act:a6a1'),
-      findFlashAct('flash-act:hiveswap'),
-      findFlashAct('flash-act:cool-and-new-web-comic'),
-      findFlashAct('flash-act:sunday-night-strifin'),
-    ];
-
-    const sideNames = [
-      (homestuckSide1
-        ? `Side 1 (Acts 1-5)`
-        : `All flashes & games`),
-      `Side 2 (Acts 6-7)`,
-      `Additional Canon`,
-      `Fan Adventures`,
-      `Fan Games & More`,
-    ];
-
-    const sideColors = [
-      (homestuckSide1
-        ? '#4ac925'
-        : null),
-      '#3796c6',
-      '#f2a400',
-      '#c466ff',
-      '#32c7fe',
-    ];
-
-    filterMultipleArrays(sideFirstActs, sideNames, sideColors,
-      firstAct => firstAct);
-
-    const sideFirstActIndexes =
-      sideFirstActs
-        .map(act => sprawl.flashActData.indexOf(act));
-
-    const actSideIndexes =
-      sprawl.flashActData
-        .map((act, actIndex) => actIndex)
-        .map(actIndex =>
-          sideFirstActIndexes
-            .findIndex((firstActIndex, i) =>
-              i === sideFirstActs.length - 1 ||
-                firstActIndex <= actIndex &&
-                sideFirstActIndexes[i + 1] > actIndex));
-
-    const sideActs =
-      sideNames
-        .map((name, sideIndex) =>
-          stitchArrays({
-            act: sprawl.flashActData,
-            actSideIndex: actSideIndexes,
-          }).filter(({actSideIndex}) => actSideIndex === sideIndex)
-            .map(({act}) => act));
-
-    const currentActFlashes =
-      act.flashes;
-
-    const currentFlashIndex =
-      currentActFlashes.indexOf(flash);
-
-    const currentSideIndex =
-      actSideIndexes[sprawl.flashActData.indexOf(act)];
-
-    const currentSideActs =
-      sideActs[currentSideIndex];
-
-    const currentActIndex =
-      currentSideActs.indexOf(act);
-
-    const fallbackListTerminology =
-      (currentSideIndex <= 1
-        ? 'flashesInThisAct'
-        : 'entriesInThisSection');
-
-    return {
-      sideNames,
-      sideColors,
-      sideActs,
-
-      currentSideIndex,
-      currentSideActs,
-      currentActIndex,
-      currentActFlashes,
-      currentFlashIndex,
+  contentDependencies: [
+    'generateFlashActSidebarCurrentActBox',
+    'generateFlashActSidebarSideMapBox',
+    'generatePageSidebar',
+  ],
 
-      fallbackListTerminology,
-    };
-  },
+  relations: (relation, act, flash) => ({
+    sidebar:
+      relation('generatePageSidebar'),
 
-  relations: (relation, query, sprawl, act, _flash) => ({
-    currentActLink:
-      relation('linkFlashAct', act),
+    currentActBox:
+      relation('generateFlashActSidebarCurrentActBox', act, flash),
 
-    flashIndexLink:
-      relation('linkFlashIndex'),
-
-    sideActLinks:
-      query.sideActs
-        .map(acts => acts
-          .map(act => relation('linkFlashAct', act))),
-
-    currentActFlashLinks:
-      act.flashes
-        .map(flash => relation('linkFlash', flash)),
+    sideMapBox:
+      relation('generateFlashActSidebarSideMapBox', act, flash),
   }),
 
-  data: (query, sprawl, act, flash) => ({
+  data: (_act, flash) => ({
     isFlashActPage: !flash,
-
-    sideColors: query.sideColors,
-    sideNames: query.sideNames,
-
-    currentSideIndex: query.currentSideIndex,
-    currentActIndex: query.currentActIndex,
-    currentFlashIndex: query.currentFlashIndex,
-
-    customListTerminology: act.listTerminology,
-    fallbackListTerminology: query.fallbackListTerminology,
   }),
 
-  generate(data, relations, {getColors, html, language}) {
-    const currentActBoxContent = html.tags([
-      html.tag('h1', relations.currentActLink),
-
-      html.tag('details',
-        (data.isFlashActPage
-          ? {}
-          : {class: 'current', open: true}),
-        [
-          html.tag('summary',
-            html.tag('span', {class: 'group-name'},
-              (data.customListTerminology
-                ? language.sanitize(data.customListTerminology)
-                : language.$('flashSidebar.flashList', data.fallbackListTerminology)))),
-
-          html.tag('ul',
-            relations.currentActFlashLinks
-              .map((flashLink, index) =>
-                html.tag('li',
-                  index === data.currentFlashIndex &&
-                    {class: 'current'},
-
-                  flashLink))),
-        ]),
-    ]);
-
-    const sideMapBoxContent = html.tags([
-      html.tag('h1', relations.flashIndexLink),
-
-      stitchArrays({
-        sideName: data.sideNames,
-        sideColor: data.sideColors,
-        actLinks: relations.sideActLinks,
-      }).map(({sideName, sideColor, actLinks}, sideIndex) =>
-          html.tag('details',
-            sideIndex === data.currentSideIndex &&
-              {class: 'current'},
-
-            data.isFlashActPage &&
-            sideIndex === data.currentSideIndex &&
-              {open: true},
-
-            sideColor &&
-              {style: `--primary-color: ${getColors(sideColor).primary}`},
-
-            [
-              html.tag('summary',
-                html.tag('span', {class: 'group-name'},
-                  sideName)),
-
-              html.tag('ul',
-                actLinks.map((actLink, actIndex) =>
-                  html.tag('li',
-                    sideIndex === data.currentSideIndex &&
-                    actIndex === data.currentActIndex &&
-                      {class: 'current'},
-
-                    actLink))),
-            ])),
-    ]);
-
-    const sideMapBox = {
-      class: 'flash-act-map-sidebar-box',
-      content: sideMapBoxContent,
-    };
-
-    const currentActBox = {
-      class: 'flash-current-act-sidebar-box',
-      content: currentActBoxContent,
-    };
-
-    return {
-      leftSidebarMultiple:
+  generate: (data, relations) =>
+    relations.sidebar.slots({
+      boxes:
         (data.isFlashActPage
-          ? [sideMapBox, currentActBox]
-          : [currentActBox, sideMapBox]),
-    };
-  },
+          ? [relations.sideMapBox, relations.currentActBox]
+          : [relations.currentActBox, relations.sideMapBox]),
+    }),
 };
diff --git a/src/content/dependencies/generateFlashActSidebarCurrentActBox.js b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js
new file mode 100644
index 00000000..6d152c7c
--- /dev/null
+++ b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js
@@ -0,0 +1,64 @@
+export default {
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkFlash',
+    'linkFlashAct',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, act, _flash) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    actLink:
+      relation('linkFlashAct', act),
+
+    flashLinks:
+      act.flashes
+        .map(flash => relation('linkFlash', flash)),
+  }),
+
+  data: (act, flash) => ({
+    isFlashActPage:
+      !flash,
+
+    currentFlashIndex:
+      act.flashes.indexOf(flash),
+
+    customListTerminology:
+      act.listTerminology,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.box.slots({
+      attributes: {class: 'flash-act-map-sidebar-box'},
+
+      content: [
+        html.tag('h1', relations.actLink),
+
+        html.tag('details',
+          (data.isFlashActPage
+            ? {}
+            : {class: 'current', open: true}),
+
+          [
+            html.tag('summary',
+              html.tag('span',
+                html.tag('b',
+                  (data.customListTerminology
+                    ? language.sanitize(data.customListTerminology)
+                    : language.$('flashSidebar.flashList.entriesInThisSection'))))),
+
+            html.tag('ul',
+              relations.flashLinks
+                .map((flashLink, index) =>
+                  html.tag('li',
+                    index === data.currentFlashIndex &&
+                      {class: 'current'},
+
+                    flashLink))),
+          ]),
+        ],
+    }),
+};
diff --git a/src/content/dependencies/generateFlashActSidebarSideMapBox.js b/src/content/dependencies/generateFlashActSidebarSideMapBox.js
new file mode 100644
index 00000000..7b26ef31
--- /dev/null
+++ b/src/content/dependencies/generateFlashActSidebarSideMapBox.js
@@ -0,0 +1,85 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generatePageSidebarBox',
+    'linkFlashAct',
+    'linkFlashIndex',
+  ],
+
+  extraDependencies: ['html', 'wikiData'],
+
+  sprawl: ({flashSideData}) => ({flashSideData}),
+
+  relations: (relation, sprawl, _act, _flash) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    flashIndexLink:
+      relation('linkFlashIndex'),
+
+    sideColorStyles:
+      sprawl.flashSideData
+        .map(side => relation('generateColorStyleAttribute', side.color)),
+
+    sideActLinks:
+      sprawl.flashSideData
+        .map(side => side.acts
+          .map(act => relation('linkFlashAct', act))),
+  }),
+
+  data: (sprawl, act, flash) => ({
+    isFlashActPage:
+      !flash,
+
+    sideNames:
+      sprawl.flashSideData
+        .map(side => side.name),
+
+    currentSideIndex:
+      sprawl.flashSideData.indexOf(act.side),
+
+    currentActIndex:
+      act.side.acts.indexOf(act),
+  }),
+
+  generate: (data, relations, {html}) =>
+    relations.box.slots({
+      attributes: {class: 'flash-act-map-sidebar-box'},
+
+      content: [
+        html.tag('h1', relations.flashIndexLink),
+
+        stitchArrays({
+          sideName: data.sideNames,
+          sideColorStyle: relations.sideColorStyles,
+          actLinks: relations.sideActLinks,
+        }).map(({sideName, sideColorStyle, actLinks}, sideIndex) =>
+            html.tag('details',
+              sideIndex === data.currentSideIndex &&
+                {class: 'current'},
+
+              data.isFlashActPage &&
+              sideIndex === data.currentSideIndex &&
+                {open: true},
+
+              sideColorStyle.slot('context', 'primary-only'),
+
+              [
+                html.tag('summary',
+                  html.tag('span',
+                    html.tag('b', sideName))),
+
+                html.tag('ul',
+                  actLinks.map((actLink, actIndex) =>
+                    html.tag('li',
+                      sideIndex === data.currentSideIndex &&
+                      actIndex === data.currentActIndex &&
+                        {class: 'current'},
+
+                      actLink))),
+              ])),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateFlashArtworkColumn.js b/src/content/dependencies/generateFlashArtworkColumn.js
new file mode 100644
index 00000000..5987df9e
--- /dev/null
+++ b/src/content/dependencies/generateFlashArtworkColumn.js
@@ -0,0 +1,11 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations: (relation, flash) => ({
+    coverArtwork:
+      relation('generateCoverArtwork', flash.coverArtwork),
+  }),
+
+  generate: (relations) =>
+    relations.coverArtwork,
+};
diff --git a/src/content/dependencies/generateFlashCoverArtwork.js b/src/content/dependencies/generateFlashCoverArtwork.js
deleted file mode 100644
index 374fa3f8..00000000
--- a/src/content/dependencies/generateFlashCoverArtwork.js
+++ /dev/null
@@ -1,12 +0,0 @@
-export default {
-  contentDependencies: ['generateCoverArtwork'],
-
-  relations: (relation) =>
-    ({coverArtwork: relation('generateCoverArtwork')}),
-
-  data: (flash) =>
-    ({path: ['media.flashArt', flash.directory, flash.coverArtFileExtension]}),
-
-  generate: (data, relations) =>
-    relations.coverArtwork.slot('path', data.path),
-};
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index 57072a1f..2788406c 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -20,7 +20,7 @@ export default {
 
     const jumpActs =
       flashActs
-        .filter(act => act.jump);
+        .filter(act => act.side.acts.indexOf(act) === 0);
 
     return {flashActs, jumpActs};
   },
@@ -31,7 +31,7 @@ export default {
 
     jumpLinkColorStyles:
       query.jumpActs
-        .map(act => relation('generateColorStyleAttribute', act.jumpColor)),
+        .map(act => relation('generateColorStyleAttribute', act.side.color)),
 
     actColorStyles:
       query.flashActs
@@ -53,7 +53,7 @@ export default {
     actCoverGridImages:
       query.flashActs
         .map(act => act.flashes
-          .map(() => relation('image'))),
+          .map(flash => relation('image', flash.coverArtwork))),
   }),
 
   data: (query) => ({
@@ -63,7 +63,7 @@ export default {
 
     jumpLinkLabels:
       query.jumpActs
-        .map(act => act.jump),
+        .map(act => act.side.name),
 
     actAnchors:
       query.flashActs
@@ -73,82 +73,72 @@ export default {
       query.flashActs
         .map(act => act.flashes
           .map(flash => flash.name)),
-
-    actCoverGridPaths:
-      query.flashActs
-        .map(act => act.flashes
-          .map(flash => ['media.flashArt', flash.directory, flash.coverArtFileExtension])),
   }),
 
   generate: (data, relations, {html, language}) =>
-    relations.layout.slots({
-      title: language.$('flashIndex.title'),
-      headingMode: 'static',
-
-      mainClasses: ['flash-index'],
-      mainContent: [
-        !empty(data.jumpLinkLabels) && [
-          html.tag('p', {class: 'quick-info'},
-            language.$('misc.jumpTo')),
-
-          html.tag('ul', {class: 'quick-info'},
-            stitchArrays({
-              colorStyle: relations.jumpLinkColorStyles,
-              anchor: data.jumpLinkAnchors,
-              label: data.jumpLinkLabels,
-            }).map(({colorStyle, anchor, label}) =>
-                html.tag('li',
-                  html.tag('a',
-                    {href: '#' + anchor},
-                    colorStyle,
-                    label)))),
-        ],
+    language.encapsulate('flashIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+        headingMode: 'static',
+
+        mainClasses: ['flash-index'],
+        mainContent: [
+          html.tags([
+            html.tag('p', {class: 'quick-info'},
+              {[html.onlyIfSiblings]: true},
+              language.$('misc.jumpTo')),
+
+            html.tag('ul', {class: 'quick-info'},
+              {[html.onlyIfContent]: true},
+              stitchArrays({
+                colorStyle: relations.jumpLinkColorStyles,
+                anchor: data.jumpLinkAnchors,
+                label: data.jumpLinkLabels,
+              }).map(({colorStyle, anchor, label}) =>
+                  html.tag('li',
+                    html.tag('a',
+                      {href: '#' + anchor},
+                      colorStyle,
+                      label)))),
+          ]),
 
-        stitchArrays({
-          colorStyle: relations.actColorStyles,
-          actLink: relations.actLinks,
-          anchor: data.actAnchors,
-
-          coverGrid: relations.actCoverGrids,
-          coverGridImages: relations.actCoverGridImages,
-          coverGridLinks: relations.actCoverGridLinks,
-          coverGridNames: data.actCoverGridNames,
-          coverGridPaths: data.actCoverGridPaths,
-        }).map(({
-            colorStyle,
-            actLink,
-            anchor,
-
-            coverGrid,
-            coverGridImages,
-            coverGridLinks,
-            coverGridNames,
-            coverGridPaths,
-          }, index) => [
-            html.tag('h2',
-              {id: anchor},
+          stitchArrays({
+            colorStyle: relations.actColorStyles,
+            actLink: relations.actLinks,
+            anchor: data.actAnchors,
+
+            coverGrid: relations.actCoverGrids,
+            coverGridImages: relations.actCoverGridImages,
+            coverGridLinks: relations.actCoverGridLinks,
+            coverGridNames: data.actCoverGridNames,
+          }).map(({
               colorStyle,
-              actLink),
-
-            coverGrid.slots({
-              links: coverGridLinks,
-              names: coverGridNames,
-              lazy: index === 0 ? 4 : true,
-
-              images:
-                stitchArrays({
-                  image: coverGridImages,
-                  path: coverGridPaths,
-                }).map(({image, path}) =>
-                    image.slot('path', path)),
-            }),
-          ]),
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {auto: 'current'},
-      ],
-    }),
+              actLink,
+              anchor,
+
+              coverGrid,
+              coverGridImages,
+              coverGridLinks,
+              coverGridNames,
+            }, index) => [
+              html.tag('h2',
+                {id: anchor},
+                colorStyle,
+                actLink),
+
+              coverGrid.slots({
+                links: coverGridLinks,
+                images: coverGridImages,
+                names: coverGridNames,
+                lazy: index === 0 ? 4 : true,
+              }),
+            ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index c60f9696..ee043bfa 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -2,10 +2,13 @@ import {empty} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generateContributionList',
     'generateFlashActSidebar',
-    'generateFlashCoverArtwork',
+    'generateFlashArtworkColumn',
     'generateFlashNavAccent',
     'generatePageLayout',
     'generateTrackList',
@@ -18,158 +21,186 @@ export default {
   query(flash) {
     const query = {};
 
-    if (flash.page || !empty(flash.urls)) {
-      query.urls = [];
+    query.urls = [];
 
-      if (flash.page) {
-        query.urls.push(`https://homestuck.com/story/${flash.page}`);
-      }
+    if (flash.page) {
+      query.urls.push(`https://homestuck.com/story/${flash.page}`);
+    }
 
-      if (!empty(flash.urls)) {
-        query.urls.push(...flash.urls);
-      }
+    if (!empty(flash.urls)) {
+      query.urls.push(...flash.urls);
     }
 
     return query;
   },
 
-  relations(relation, query, flash) {
-    const relations = {};
-    const sections = relations.sections = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.sidebar =
-      relation('generateFlashActSidebar', flash.act, flash);
+  relations: (relation, query, flash) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    if (query.urls) {
-      relations.externalLinks =
-        query.urls.map(url => relation('linkExternal', url));
-    }
+    sidebar:
+      relation('generateFlashActSidebar', flash.act, flash),
 
-    // TODO: Flashes always have cover art (#175)
-    /* eslint-disable-next-line no-constant-condition */
-    if (true) {
-      relations.cover =
-        relation('generateFlashCoverArtwork', flash);
-    }
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', flash.additionalNames),
 
-    // Section: navigation bar
+    externalLinks:
+      query.urls
+        .map(url => relation('linkExternal', url)),
 
-    const nav = sections.nav = {};
+    artworkColumn:
+      relation('generateFlashArtworkColumn', flash),
 
-    nav.flashActLink =
-      relation('linkFlashAct', flash.act);
+    contentHeading:
+      relation('generateContentHeading'),
 
-    nav.flashNavAccent =
-      relation('generateFlashNavAccent', flash);
+    contentContentHeading:
+      relation('generateContentContentHeading', flash),
 
-    // Section: Featured tracks
+    flashActLink:
+      relation('linkFlashAct', flash.act),
 
-    if (!empty(flash.featuredTracks)) {
-      const featuredTracks = sections.featuredTracks = {};
+    flashNavAccent:
+      relation('generateFlashNavAccent', flash),
 
-      featuredTracks.heading =
-        relation('generateContentHeading');
+    featuredTracksList:
+      relation('generateTrackList', flash.featuredTracks),
 
-      featuredTracks.list =
-        relation('generateTrackList', flash.featuredTracks);
-    }
+    contributorContributionList:
+      relation('generateContributionList', flash.contributorContribs),
 
-    // Section: Contributors
+    artistCommentaryEntries:
+      flash.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
 
-    if (!empty(flash.contributorContribs)) {
-      const contributors = sections.contributors = {};
-
-      contributors.heading =
-        relation('generateContentHeading');
-
-      contributors.list =
-        relation('generateContributionList', flash.contributorContribs);
-    }
-
-    return relations;
-  },
+    creditSourceEntries:
+      flash.creditingSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
+  }),
 
-  data(query, flash) {
-    const data = {};
+  data: (_query, flash) => ({
+    name:
+      flash.name,
 
-    data.name = flash.name;
-    data.color = flash.color;
-    data.date = flash.date;
+    color:
+      flash.color,
 
-    return data;
-  },
+    date:
+      flash.date,
+  }),
 
-  generate(data, relations, {html, language}) {
-    const {sections: sec} = relations;
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('flashPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            flash: data.name,
+          }),
 
-    return relations.layout.slots({
-      title:
-        language.$('flashPage.title', {
-          flash: data.name,
-        }),
+        color: data.color,
+        headingMode: 'sticky',
 
-      color: data.color,
-      headingMode: 'sticky',
+        additionalNames: relations.additionalNamesBox,
 
-      cover:
-        (relations.cover
-          ? relations.cover.slots({
-              alt: language.$('misc.alt.flashArt'),
-            })
-          : null),
+        artworkColumnContent: relations.artworkColumn,
 
-      mainContent: [
-        html.tag('p',
-          language.$('releaseInfo.released', {
-            date: language.formatDate(data.date),
-          })),
+        mainContent: [
+          html.tag('p',
+            language.$('releaseInfo.released', {
+              date: language.formatDate(data.date),
+            })),
 
-        relations.externalLinks &&
           html.tag('p',
+            {[html.onlyIfContent]: true},
+
             language.$('releaseInfo.playOn', {
+              [language.onlyIfOptions]: ['links'],
+
               links:
                 language.formatDisjunctionList(
                   relations.externalLinks
                     .map(link => link.slot('context', 'flash'))),
             })),
 
-        sec.featuredTracks && [
-          sec.featuredTracks.heading
-            .slots({
-              id: 'features',
-              title:
-                language.$('releaseInfo.tracksFeatured', {
-                  flash: html.tag('i', data.name),
-                }),
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate('releaseInfo', capsule => [
+              !html.isBlank(relations.artistCommentaryEntries) &&
+                language.encapsulate(capsule, 'readCommentary', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#artist-commentary'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.creditSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditingSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#crediting-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'features'},
+                title:
+                  language.$('releaseInfo.tracksFeatured', {
+                    flash: html.tag('i', data.name),
+                  }),
+              }),
+
+            relations.featuredTracksList,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'contributors'},
+                title: language.$('releaseInfo.contributors'),
+              }),
+
+            relations.contributorContributionList.slots({
+              chronologyKind: 'flash',
             }),
-
-          sec.featuredTracks.list,
+          ]),
+
+          html.tags([
+            relations.contentContentHeading.clone()
+              .slots({
+                attributes: {id: 'artist-commentary'},
+                string: 'misc.artistCommentary',
+              }),
+
+            relations.artistCommentaryEntries,
+          ]),
+
+          html.tags([
+            relations.contentContentHeading.clone()
+              .slots({
+                attributes: {id: 'crediting-sources'},
+                string: 'misc.creditingSources',
+              }),
+
+            relations.creditSourceEntries,
+          ]),
         ],
 
-        sec.contributors && [
-          sec.contributors.heading
-            .slots({
-              id: 'contributors',
-              title: language.$('releaseInfo.contributors'),
-            }),
-
-          sec.contributors.list,
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.flashActLink.slot('color', false)},
+          {auto: 'current'},
         ],
-      ],
 
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {html: sec.nav.flashActLink.slot('color', false)},
-        {auto: 'current'},
-      ],
+        navBottomRowContent: relations.flashNavAccent,
 
-      navBottomRowContent: sec.nav.flashNavAccent,
-
-      ...relations.sidebar,
-    });
-  },
+        leftSidebar: relations.sidebar,
+      })),
 };
diff --git a/src/content/dependencies/generateFlashNavAccent.js b/src/content/dependencies/generateFlashNavAccent.js
index 55e056dc..0f5d2d6b 100644
--- a/src/content/dependencies/generateFlashNavAccent.js
+++ b/src/content/dependencies/generateFlashNavAccent.js
@@ -1,16 +1,17 @@
-import {atOffset, empty} from '#sugar';
+import {atOffset} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generatePreviousNextLinks',
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
     'linkFlash',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({flashActData}) {
-    return {flashActData};
-  },
+  sprawl: ({flashActData}) =>
+    ({flashActData}),
 
   query(sprawl, flash) {
     // Don't sort chronologically here. The previous/next buttons should match
@@ -31,43 +32,35 @@ export default {
     return {previousFlash, nextFlash};
   },
 
-  relations(relation, query) {
-    const relations = {};
-
-    if (query.previousFlash || query.nextFlash) {
-      relations.previousNextLinks =
-        relation('generatePreviousNextLinks');
-
-      relations.previousFlashLink =
-        (query.previousFlash
-          ? relation('linkFlash', query.previousFlash)
-          : null);
-
-      relations.nextFlashLink =
-        (query.nextFlash
-          ? relation('linkFlash', query.nextFlash)
-          : null);
-    }
-
-    return relations;
-  },
-
-  generate(relations, {html, language}) {
-    const {content: previousNextLinks = []} =
-      relations.previousNextLinks &&
-        relations.previousNextLinks.slots({
-          previousLink: relations.previousFlashLink,
-          nextLink: relations.nextFlashLink,
-        });
-
-    const allLinks = [
-      ...previousNextLinks,
-    ].filter(Boolean);
-
-    if (empty(allLinks)) {
-      return html.blank();
-    }
-
-    return `(${language.formatUnitList(allLinks)})`;
-  },
+  relations: (relation, query) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+
+    previousFlashLink:
+      (query.previousFlash
+        ? relation('linkFlash', query.previousFlash)
+        : null),
+
+    nextFlashLink:
+      (query.nextFlash
+        ? relation('linkFlash', query.nextFlash)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.switcher.slots({
+      links: [
+        relations.previousLink
+          .slot('link', relations.previousFlashLink),
+
+        relations.nextLink
+          .slot('link', relations.nextFlashLink),
+      ],
+    }),
 };
diff --git a/src/content/dependencies/generateGridActionLinks.js b/src/content/dependencies/generateGridActionLinks.js
index f5b1aaa6..585a02b9 100644
--- a/src/content/dependencies/generateGridActionLinks.js
+++ b/src/content/dependencies/generateGridActionLinks.js
@@ -1,5 +1,3 @@
-import {empty} from '#sugar';
-
 export default {
   extraDependencies: ['html'],
 
@@ -7,16 +5,12 @@ export default {
     actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
   },
 
-  generate(slots, {html}) {
-    if (empty(slots.actionLinks)) {
-      return html.blank();
-    }
+  generate: (slots, {html}) =>
+    html.tag('div', {class: 'grid-actions'},
+      {[html.onlyIfContent]: true},
 
-    return (
-      html.tag('div', {class: 'grid-actions'},
-        slots.actionLinks
-          .filter(Boolean)
-          .map(link => link
-            .slot('attributes', {class: ['grid-item', 'box']}))));
-  },
+      (slots.actionLinks ?? [])
+        .filter(link => link && !html.isBlank(link))
+        .map(link => link
+          .slot('attributes', {class: ['grid-item', 'box']}))),
 };
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
index b29c586f..dfdad0e8 100644
--- a/src/content/dependencies/generateGroupGalleryPage.js
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -1,15 +1,16 @@
 import {sortChronologically} from '#sort';
-import {empty, stitchArrays} from '#sugar';
 import {filterItemsForCarousel, getTotalDuration} from '#wiki-data';
 
 export default {
   contentDependencies: [
     'generateCoverCarousel',
-    'generateCoverGrid',
+    'generateGroupGalleryPageAlbumsByDateView',
+    'generateGroupGalleryPageAlbumsBySeriesView',
     'generateGroupNavLinks',
     'generateGroupSecondaryNav',
-    'generateGroupSidebar',
+    'generateIntrapageDotSwitcher',
     'generatePageLayout',
+    'generateQuickDescription',
     'image',
     'linkAlbum',
     'linkListing',
@@ -20,116 +21,93 @@ export default {
   sprawl: ({wikiInfo}) =>
     ({enableGroupUI: wikiInfo.enableGroupUI}),
 
-  relations(relation, sprawl, group) {
-    const relations = {};
+  query(_sprawl, group) {
+    const query = {};
 
-    const albums =
+    query.allAlbums =
       sortChronologically(group.albums.slice(), {latestFirst: true});
 
-    relations.layout =
-      relation('generatePageLayout');
+    query.allTracks =
+      query.allAlbums.flatMap((album) => album.tracks);
 
-    relations.navLinks =
-      relation('generateGroupNavLinks', group);
+    query.carouselAlbums =
+      filterItemsForCarousel(group.featuredAlbums);
 
-    if (sprawl.enableGroupUI) {
-      relations.secondaryNav =
-        relation('generateGroupSecondaryNav', group);
-
-      relations.sidebar =
-        relation('generateGroupSidebar', group);
-    }
+    return query;
+  },
 
-    const carouselAlbums = filterItemsForCarousel(group.featuredAlbums);
+  relations: (relation, query, sprawl, group) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    if (!empty(carouselAlbums)) {
-      relations.coverCarousel =
-        relation('generateCoverCarousel');
+    navLinks:
+      relation('generateGroupNavLinks', group),
 
-      relations.carouselLinks =
-        carouselAlbums
-          .map(album => relation('linkAlbum', album));
+    secondaryNav:
+      (sprawl.enableGroupUI
+        ? relation('generateGroupSecondaryNav', group)
+        : null),
 
-      relations.carouselImages =
-        carouselAlbums
-          .map(album => relation('image', album.artTags));
-    }
+    coverCarousel:
+      relation('generateCoverCarousel'),
 
-    relations.coverGrid =
-      relation('generateCoverGrid');
+    carouselLinks:
+      query.carouselAlbums
+        .map(album => relation('linkAlbum', album)),
 
-    relations.gridLinks =
-      albums
-        .map(album => relation('linkAlbum', album));
+    carouselImages:
+      query.carouselAlbums
+        .map(album => relation('image', album.coverArtworks[0])),
 
-    relations.gridImages =
-      albums.map(album =>
-        (album.hasCoverArt
-          ? relation('image', album.artTags)
-          : relation('image')));
+    quickDescription:
+      relation('generateQuickDescription', group),
 
-    return relations;
-  },
+    albumViewSwitcher:
+      relation('generateIntrapageDotSwitcher'),
 
-  data(sprawl, group) {
-    const data = {};
+    albumsBySeriesView:
+      relation('generateGroupGalleryPageAlbumsBySeriesView', group),
 
-    data.name = group.name;
-    data.color = group.color;
+    albumsByDateView:
+      relation('generateGroupGalleryPageAlbumsByDateView', group),
+  }),
 
-    const albums = sortChronologically(group.albums.slice(), {latestFirst: true});
-    const tracks = albums.flatMap((album) => album.tracks);
+  data: (query, _sprawl, group) => ({
+    name:
+      group.name,
 
-    data.numAlbums = albums.length;
-    data.numTracks = tracks.length;
-    data.totalDuration = getTotalDuration(tracks, {originalReleasesOnly: true});
+    color:
+      group.color,
 
-    data.gridNames = albums.map(album => album.name);
-    data.gridDurations = albums.map(album => getTotalDuration(album.tracks));
-    data.gridNumTracks = albums.map(album => album.tracks.length);
+    numAlbums:
+      query.allAlbums.length,
 
-    data.gridPaths =
-      albums.map(album =>
-        (album.hasCoverArt
-          ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-          : null));
+    numTracks:
+      query.allTracks.length,
 
-    const carouselAlbums = filterItemsForCarousel(group.featuredAlbums);
+    totalDuration:
+      getTotalDuration(query.allTracks, {mainReleasesOnly: true}),
+  }),
 
-    if (!empty(group.featuredAlbums)) {
-      data.carouselPaths =
-        carouselAlbums.map(album =>
-          (album.hasCoverArt
-            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-            : null));
-    }
-
-    return data;
-  },
-
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
-        title: language.$('groupGalleryPage.title', {group: data.name}),
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupGalleryPage', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title', {group: data.name}),
         headingMode: 'static',
 
         color: data.color,
 
         mainClasses: ['top-index'],
         mainContent: [
-          relations.coverCarousel
-            ?.slots({
-              links: relations.carouselLinks,
-              images:
-                stitchArrays({
-                  image: relations.carouselImages,
-                  path: data.carouselPaths,
-                }).map(({image, path}) =>
-                    image.slot('path', path)),
-            }),
+          relations.coverCarousel.slots({
+            links: relations.carouselLinks,
+            images: relations.carouselImages,
+          }),
+
+          relations.quickDescription,
 
           html.tag('p', {class: 'quick-info'},
-            language.$('groupGalleryPage.infoLine', {
+            language.$(pageCapsule, 'infoLine', {
               tracks:
                 html.tag('b',
                   language.countTracks(data.numTracks, {
@@ -149,48 +127,78 @@ export default {
                   })),
             })),
 
-          relations.coverGrid
-            .slots({
-              links: relations.gridLinks,
-              names: data.gridNames,
-              images:
-                stitchArrays({
-                  image: relations.gridImages,
-                  path: data.gridPaths,
-                  name: data.gridNames,
-                }).map(({image, path, name}) =>
-                    image.slots({
-                      path,
-                      missingSourceContent:
-                        language.$('misc.albumGrid.noCoverArt', {
-                          album: name,
-                        }),
-                    })),
-              info:
-                stitchArrays({
-                  numTracks: data.gridNumTracks,
-                  duration: data.gridDurations,
-                }).map(({numTracks, duration}) =>
-                    language.$('misc.albumGrid.details', {
-                      tracks: language.countTracks(numTracks, {unit: true}),
-                      time: language.formatDuration(duration),
-                    })),
-            }),
+          ([
+            !html.isBlank(relations.albumsBySeriesView),
+            !html.isBlank(relations.albumsByDateView)
+          ]).filter(Boolean).length > 1 &&
+
+            language.encapsulate(pageCapsule, 'albumViewSwitcher', capsule =>
+              html.tag('p', {class: 'gallery-view-switcher'},
+                {class: ['drop', 'shiny']},
+
+                {[html.onlyIfContent]: true},
+                {[html.joinChildren]: html.tag('br')},
+
+                [
+                  language.$(capsule),
+
+                  relations.albumViewSwitcher.slots({
+                    initialOptionIndex: 0,
+
+                    titles: [
+                      !html.isBlank(relations.albumsByDateView) &&
+                        language.$(capsule, 'byDate'),
+
+                      !html.isBlank(relations.albumsBySeriesView) &&
+                        language.$(capsule, 'bySeries'),
+                    ].filter(Boolean),
+
+                    targetIDs: [
+                      !html.isBlank(relations.albumsByDateView) &&
+                        'group-album-gallery-by-date',
+
+                      !html.isBlank(relations.albumsBySeriesView) &&
+                        'group-album-gallery-by-series',
+                    ].filter(Boolean),
+                  }),
+                ])),
+
+          /*
+          data.trackGridLabels.some(value => value !== null) &&
+            html.tag('p', {class: 'gallery-set-switcher'},
+              language.encapsulate(pageCapsule, 'setSwitcher', switcherCapsule =>
+                language.$(switcherCapsule, {
+                  sets:
+                    relations.setSwitcher.slots({
+                      initialOptionIndex: 0,
+
+                      titles:
+                        data.trackGridLabels.map(label =>
+                          label ??
+                          language.$(switcherCapsule, 'unlabeledSet')),
+
+                      targetIDs:
+                        data.trackGridIDs,
+                    }),
+                }))),
+          */
+
+          relations.albumsByDateView,
+
+          relations.albumsBySeriesView.slots({
+            attributes: [
+              !html.isBlank(relations.albumsBySeriesView) &&
+                {style: 'display: none'},
+            ],
+          }),
         ],
 
-        ...
-          relations.sidebar
-            ?.slot('currentExtra', 'gallery')
-            ?.content,
-
         navLinkStyle: 'hierarchical',
         navLinks:
           relations.navLinks
             .slot('currentExtra', 'gallery')
             .content,
 
-        secondaryNav:
-          relations.secondaryNav ?? null,
-      });
-  },
+        secondaryNav: relations.secondaryNav,
+      })),
 };
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js
new file mode 100644
index 00000000..7d9aa2d2
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js
@@ -0,0 +1,66 @@
+import {stitchArrays} from '#sugar';
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateCoverGrid', 'image', 'linkAlbum'],
+  extraDependencies: ['language'],
+
+  relations: (relation, albums, _group) => ({
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      albums.map(album =>
+        relation('linkAlbum', album)),
+
+    images:
+      albums.map(album =>
+        (album.hasCoverArt
+          ? relation('image', album.coverArtworks[0])
+          : relation('image')))
+  }),
+
+  data: (albums, group) => ({
+    names:
+      albums.map(album => album.name),
+
+    durations:
+      albums.map(album => getTotalDuration(album.tracks)),
+
+    tracks:
+      albums.map(album => album.tracks.length),
+
+    notFromThisGroup:
+      albums.map(album => !album.groups.includes(group)),
+  }),
+
+  generate: (data, relations, {language}) =>
+    language.encapsulate('misc.coverGrid', capsule =>
+      relations.coverGrid.slots({
+        links: relations.links,
+        names: data.names,
+        notFromThisGroup: data.notFromThisGroup,
+
+        images:
+          stitchArrays({
+            image: relations.images,
+            name: data.names,
+          }).map(({image, name}) =>
+              image.slots({
+                missingSourceContent:
+                  language.$(capsule, 'noCoverArt', {
+                    album: name,
+                  }),
+              })),
+
+        info:
+          stitchArrays({
+            tracks: data.tracks,
+            duration: data.durations,
+          }).map(({tracks, duration}) =>
+              language.$(capsule, 'details.albumLength', {
+                tracks: language.countTracks(tracks, {unit: true}),
+                time: language.formatDuration(duration),
+              })),
+      })),
+};
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js
new file mode 100644
index 00000000..b7d01eb5
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js
@@ -0,0 +1,39 @@
+import {sortChronologically} from '#sort';
+
+export default {
+  contentDependencies: ['generateGroupGalleryPageAlbumGrid'],
+  extraDependencies: ['html', 'language'],
+
+  query: (group) => ({
+    albums:
+      sortChronologically(group.albums, {latestFirst: true}),
+  }),
+
+  relations: (relation, query, group) => ({
+    albumGrid:
+      relation('generateGroupGalleryPageAlbumGrid',
+        query.albums,
+        group),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    language.encapsulate('groupGalleryPage.albumsByDate', capsule =>
+      html.tag('div', {id: 'group-album-gallery-by-date'},
+        slots.attributes,
+
+        {[html.onlyIfContent]: true},
+
+        html.tag('section', [
+          html.tag('h2',
+            language.$(capsule, 'title')),
+
+          relations.albumGrid,
+        ]))),
+};
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js
new file mode 100644
index 00000000..0337275f
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js
@@ -0,0 +1,26 @@
+export default {
+  contentDependencies: ['generateGroupGalleryPageSeriesSection'],
+  extraDependencies: ['html'],
+
+  relations: (relation, group) => ({
+    seriesSections:
+      group.serieses
+        .map(series =>
+          relation('generateGroupGalleryPageSeriesSection', series)),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('div', {id: 'group-album-gallery-by-series'},
+      slots.attributes,
+
+      {[html.onlyIfContent]: true},
+
+      relations.seriesSections),
+};
diff --git a/src/content/dependencies/generateGroupGalleryPageSeriesSection.js b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js
new file mode 100644
index 00000000..2ccead5d
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js
@@ -0,0 +1,156 @@
+import {sortChronologically} from '#sort';
+
+export default {
+  contentDependencies: [
+    'generateExpandableGallerySection',
+    'generateGroupGalleryPageAlbumGrid',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(series) {
+    const query = {};
+
+    // Includes undated albums.
+    const albumsLatestFirst =
+      sortChronologically(series.albums, {latestFirst: true});
+
+    query.albumsAboveCut = albumsLatestFirst.slice(0, 4);
+    query.albumsBelowCut = albumsLatestFirst.slice(4);
+
+    query.allAlbumsDated =
+      series.albums.every(album => album.date);
+
+    query.anyAlbumNotFromThisGroup =
+      series.albums.some(album => !album.groups.includes(series.group));
+
+    query.latestAlbum =
+      albumsLatestFirst
+        .filter(album => album.date)
+        .at(0) ??
+      null;
+
+    query.earliestAlbum =
+      albumsLatestFirst
+        .filter(album => album.date)
+        .at(-1) ??
+      null;
+
+    return query;
+  },
+
+  relations: (relation, query, series) => ({
+    gallerySection:
+      relation('generateExpandableGallerySection'),
+
+    gridAboveCut:
+      relation('generateGroupGalleryPageAlbumGrid',
+        query.albumsAboveCut,
+        series.group),
+
+    gridBelowCut:
+      relation('generateGroupGalleryPageAlbumGrid',
+        query.albumsBelowCut,
+        series.group),
+  }),
+
+  data: (query, series) => ({
+    name:
+      series.name,
+
+    groupName:
+      series.group.name,
+
+    albums:
+      series.albums.length,
+
+    tracks:
+      series.albums
+        .flatMap(album => album.tracks)
+        .length,
+
+    allAlbumsDated:
+      query.allAlbumsDated,
+
+    anyAlbumNotFromThisGroup:
+      query.anyAlbumNotFromThisGroup,
+
+    earliestAlbumDate:
+      (query.earliestAlbum
+        ? query.earliestAlbum.date
+        : null),
+
+    latestAlbumDate:
+      (query.latestAlbum
+        ? query.latestAlbum.date
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupGalleryPage.albumSection', capsule =>
+      relations.gallerySection.slots({
+        title: data.name,
+
+        contentAboveCut: relations.gridAboveCut,
+        contentBelowCut: relations.gridBelowCut,
+
+        caption:
+          language.encapsulate(capsule, 'caption', captionCapsule =>
+            html.tags([
+              data.anyAlbumNotFromThisGroup &&
+                language.$(captionCapsule, 'seriesAlbumsNotFromGroup', {
+                  marker:
+                    language.$('misc.coverGrid.details.notFromThisGroup.marker'),
+
+                  series:
+                    html.tag('i', data.name),
+
+                  group: data.groupName,
+                }),
+
+              language.encapsulate(captionCapsule, workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.tracks =
+                  html.tag('b',
+                    language.countTracks(data.tracks, {unit: true}));
+
+                workingOptions.albums =
+                  html.tag('b',
+                    language.countAlbums(data.albums, {unit: true}));
+
+                if (data.allAlbumsDated) {
+                  const earliestDate = data.earliestAlbumDate;
+                  const latestDate = data.latestAlbumDate;
+
+                  const earliestYear = earliestDate.getFullYear();
+                  const latestYear = latestDate.getFullYear();
+
+                  if (earliestYear === latestYear) {
+                    if (data.albums === 1) {
+                      workingCapsule += '.withDate';
+                      workingOptions.date =
+                        language.formatDate(earliestDate);
+                    } else {
+                      workingCapsule += '.withYear';
+                      workingOptions.year =
+                        language.formatYear(earliestDate);
+                    }
+                  } else {
+                    workingCapsule += '.withYearRange';
+                    workingOptions.yearRange =
+                      language.formatYearRange(earliestDate, latestDate);
+                  }
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              }),
+            ], {[html.joinChildren]: html.tag('br')})),
+
+        expandCue:
+          language.$(capsule, 'expand'),
+
+        collapseCue:
+          language.$(capsule, 'collapse'),
+      })),
+};
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
index 2e1d1688..7b9c2afa 100644
--- a/src/content/dependencies/generateGroupInfoPage.js
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -1,218 +1,179 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateAbsoluteDatetimestamp',
     'generateColorStyleAttribute',
-    'generateContentHeading',
+    'generateGroupInfoPageAlbumsSection',
     'generateGroupNavLinks',
     'generateGroupSecondaryNav',
     'generateGroupSidebar',
     'generatePageLayout',
-    'linkAlbum',
+    'linkArtist',
     'linkExternal',
-    'linkGroupGallery',
-    'linkGroup',
     'transformContent',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({wikiInfo}) {
-    return {
-      enableGroupUI: wikiInfo.enableGroupUI,
-    };
-  },
-
-  query(sprawl, group) {
-    const albums =
-      group.albums;
-
-    const albumGroups =
-      albums
-        .map(album => album.groups);
-
-    const albumOtherCategory =
-      albumGroups
-        .map(groups => groups
-          .map(group => group.category)
-          .find(category => category !== group.category));
-
-    const albumOtherGroups =
-      stitchArrays({
-        groups: albumGroups,
-        category: albumOtherCategory,
-      }).map(({groups, category}) =>
-          groups
-            .filter(group => group.category === category));
-
-    return {albums, albumOtherGroups};
-  },
-
-  relations(relation, query, sprawl, group) {
-    const relations = {};
-    const sec = relations.sections = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.navLinks =
-      relation('generateGroupNavLinks', group);
-
-    if (sprawl.enableGroupUI) {
-      relations.secondaryNav =
-        relation('generateGroupSecondaryNav', group);
-
-      relations.sidebar =
-        relation('generateGroupSidebar', group);
-    }
-
-    sec.info = {};
-
-    if (!empty(group.urls)) {
-      sec.info.visitLinks =
-        group.urls
-          .map(url => relation('linkExternal', url));
-    }
-
-    if (group.description) {
-      sec.info.description =
-        relation('transformContent', group.description);
-    }
-
-    if (!empty(query.albums)) {
-      sec.albums = {};
-
-      sec.albums.heading =
-        relation('generateContentHeading');
-
-      sec.albums.galleryLink =
-        relation('linkGroupGallery', group);
-
-      sec.albums.albumColorStyles =
-        query.albums
-          .map(album => relation('generateColorStyleAttribute', album.color));
-
-      sec.albums.albumLinks =
-        query.albums
-          .map(album => relation('linkAlbum', album));
-
-      sec.albums.otherGroupLinks =
-        query.albumOtherGroups
-          .map(groups => groups
-            .map(group => relation('linkGroup', group)));
-
-      sec.albums.datetimestamps =
-        group.albums.map(album =>
-          (album.date
-            ? relation('generateAbsoluteDatetimestamp', album.date)
-            : null));
-    }
-
-    return relations;
-  },
+  sprawl: ({wikiInfo}) => ({
+    enableGroupUI:
+      wikiInfo.enableGroupUI,
+
+    wikiColor:
+      wikiInfo.color,
+  }),
+
+  query: (_sprawl, group) => ({
+    aliasLinkedArtists:
+      group.closelyLinkedArtists
+        .filter(({annotation}) =>
+          annotation === 'alias'),
+
+    generalLinkedArtists:
+      group.closelyLinkedArtists
+        .filter(({annotation}) =>
+          annotation !== 'alias'),
+  }),
+
+  relations: (relation, query, sprawl, group) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    navLinks:
+      relation('generateGroupNavLinks', group),
+
+    secondaryNav:
+      (sprawl.enableGroupUI
+        ? relation('generateGroupSecondaryNav', group)
+        : null),
+
+    sidebar:
+      (sprawl.enableGroupUI
+        ? relation('generateGroupSidebar', group)
+        : null),
+
+    wikiColorAttribute:
+      relation('generateColorStyleAttribute', sprawl.wikiColor),
+
+    closeArtistLinks:
+      query.generalLinkedArtists
+        .map(({artist}) => relation('linkArtist', artist)),
+
+    aliasArtistLinks:
+      query.aliasLinkedArtists
+        .map(({artist}) => relation('linkArtist', artist)),
+
+    visitLinks:
+      group.urls
+        .map(url => relation('linkExternal', url)),
+
+    description:
+      relation('transformContent', group.description),
+
+    albumSection:
+      relation('generateGroupInfoPageAlbumsSection', group),
+  }),
+
+  data: (query, _sprawl, group) => ({
+    name:
+      group.name,
+
+    color:
+      group.color,
+
+    closeArtistAnnotations:
+      query.generalLinkedArtists
+        .map(({annotation}) => annotation),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupInfoPage', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title', {group: data.name}),
+        headingMode: 'sticky',
+        color: data.color,
 
-  data(query, sprawl, group) {
-    const data = {};
+        mainContent: [
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate(pageCapsule, 'closelyLinkedArtists', capsule => [
+              language.encapsulate(capsule, capsule => {
+                const [workingCapsule, option] =
+                  (relations.closeArtistLinks.length === 0
+                    ? [null, null]
+                 : relations.closeArtistLinks.length === 1
+                    ? [language.encapsulate(capsule, 'one'), 'artist']
+                    : [language.encapsulate(capsule, 'multiple'), 'artists']);
+
+                if (!workingCapsule) return html.blank();
+
+                return language.$(workingCapsule, {
+                  [option]:
+                    language.formatUnitList(
+                      stitchArrays({
+                        link: relations.closeArtistLinks,
+                        annotation: data.closeArtistAnnotations,
+                      }).map(({link, annotation}) =>
+                          language.encapsulate(capsule, 'artist', workingCapsule => {
+                            const workingOptions = {};
+
+                            workingOptions.artist =
+                              link.slots({
+                                attributes: [relations.wikiColorAttribute],
+                              });
+
+                            if (annotation) {
+                              workingCapsule += '.withAnnotation';
+                              workingOptions.annotation = annotation;
+                            }
+
+                            return language.$(workingCapsule, workingOptions);
+                          }))),
+                });
+              }),
 
-    data.name = group.name;
-    data.color = group.color;
+              language.$(capsule, 'aliases', {
+                [language.onlyIfOptions]: ['aliases'],
 
-    return data;
-  },
+                aliases:
+                  language.formatConjunctionList(
+                    relations.aliasArtistLinks.map(link =>
+                      link.slots({
+                        attributes: [relations.wikiColorAttribute],
+                      }))),
+              }),
+            ])),
 
-  generate(data, relations, {html, language}) {
-    const {sections: sec} = relations;
+          html.tag('p',
+            {[html.onlyIfContent]: true},
 
-    return relations.layout
-      .slots({
-        title: language.$('groupInfoPage.title', {group: data.name}),
-        headingMode: 'sticky',
-        color: data.color,
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
 
-        mainContent: [
-          sec.info.visitLinks &&
-            html.tag('p',
-              language.$('releaseInfo.visitOn', {
-                links:
-                  language.formatDisjunctionList(
-                    sec.info.visitLinks
-                      .map(link => link.slot('context', 'group'))),
-              })),
+              links:
+                language.formatDisjunctionList(
+                  relations.visitLinks
+                    .map(link => link.slot('context', 'group'))),
+            })),
 
           html.tag('blockquote',
             {[html.onlyIfContent]: true},
-            sec.info.description
-              ?.slot('mode', 'multiline')),
-
-          sec.albums && [
-            sec.albums.heading
-              .slots({
-                tag: 'h2',
-                title: language.$('groupInfoPage.albumList.title'),
-              }),
+            relations.description.slot('mode', 'multiline')),
 
-            html.tag('p',
-              language.$('groupInfoPage.viewAlbumGallery', {
-                link:
-                  sec.albums.galleryLink
-                    .slot('content', language.$('groupInfoPage.viewAlbumGallery.link')),
-              })),
-
-            html.tag('ul',
-              stitchArrays({
-                albumLink: sec.albums.albumLinks,
-                otherGroupLinks: sec.albums.otherGroupLinks,
-                datetimestamp: sec.albums.datetimestamps,
-                albumColorStyle: sec.albums.albumColorStyles,
-              }).map(({
-                  albumLink,
-                  otherGroupLinks,
-                  datetimestamp,
-                  albumColorStyle,
-                }) => {
-                  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 (!empty(otherGroupLinks)) {
-                    parts.push('withOtherGroup');
-                    options.otherGroupAccent =
-                      html.tag('span', {class: 'other-group-accent'},
-                        language.$(prefix, 'otherGroupAccent', {
-                          groups:
-                            language.formatConjunctionList(
-                              otherGroupLinks.map(groupLink =>
-                                groupLink.slot('color', false))),
-                        }));
-                  }
-
-                  return (
-                    html.tag('li',
-                      albumColorStyle,
-                      language.$(...parts, options)));
-                })),
-          ],
+          relations.albumSection,
         ],
 
-        ...relations.sidebar?.content ?? {},
+        leftSidebar:
+          (relations.sidebar
+            ? relations.sidebar
+                .content /* TODO: Kludge. */
+            : null),
 
         navLinkStyle: 'hierarchical',
         navLinks: relations.navLinks.content,
 
         secondaryNav: relations.secondaryNav ?? null,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js b/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js
new file mode 100644
index 00000000..df42598d
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js
@@ -0,0 +1,47 @@
+import {sortChronologically} from '#sort';
+
+export default {
+  contentDependencies: ['generateGroupInfoPageAlbumsListItem'],
+
+  extraDependencies: ['html'],
+
+  query: (group) => ({
+    // Typically, a latestFirst: false (default) chronological sort would be
+    // appropriate here, but navigation between adjacent albums in a group is a
+    // rather "essential" movement or relationship in the wiki, and we consider
+    // the sorting order of a group's gallery page (latestFirst: true) to be
+    // "canonical" in this regard. We exactly match its sort here, but reverse
+    // it, to still present earlier albums preceding later ones.
+    albums:
+      sortChronologically(group.albums.slice(), {latestFirst: true})
+        .reverse(),
+  }),
+
+  relations: (relation, query, group) => ({
+    items:
+      query.albums
+        .map(album =>
+          relation('generateGroupInfoPageAlbumsListItem',
+            album,
+            group)),
+  }),
+
+  slots: {
+    hidden: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('ul',
+      {id: 'group-album-list-by-date'},
+
+      slots.hidden && {style: 'display: none'},
+
+      {[html.onlyIfContent]: true},
+
+      relations.items
+        .map(item =>
+          item.slot('accentMode', 'groups'))),
+};
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js b/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js
new file mode 100644
index 00000000..bcd5d288
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js
@@ -0,0 +1,87 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateGroupInfoPageAlbumsListItem',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (group) => ({
+    closelyLinkedArtists:
+      group.closelyLinkedArtists
+        .map(({artist}) => artist),
+  }),
+
+  relations: (relation, _query, group) => ({
+    seriesHeadings:
+      group.serieses
+        .map(() => relation('generateContentHeading')),
+
+    seriesItems:
+      group.serieses
+        .map(series => series.albums
+          .map(album =>
+            relation('generateGroupInfoPageAlbumsListItem',
+              album,
+              group))),
+  }),
+
+  data: (query, group) => ({
+    seriesNames:
+      group.serieses
+        .map(series => series.name),
+
+    seriesItemsShowArtists:
+      group.serieses.map(series =>
+        (series.showAlbumArtists === 'all'
+          ? new Array(series.albums.length).fill(true)
+       : series.showAlbumArtists === 'differing'
+          ? series.albums.map(album =>
+              album.artistContribs
+                .map(contrib => contrib.artist)
+                .some(artist => !query.closelyLinkedArtists.includes(artist)))
+          : new Array(series.albums.length).fill(false))),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupInfoPage.albumList', listCapsule =>
+      html.tag('dl',
+        {id: 'group-album-list-by-series'},
+        {class: 'group-series-list'},
+
+        {[html.onlyIfContent]: true},
+
+        stitchArrays({
+          name: data.seriesNames,
+          itemsShowArtists: data.seriesItemsShowArtists,
+          heading: relations.seriesHeadings,
+          items: relations.seriesItems,
+        }).map(({
+            name,
+            itemsShowArtists,
+            heading,
+            items,
+          }) =>
+            html.tags([
+              heading.slots({
+                tag: 'dt',
+                title:
+                  language.$(listCapsule, 'series', {
+                    series: name,
+                  }),
+              }),
+
+              html.tag('dd',
+                html.tag('ul',
+                  stitchArrays({
+                    item: items,
+                    showArtists: itemsShowArtists,
+                  }).map(({item, showArtists}) =>
+                      item.slots({
+                        accentMode:
+                          (showArtists ? 'artists' : null),
+                      })))),
+            ])))),
+};
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
new file mode 100644
index 00000000..4680cb46
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
@@ -0,0 +1,137 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateArtistCredit',
+    'generateColorStyleAttribute',
+    'linkAlbum',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (album, group) => {
+    const otherCategory =
+      album.groups
+        .map(group => group.category)
+        .find(category => category !== group.category);
+
+    const otherGroups =
+      album.groups
+        .filter(group => group.category === otherCategory);
+
+    return {otherGroups};
+  },
+
+  relations: (relation, query, album, _group) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute', album.color),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    datetimestamp:
+      (album.date
+        ? relation('generateAbsoluteDatetimestamp', album.date)
+        : null),
+
+    artistCredit:
+      relation('generateArtistCredit', album.artistContribs, []),
+
+    otherGroupLinks:
+      query.otherGroups
+        .map(group => relation('linkGroup', group)),
+  }),
+
+  data: (_query, album, group) => ({
+    groupName:
+      group.name,
+
+    notFromThisGroup:
+      !group.albums.includes(album),
+  }),
+
+  slots: {
+    accentMode: {
+      validate: v => v.is('groups', 'artists'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('li',
+      relations.colorStyle,
+
+      language.encapsulate('groupInfoPage.albumList.item', itemCapsule =>
+        language.encapsulate(itemCapsule, workingCapsule => {
+          const workingOptions = {};
+
+          workingOptions.album =
+            relations.albumLink.slot('color', false);
+
+          const yearCapsule = language.encapsulate(itemCapsule, 'withYear');
+
+          if (relations.datetimestamp) {
+            workingCapsule += '.withYear';
+            workingOptions.yearAccent =
+              language.$(yearCapsule, 'accent', {
+                year:
+                  relations.datetimestamp.slots({style: 'year', tooltip: true}),
+              });
+          }
+
+          const otherGroupCapsule = language.encapsulate(itemCapsule, 'withOtherGroup');
+
+          if (
+            (slots.accentMode === 'groups' ||
+             slots.accentMode === null) &&
+            data.notFromThisGroup
+          ) {
+            workingCapsule += '.withOtherGroup';
+            workingOptions.otherGroupAccent =
+              html.tag('span', {class: 'other-group-accent'},
+                language.$(otherGroupCapsule, 'notFromThisGroup', {
+                  group:
+                    data.groupName,
+                }));
+          } else if (
+            slots.accentMode === 'groups' &&
+            !empty(relations.otherGroupLinks)
+          ) {
+            workingCapsule += '.withOtherGroup';
+            workingOptions.otherGroupAccent =
+              html.tag('span', {class: 'other-group-accent'},
+                language.$(otherGroupCapsule, 'accent', {
+                  groups:
+                    language.formatConjunctionList(
+                      relations.otherGroupLinks.map(groupLink =>
+                        groupLink.slot('color', false))),
+                }));
+          }
+
+          const artistCapsule = language.encapsulate(itemCapsule, 'withArtists');
+          const {artistCredit} = relations;
+
+          artistCredit.setSlots({
+            normalStringKey:
+              artistCapsule + '.by',
+
+            featuringStringKey:
+              artistCapsule + '.featuring',
+
+            normalFeaturingStringKey:
+              artistCapsule + '.by.featuring',
+          });
+
+          if (slots.accentMode === 'artists' && !html.isBlank(artistCredit)) {
+            workingCapsule += '.withArtists';
+            workingOptions.by =
+              html.tag('span', {class: 'by'},
+                // TODO: This is obviously evil.
+                html.metatag('chunkwrap', {split: /,| (?=and)/},
+                  html.resolve(artistCredit)));
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }))),
+};
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsSection.js b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js
new file mode 100644
index 00000000..0b678e9d
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js
@@ -0,0 +1,93 @@
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateGroupInfoPageAlbumsListByDate',
+    'generateGroupInfoPageAlbumsListBySeries',
+    'generateIntrapageDotSwitcher',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, group) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+
+    galleryLink:
+      relation('linkGroupGallery', group),
+
+    albumsListByDate:
+      relation('generateGroupInfoPageAlbumsListByDate', group),
+
+    albumsListBySeries:
+      relation('generateGroupInfoPageAlbumsListBySeries', group),
+
+    viewSwitcher:
+      relation('generateIntrapageDotSwitcher'),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('groupInfoPage', pageCapsule =>
+      language.encapsulate(pageCapsule, 'albumList', listCapsule =>
+        html.tags([
+          relations.contentHeading
+            .slots({
+              tag: 'h2',
+              title: language.$(listCapsule, 'title'),
+            }),
+
+          html.tag('p',
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(pageCapsule, 'viewAlbumGallery', viewAlbumGalleryCapsule =>
+              language.encapsulate(viewAlbumGalleryCapsule, workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.link =
+                  relations.galleryLink
+                    .slot('content',
+                      language.$(viewAlbumGalleryCapsule, 'link'));
+
+                if (
+                  !html.isBlank(relations.albumsListByDate) &&
+                  !html.isBlank(relations.albumsListBySeries)
+                ) {
+                  workingCapsule += '.withViewSwitcher';
+                  workingOptions.viewSwitcher =
+                    html.tag('span', {class: 'group-view-switcher'},
+                      language.encapsulate(pageCapsule, 'viewSwitcher', switcherCapsule =>
+                        language.$(switcherCapsule, {
+                          options:
+                            relations.viewSwitcher.slots({
+                              initialOptionIndex: 0,
+
+                              titles: [
+                                language.$(switcherCapsule, 'bySeries'),
+                                language.$(switcherCapsule, 'byDate'),
+                              ],
+
+                              targetIDs: [
+                                'group-album-list-by-series',
+                                'group-album-list-by-date',
+                              ],
+                            }),
+                        })));
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              }))),
+
+          ((!html.isBlank(relations.albumsListByDate) &&
+            !html.isBlank(relations.albumsListBySeries))
+
+            ? [
+                relations.albumsListBySeries,
+                relations.albumsListByDate.slot('hidden', true),
+              ]
+
+            : [
+                relations.albumsListBySeries,
+                relations.albumsListByDate,
+              ]),
+        ]))),
+};
diff --git a/src/content/dependencies/generateGroupNavAccent.js b/src/content/dependencies/generateGroupNavAccent.js
new file mode 100644
index 00000000..0e4ebe8a
--- /dev/null
+++ b/src/content/dependencies/generateGroupNavAccent.js
@@ -0,0 +1,53 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'linkGroup',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, group) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    infoLink:
+      relation('linkGroup', group),
+
+    galleryLink:
+      (empty(group.albums)
+        ? null
+        : relation('linkGroupGallery', group)),
+  }),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate: (relations, slots, {language}) =>
+    relations.switcher.slots({
+      links: [
+        relations.infoLink.slots({
+          attributes: [
+            slots.currentExtra === null &&
+              {class: 'current'},
+          ],
+
+          content: language.$('misc.nav.info'),
+        }),
+
+        relations.galleryLink?.slots({
+          attributes: [
+            slots.currentExtra === 'gallery' &&
+              {class: 'current'},
+          ],
+
+          content: language.$('misc.nav.gallery'),
+        }),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateGroupNavLinks.js b/src/content/dependencies/generateGroupNavLinks.js
index 5cde2ab4..bdc3ee4c 100644
--- a/src/content/dependencies/generateGroupNavLinks.js
+++ b/src/content/dependencies/generateGroupNavLinks.js
@@ -1,48 +1,25 @@
-import {empty} from '#sugar';
-
 export default {
-  contentDependencies: [
-    'linkGroup',
-    'linkGroupGallery',
-  ],
-
+  contentDependencies: ['generateGroupNavAccent', 'linkGroup'],
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupCategoryData, wikiInfo}) {
-    return {
-      groupCategoryData,
-      enableGroupUI: wikiInfo.enableGroupUI,
-      enableListings: wikiInfo.enableListings,
-    };
-  },
-
-  relations(relation, sprawl, group) {
-    if (!sprawl.enableGroupUI) {
-      return {};
-    }
-
-    const relations = {};
+  sprawl: ({groupCategoryData, wikiInfo}) => ({
+    groupCategoryData,
+    enableGroupUI: wikiInfo.enableGroupUI,
+    enableListings: wikiInfo.enableListings,
+  }),
 
-    relations.mainLink =
-      relation('linkGroup', group);
+  relations: (relation, _sprawl, group) => ({
+    mainLink:
+      relation('linkGroup', group),
 
-    relations.infoLink =
-      relation('linkGroup', group);
+    accent:
+      relation('generateGroupNavAccent', group),
+  }),
 
-    if (!empty(group.albums)) {
-      relations.galleryLink =
-        relation('linkGroupGallery', group);
-    }
-
-    return relations;
-  },
-
-  data(sprawl) {
-    return {
-      enableGroupUI: sprawl.enableGroupUI,
-      enableListings: sprawl.enableListings,
-    };
-  },
+  data: (sprawl, _group) => ({
+    enableGroupUI: sprawl.enableGroupUI,
+    enableListings: sprawl.enableListings,
+  }),
 
   slots: {
     showExtraLinks: {type: 'boolean', default: false},
@@ -52,53 +29,31 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {language}) {
-    if (!data.enableGroupUI) {
-      return [
-        {auto: 'home'},
-        {auto: 'current'},
-      ];
-    }
-
-    const infoLink =
-      relations.infoLink.slots({
-        attributes: {class: slots.currentExtra === null && 'current'},
-        content: language.$('misc.nav.info'),
-      });
-
-    const extraLinks = [
-      relations.galleryLink?.slots({
-        attributes: {class: slots.currentExtra === 'gallery' && 'current'},
-        content: language.$('misc.nav.gallery'),
-      }),
-    ];
-
-    const extrasPart =
-      (empty(extraLinks)
-        ? ''
-        : language.formatUnitList([infoLink, ...extraLinks]));
-
-    const accent =
-      (extrasPart
-        ? `(${extrasPart})`
-        : null);
-
-    return [
-      {auto: 'home'},
-
-      data.enableListings &&
-        {
-          path: ['localized.listingIndex'],
-          title: language.$('listingIndex.title'),
-        },
-
-      {
-        accent,
-        html:
-          language.$('groupPage.nav.group', {
-            group: relations.mainLink,
-          }),
-      },
-    ].filter(Boolean);
-  },
+  generate: (data, relations, slots, {language}) =>
+    (data.enableGroupUI
+      ? [
+          {auto: 'home'},
+
+          data.enableListings &&
+            {
+              path: ['localized.listingIndex'],
+              title: language.$('listingIndex.title'),
+            },
+
+          {
+            html:
+              language.$('groupPage.nav.group', {
+                group: relations.mainLink,
+              }),
+
+            accent:
+              relations.accent
+                .slot('currentExtra', slots.currentExtra),
+          },
+        ].filter(Boolean)
+
+      : [
+          {auto: 'home'},
+          {auto: 'current'},
+        ]),
 };
diff --git a/src/content/dependencies/generateGroupSecondaryNav.js b/src/content/dependencies/generateGroupSecondaryNav.js
index 17eb5083..c48f3142 100644
--- a/src/content/dependencies/generateGroupSecondaryNav.js
+++ b/src/content/dependencies/generateGroupSecondaryNav.js
@@ -1,100 +1,20 @@
-import {atOffset} from '#sugar';
-
 export default {
   contentDependencies: [
-    'generateColorStyleAttribute',
-    'generatePreviousNextLinks',
     'generateSecondaryNav',
-    'linkGroupDynamically',
-    'linkListing',
+    'generateGroupSecondaryNavCategoryPart',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
-
-  sprawl: ({listingSpec, wikiInfo}) => ({
-    groupsByCategoryListing:
-      (wikiInfo.enableListings
-        ? listingSpec
-            .find(l => l.directory === 'groups/by-category')
-        : null),
-  }),
-
-  query(sprawl, group) {
-    const groups = group.category.groups;
-    const index = groups.indexOf(group);
-
-    return {
-      previousGroup:
-        atOffset(groups, index, -1),
-
-      nextGroup:
-        atOffset(groups, index, +1),
-    };
-  },
-
-  relations(relation, query, sprawl, group) {
-    const relations = {};
-
-    relations.secondaryNav =
-      relation('generateSecondaryNav');
-
-    if (sprawl.groupsByCategoryListing) {
-      relations.categoryLink =
-        relation('linkListing', sprawl.groupsByCategoryListing);
-    }
+  relations: (relation, group) => ({
+    secondaryNav:
+      relation('generateSecondaryNav'),
 
-    relations.colorStyle =
-      relation('generateColorStyleAttribute', group.category.color);
-
-    if (query.previousGroup || query.nextGroup) {
-      relations.previousNextLinks =
-        relation('generatePreviousNextLinks');
-    }
-
-    relations.previousGroupLink =
-      (query.previousGroup
-        ? relation('linkGroupDynamically', query.previousGroup)
-        : null);
-
-    relations.nextGroupLink =
-      (query.nextGroup
-        ? relation('linkGroupDynamically', query.nextGroup)
-        : null);
-
-    return relations;
-  },
-
-  data: (query, sprawl, group) => ({
-    categoryName: group.category.name,
+    categoryPart:
+      relation('generateGroupSecondaryNavCategoryPart', group.category, group),
   }),
 
-  generate(data, relations, {html, language}) {
-    const {content: previousNextPart} =
-      relations.previousNextLinks.slots({
-        previousLink: relations.previousGroupLink,
-        nextLink: relations.nextGroupLink,
-        id: true,
-      });
-
-    const {categoryLink} = relations;
-
-    categoryLink?.setSlot('content', data.categoryName);
-
-    return relations.secondaryNav.slots({
-      class: 'nav-links-groups',
-      content:
-        (relations.previousGroupLink || relations.nextGroupLink
-          ? html.tag('span', {class: 'nav-link'},
-              relations.colorStyle.slot('context', 'primary-only'),
-
-              [
-                categoryLink?.slot('color', false),
-                `(${language.formatUnitList(previousNextPart)})`,
-              ])
-       : categoryLink
-          ? html.tag('span', {class: 'nav-link'},
-              categoryLink)
-          : html.blank()),
-    });
-  },
+  generate: (relations) =>
+    relations.secondaryNav.slots({
+      attributes: {class: 'nav-links-groups'},
+      content: relations.categoryPart,
+    }),
 };
diff --git a/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js b/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js
new file mode 100644
index 00000000..b2adb9f8
--- /dev/null
+++ b/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js
@@ -0,0 +1,79 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateSecondaryNavParentSiblingsPart',
+    'linkGroupDynamically',
+    'linkListing',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({listingSpec, wikiInfo}) => ({
+    groupsByCategoryListing:
+      (wikiInfo.enableListings
+        ? listingSpec
+            .find(l => l.directory === 'groups/by-category')
+        : null),
+  }),
+
+  query(sprawl, category, group) {
+    const groups = category.groups;
+    const index = groups.indexOf(group);
+
+    return {
+      previousGroup:
+        atOffset(groups, index, -1),
+
+      nextGroup:
+        atOffset(groups, index, +1),
+    };
+  },
+
+  relations: (relation, query, sprawl, category, group) => ({
+    parentSiblingsPart:
+      relation('generateSecondaryNavParentSiblingsPart'),
+
+    categoryLink:
+      (sprawl.groupsByCategoryListing
+        ? relation('linkListing', sprawl.groupsByCategoryListing)
+        : null),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', group.category.color),
+
+    previousGroupLink:
+      (query.previousGroup
+        ? relation('linkGroupDynamically', query.previousGroup)
+        : null),
+
+    nextGroupLink:
+      (query.nextGroup
+        ? relation('linkGroupDynamically', query.nextGroup)
+        : null),
+  }),
+
+  data: (_query, _sprawl, category, _group) => ({
+    name: category.name,
+  }),
+
+  generate: (data, relations, {language}) =>
+    relations.parentSiblingsPart.slots({
+      colorStyle: relations.colorStyle,
+      id: true,
+
+      mainLink:
+        (relations.categoryLink
+          ? relations.categoryLink.slots({
+              content: language.sanitize(data.name),
+            })
+          : null),
+
+      previousLink: relations.previousGroupLink,
+      nextLink: relations.nextGroupLink,
+
+      stringsKey: 'groupPage.secondaryNav.category',
+      mainLinkOption: 'category',
+    }),
+};
diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js
index 98b288fa..0888cbbe 100644
--- a/src/content/dependencies/generateGroupSidebar.js
+++ b/src/content/dependencies/generateGroupSidebar.js
@@ -1,18 +1,25 @@
 export default {
-  contentDependencies: ['generateGroupSidebarCategoryDetails'],
+  contentDependencies: [
+    'generateGroupSidebarCategoryDetails',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+  ],
+
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupCategoryData}) {
-    return {groupCategoryData};
-  },
+  sprawl: ({groupCategoryData}) => ({groupCategoryData}),
 
-  relations(relation, sprawl, group) {
-    return {
-      categoryDetails:
-        sprawl.groupCategoryData.map(category =>
-          relation('generateGroupSidebarCategoryDetails', category, group)),
-    };
-  },
+  relations: (relation, sprawl, group) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    categoryDetails:
+      sprawl.groupCategoryData.map(category =>
+        relation('generateGroupSidebarCategoryDetails', category, group)),
+  }),
 
   slots: {
     currentExtra: {
@@ -20,17 +27,20 @@ export default {
     },
   },
 
-  generate(relations, slots, {html, language}) {
-    return {
-      leftSidebarClass: 'category-map-sidebar-box',
-      leftSidebarContent: [
-        html.tag('h1',
-          language.$('groupSidebar.title')),
+  generate: (relations, slots, {html, language}) =>
+    relations.sidebar.slots({
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'category-map-sidebar-box'},
+          content: [
+            html.tag('h1',
+              language.$('groupSidebar.title')),
 
-        relations.categoryDetails
-          .map(details =>
-            details.slot('currentExtra', slots.currentExtra)),
+            relations.categoryDetails
+              .map(details =>
+                details.slot('currentExtra', slots.currentExtra)),
+          ],
+        }),
       ],
-    };
-  },
+    }),
 };
diff --git a/src/content/dependencies/generateGroupSidebarCategoryDetails.js b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
index 69de373b..208ccd07 100644
--- a/src/content/dependencies/generateGroupSidebarCategoryDetails.js
+++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
@@ -46,37 +46,36 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {html, language}) {
-    return html.tag('details',
-      data.isCurrentCategory &&
-        {class: 'current', open: true},
-
-      [
-        html.tag('summary',
-          relations.colorStyle,
-
-          html.tag('span',
-            language.$('groupSidebar.groupList.category', {
-              category:
-                html.tag('span', {class: 'group-name'},
-                  data.name),
-            }))),
-
-        html.tag('ul',
-          stitchArrays(({
-            infoLink: relations.groupInfoLinks,
-            galleryLink: relations.groupGalleryLinks,
-          })).map(({infoLink, galleryLink}, index) =>
-                html.tag('li',
-                  index === data.currentGroupIndex &&
-                    {class: 'current'},
-
-                  language.$('groupSidebar.groupList.item', {
-                    group:
-                      (slots.currentExtra === 'gallery'
-                        ? galleryLink ?? infoLink
-                        : infoLink),
-                  })))),
-      ]);
-  },
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('groupSidebar.groupList', capsule =>
+      html.tag('details',
+        data.isCurrentCategory &&
+          {class: 'current', open: true},
+
+        [
+          html.tag('summary',
+            relations.colorStyle,
+
+            html.tag('span',
+              language.$(capsule, 'category', {
+                category:
+                  html.tag('b', data.name),
+              }))),
+
+          html.tag('ul',
+            stitchArrays(({
+              infoLink: relations.groupInfoLinks,
+              galleryLink: relations.groupGalleryLinks,
+            })).map(({infoLink, galleryLink}, index) =>
+                  html.tag('li',
+                    index === data.currentGroupIndex &&
+                      {class: 'current'},
+
+                    language.$(capsule, 'item', {
+                      group:
+                        (slots.currentExtra === 'gallery'
+                          ? galleryLink ?? infoLink
+                          : infoLink),
+                    })))),
+        ])),
 };
diff --git a/src/content/dependencies/generateImageOverlay.js b/src/content/dependencies/generateImageOverlay.js
new file mode 100644
index 00000000..cfb78a1b
--- /dev/null
+++ b/src/content/dependencies/generateImageOverlay.js
@@ -0,0 +1,50 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  generate: ({html, language}) =>
+    html.tag('div', {id: 'image-overlay-container'},
+      html.tag('div', {id: 'image-overlay-content-container'}, [
+        html.tag('span', {id: 'image-overlay-image-area'},
+          html.tag('span', {id: 'image-overlay-image-layout'}, [
+            html.tag('img', {id: 'image-overlay-image'}),
+            html.tag('img', {id: 'image-overlay-image-thumb'}),
+          ])),
+
+        html.tag('div', {id: 'image-overlay-action-container'},
+          language.encapsulate('releaseInfo.viewOriginalFile', capsule => [
+            html.tag('div', {id: 'image-overlay-action-content-without-size'},
+              language.$(capsule, {
+                link: html.tag('a', {class: 'image-overlay-view-original'},
+                  language.$(capsule, 'link')),
+              })),
+
+            html.tag('div', {id: 'image-overlay-action-content-with-size'}, [
+              language.$(capsule, 'withSize', {
+                link:
+                  html.tag('a', {class: 'image-overlay-view-original'},
+                    language.$(capsule, 'link')),
+
+                size:
+                  html.tag('span',
+                    {[html.joinChildren]: ''},
+                    [
+                      html.tag('span', {id: 'image-overlay-file-size-kilobytes'},
+                        language.$('count.fileSize.kilobytes', {
+                          kilobytes:
+                            html.tag('span', {class: 'image-overlay-file-size-count'}),
+                        })),
+
+                      html.tag('span', {id: 'image-overlay-file-size-megabytes'},
+                        language.$('count.fileSize.megabytes', {
+                          megabytes:
+                            html.tag('span', {class: 'image-overlay-file-size-count'}),
+                        })),
+                    ]),
+              }),
+
+              html.tag('span', {id: 'image-overlay-file-size-warning'},
+                language.$(capsule, 'sizeWarning')),
+            ]),
+          ])),
+      ])),
+};
diff --git a/src/content/dependencies/generateInterpageDotSwitcher.js b/src/content/dependencies/generateInterpageDotSwitcher.js
new file mode 100644
index 00000000..5a33444e
--- /dev/null
+++ b/src/content/dependencies/generateInterpageDotSwitcher.js
@@ -0,0 +1,31 @@
+export default {
+  contentDependencies: ['generateDotSwitcherTemplate'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    template:
+      relation('generateDotSwitcherTemplate'),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    links: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+  },
+
+  generate: (relations, slots) =>
+    relations.template.slots({
+      attributes: [
+        {class: 'interpage'},
+        slots.attributes,
+      ],
+
+      // TODO: Do something to set a class on a link to the current page??
+      options: slots.links,
+    }),
+};
diff --git a/src/content/dependencies/generateIntrapageDotSwitcher.js b/src/content/dependencies/generateIntrapageDotSwitcher.js
new file mode 100644
index 00000000..1d58367d
--- /dev/null
+++ b/src/content/dependencies/generateIntrapageDotSwitcher.js
@@ -0,0 +1,49 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateDotSwitcherTemplate'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    template:
+      relation('generateDotSwitcherTemplate'),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    initialOptionIndex: {type: 'number'},
+
+    titles: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    targetIDs: {
+      validate: v => v.strictArrayOf(v.isString),
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    relations.template.slots({
+      attributes: [
+        {class: 'intrapage'},
+        slots.attributes,
+      ],
+
+      initialOptionIndex: slots.initialOptionIndex,
+
+      options:
+        stitchArrays({
+          title: slots.titles,
+          targetID: slots.targetIDs,
+        }).map(({title, targetID}) =>
+            html.tag('a', {href: '#'},
+              {'data-target-id': targetID},
+              {[html.onlyIfContent]: true},
+
+              language.sanitize(title))),
+    }),
+};
diff --git a/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js
new file mode 100644
index 00000000..0a929429
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['generateListAllAdditionalFilesChunk'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _album, additionalFiles) => ({
+    chunk:
+      relation('generateListAllAdditionalFilesChunk', additionalFiles),
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (relations, slots, {language}) =>
+    language.encapsulate('listingPage', slots.stringsKey, pageCapsule =>
+      relations.chunk.slots({
+        title:
+          language.$(pageCapsule, 'albumFiles'),
+
+        stringsKey: slots.stringsKey,
+      })),
+};
diff --git a/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js b/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js
new file mode 100644
index 00000000..a0af1375
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js
@@ -0,0 +1,51 @@
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateListAllAdditionalFilesAlbumChunk',
+    'generateListAllAdditionalFilesTrackChunk',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album, property) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    albumChunk:
+      relation('generateListAllAdditionalFilesAlbumChunk',
+        album,
+        album[property] ?? []),
+
+    trackChunks:
+      album.tracks.map(track =>
+        relation('generateListAllAdditionalFilesTrackChunk',
+          track,
+          track[property] ?? [])),
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tags([
+      relations.heading.slots({
+        tag: 'h3',
+        title: relations.albumLink,
+      }),
+
+      html.tag('dl',
+        {[html.onlyIfContent]: true},
+
+        [
+          relations.albumChunk.slot('stringsKey', slots.stringsKey),
+
+          relations.trackChunks.map(trackChunk =>
+            trackChunk.slot('stringsKey', slots.stringsKey)),
+        ]),
+    ]),
+};
diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
index b046ccaf..df652efd 100644
--- a/src/content/dependencies/generateListAllAdditionalFilesChunk.js
+++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
@@ -1,84 +1,99 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
+  contentDependencies: ['linkAdditionalFile'],
   extraDependencies: ['html', 'language'],
 
+  relations: (relation, additionalFiles) => ({
+    links:
+      additionalFiles
+        .map(file => file.filenames
+          .map(filename => relation('linkAdditionalFile', file, filename))),
+  }),
+
+  data: (additionalFiles) => ({
+    titles:
+      additionalFiles
+        .map(file => file.title),
+
+    filenames:
+      additionalFiles
+        .map(file => file.filenames),
+  }),
+
   slots: {
     title: {
       type: 'html',
       mutable: false,
     },
 
-    additionalFileTitles: {
-      validate: v => v.strictArrayOf(v.isHTML),
-    },
+    stringsKey: {type: 'string'},
+  },
 
-    additionalFileLinks: {
-      validate: v => v.strictArrayOf(v.strictArrayOf(v.isHTML)),
-    },
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('listingPage', slots.stringsKey, pageCapsule =>
+      html.tags([
+        html.tag('dt',
+          {[html.onlyIfSiblings]: true},
+          slots.title),
 
-    additionalFileFiles: {
-      validate: v => v.strictArrayOf(v.strictArrayOf(v.isString)),
-    },
+        html.tag('dd',
+          {[html.onlyIfContent]: true},
 
-    stringsKey: {type: 'string'},
-  },
+          html.tag('ul',
+          {[html.onlyIfContent]: true},
 
-  generate(slots, {html, language}) {
-    if (empty(slots.additionalFileLinks)) {
-      return html.blank();
-    }
+            stitchArrays({
+              title: data.titles,
+              links: relations.links,
+              filenames: data.filenames,
+            }).map(({
+                title,
+                links,
+                filenames,
+              }) =>
+                language.encapsulate(pageCapsule, 'file', capsule =>
+                  (links.length === 1
+                    ? html.tag('li',
+                        links[0].slots({
+                          content:
+                            language.$(capsule, {
+                              title: title,
+                            }),
+                        }))
 
-    return html.tags([
-      html.tag('dt', slots.title),
-      html.tag('dd',
-        html.tag('ul',
-          stitchArrays({
-            additionalFileTitle: slots.additionalFileTitles,
-            additionalFileLinks: slots.additionalFileLinks,
-            additionalFileFiles: slots.additionalFileFiles,
-          }).map(({
-              additionalFileTitle,
-              additionalFileLinks,
-              additionalFileFiles,
-            }) =>
-              (additionalFileLinks.length === 1
-                ? html.tag('li',
-                    additionalFileLinks[0].slots({
-                      content:
-                        language.$('listingPage', slots.stringsKey, 'file', {
-                          title: additionalFileTitle,
-                        }),
-                    }))
+                 : links.length === 0
+                    ? html.tag('li',
+                        language.$(capsule, 'withNoFiles', {
+                          title: title,
+                        }))
 
-                : html.tag('li', {class: 'has-details'},
-                    html.tag('details', [
-                      html.tag('summary',
-                        html.tag('span',
-                          language.$('listingPage', slots.stringsKey, 'file.withMultipleFiles', {
-                            title:
-                              html.tag('span', {class: 'group-name'},
-                                additionalFileTitle),
+                    : html.tag('li', {class: 'has-details'},
+                        html.tag('details', [
+                          html.tag('summary',
+                            html.tag('span',
+                              language.$(capsule, 'withMultipleFiles', {
+                                title:
+                                  html.tag('b', title),
 
-                            files:
-                              language.countAdditionalFiles(
-                                additionalFileLinks.length,
-                                {unit: true}),
-                          }))),
+                                files:
+                                  language.countAdditionalFiles(
+                                    links.length,
+                                    {unit: true}),
+                              }))),
 
-                      html.tag('ul',
-                        stitchArrays({
-                          additionalFileLink: additionalFileLinks,
-                          additionalFileFile: additionalFileFiles,
-                        }).map(({additionalFileLink, additionalFileFile}) =>
-                            html.tag('li',
-                              additionalFileLink.slots({
-                                content:
-                                  language.$('listingPage', slots.stringsKey, 'file', {
-                                    title: additionalFileFile,
-                                  }),
-                              })))),
-                    ])))))),
-    ]);
-  },
+                          html.tag('ul',
+                            stitchArrays({
+                              link: links,
+                              filename: filenames,
+                            }).map(({link, filename}) =>
+                                html.tag('li',
+                                  link.slots({
+                                    content:
+                                      language.$(capsule, {
+                                        title: filename,
+                                      }),
+                                  })))),
+                        ]))))))),
+      ])),
 };
diff --git a/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js b/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js
new file mode 100644
index 00000000..b2e5addf
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js
@@ -0,0 +1,23 @@
+export default {
+  contentDependencies: ['generateListAllAdditionalFilesChunk', 'linkTrack'],
+  extraDependencies: ['html'],
+
+  relations: (relation, track, additionalFiles) => ({
+    trackLink:
+      relation('linkTrack', track),
+
+    chunk:
+      relation('generateListAllAdditionalFilesChunk', additionalFiles),
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (relations, slots) =>
+    relations.chunk.slots({
+      title: relations.trackLink,
+      stringsKey: slots.stringsKey,
+    }),
+};
+
diff --git a/src/content/dependencies/generateListingIndexList.js b/src/content/dependencies/generateListingIndexList.js
index ed153652..78622e6e 100644
--- a/src/content/dependencies/generateListingIndexList.js
+++ b/src/content/dependencies/generateListingIndexList.js
@@ -107,8 +107,8 @@ export default {
 
                 [
                   html.tag('summary',
-                    html.tag('span', {class: 'group-name'},
-                      targetTitle)),
+                    html.tag('span',
+                      html.tag('b', targetTitle))),
 
                   listingLinkList,
                 ])));
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
index aa661abd..5f9a99a9 100644
--- a/src/content/dependencies/generateListingPage.js
+++ b/src/content/dependencies/generateListingPage.js
@@ -34,13 +34,15 @@ export default {
       relations.sameTargetListingLinks =
         listing.target.listings
           .map(listing => relation('linkListing', listing));
+    } else {
+      relations.sameTargetListingLinks = [];
     }
 
-    if (!empty(listing.seeAlso)) {
-      relations.seeAlsoLinks =
-        listing.seeAlso
-          .map(listing => relation('linkListing', listing));
-    }
+    relations.seeAlsoLinks =
+      (!empty(listing.seeAlso)
+        ? listing.seeAlso
+            .map(listing => relation('linkListing', listing))
+        : []);
 
     return relations;
   },
@@ -167,33 +169,37 @@ export default {
       headingMode: 'sticky',
 
       mainContent: [
-        relations.sameTargetListingLinks &&
-          html.tag('p',
-            language.$('listingPage.listingsFor', {
-              target:
-                language.$('listingPage.target', data.targetStringsKey),
-
-              listings:
-                language.formatUnitList(
-                  stitchArrays({
-                    link: relations.sameTargetListingLinks,
-                    stringsKey: data.sameTargetListingStringsKeys,
-                  }).map(({link, stringsKey}, index) =>
-                      html.tag('span',
-                        index === data.sameTargetListingsCurrentIndex &&
-                          {class: 'current'},
-
-                        link.slots({
-                          attributes: {class: 'nowrap'},
-                          content: language.$('listingPage', stringsKey, 'title.short'),
-                        })))),
-            })),
-
-        relations.seeAlsoLinks &&
-          html.tag('p',
-            language.$('listingPage.seeAlso', {
-              listings: language.formatUnitList(relations.seeAlsoLinks),
-            })),
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          language.$('listingPage.listingsFor', {
+            [language.onlyIfOptions]: ['listings'],
+
+            target:
+              language.$('listingPage.target', data.targetStringsKey),
+
+            listings:
+              language.formatUnitList(
+                stitchArrays({
+                  link: relations.sameTargetListingLinks,
+                  stringsKey: data.sameTargetListingStringsKeys,
+                }).map(({link, stringsKey}, index) =>
+                    html.tag('span',
+                      index === data.sameTargetListingsCurrentIndex &&
+                        {class: 'current'},
+
+                      link.slots({
+                        attributes: {class: 'nowrap'},
+                        content: language.$('listingPage', stringsKey, 'title.short'),
+                      })))),
+          })),
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          language.$('listingPage.seeAlso', {
+            [language.onlyIfOptions]: ['listings'],
+            listings:
+              language.formatUnitList(relations.seeAlsoLinks),
+          })),
 
         slots.content,
 
@@ -243,7 +249,7 @@ export default {
                   .clone()
                   .slots({
                     tag: 'dt',
-                    id,
+                    attributes: [id && {id}],
 
                     title:
                       formatListingString({
@@ -276,7 +282,7 @@ export default {
         {auto: 'current'},
       ],
 
-      ...relations.sidebar,
+      leftSidebar: relations.sidebar,
     });
   },
 };
diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js
index 1cdd236b..aeac05cf 100644
--- a/src/content/dependencies/generateListingSidebar.js
+++ b/src/content/dependencies/generateListingSidebar.js
@@ -1,21 +1,37 @@
 export default {
-  contentDependencies: ['generateListingIndexList', 'linkListingIndex'],
+  contentDependencies: [
+    'generateListingIndexList',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'linkListingIndex',
+  ],
+
   extraDependencies: ['html'],
 
-  relations(relation, currentListing) {
-    return {
-      listingIndexLink: relation('linkListingIndex'),
-      listingIndexList: relation('generateListingIndexList', currentListing),
-    };
-  },
+  relations: (relation, currentListing) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    listingIndexLink:
+      relation('linkListingIndex'),
+
+    listingIndexList:
+      relation('generateListingIndexList', currentListing),
+  }),
 
-  generate(relations, {html}) {
-    return {
-      leftSidebarClass: 'listing-map-sidebar-box',
-      leftSidebarContent: [
-        html.tag('h1', relations.listingIndexLink),
-        relations.listingIndexList.slot('mode', 'sidebar'),
+  generate: (relations, {html}) =>
+    relations.sidebar.slots({
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'listing-map-sidebar-box'},
+          content: [
+            html.tag('h1', relations.listingIndexLink),
+            relations.listingIndexList.slot('mode', 'sidebar'),
+          ],
+        }),
       ],
-    };
-  },
+    }),
 };
diff --git a/src/content/dependencies/generateListingsIndexPage.js b/src/content/dependencies/generateListingsIndexPage.js
index 1b1c8559..b57ebe15 100644
--- a/src/content/dependencies/generateListingsIndexPage.js
+++ b/src/content/dependencies/generateListingsIndexPage.js
@@ -83,7 +83,7 @@ export default {
         {auto: 'current'},
       ],
 
-      ...relations.sidebar,
+      leftSidebar: relations.sidebar,
     });
   },
 };
diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js
new file mode 100644
index 00000000..0c91ce0c
--- /dev/null
+++ b/src/content/dependencies/generateLyricsEntry.js
@@ -0,0 +1,91 @@
+export default {
+  contentDependencies: ['linkArtist', 'linkExternal', 'transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    content:
+      relation('transformContent', entry.body),
+
+    artistText:
+      relation('transformContent', entry.artistText),
+
+    artistLinks:
+      entry.artists
+        .filter(artist => artist.name !== 'HSMusic Wiki') // smh
+        .map(artist => relation('linkArtist', artist)),
+
+    sourceLinks:
+      entry.sourceURLs
+        .map(url => relation('linkExternal', url)),
+
+    originDetails:
+      relation('transformContent', entry.originDetails),
+  }),
+
+  data: (entry) => ({
+    isWikiLyrics:
+      entry.isWikiLyrics,
+
+    hasSquareBracketAnnotations:
+      entry.hasSquareBracketAnnotations,
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('misc.lyrics', capsule =>
+      html.tag('div', {class: 'lyrics-entry'},
+        slots.attributes,
+
+        [
+          html.tag('p', {class: 'lyrics-details'},
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            [
+              language.$(capsule, 'source', {
+                [language.onlyIfOptions]: ['source'],
+
+                source:
+                  language.formatUnitList(
+                    relations.sourceLinks.map(link =>
+                      link.slots({
+                        indicateExternal: true,
+                        tab: 'separate',
+                      }))),
+              }),
+
+              data.isWikiLyrics &&
+                language.$(capsule, 'contributors', {
+                  [language.onlyIfOptions]: ['contributors'],
+
+                  contributors:
+                    (html.isBlank(relations.artistText)
+                      ? language.formatUnitList(relations.artistLinks)
+                      : relations.artistText.slot('mode', 'inline')),
+                }),
+
+              // This check is doubled up only for clarity: entries are coded
+              // in data so that `hasSquareBracketAnnotations` is only true
+              // if `isWikiLyrics` is also true.
+              data.isWikiLyrics &&
+              data.hasSquareBracketAnnotations &&
+                language.$(capsule, 'squareBracketAnnotations'),
+            ]),
+
+          html.tag('p', {class: 'origin-details'},
+            {[html.onlyIfContent]: true},
+
+            relations.originDetails.slots({
+              mode: 'inline',
+              absorbPunctuationFollowingExternalLinks: false,
+            })),
+
+          relations.content.slot('mode', 'lyrics'),
+        ])),
+};
diff --git a/src/content/dependencies/generateLyricsSection.js b/src/content/dependencies/generateLyricsSection.js
new file mode 100644
index 00000000..f6b719a9
--- /dev/null
+++ b/src/content/dependencies/generateLyricsSection.js
@@ -0,0 +1,81 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateIntrapageDotSwitcher',
+    'generateLyricsEntry',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entries) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    switcher:
+      relation('generateIntrapageDotSwitcher'),
+
+    entries:
+      entries
+        .map(entry => relation('generateLyricsEntry', entry)),
+
+    annotations:
+      entries
+        .map(entry => entry.annotation)
+        .map(annotation => relation('transformContent', annotation)),
+  }),
+
+  data: (entries) => ({
+    ids:
+      Array.from(
+        {length: entries.length},
+        (_, index) => 'lyrics-entry-' + index),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo.lyrics', capsule =>
+      html.tags([
+        relations.heading
+          .slots({
+            attributes: {id: 'lyrics'},
+            title: language.$(capsule),
+          }),
+
+        html.tag('p', {class: 'lyrics-switcher'},
+          {[html.onlyIfContent]: true},
+
+          language.$(capsule, 'switcher', {
+            [language.onlyIfOptions]: ['entries'],
+
+            entries:
+              relations.switcher.slots({
+                initialOptionIndex: 0,
+
+                titles:
+                  relations.annotations.map(annotation =>
+                    annotation.slots({
+                      mode: 'inline',
+                      textOnly: true,
+                    })),
+
+                targetIDs:
+                  data.ids,
+              }),
+          })),
+
+        stitchArrays({
+          entry: relations.entries,
+          id: data.ids,
+        }).map(({entry, id}, index) =>
+            entry.slots({
+              attributes: [
+                {id},
+
+                index >= 1 &&
+                  {style: 'display: none'},
+              ],
+            })),
+      ])),
+};
diff --git a/src/content/dependencies/generateNewsEntryNavAccent.js b/src/content/dependencies/generateNewsEntryNavAccent.js
new file mode 100644
index 00000000..5d168e41
--- /dev/null
+++ b/src/content/dependencies/generateNewsEntryNavAccent.js
@@ -0,0 +1,40 @@
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
+    'linkNewsEntry',
+  ],
+
+  relations: (relation, previousEntry, nextEntry) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+
+    previousEntryLink:
+      (previousEntry
+        ? relation('linkNewsEntry', previousEntry)
+        : null),
+
+    nextEntryLink:
+      (nextEntry
+        ? relation('linkNewsEntry', nextEntry)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.switcher.slots({
+      links: [
+        relations.previousLink
+          .slot('link', relations.previousEntryLink),
+
+        relations.nextLink
+          .slot('link', relations.nextEntryLink),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateNewsEntryPage.js b/src/content/dependencies/generateNewsEntryPage.js
index bcba7194..4abd87d1 100644
--- a/src/content/dependencies/generateNewsEntryPage.js
+++ b/src/content/dependencies/generateNewsEntryPage.js
@@ -3,10 +3,9 @@ import {atOffset} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateNewsEntryNavAccent',
     'generateNewsEntryReadAnotherLinks',
     'generatePageLayout',
-    'generatePreviousNextLinks',
-    'linkNewsEntry',
     'linkNewsIndex',
     'transformContent',
   ],
@@ -31,101 +30,76 @@ export default {
     return {previousEntry, nextEntry};
   },
 
-  relations(relation, query, sprawl, newsEntry) {
-    const relations = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.content =
-      relation('transformContent', newsEntry.content);
-
-    relations.newsIndexLink =
-      relation('linkNewsIndex');
-
-    relations.currentEntryLink =
-      relation('linkNewsEntry', newsEntry);
-
-    if (query.previousEntry || query.nextEntry) {
-      relations.previousNextLinks =
-        relation('generatePreviousNextLinks');
-
-      relations.readAnotherLinks =
-        relation('generateNewsEntryReadAnotherLinks',
-          newsEntry,
-          query.previousEntry,
-          query.nextEntry);
-
-      if (query.previousEntry) {
-        relations.previousEntryNavLink =
-          relation('linkNewsEntry', query.previousEntry);
-      }
-
-      if (query.nextEntry) {
-        relations.nextEntryNavLink =
-          relation('linkNewsEntry', query.nextEntry);
-      }
-    }
-
-    return relations;
-  },
-
-  data(query, sprawl, newsEntry) {
-    return {
-      name: newsEntry.name,
-      date: newsEntry.date,
-
-      daysSincePreviousEntry:
-        query.previousEntry &&
-          Math.round((newsEntry.date - query.previousEntry.date) / 86400000),
-
-      daysUntilNextEntry:
-        query.nextEntry &&
-          Math.round((query.nextEntry.date - newsEntry.date) / 86400000),
-
-      previousEntryDate:
-        query.previousEntry?.date,
-
-      nextEntryDate:
-        query.nextEntry?.date,
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title:
-        language.$('newsEntryPage.title', {
-          entry: data.name,
-        }),
-
-      headingMode: 'sticky',
-
-      mainClasses: ['long-content'],
-      mainContent: [
-        html.tag('p',
-          language.$('newsEntryPage.published', {
-            date: language.formatDate(data.date),
-          })),
-
-        relations.content,
-        relations.readAnotherLinks,
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {html: relations.newsIndexLink},
-        {
-          auto: 'current',
-          accent:
-            (relations.previousNextLinks
-              ? `(${language.formatUnitList(relations.previousNextLinks.slots({
-                  previousLink: relations.previousEntryNavLink ?? null,
-                  nextLink: relations.nextEntryNavLink ?? null,
-                }).content)})`
-              : null),
-        },
-      ],
-    });
-  },
+  relations: (relation, query, sprawl, newsEntry) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    content:
+      relation('transformContent', newsEntry.content),
+
+    newsIndexLink:
+      relation('linkNewsIndex'),
+
+    readAnotherLinks:
+      relation('generateNewsEntryReadAnotherLinks',
+        newsEntry,
+        query.previousEntry,
+        query.nextEntry),
+
+    navAccent:
+      relation('generateNewsEntryNavAccent',
+        query.previousEntry,
+        query.nextEntry),
+  }),
+
+  data: (query, sprawl, newsEntry) => ({
+    name: newsEntry.name,
+    date: newsEntry.date,
+
+    daysSincePreviousEntry:
+      query.previousEntry &&
+        Math.round((newsEntry.date - query.previousEntry.date) / 86400000),
+
+    daysUntilNextEntry:
+      query.nextEntry &&
+        Math.round((query.nextEntry.date - newsEntry.date) / 86400000),
+
+    previousEntryDate:
+      query.previousEntry?.date,
+
+    nextEntryDate:
+      query.nextEntry?.date,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('newsEntryPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            entry: data.name,
+          }),
+
+        headingMode: 'sticky',
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          html.tag('p',
+            language.$(pageCapsule, 'published', {
+              date: language.formatDate(data.date),
+            })),
+
+          relations.content,
+          relations.readAnotherLinks,
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.newsIndexLink},
+          {
+            auto: 'current',
+            accent: relations.navAccent,
+          },
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateNewsIndexPage.js b/src/content/dependencies/generateNewsIndexPage.js
index 539af804..02964ce8 100644
--- a/src/content/dependencies/generateNewsIndexPage.js
+++ b/src/content/dependencies/generateNewsIndexPage.js
@@ -57,37 +57,38 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title: language.$('newsIndex.title'),
-      headingMode: 'sticky',
-
-      mainClasses: ['long-content', 'news-index'],
-      mainContent:
-        stitchArrays({
-          entryLink: relations.entryLinks,
-          viewRestLink: relations.viewRestLinks,
-          content: relations.entryContents,
-          date: data.entryDates,
-          directory: data.entryDirectories,
-        }).map(({entryLink, viewRestLink, content, date, directory}) =>
-            html.tag('article', {id: directory}, [
-              html.tag('h2', [
-                html.tag('time', language.formatDate(date)),
-                entryLink,
-              ]),
-
-              content,
-
-              viewRestLink
-                ?.slot('content', language.$('newsIndex.entry.viewRest')),
-            ])),
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {auto: 'current'},
-      ],
-    });
-  },
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('newsIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+        headingMode: 'sticky',
+
+        mainClasses: ['long-content', 'news-index'],
+        mainContent:
+          stitchArrays({
+            entryLink: relations.entryLinks,
+            viewRestLink: relations.viewRestLinks,
+            content: relations.entryContents,
+            date: data.entryDates,
+            directory: data.entryDirectories,
+          }).map(({entryLink, viewRestLink, content, date, directory}) =>
+              language.encapsulate(pageCapsule, 'entry', entryCapsule =>
+                html.tag('article', {id: directory}, [
+                  html.tag('h2', [
+                    html.tag('time', language.formatDate(date)),
+                    entryLink,
+                  ]),
+
+                  content,
+
+                  viewRestLink
+                    ?.slot('content', language.$(entryCapsule, 'viewRest')),
+                ]))),
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateNextLink.js b/src/content/dependencies/generateNextLink.js
new file mode 100644
index 00000000..2e48cd2b
--- /dev/null
+++ b/src/content/dependencies/generateNextLink.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['generatePreviousNextLink'],
+
+  relations: (relation) => ({
+    link:
+      relation('generatePreviousNextLink'),
+  }),
+
+  generate: (relations) =>
+    relations.link.slots({
+      direction: 'next',
+    }),
+};
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 9e9b4615..0326f415 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -1,92 +1,42 @@
 import {openAggregate} from '#aggregate';
-import {empty} from '#sugar';
-
-function sidebarSlots(side) {
-  return {
-    // Content is a flat HTML array. It'll generate one sidebar section
-    // if specified.
-    [side + 'Content']: {
-      type: 'html',
-      mutable: false,
-    },
-
-    // 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,
-          })),
-    },
-
-    // Sticky mode controls which sidebar section(s), if any, follow the
-    // scroll position, "sticking" to the top of the browser viewport.
-    //
-    // 'last' - last or only sidebar box is sticky
-    // 'column' - entire column, incl. multiple boxes from top, is sticky
-    // 'none' - sidebar not sticky at all, stays at top of page
-    //
-    // Note: This doesn't affect the content of any sidebar section, only
-    // 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
-    // thin. (This is the default.) Override as false to make the sidebar
-    // stay visible in thinner viewports, where the page layout will be
-    // reflowed so the sidebar is as wide as the screen and appears below
-    // nav, above the main content.
-    [side + 'Collapse']: {type: 'boolean', default: true},
-
-    // Wide sidebars generally take up more horizontal space in the normal
-    // page layout, and should be used if the content of the sidebar has
-    // a greater than typical focus compared to main content.
-    [side + 'Wide']: {type: 'boolean', defualt: false},
-  };
-}
+import {atOffset, empty, repeat} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateColorStyleRules',
+    'generateColorStyleTag',
     'generateFooterLocalizationLinks',
+    'generateImageOverlay',
+    'generatePageSidebar',
+    'generateSearchSidebarBox',
+    'generateStaticURLStyleTag',
     'generateStickyHeadingContainer',
+    'generateWikiWallpaperStyleTag',
     'transformContent',
   ],
 
   extraDependencies: [
-    'cachebust',
     'getColors',
     'html',
     'language',
     'pagePath',
+    'pagePathStringFromRoot',
     'to',
     'wikiData',
   ],
 
-  sprawl({wikiInfo}) {
-    return {
-      footerContent: wikiInfo.footerContent,
-      wikiColor: wikiInfo.color,
-      wikiName: wikiInfo.nameShort,
-    };
-  },
+  sprawl: ({wikiInfo}) => ({
+    enableSearch: wikiInfo.enableSearch,
+    footerContent: wikiInfo.footerContent,
+    wikiColor: wikiInfo.color,
+    wikiName: wikiInfo.nameShort,
+    canonicalBase: wikiInfo.canonicalBase,
+  }),
 
-  data({wikiColor, wikiName}) {
-    return {
-      wikiColor,
-      wikiName,
-    };
-  },
+  data: (sprawl) => ({
+    wikiColor: sprawl.wikiColor,
+    wikiName: sprawl.wikiName,
+    canonicalBase: sprawl.canonicalBase,
+  }),
 
   relations(relation, sprawl) {
     const relations = {};
@@ -97,13 +47,30 @@ export default {
     relations.stickyHeadingContainer =
       relation('generateStickyHeadingContainer');
 
+    relations.sidebar =
+      relation('generatePageSidebar');
+
+    if (sprawl.enableSearch) {
+      relations.searchBox =
+        relation('generateSearchSidebarBox');
+    }
+
     if (sprawl.footerContent) {
       relations.defaultFooterContent =
         relation('transformContent', sprawl.footerContent);
     }
 
-    relations.colorStyleRules =
-      relation('generateColorStyleRules');
+    relations.colorStyleTag =
+      relation('generateColorStyleTag');
+
+    relations.staticURLStyleTag =
+      relation('generateStaticURLStyleTag');
+
+    relations.wikiWallpaperStyleTag =
+      relation('generateWikiWallpaperStyleTag');
+
+    relations.imageOverlay =
+      relation('generateImageOverlay');
 
     return relations;
   },
@@ -115,6 +82,16 @@ export default {
     },
 
     showWikiNameInTitle: {
+      validate: v => v.is(true, false, 'auto'),
+      default: 'auto',
+    },
+
+    subtitle: {
+      type: 'html',
+      mutable: false,
+    },
+
+    showSearch: {
       type: 'boolean',
       default: true,
     },
@@ -124,7 +101,7 @@ export default {
       mutable: false,
     },
 
-    cover: {
+    artworkColumnContent: {
       type: 'html',
       mutable: false,
     },
@@ -138,9 +115,9 @@ export default {
 
     color: {validate: v => v.isColor},
 
-    styleRules: {
-      validate: v => v.sparseArrayOf(v.isHTML),
-      default: [],
+    styleTags: {
+      type: 'html',
+      mutable: false,
     },
 
     mainClasses: {
@@ -162,8 +139,15 @@ export default {
 
     // Sidebars
 
-    ...sidebarSlots('leftSidebar'),
-    ...sidebarSlots('rightSidebar'),
+    leftSidebar: {
+      type: 'html',
+      mutable: true,
+    },
+
+    rightSidebar: {
+      type: 'html',
+      mutable: true,
+    },
 
     // Banner
 
@@ -256,16 +240,59 @@ export default {
   },
 
   generate(data, relations, slots, {
-    cachebust,
     getColors,
     html,
     language,
     pagePath,
+    pagePathStringFromRoot,
     to,
   }) {
     const colors = getColors(slots.color ?? data.wikiColor);
     const hasSocialEmbed = !html.isBlank(slots.socialEmbed);
 
+    // Hilariously jank. Sorry! We're going to need this content later ANYWAY,
+    // so it's "fine" to stringify it here, but this DOES mean that we're
+    // stringifying (and resolving) the content without the context that it's
+    // e.g. going to end up in a page HTML hierarchy. Might have implications
+    // later, mainly for: https://github.com/hsmusic/hsmusic-wiki/issues/434
+    const mainContentHTML = html.tags([slots.mainContent]).toString();
+    const hasID = id => mainContentHTML.includes(`id="${id}"`);
+
+    const oEmbedJSONHref =
+      (hasSocialEmbed && data.canonicalBase
+        ? data.canonicalBase +
+          pagePathStringFromRoot +
+          'oembed.json'
+        : null);
+
+    const canonicalHref =
+      (data.canonicalBase
+        ? data.canonicalBase + pagePathStringFromRoot
+        : null);
+
+    const primaryCover = (() => {
+      const apparentFirst = tag => html.smooth(tag).content[0];
+
+      const maybeTemplate =
+        apparentFirst(slots.artworkColumnContent);
+
+      if (!maybeTemplate) return null;
+
+      const maybeTemplateContent =
+        html.resolve(maybeTemplate, {normalize: 'tag'});
+
+      const maybeCoverArtwork =
+        apparentFirst(maybeTemplateContent);
+
+      if (!maybeCoverArtwork) return null;
+
+      if (maybeCoverArtwork.attributes.has('class', 'cover-artwork')) {
+        return maybeTemplate;
+      } else {
+        return null;
+      }
+    })();
+
     const titleContentsHTML =
       (html.isBlank(slots.title)
         ? null
@@ -280,35 +307,59 @@ export default {
       (html.isBlank(slots.title)
         ? null
      : slots.headingMode === 'sticky'
-        ? relations.stickyHeadingContainer.slots({
-            title: titleContentsHTML,
-            cover: slots.cover,
-          })
+        ? [
+            relations.stickyHeadingContainer.slots({
+              title: titleContentsHTML,
+              cover: primaryCover,
+            }),
+
+            relations.stickyHeadingContainer.clone().slots({
+              rootAttributes: {inert: true},
+            }),
+          ]
         : html.tag('h1', titleContentsHTML));
 
+    // TODO: There could be neat interactions with the sticky heading here,
+    // but for now subtitle is totally separate.
+    const subtitleHTML =
+      (html.isBlank(slots.subtitle)
+        ? null
+        : html.tag('h2', {class: 'page-subtitle'},
+            language.sanitize(slots.subtitle)));
+
     let footerContent = slots.footerContent;
 
     if (html.isBlank(footerContent) && relations.defaultFooterContent) {
-      footerContent = relations.defaultFooterContent
-        .slot('mode', 'multiline');
+      footerContent =
+        relations.defaultFooterContent.slots({
+          mode: 'multiline',
+          indicateExternalLinks: false,
+        });
     }
 
     const mainHTML =
       html.tag('main', {id: 'content'},
         {class: slots.mainClasses},
 
+        !html.isBlank(subtitleHTML) &&
+          {class: 'has-subtitle'},
+
         [
           titleHTML,
 
-          html.tag('div', {id: 'cover-art-container'},
+          html.tag('div', {id: 'artwork-column'},
             {[html.onlyIfContent]: true},
-            slots.cover),
+            {class: 'isolate-tooltip-z-indexing'},
+
+            slots.artworkColumnContent),
+
+          subtitleHTML,
 
           slots.additionalNames,
 
           html.tag('div', {class: 'main-content-container'},
             {[html.onlyIfContent]: true},
-            slots.mainContent),
+            mainContentHTML),
         ]);
 
     const footerHTML =
@@ -343,34 +394,32 @@ export default {
 
             slots.navLinks
               ?.filter(Boolean)
-              ?.map((cur, i) => {
+              ?.map((cur, i, entries) => {
                 let content;
 
                 if (cur.html) {
                   content = cur.html;
                 } else {
+                  const attributes = html.attributes();
                   let title;
-                  let href;
 
                   switch (cur.auto) {
                     case 'home':
                       title = data.wikiName;
-                      href = to('localized.home');
+                      attributes.set('href', to('localized.home'));
                       break;
                     case 'current':
                       title = slots.title;
-                      href = '';
+                      attributes.set('href', '');
                       break;
                     case null:
                     case undefined:
                       title = cur.title;
-                      href = to(...cur.path);
+                      attributes.set('href', to(...cur.path));
                       break;
                   }
 
-                  content = html.tag('a',
-                    {href},
-                    title);
+                  content = html.tag('a', attributes, title);
                 }
 
                 const showAsCurrent =
@@ -379,98 +428,106 @@ export default {
                   (slots.navLinkStyle === 'hierarchical' &&
                     i === slots.navLinks.length - 1);
 
-                return (
+                const navLink =
                   html.tag('span', {class: 'nav-link'},
                     showAsCurrent &&
                       {class: 'current'},
 
-                    i > 0 &&
-                      {class: 'has-divider'},
-
                     [
                       html.tag('span', {class: 'nav-link-content'},
-                        // Use inline-block styling on the content span,
-                        // rather than wrapping the whole nav-link in a proper
-                        // blockwrap, so that if the content spans multiple
-                        // lines, it'll kick the accent down beneath it.
-                        i > 0 &&
-                          {class: 'blockwrap'},
-
                         content),
 
                       html.tag('span', {class: 'nav-link-accent'},
+                        {[html.noEdgeWhitespace]: true},
                         {[html.onlyIfContent]: true},
-                        cur.accent),
-                    ]));
+
+                        language.$('misc.navAccent', {
+                          [language.onlyIfOptions]: ['links'],
+                          links: cur.accent,
+                        })),
+                    ]);
+
+                if (slots.navLinkStyle === 'index') {
+                  return navLink;
+                }
+
+                const prev =
+                  atOffset(entries, i, -1);
+
+                if (
+                  prev &&
+                  prev.releaseRestToWrapTogether !== true &&
+                  (prev.releaseRestToWrapTogether === false ||
+                   prev.auto === 'home')
+                ) {
+                  return navLink;
+                } else {
+                  return html.metatag('blockwrap', navLink);
+                }
               })),
 
           html.tag('div', {class: 'nav-bottom-row'},
             {[html.onlyIfContent]: true},
-            slots.navBottomRowContent),
+
+            language.$('misc.navAccent', {
+              [language.onlyIfOptions]: ['links'],
+              links: slots.navBottomRowContent,
+            })),
 
           html.tag('div', {class: 'nav-content'},
             {[html.onlyIfContent]: true},
             slots.navContent),
         ]);
 
-    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'];
-      const collapse = slots[side + 'Collapse'];
-
-      let sidebarClasses = [];
-      let sidebarContent = html.blank();
-
-      if (!html.isBlank(content)) {
-        sidebarClasses = ['sidebar', topClass];
-        sidebarContent = content;
-      } else if (multiple) {
-        sidebarClasses = ['sidebar-multiple', topClass];
-        sidebarContent =
-          multiple
-            .filter(Boolean)
-            .map(box =>
-              html.tag('div', {class: 'sidebar'},
-                {[html.onlyIfContent]: true},
-                {class: box.class},
-                box.content));
-      }
+    const getSidebar = (side, id, needed) => {
+      const sidebar =
+        (html.isBlank(slots[side])
+          ? (needed
+              ? relations.sidebar.clone()
+              : html.blank())
+          : slots[side]);
 
-      if (html.isBlank(sidebarContent)) {
-        return html.blank();
+      if (html.isBlank(sidebar) && !needed) {
+        return sidebar;
       }
 
-      return html.tag('div', {class: 'sidebar-column'},
-        {id, class: sidebarClasses},
-
-        wide &&
-          {class: 'wide'},
+      return sidebar.slots({
+        attributes:
+          sidebar
+            .getSlotValue('attributes')
+            .with({id}),
+      });
+    }
 
-        !collapse &&
-          {class: 'no-hide'},
+    const willShowSearch =
+      slots.showSearch && relations.searchBox;
 
-        stickyMode !== 'static' &&
-          {class: `sticky-${stickyMode}`},
+    let showingSidebarLeft;
+    let showingSidebarRight;
+    let sidebarsInContentColumn = false;
 
-        sidebarContent);
-    }
+    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left', willShowSearch);
+    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right', false);
 
-    const sidebarLeftHTML = generateSidebarHTML('leftSidebar', 'sidebar-left');
-    const sidebarRightHTML = generateSidebarHTML('rightSidebar', 'sidebar-right');
+    if (willShowSearch) {
+      if (html.isBlank(leftSidebar)) {
+        sidebarsInContentColumn = true;
+        showingSidebarLeft = true;
+      }
 
-    const hasSidebarLeft = !html.isBlank(sidebarLeftHTML);
-    const hasSidebarRight = !html.isBlank(sidebarRightHTML);
+      leftSidebar.setSlot(
+        'boxes',
+        html.tags([
+          relations.searchBox,
+          leftSidebar.getSlotValue('boxes'),
+        ]));
+    }
 
-    const collapseSidebars = slots.leftSidebarCollapse && slots.rightSidebarCollapse;
+    const hasSidebarLeft = !html.isBlank(html.resolve(leftSidebar));
+    const hasSidebarRight = !html.isBlank(html.resolve(rightSidebar));
 
-    const hasID = (() => {
-      // Hilariously jank. Sorry!
-      const mainContentHTML = slots.mainContent.toString();
-      return id => mainContentHTML.includes(`id="${id}"`);
-    })();
+    showingSidebarLeft ??= hasSidebarLeft;
+    showingSidebarRight ??= hasSidebarRight;
 
     const processSkippers = skipperList =>
       skipperList
@@ -478,8 +535,11 @@ export default {
           (condition === undefined
             ? hasID(id)
             : condition))
+
         .map(({id, string}) =>
           html.tag('span', {class: 'skipper'},
+            {'data-for': id},
+
             html.tag('a',
               {href: `#${id}`},
               language.$('misc.skippers', string))));
@@ -529,51 +589,40 @@ export default {
               {id: 'additional-files', string: 'additionalFiles'},
               {id: 'commentary', string: 'commentary'},
               {id: 'artist-commentary', string: 'artistCommentary'},
+              {id: 'crediting-sources', string: 'creditingSources'},
+              {id: 'referencing-sources', string: 'referencingSources'},
             ])),
         ]);
 
-    const imageOverlayHTML = html.tag('div', {id: 'image-overlay-container'},
-      html.tag('div', {id: 'image-overlay-content-container'}, [
-        html.tag('a', {id: 'image-overlay-image-container'}, [
-          html.tag('img', {id: 'image-overlay-image'}),
-          html.tag('img', {id: 'image-overlay-image-thumb'}),
-        ]),
-        html.tag('div', {id: 'image-overlay-action-container'}, [
-          html.tag('div', {id: 'image-overlay-action-content-without-size'},
-            language.$('releaseInfo.viewOriginalFile', {
-              link: html.tag('a', {class: 'image-overlay-view-original'},
-                language.$('releaseInfo.viewOriginalFile.link')),
-            })),
+    const slottedStyleTags =
+      html.smush(slots.styleTags);
 
-          html.tag('div', {id: 'image-overlay-action-content-with-size'}, [
-            language.$('releaseInfo.viewOriginalFile.withSize', {
-              link:
-                html.tag('a', {class: 'image-overlay-view-original'},
-                  language.$('releaseInfo.viewOriginalFile.link')),
-
-              size:
-                html.tag('span',
-                  {[html.joinChildren]: ''},
-                  [
-                    html.tag('span', {id: 'image-overlay-file-size-kilobytes'},
-                      language.$('count.fileSize.kilobytes', {
-                        kilobytes:
-                          html.tag('span', {class: 'image-overlay-file-size-count'}),
-                      })),
-
-                    html.tag('span', {id: 'image-overlay-file-size-megabytes'},
-                      language.$('count.fileSize.megabytes', {
-                        megabytes:
-                          html.tag('span', {class: 'image-overlay-file-size-count'}),
-                      })),
-                  ]),
-            }),
+    const slottedWallpaperStyleTag =
+      slottedStyleTags.content
+        .find(tag => tag.attributes.has('class', 'wallpaper-style'));
 
-            html.tag('span', {id: 'image-overlay-file-size-warning'},
-              language.$('releaseInfo.viewOriginalFile.sizeWarning')),
-          ]),
-        ]),
-      ]));
+    const fallbackWallpaperStyleTag =
+      (slottedWallpaperStyleTag
+        ? html.blank()
+        : relations.wikiWallpaperStyleTag);
+
+    const usingWallpaperStyleTag =
+      (slottedWallpaperStyleTag
+        ? slottedWallpaperStyleTag
+        : html.resolve(fallbackWallpaperStyleTag, {normalize: 'tag'}));
+
+    const numWallpaperParts =
+      (usingWallpaperStyleTag &&
+       usingWallpaperStyleTag.attributes.has('data-wallpaper-mode', 'parts')
+        ? parseInt(usingWallpaperStyleTag.attributes.get('data-num-wallpaper-parts'))
+        : 0);
+
+    const wallpaperPartsHTML =
+      html.tag('div', {class: 'wallpaper-parts'},
+        {[html.onlyIfContent]: true},
+
+        repeat(numWallpaperParts, () =>
+          html.tag('div', {class: 'wallpaper-part'})));
 
     const layoutHTML = [
       navHTML,
@@ -583,15 +632,11 @@ export default {
 
       slots.secondaryNav,
 
-      html.tag('div', {class: 'layout-columns'},
-        !collapseSidebars &&
-          {class: 'vertical-when-thin'},
-
-        [
-          sidebarLeftHTML,
-          mainHTML,
-          sidebarRightHTML,
-        ]),
+      html.tag('div', {class: 'layout-columns'}, [
+        leftSidebar,
+        mainHTML,
+        rightSidebar,
+      ]),
 
       slots.bannerPosition === 'bottom' &&
         slots.banner,
@@ -614,6 +659,8 @@ export default {
         {'data-rebase-localized': to('localized.root')},
         {'data-rebase-shared': to('shared.root')},
         {'data-rebase-media': to('media.root')},
+        {'data-rebase-thumb': to('thumb.root')},
+        {'data-rebase-lib': to('staticLib.root')},
         {'data-rebase-data': to('data.root')},
 
         [
@@ -621,14 +668,30 @@ export default {
 
           html.tag('head', [
             html.tag('title',
-              (slots.showWikiNameInTitle
-                ? language.formatString('misc.pageTitle.withWikiName', {
-                    title: slots.title,
-                    wikiName: data.wikiName,
-                  })
-                : language.formatString('misc.pageTitle', {
-                    title: slots.title,
-                  }))),
+              language.encapsulate('misc.pageTitle', workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.title = slots.title;
+
+                if (!html.isBlank(slots.subtitle)) {
+                  workingCapsule += '.withSubtitle';
+                  workingOptions.subtitle = slots.subtitle;
+                }
+
+                const showWikiName =
+                  (slots.showWikiNameInTitle === true
+                    ? true
+                 : slots.showWikiNameInTitle === 'auto'
+                    ? html.isBlank(slots.subtitle)
+                    : false);
+
+                if (showWikiName) {
+                  workingCapsule += '.withWikiName';
+                  workingOptions.wikiName = data.wikiName;
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })),
 
             html.tag('meta', {charset: 'utf-8'}),
             html.tag('meta', {
@@ -660,13 +723,15 @@ export default {
               Object.entries(meta)
                 .filter(([key, value]) => value)
                 .map(([key, value]) => html.tag('meta', {[key]: value}))),
+            */
 
-            canonical &&
+            canonicalHref &&
               html.tag('link', {
                 rel: 'canonical',
-                href: canonical,
+                href: canonicalHref,
               }),
 
+            /*
             ...(
               localizedCanonical
                 .map(({lang, href}) => html.tag('link', {
@@ -674,7 +739,6 @@ export default {
                   hreflang: lang,
                   href,
                 }))),
-
             */
 
             hasSocialEmbed &&
@@ -682,37 +746,55 @@ export default {
                 .clone()
                 .slot('mode', 'html'),
 
+            oEmbedJSONHref &&
+              html.tag('link', {
+                type: 'application/json+oembed',
+                href: oEmbedJSONHref,
+              }),
+
             html.tag('link', {
               rel: 'stylesheet',
-              href: to('shared.staticFile', 'site6.css', cachebust),
+              href: to('staticCSS.path', 'site.css'),
             }),
 
-            html.tag('style', [
-              relations.colorStyleRules
-                .slot('color', slots.color ?? data.wikiColor),
-              slots.styleRules,
-            ]),
+            relations.colorStyleTag
+              .slot('color', slots.color ?? data.wikiColor),
+
+            relations.staticURLStyleTag,
+
+            fallbackWallpaperStyleTag,
+
+            slottedStyleTags,
+
+            html.tag('script', {
+              src: to('staticLib.path', 'chroma-js/chroma.min.js'),
+            }),
 
             html.tag('script', {
-              src: to('shared.staticFile', 'lazy-loading.js', cachebust),
+              blocking: 'render',
+              src: to('staticJS.path', 'lazy-loading.js'),
+            }),
+
+            html.tag('script', {
+              blocking: 'render',
+              type: 'module',
+              src: to('staticJS.path', 'client/index.js'),
             }),
           ]),
 
           html.tag('body',
             [
-              html.tag('div', {id: 'page-container'},
-                (hasSidebarLeft || hasSidebarRight
-                  ? {class: 'has-one-sidebar'}
-                  : {class: 'has-zero-sidebars'}),
+              wallpaperPartsHTML,
 
-                hasSidebarLeft && hasSidebarRight &&
-                  {class: 'has-two-sidebars'},
+              html.tag('div', {id: 'page-container'},
+                showingSidebarLeft &&
+                  {class: 'showing-sidebar-left'},
 
-                hasSidebarLeft &&
-                  {class: 'has-sidebar-left'},
+                showingSidebarRight &&
+                  {class: 'showing-sidebar-right'},
 
-                hasSidebarRight &&
-                  {class: 'has-sidebar-right'},
+                sidebarsInContentColumn &&
+                  {class: 'sidebars-in-content-column'},
 
                 [
                   skippersHTML,
@@ -720,12 +802,7 @@ export default {
                 ]),
 
               // infoCardHTML,
-              imageOverlayHTML,
-
-              html.tag('script', {
-                type: 'module',
-                src: to('shared.staticFile', 'client3.js', cachebust),
-              }),
+              relations.imageOverlay,
             ]),
         ])
     ]).toString();
diff --git a/src/content/dependencies/generatePageSidebar.js b/src/content/dependencies/generatePageSidebar.js
new file mode 100644
index 00000000..d3b55580
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebar.js
@@ -0,0 +1,90 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    // Attributes to apply to the whole sidebar. This be added to the
+    // containing sidebar-column, arr - specify attributes on each section if
+    // that's more suitable.
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    // Content boxes to line up vertically in the sidebar.
+    boxes: {
+      type: 'html',
+      mutable: false,
+    },
+
+    // Sticky mode controls which sidebar sections, if any, follow the
+    // scroll position, "sticking" to the top of the browser viewport.
+    //
+    // 'column' - entire column, incl. multiple boxes from top, is sticky
+    // 'static' - sidebar not sticky at all, stays at top of page
+    //
+    // Note: This doesn't affect the content of any sidebar section, only
+    // the whole section's containing box (or the sidebar column as a whole).
+    stickyMode: {
+      validate: v => v.is('column', 'static'),
+      default: 'static',
+    },
+
+    // Wide sidebars generally take up more horizontal space in the normal
+    // page layout, and should be used if the content of the sidebar has
+    // a greater than typical focus compared to main content.
+    wide: {
+      type: 'boolean',
+      default: false,
+    },
+
+    // Provide to include all the HTML for the sidebar in place as usual,
+    // but start it out totally invisible. This is mainly so client-side
+    // JavaScript can show the sidebar if it needs to (and has a target
+    // to slot its own content into). If there are no boxes and this
+    // option *isn't* provided, then the sidebar will just be blank.
+    initiallyHidden: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate(slots, {html}) {
+    const attributes =
+      html.attributes({class: [
+        'sidebar-column',
+        'sidebar-multiple',
+      ]});
+
+    attributes.add(slots.attributes);
+
+    if (slots.wide) {
+      attributes.add('class', 'wide');
+    }
+
+    if (slots.stickyMode !== 'static') {
+      attributes.add('class', `sticky-${slots.stickyMode}`);
+    }
+
+    const {content: boxes} = html.smooth(slots.boxes);
+
+    const allBoxesCollapsible =
+      boxes.every(box =>
+        html.resolve(box)
+          .attributes
+          .has('class', 'collapsible'));
+
+    if (allBoxesCollapsible) {
+      attributes.add('class', 'all-boxes-collapsible');
+    }
+
+    if (slots.initiallyHidden) {
+      attributes.add('class', 'initially-hidden');
+    }
+
+    if (html.isBlank(slots.boxes) && !slots.initiallyHidden) {
+      return html.blank();
+    } else {
+      return html.tag('div', attributes, slots.boxes);
+    }
+  },
+};
diff --git a/src/content/dependencies/generatePageSidebarBox.js b/src/content/dependencies/generatePageSidebarBox.js
new file mode 100644
index 00000000..26b30494
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebarBox.js
@@ -0,0 +1,30 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    collapsible: {
+      type: 'boolean',
+      default: true,
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('div', {class: 'sidebar'},
+      {[html.onlyIfContent]: true},
+
+      slots.collapsible &&
+        {class: 'collapsible'},
+
+      slots.attributes,
+      slots.content),
+};
diff --git a/src/content/dependencies/generatePageSidebarConjoinedBox.js b/src/content/dependencies/generatePageSidebarConjoinedBox.js
new file mode 100644
index 00000000..7974c707
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebarConjoinedBox.js
@@ -0,0 +1,38 @@
+// This component is kind of unfortunately magical. It reads the content of
+// various boxes and joins them together, discarding the boxes' attributes.
+// Since it requires access to the actual box *templates* (rather than those
+// templates' resolved content), take care when slotting into this.
+
+export default {
+  contentDependencies: ['generatePageSidebarBox'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    box:
+      relation('generatePageSidebarBox'),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    boxes: {
+      validate: v => v.looseArrayOf(v.isTemplate),
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    relations.box.slots({
+      attributes: slots.attributes,
+      content:
+        slots.boxes.slice()
+          .map(box => box.getSlotValue('content'))
+          .map((content, index, {length}) => [
+            content,
+            index < length - 1 &&
+              html.tag('hr', {class: 'cute'}),
+          ]),
+    }),
+};
diff --git a/src/content/dependencies/generatePreviousLink.js b/src/content/dependencies/generatePreviousLink.js
new file mode 100644
index 00000000..775367f9
--- /dev/null
+++ b/src/content/dependencies/generatePreviousLink.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['generatePreviousNextLink'],
+
+  relations: (relation) => ({
+    link:
+      relation('generatePreviousNextLink'),
+  }),
+
+  generate: (relations) =>
+    relations.link.slots({
+      direction: 'previous',
+    }),
+};
diff --git a/src/content/dependencies/generatePreviousNextLink.js b/src/content/dependencies/generatePreviousNextLink.js
new file mode 100644
index 00000000..afae1228
--- /dev/null
+++ b/src/content/dependencies/generatePreviousNextLink.js
@@ -0,0 +1,58 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    link: {
+      type: 'html',
+      mutable: true,
+    },
+
+    direction: {
+      validate: v => v.is('previous', 'next'),
+    },
+
+    id: {
+      type: 'boolean',
+      default: true,
+    },
+
+    showWithoutLink: {
+      type: 'boolean',
+      default: true,
+    },
+  },
+
+  generate(slots, {html, language}) {
+    if (!slots.direction) {
+      return html.blank();
+    }
+
+    const attributes = html.attributes();
+
+    if (slots.id) {
+      attributes.set('id', `${slots.direction}-button`);
+    }
+
+    if (html.isBlank(slots.link)) {
+      if (slots.showWithoutLink) {
+        return (
+          html.tag('a', {class: 'inert-previous-next-link'},
+            attributes,
+            language.$('misc.nav', slots.direction)));
+      } else {
+        return html.blank();
+      }
+    }
+
+    return html.resolve(slots.link, {
+      slots: {
+        tooltipStyle: 'browser',
+        color: false,
+        attributes,
+
+        content:
+          language.$('misc.nav', slots.direction),
+      }
+    });
+  },
+};
diff --git a/src/content/dependencies/generatePreviousNextLinks.js b/src/content/dependencies/generatePreviousNextLinks.js
deleted file mode 100644
index 9771de39..00000000
--- a/src/content/dependencies/generatePreviousNextLinks.js
+++ /dev/null
@@ -1,50 +0,0 @@
-export default {
-  // Returns an array with the slotted previous and next links, prepared
-  // for inclusion in a page's navigation bar. Include with other links
-  // in the nav bar and then join them all as a unit list, for example.
-
-  extraDependencies: ['html', 'language'],
-
-  slots: {
-    previousLink: {
-      type: 'html',
-      mutable: true,
-    },
-
-    nextLink: {
-      type: 'html',
-      mutable: true,
-    },
-
-    id: {
-      type: 'boolean',
-      default: true,
-    },
-  },
-
-  generate(slots, {html, language}) {
-    const previousNext = [];
-
-    if (!html.isBlank(slots.previousLink)) {
-      previousNext.push(
-        slots.previousLink.slots({
-          tooltipStyle: 'browser',
-          color: false,
-          attributes: {id: slots.id && 'previous-button'},
-          content: language.$('misc.nav.previous'),
-        }));
-    }
-
-    if (!html.isBlank(slots.nextLink)) {
-      previousNext.push(
-        slots.nextLink.slots({
-          tooltipStyle: 'browser',
-          color: false,
-          attributes: {id: slots.id && 'next-button'},
-          content: language.$('misc.nav.next'),
-        }));
-    }
-
-    return previousNext;
-  },
-};
diff --git a/src/content/dependencies/generateQuickDescription.js b/src/content/dependencies/generateQuickDescription.js
new file mode 100644
index 00000000..e144503e
--- /dev/null
+++ b/src/content/dependencies/generateQuickDescription.js
@@ -0,0 +1,134 @@
+export default {
+  contentDependencies: ['transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  query: (thing) => ({
+    hasDescription:
+      !!thing.description,
+
+    hasLongerDescription:
+      thing.description &&
+      thing.descriptionShort &&
+      thing.descriptionShort !== thing.description,
+  }),
+
+  relations: (relation, query, thing) => ({
+    description:
+      (query.hasLongerDescription || !thing.description
+        ? null
+        : relation('transformContent', thing.description)),
+
+    descriptionShort:
+      (query.hasLongerDescription
+        ? relation('transformContent', thing.descriptionShort)
+        : null),
+
+    descriptionLong:
+      (query.hasLongerDescription
+        ? relation('transformContent', thing.description)
+        : null),
+  }),
+
+  data: (query) => ({
+    hasDescription: query.hasDescription,
+    hasLongerDescription: query.hasLongerDescription,
+  }),
+
+  slots: {
+    extraReadingLinks: {
+      validate: v => v.sparseArrayOf(v.isHTML),
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const prefix = 'misc.quickDescription';
+
+    const actionsWithoutLongerDescription =
+      (data.hasLongerDescription
+        ? null
+     : slots.extraReadingLinks
+        ? language.$(prefix, 'readMore', {
+            links:
+              language.formatDisjunctionList(slots.extraReadingLinks),
+          })
+        : null);
+
+    const wrapExpandCollapseLink = (expandCollapse, content) =>
+      html.tag('a', {class: `${expandCollapse}-link`},
+        {href: '#'},
+        content);
+
+    const actionsWhenCollapsed =
+      (data.hasLongerDescription && slots.extraReadingLinks
+        ? language.$(prefix, 'expandDescription.orReadMore', {
+            links:
+              language.formatDisjunctionList(slots.extraReadingLinks),
+            expand:
+              wrapExpandCollapseLink('expand',
+                language.$(prefix, 'expandDescription.orReadMore.expand')),
+          })
+     : data.hasLongerDescription
+        ? language.$(prefix, 'expandDescription', {
+            expand:
+              wrapExpandCollapseLink('expand',
+                language.$(prefix, 'expandDescription.expand')),
+          })
+        : null);
+
+    const actionsWhenExpanded =
+      (data.hasLongerDescription && slots.extraReadingLinks
+        ? language.$(prefix, 'collapseDescription.orReadMore', {
+            links:
+              language.formatDisjunctionList(slots.extraReadingLinks),
+            collapse:
+              wrapExpandCollapseLink('collapse',
+                language.$(prefix, 'collapseDescription.orReadMore.collapse')),
+          })
+     : data.hasLongerDescription
+        ? language.$(prefix, 'collapseDescription', {
+            collapse:
+              wrapExpandCollapseLink('collapse',
+                language.$(prefix, 'collapseDescription.collapse')),
+          })
+        : null);
+
+    const wrapActions = (attributes, children) =>
+      html.tag('p', {class: 'quick-description-actions'},
+        {[html.onlyIfContent]: true},
+        attributes,
+
+        children);
+
+    const wrapContent = (attributes, content) =>
+      html.tag('blockquote', {class: 'description-content'},
+        {[html.onlyIfContent]: true},
+        attributes,
+
+        content?.slot('mode', 'multiline'));
+
+    return (
+      html.tag('div', {class: 'quick-description'},
+        {[html.onlyIfContent]: true},
+
+        data.hasLongerDescription &&
+          {class: 'collapsed'},
+
+        !data.hasLongerDescription &&
+        !slots.extraReadingLinks &&
+          {class: 'has-content-only'},
+
+        !data.hasDescription &&
+        slots.extraReadingLinks &&
+          {class: 'has-external-links-only'},
+
+        [
+          wrapContent(null, relations.description),
+          wrapContent({class: 'short'}, relations.descriptionShort),
+          wrapContent({class: 'long'}, relations.descriptionLong),
+
+          wrapActions(null, actionsWithoutLongerDescription),
+          wrapActions({class: 'when-collapsed'}, actionsWhenCollapsed),
+          wrapActions({class: 'when-expanded'}, actionsWhenExpanded),
+        ]));
+  },
+};
diff --git a/src/content/dependencies/generateReferencedArtworksPage.js b/src/content/dependencies/generateReferencedArtworksPage.js
new file mode 100644
index 00000000..83451eca
--- /dev/null
+++ b/src/content/dependencies/generateReferencedArtworksPage.js
@@ -0,0 +1,100 @@
+export default {
+  contentDependencies: [
+    'generateCoverArtwork',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkAnythingMan',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, artwork) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    cover:
+      relation('generateCoverArtwork', artwork),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      artwork.referencedArtworks.map(({artwork}) =>
+        relation('linkAnythingMan', artwork.thing)),
+
+    images:
+      artwork.referencedArtworks.map(({artwork}) =>
+        relation('image', artwork)),
+  }),
+
+  data: (artwork) => ({
+    color:
+      artwork.thing.color,
+
+    count:
+      artwork.referencedArtworks.length,
+
+    names:
+      artwork.referencedArtworks
+        .map(({artwork}) => artwork.thing.name),
+
+    coverArtistNames:
+      artwork.referencedArtworks
+        .map(({artwork}) =>
+          artwork.artistContribs
+            .map(contrib => contrib.artist.name)),
+  }),
+
+  slots: {
+    styleTags: {type: 'html', mutable: false},
+
+    title: {type: 'html', mutable: false},
+
+    navLinks: {validate: v => v.isArray},
+    navBottomRowContent: {type: 'html', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('referencedArtworksPage', pageCapsule =>
+      relations.layout.slots({
+        title: slots.title,
+        subtitle: language.$(pageCapsule, 'subtitle'),
+
+        color: data.color,
+        styleTags: slots.styleTags,
+
+        artworkColumnContent:
+          relations.cover.slots({
+            showArtistDetails: true,
+          }),
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          html.tag('p', {class: 'quick-info'},
+            language.$(pageCapsule, 'statsLine', {
+              artworks:
+                language.countArtworks(data.count, {
+                  unit: true,
+                }),
+            })),
+
+          relations.coverGrid.slots({
+            links: relations.links,
+            images: relations.images,
+            names: data.names,
+
+            info:
+              data.coverArtistNames.map(names =>
+                language.$('misc.coverGrid.details.coverArtists', {
+                  artists:
+                    language.formatUnitList(names),
+                })),
+          }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: slots.navLinks,
+        navBottomRowContent: slots.navBottomRowContent,
+      })),
+};
diff --git a/src/content/dependencies/generateReferencingArtworksPage.js b/src/content/dependencies/generateReferencingArtworksPage.js
new file mode 100644
index 00000000..e97b01f8
--- /dev/null
+++ b/src/content/dependencies/generateReferencingArtworksPage.js
@@ -0,0 +1,100 @@
+export default {
+  contentDependencies: [
+    'generateCoverArtwork',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkAnythingMan',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, artwork) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    cover:
+      relation('generateCoverArtwork', artwork),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      artwork.referencedByArtworks.map(({artwork}) =>
+        relation('linkAnythingMan', artwork.thing)),
+
+    images:
+      artwork.referencedByArtworks.map(({artwork}) =>
+        relation('image', artwork)),
+  }),
+
+  data: (artwork) => ({
+    color:
+      artwork.thing.color,
+
+    count:
+      artwork.referencedByArtworks.length,
+
+    names:
+      artwork.referencedByArtworks
+        .map(({artwork}) => artwork.thing.name),
+
+    coverArtistNames:
+      artwork.referencedByArtworks
+        .map(({artwork}) =>
+          artwork.artistContribs
+            .map(contrib => contrib.artist.name)),
+  }),
+
+  slots: {
+    styleTags: {type: 'html', mutable: false},
+
+    title: {type: 'html', mutable: false},
+
+    navLinks: {validate: v => v.isArray},
+    navBottomRowContent: {type: 'html', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('referencingArtworksPage', pageCapsule =>
+      relations.layout.slots({
+        title: slots.title,
+        subtitle: language.$(pageCapsule, 'subtitle'),
+
+        color: data.color,
+        styleTags: slots.styleTags,
+
+        artworkColumnContent:
+          relations.cover.slots({
+            showArtistDetails: true,
+          }),
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          html.tag('p', {class: 'quick-info'},
+            language.$(pageCapsule, 'statsLine', {
+              artworks:
+                language.countArtworks(data.count, {
+                  unit: true,
+                }),
+            })),
+
+          relations.coverGrid.slots({
+            links: relations.links,
+            images: relations.images,
+            names: data.names,
+
+            info:
+              data.coverArtistNames.map(names =>
+                language.$('misc.coverGrid.details.coverArtists', {
+                  artists:
+                    language.formatUnitList(names),
+                })),
+          }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: slots.navLinks,
+        navBottomRowContent: slots.navBottomRowContent,
+      })),
+};
diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js
index 2e6c4709..016e0a2c 100644
--- a/src/content/dependencies/generateReleaseInfoContributionsLine.js
+++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js
@@ -1,42 +1,31 @@
-import {empty} from '#sugar';
-
 export default {
-  contentDependencies: ['linkContribution'],
-  extraDependencies: ['html', 'language'],
-
-  relations(relation, contributions) {
-    if (empty(contributions)) {
-      return {};
-    }
+  contentDependencies: ['generateArtistCredit'],
+  extraDependencies: ['html'],
 
-    return {
-      contributionLinks:
-        contributions
-          .map(contrib => relation('linkContribution', contrib)),
-    };
-  },
+  relations: (relation, contributions) => ({
+    credit:
+      relation('generateArtistCredit', contributions, []),
+  }),
 
   slots: {
     stringKey: {type: 'string'},
+    featuringStringKey: {type: 'string'},
 
-    showContribution: {type: 'boolean', default: true},
-    showIcons: {type: 'boolean', default: true},
+    chronologyKind: {type: 'string'},
   },
 
-  generate(relations, slots, {html, language}) {
-    if (!relations.contributionLinks) {
-      return html.blank();
-    }
+  generate: (relations, slots) =>
+    relations.credit.slots({
+      showAnnotation: true,
+      showExternalLinks: true,
+      showChronology: true,
+      showWikiEdits: true,
 
-    return language.$(slots.stringKey, {
-      artists:
-        language.formatConjunctionList(
-          relations.contributionLinks.map(link =>
-            link.slots({
-              showContribution: slots.showContribution,
-              showIcons: slots.showIcons,
-              iconMode: 'tooltip',
-            }))),
-    });
-  },
+      trimAnnotation: false,
+
+      chronologyKind: slots.chronologyKind,
+
+      normalStringKey: slots.stringKey,
+      normalFeaturingStringKey: slots.featuringStringKey,
+    }),
 };
diff --git a/src/content/dependencies/generateReleaseInfoListenLine.js b/src/content/dependencies/generateReleaseInfoListenLine.js
new file mode 100644
index 00000000..f2a6dd29
--- /dev/null
+++ b/src/content/dependencies/generateReleaseInfoListenLine.js
@@ -0,0 +1,150 @@
+import {isExternalLinkContext} from '#external-links';
+import {empty, stitchArrays, unique} from '#sugar';
+
+function getReleaseContext(urlString, {
+  _artistURLs,
+  albumArtistURLs,
+}) {
+  const composerBandcampDomains =
+    albumArtistURLs
+      .filter(url => url.hostname.endsWith('.bandcamp.com'))
+      .map(url => url.hostname);
+
+  const url = new URL(urlString);
+
+  if (url.hostname === 'homestuck.bandcamp.com') {
+    return 'officialRelease';
+  }
+
+  if (composerBandcampDomains.includes(url.hostname)) {
+    return 'composerRelease';
+  }
+
+  return null;
+}
+
+export default {
+  contentDependencies: ['linkExternal'],
+  extraDependencies: ['html', 'language'],
+
+  query(thing) {
+    const query = {};
+
+    query.album =
+      (thing.album
+        ? thing.album
+        : thing);
+
+    query.artists =
+      thing.artistContribs
+        .map(contrib => contrib.artist);
+
+    query.artistGroups =
+      query.artists
+        .flatMap(artist => artist.closelyLinkedGroups)
+        .map(({group}) => group);
+
+    query.albumArtists =
+      query.album.artistContribs
+        .map(contrib => contrib.artist);
+
+    query.albumArtistGroups =
+      query.albumArtists
+        .flatMap(artist => artist.closelyLinkedGroups)
+        .map(({group}) => group);
+
+    return query;
+  },
+
+  relations: (relation, _query, thing) => ({
+    links:
+      thing.urls.map(url => relation('linkExternal', url)),
+  }),
+
+  data(query, thing) {
+    const data = {};
+
+    data.name = thing.name;
+
+    const artistURLs =
+      unique([
+        ...query.artists.flatMap(artist => artist.urls),
+        ...query.artistGroups.flatMap(group => group.urls),
+      ]).map(url => new URL(url));
+
+    const albumArtistURLs =
+      unique([
+        ...query.albumArtists.flatMap(artist => artist.urls),
+        ...query.albumArtistGroups.flatMap(group => group.urls),
+      ]).map(url => new URL(url));
+
+    const boundGetReleaseContext = urlString =>
+      getReleaseContext(urlString, {
+        artistURLs,
+        albumArtistURLs,
+      });
+
+    let releaseContexts =
+      thing.urls.map(boundGetReleaseContext);
+
+    const albumReleaseContexts =
+      query.album.urls.map(boundGetReleaseContext);
+
+    const presentReleaseContexts =
+      unique(releaseContexts.filter(Boolean));
+
+    const presentAlbumReleaseContexts =
+      unique(albumReleaseContexts.filter(Boolean));
+
+    if (
+      presentReleaseContexts.length <= 1 &&
+      presentAlbumReleaseContexts.length <= 1
+    ) {
+      releaseContexts =
+        thing.urls.map(() => null);
+    }
+
+    data.releaseContexts = releaseContexts;
+
+    return data;
+  },
+
+  slots: {
+    visibleWithoutLinks: {
+      type: 'boolean',
+      default: false,
+    },
+
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('releaseInfo.listenOn', capsule =>
+      (empty(relations.links) && slots.visibleWithoutLinks
+        ? language.$(capsule, 'noLinks', {
+            name:
+              html.tag('i', data.name),
+          })
+
+        : language.$('releaseInfo.listenOn', {
+            [language.onlyIfOptions]: ['links'],
+
+            links:
+              language.formatDisjunctionList(
+                stitchArrays({
+                  link: relations.links,
+                  releaseContext: data.releaseContexts,
+                }).map(({link, releaseContext}) =>
+                    link.slot('context', [
+                      ...
+                      (Array.isArray(slots.context)
+                        ? slots.context
+                        : [slots.context]),
+
+                      releaseContext,
+                    ]))),
+          }))),
+};
diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js
new file mode 100644
index 00000000..308a1105
--- /dev/null
+++ b/src/content/dependencies/generateSearchSidebarBox.js
@@ -0,0 +1,82 @@
+export default {
+  contentDependencies: ['generatePageSidebarBox'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('misc.search', capsule =>
+      relations.sidebarBox.slots({
+        attributes: {class: 'wiki-search-sidebar-box'},
+        collapsible: false,
+
+        content: [
+          html.tag('label', {class: 'wiki-search-label'},
+            html.tag('input', {class: 'wiki-search-input'},
+              {type: 'search'},
+
+              {
+                placeholder:
+                  language.$(capsule, 'placeholder').toString(),
+              })),
+
+          html.tag('template', {class: 'wiki-search-preparing-string'},
+            language.$(capsule, 'preparing')),
+
+          html.tag('template', {class: 'wiki-search-loading-data-string'},
+            language.$(capsule, 'loadingData')),
+
+          html.tag('template', {class: 'wiki-search-searching-string'},
+            language.$(capsule, 'searching')),
+
+          html.tag('template', {class: 'wiki-search-failed-string'},
+            language.$(capsule, 'failed')),
+
+          html.tag('template', {class: 'wiki-search-no-results-string'},
+            language.$(capsule, 'noResults')),
+
+          html.tag('template', {class: 'wiki-search-current-result-string'},
+            language.$(capsule, 'currentResult')),
+
+          html.tag('template', {class: 'wiki-search-end-search-string'},
+            language.$(capsule, 'endSearch')),
+
+          language.encapsulate(capsule, 'resultKind', capsule => [
+            html.tag('template', {class: 'wiki-search-album-result-kind-string'},
+              language.$(capsule, 'album')),
+
+            html.tag('template', {class: 'wiki-search-artist-result-kind-string'},
+              language.$(capsule, 'artist')),
+
+            html.tag('template', {class: 'wiki-search-group-result-kind-string'},
+              language.$(capsule, 'group')),
+
+            html.tag('template', {class: 'wiki-search-tag-result-kind-string'},
+              language.$(capsule, 'artTag')),
+          ]),
+
+          language.encapsulate(capsule, 'resultFilter', capsule => [
+            html.tag('template', {class: 'wiki-search-album-result-filter-string'},
+              language.$(capsule, 'album')),
+
+            html.tag('template', {class: 'wiki-search-artist-result-filter-string'},
+              language.$(capsule, 'artist')),
+
+            html.tag('template', {class: 'wiki-search-flash-result-filter-string'},
+              language.$(capsule, 'flash')),
+
+            html.tag('template', {class: 'wiki-search-group-result-filter-string'},
+              language.$(capsule, 'group')),
+
+            html.tag('template', {class: 'wiki-search-track-result-filter-string'},
+              language.$(capsule, 'track')),
+
+            html.tag('template', {class: 'wiki-search-tag-result-filter-string'},
+              language.$(capsule, 'artTag')),
+          ]),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateSecondaryNav.js b/src/content/dependencies/generateSecondaryNav.js
index e9aef66e..9ce7ce9b 100644
--- a/src/content/dependencies/generateSecondaryNav.js
+++ b/src/content/dependencies/generateSecondaryNav.js
@@ -7,14 +7,24 @@ export default {
       mutable: false,
     },
 
-    class: {
-      validate: v => v.anyOf(v.isString, v.sparseArrayOf(v.isString)),
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    alwaysVisible: {
+      type: 'boolean',
+      default: false,
     },
   },
 
   generate: (slots, {html}) =>
     html.tag('nav', {id: 'secondary-nav'},
       {[html.onlyIfContent]: true},
-      {class: slots.class},
+      slots.attributes,
+
+      slots.alwaysVisible &&
+        {class: 'always-visible'},
+
       slots.content),
 };
diff --git a/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js b/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js
new file mode 100644
index 00000000..f204f1fb
--- /dev/null
+++ b/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js
@@ -0,0 +1,115 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
+    'linkAlbumDynamically',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+  }),
+
+  slots: {
+    showPreviousNext: {
+      type: 'boolean',
+      default: true,
+    },
+
+    id: {
+      type: 'boolean',
+      default: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    colorStyle: {
+      type: 'html',
+      mutable: true,
+    },
+
+    mainLink: {
+      type: 'html',
+      mutable: true,
+    },
+
+    previousLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    nextLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    stringsKey: {
+      type: 'string',
+    },
+
+    mainLinkOption: {
+      type: 'string',
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    html.tag('span',
+      {[html.onlyIfContent]: true},
+      {[html.noEdgeWhitespace]: true},
+
+      slots.attributes,
+
+      !html.isBlank(slots.colorStyle) &&
+        slots.colorStyle
+          .slot('context', 'primary-only'),
+
+      language.encapsulate(slots.stringsKey, workingCapsule => {
+        const workingOptions = {
+          [language.onlyIfOptions]: [slots.mainLinkOption],
+        };
+
+        workingOptions[slots.mainLinkOption] =
+          (html.isBlank(slots.mainLink)
+            ? null
+            : slots.mainLink
+                .slot('color', false));
+
+        if (slots.showPreviousNext) addPreviousNext: {
+          if (html.isBlank(slots.previousLink) && html.isBlank(slots.nextLink)) {
+            break addPreviousNext;
+          }
+
+          workingCapsule += '.withPreviousNext';
+          workingOptions.previousNext =
+            relations.switcher.slots({
+              links: [
+                relations.previousLink.slots({
+                  id: slots.id,
+                  link: slots.previousLink,
+                }),
+
+                relations.nextLink.slots({
+                  id: slots.id,
+                  link: slots.nextLink,
+                }),
+              ],
+            });
+        }
+
+        return language.$(workingCapsule, workingOptions);
+      })),
+};
diff --git a/src/content/dependencies/generateSocialEmbed.js b/src/content/dependencies/generateSocialEmbed.js
index 0144c7fb..513ea518 100644
--- a/src/content/dependencies/generateSocialEmbed.js
+++ b/src/content/dependencies/generateSocialEmbed.js
@@ -1,5 +1,5 @@
 export default {
-  extraDependencies: ['html', 'language', 'wikiData'],
+  extraDependencies: ['absoluteTo', 'html', 'language', 'wikiData'],
 
   sprawl({wikiInfo}) {
     return {
@@ -23,10 +23,10 @@ export default {
 
     headingContent: {type: 'string'},
     headingLink: {type: 'string'},
-    imagePath: {type: 'string'},
+    imagePath: {validate: v => v.strictArrayOf(v.isString)},
   },
 
-  generate(data, slots, {html, language}) {
+  generate(data, slots, {absoluteTo, html, language}) {
     switch (slots.mode) {
       case 'html':
         return html.tags([
@@ -40,17 +40,22 @@ export default {
             }),
 
           slots.imagePath &&
-            html.tag('meta', {property: 'og:image', content: slots.imagePath}),
+            html.tag('meta', {
+              property: 'og:image',
+              content: absoluteTo(...slots.imagePath),
+            }),
         ]);
 
       case 'json':
         return JSON.stringify({
           author_name:
             (slots.headingContent
-              ? language.$('misc.socialEmbed.heading', {
-                  wikiName: data.shortWikiName,
-                  heading: slots.headingContent,
-                })
+              ? html.resolve(
+                  language.$('misc.socialEmbed.heading', {
+                    wikiName: data.shortWikiName,
+                    heading: slots.headingContent,
+                  }),
+                  {normalize: 'string'})
               : undefined),
 
           author_url:
diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js
index 226152c7..931352b4 100644
--- a/src/content/dependencies/generateStaticPage.js
+++ b/src/content/dependencies/generateStaticPage.js
@@ -23,17 +23,19 @@ export default {
         title: data.name,
         headingMode: 'sticky',
 
-        styleRules:
-          (data.stylesheet
-            ? [data.stylesheet]
-            : []),
+        styleTags: [
+          html.tag('style', {class: 'static-page-style'},
+            {[html.onlyIfContent]: true},
+            data.stylesheet),
+        ],
 
         mainClasses: ['long-content'],
         mainContent: [
           relations.content,
 
-          data.script &&
-            html.tag('script', data.script),
+          html.tag('script',
+            {[html.onlyIfContent]: true},
+            data.script),
         ],
 
         navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateStaticURLStyleTag.js b/src/content/dependencies/generateStaticURLStyleTag.js
new file mode 100644
index 00000000..b927e5d6
--- /dev/null
+++ b/src/content/dependencies/generateStaticURLStyleTag.js
@@ -0,0 +1,23 @@
+export default {
+  contentDependencies: ['generateStyleTag'],
+  extraDependencies: ['to'],
+
+  relations: (relation) => ({
+    styleTag:
+      relation('generateStyleTag'),
+  }),
+
+  generate: (relations, {to}) =>
+    relations.styleTag.slots({
+      attributes: {class: 'static-url-style'},
+
+      rules: [
+        {
+          select: '.image-media-link::after',
+          declare: [
+            `mask-image: url("${to('staticMisc.path', 'image.svg')}");`
+          ],
+        },
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js
index 9becfb26..ec3062a3 100644
--- a/src/content/dependencies/generateStickyHeadingContainer.js
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -2,6 +2,11 @@ export default {
   extraDependencies: ['html'],
 
   slots: {
+    rootAttributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
     title: {
       type: 'html',
       mutable: false,
@@ -13,22 +18,42 @@ export default {
     },
   },
 
-  generate: (slots, {html}) =>
-    html.tag('div', {class: 'content-sticky-heading-container'},
+  generate: (slots, {html}) => html.tags([
+    html.tag('div', {class: 'content-sticky-heading-root'},
+      slots.rootAttributes,
+
       !html.isBlank(slots.cover) &&
         {class: 'has-cover'},
 
-      [
-        html.tag('div', {class: 'content-sticky-heading-row'}, [
-          html.tag('h1', slots.title),
-
+      html.tag('div', {class: 'content-sticky-heading-anchor'},
+        html.tag('div', {class: 'content-sticky-heading-container'},
           !html.isBlank(slots.cover) &&
-            html.tag('div', {class: 'content-sticky-heading-cover-container'},
-              html.tag('div', {class: 'content-sticky-heading-cover'},
-                slots.cover.slot('mode', 'thumbnail'))),
-        ]),
-
-        html.tag('div', {class: 'content-sticky-subheading-row'},
-          html.tag('h2', {class: 'content-sticky-subheading'})),
-      ]),
+            {class: 'has-cover'},
+
+          [
+            html.tag('div', {class: 'content-sticky-heading-row'}, [
+              html.tag('h1', [
+                html.tag('span', {class: 'reference-collapsed-heading'},
+                  {inert: true},
+
+                  slots.title.clone()),
+
+                slots.title,
+              ]),
+
+              html.tag('div', {class: 'content-sticky-heading-cover-container'},
+                {[html.onlyIfContent]: true},
+
+                html.tag('div', {class: 'content-sticky-heading-cover'},
+                  {[html.onlyIfContent]: true},
+
+                  (html.isBlank(slots.cover)
+                    ? html.blank()
+                    : slots.cover.slot('mode', 'thumbnail')))),
+            ]),
+
+            html.tag('div', {class: 'content-sticky-subheading-row'},
+              html.tag('h2', {class: 'content-sticky-subheading'})),
+          ]))),
+  ]),
 };
diff --git a/src/content/dependencies/generateStyleTag.js b/src/content/dependencies/generateStyleTag.js
new file mode 100644
index 00000000..5ed09ae5
--- /dev/null
+++ b/src/content/dependencies/generateStyleTag.js
@@ -0,0 +1,48 @@
+import {empty} from '#sugar';
+
+const indent = text =>
+  text
+    .split('\n')
+    .map(line => ' '.repeat(4) + line)
+    .join('\n');
+
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    rules: {
+      validate: v =>
+        v.looseArrayOf(
+          v.validateProperties({
+            select: v.isString,
+            declare: v.looseArrayOf(v.isString),
+          })),
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('style', slots.attributes,
+      {[html.onlyIfContent]: true},
+
+      slots.rules
+        .filter(Boolean)
+
+        .map(rule => ({
+          select: rule.select,
+          declare: rule.declare.filter(Boolean),
+        }))
+
+        .filter(rule => !empty(rule.declare))
+
+        .map(rule =>
+          `${rule.select} {\n` +
+          indent(rule.declare.join('\n')) + '\n' +
+          `}`)
+
+        .join('\n\n')),
+};
diff --git a/src/content/dependencies/generateTextWithTooltip.js b/src/content/dependencies/generateTextWithTooltip.js
index 462557d1..49ce1f61 100644
--- a/src/content/dependencies/generateTextWithTooltip.js
+++ b/src/content/dependencies/generateTextWithTooltip.js
@@ -36,6 +36,7 @@ export default {
     if (hasTooltip) {
       attributes = attributes.clone();
       attributes.add({
+        [html.onlyIfContent]: true,
         [html.joinChildren]: '',
         [html.noEdgeWhitespace]: true,
         class: 'text-with-tooltip',
@@ -45,11 +46,19 @@ export default {
     const textPart =
       (hasTooltip && slots.customInteractionCue
         ? html.tag('span', {class: 'hoverable'},
+            {[html.onlyIfContent]: true},
+
             slots.text)
+
      : hasTooltip
         ? html.tag('span', {class: 'hoverable'},
+            {[html.onlyIfContent]: true},
+
             html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
+              {[html.onlyIfContent]: true},
+
               slots.text))
+
         : slots.text);
 
     const content =
diff --git a/src/content/dependencies/generateTooltip.js b/src/content/dependencies/generateTooltip.js
index 81f74aec..b09ee230 100644
--- a/src/content/dependencies/generateTooltip.js
+++ b/src/content/dependencies/generateTooltip.js
@@ -21,10 +21,14 @@ export default {
   generate: (slots, {html}) =>
     html.tag('span', {class: 'tooltip'},
       {[html.noEdgeWhitespace]: true},
+      {[html.onlyIfContent]: true},
+      {[html.onlyIfSiblings]: true},
       slots.attributes,
 
       html.tag('span', {class: 'tooltip-content'},
         {[html.noEdgeWhitespace]: true},
+        {[html.onlyIfContent]: true},
         slots.contentAttributes,
+
         slots.content)),
 };
diff --git a/src/content/dependencies/generateTrackAdditionalNamesBox.js b/src/content/dependencies/generateTrackAdditionalNamesBox.js
deleted file mode 100644
index bad04b74..00000000
--- a/src/content/dependencies/generateTrackAdditionalNamesBox.js
+++ /dev/null
@@ -1,53 +0,0 @@
-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/generateTrackArtistCommentarySection.js b/src/content/dependencies/generateTrackArtistCommentarySection.js
new file mode 100644
index 00000000..c7e7f0f8
--- /dev/null
+++ b/src/content/dependencies/generateTrackArtistCommentarySection.js
@@ -0,0 +1,147 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentContentHeading',
+    'generateCommentaryEntry',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    otherSecondaryReleasesWithCommentary:
+      track.otherReleases
+        .filter(track => !track.isMainRelease)
+        .filter(track => !empty(track.commentary)),
+  }),
+
+  relations: (relation, query, track) => ({
+    contentContentHeading:
+      relation('generateContentContentHeading', track),
+
+    mainReleaseTrackLink:
+      (track.isSecondaryRelease
+        ? relation('linkTrack', track.mainReleaseTrack)
+        : null),
+
+    mainReleaseArtistCommentaryEntries:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.commentary
+            .map(entry => relation('generateCommentaryEntry', entry))
+        : null),
+
+    thisReleaseAlbumLink:
+      relation('linkAlbum', track.album),
+
+    artistCommentaryEntries:
+      track.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
+
+    otherReleaseTrackLinks:
+      query.otherSecondaryReleasesWithCommentary
+        .map(track => relation('linkTrack', track)),
+  }),
+
+  data: (query, track) => ({
+    name:
+      track.name,
+
+    isSecondaryRelease:
+      track.isSecondaryRelease,
+
+    mainReleaseName:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.name
+        : null),
+
+    mainReleaseAlbumName:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.album.name
+        : null),
+
+    mainReleaseAlbumColor:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.album.color
+        : null),
+
+    otherReleaseAlbumNames:
+      query.otherSecondaryReleasesWithCommentary
+        .map(track => track.album.name),
+
+    otherReleaseAlbumColors:
+      query.otherSecondaryReleasesWithCommentary
+        .map(track => track.album.color),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('misc.artistCommentary', capsule =>
+      html.tags([
+        relations.contentContentHeading.slots({
+          attributes: {id: 'artist-commentary'},
+          string: 'misc.artistCommentary',
+        }),
+
+        relations.artistCommentaryEntries,
+
+        data.isSecondaryRelease &&
+          html.tag('div', {class: 'inherited-commentary-section'},
+            {[html.onlyIfContent]: true},
+
+            [
+              html.tag('p', {class: ['drop', 'commentary-drop']},
+                {[html.onlyIfSiblings]: true},
+
+                language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => {
+                  const workingOptions = {};
+
+                  workingOptions.album =
+                    relations.mainReleaseTrackLink.slots({
+                      content:
+                        data.mainReleaseAlbumName,
+
+                      color:
+                        data.mainReleaseAlbumColor,
+                    });
+
+                  if (data.name !== data.mainReleaseName) {
+                    workingCapsule += '.namedDifferently';
+                    workingOptions.name =
+                      html.tag('i', data.mainReleaseName);
+                  }
+
+                  return language.$(workingCapsule, workingOptions);
+                })),
+
+              relations.mainReleaseArtistCommentaryEntries,
+            ]),
+
+        html.tag('p', {class: ['drop', 'commentary-drop']},
+          {[html.onlyIfContent]: true},
+
+          language.encapsulate(capsule, 'info.seeSpecificReleases', workingCapsule => {
+            const workingOptions = {};
+
+            workingOptions[language.onlyIfOptions] = ['albums'];
+
+            workingOptions.albums =
+              language.formatUnitList(
+                stitchArrays({
+                  trackLink: relations.otherReleaseTrackLinks,
+                  albumName: data.otherReleaseAlbumNames,
+                  albumColor: data.otherReleaseAlbumColors,
+                }).map(({trackLink, albumName, albumColor}) =>
+                    trackLink.slots({
+                      content: language.sanitize(albumName),
+                      color: albumColor,
+                    })));
+
+            if (!html.isBlank(relations.artistCommentaryEntries)) {
+              workingCapsule += '.withMainCommentary';
+            }
+
+            return language.$(workingCapsule, workingOptions);
+          })),
+      ])),
+};
diff --git a/src/content/dependencies/generateTrackArtworkColumn.js b/src/content/dependencies/generateTrackArtworkColumn.js
new file mode 100644
index 00000000..f06d735b
--- /dev/null
+++ b/src/content/dependencies/generateTrackArtworkColumn.js
@@ -0,0 +1,33 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+  extraDependencies: ['html'],
+
+  relations: (relation, track) => ({
+    albumCover:
+      (!track.hasUniqueCoverArt && track.album.hasCoverArt
+        ? relation('generateCoverArtwork', track.album.coverArtworks[0])
+        : null),
+
+    trackCovers:
+      (track.hasUniqueCoverArt
+        ? track.trackArtworks.map(artwork =>
+            relation('generateCoverArtwork', artwork))
+        : []),
+  }),
+
+  generate: (relations, {html}) =>
+    html.tags([
+      relations.albumCover?.slots({
+        showOriginDetails: true,
+        showArtTagDetails: true,
+        showReferenceDetails: true,
+      }),
+
+      relations.trackCovers.map(cover =>
+        cover.slots({
+          showOriginDetails: true,
+          showArtTagDetails: true,
+          showReferenceDetails: true,
+        })),
+    ]),
+};
diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js
deleted file mode 100644
index 6c056c9a..00000000
--- a/src/content/dependencies/generateTrackCoverArtwork.js
+++ /dev/null
@@ -1,28 +0,0 @@
-export default {
-  contentDependencies: ['generateCoverArtwork'],
-
-  relations: (relation, track) => ({
-    coverArtwork:
-      relation('generateCoverArtwork',
-        (track.hasUniqueCoverArt
-          ? 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.slots({
-      path: data.path,
-      color: data.color,
-    }),
-};
-
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 7b70d4ff..6c16ce27 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -1,579 +1,382 @@
-import {sortAlbumsTracksChronologically, sortFlashesChronologically}
-  from '#sort';
-import {empty, stitchArrays} from '#sugar';
-
-import getChronologyRelations from '../util/getChronologyRelations.js';
-
 export default {
   contentDependencies: [
-    'generateAbsoluteDatetimestamp',
-    'generateAdditionalFilesShortcut',
-    'generateAlbumAdditionalFilesList',
+    'generateAdditionalFilesList',
+    'generateAdditionalNamesBox',
     'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
-    'generateAlbumStyleRules',
-    'generateChronologyLinks',
-    'generateColorStyleAttribute',
-    'generateCommentarySection',
+    'generateAlbumStyleTags',
+    'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generateContributionList',
+    'generateLyricsSection',
     'generatePageLayout',
-    'generateRelativeDatetimestamp',
-    'generateTrackAdditionalNamesBox',
-    'generateTrackCoverArtwork',
+    'generateTrackArtistCommentarySection',
+    'generateTrackArtworkColumn',
+    'generateTrackInfoPageFeaturedByFlashesList',
+    'generateTrackInfoPageOtherReleasesList',
     'generateTrackList',
     'generateTrackListDividedByGroups',
+    'generateTrackNavLinks',
     'generateTrackReleaseInfo',
     'generateTrackSocialEmbed',
     'linkAlbum',
-    'linkArtist',
-    'linkFlash',
     'linkTrack',
     'transformContent',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
-
-  sprawl({wikiInfo}) {
-    return {
-      divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups,
-      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
-    };
-  },
-
-  relations(relation, sprawl, track) {
-    const relations = {};
-    const sections = relations.sections = {};
-    const {album} = track;
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.albumStyleRules =
-      relation('generateAlbumStyleRules', track.album, track);
-
-    relations.socialEmbed =
-      relation('generateTrackSocialEmbed', track);
-
-    relations.artistChronologyContributions =
-      getChronologyRelations(track, {
-        contributions: [
-          ...track.artistContribs ?? [],
-          ...track.contributorContribs ?? [],
-        ],
-
-        linkArtist: artist => relation('linkArtist', artist),
-        linkThing: track => relation('linkTrack', track),
-
-        getThings(artist) {
-          const getDate = thing => thing.date;
-
-          const things = [
-            ...artist.tracksAsArtist,
-            ...artist.tracksAsContributor,
-          ].filter(getDate);
-
-          return sortAlbumsTracksChronologically(things, {getDate});
-        },
-      });
-
-    relations.coverArtistChronologyContributions =
-      getChronologyRelations(track, {
-        contributions: track.coverArtistContribs ?? [],
-
-        linkArtist: artist => relation('linkArtist', artist),
-
-        linkThing: trackOrAlbum =>
-          (trackOrAlbum.album
-            ? relation('linkTrack', trackOrAlbum)
-            : relation('linkAlbum', trackOrAlbum)),
-
-        getThings(artist) {
-          const getDate = thing => thing.coverArtDate ?? thing.date;
-
-          const things = [
-            ...artist.albumsAsCoverArtist,
-            ...artist.tracksAsCoverArtist,
-          ].filter(getDate);
-
-          return sortAlbumsTracksChronologically(things, {getDate});
-        },
-      }),
-
-    relations.albumLink =
-      relation('linkAlbum', track.album);
-
-    relations.trackLink =
-      relation('linkTrack', track);
-
-    relations.albumNavAccent =
-      relation('generateAlbumNavAccent', track.album, track);
-
-    relations.chronologyLinks =
-      relation('generateChronologyLinks');
-
-    relations.sidebar =
-      relation('generateAlbumSidebar', track.album, track);
+  extraDependencies: ['html', 'language'],
 
-    const additionalFilesSection = additionalFiles => ({
-      heading: relation('generateContentHeading'),
-      list: relation('generateAlbumAdditionalFilesList', album, additionalFiles),
-    });
+  query: (track) => ({
+    mainReleaseTrack:
+      (track.isMainRelease
+        ? track
+        : track.mainReleaseTrack),
+  }),
 
-    // This'll take care of itself being blank if there's nothing to show here.
-    relations.additionalNamesBox =
-      relation('generateTrackAdditionalNamesBox', track);
+  relations: (relation, query, track) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    if (track.hasUniqueCoverArt || album.hasCoverArt) {
-      relations.cover =
-        relation('generateTrackCoverArtwork', track);
-    }
+    albumStyleTags:
+      relation('generateAlbumStyleTags', track.album, track),
 
-    // Section: Release info
+    socialEmbed:
+      relation('generateTrackSocialEmbed', track),
 
-    relations.releaseInfo =
-      relation('generateTrackReleaseInfo', track);
+    navLinks:
+      relation('generateTrackNavLinks', track),
 
-    // Section: Extra links
+    albumNavAccent:
+      relation('generateAlbumNavAccent', track.album, track),
 
-    const extra = sections.extra = {};
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', track.album),
 
-    if (!empty(track.additionalFiles)) {
-      extra.additionalFilesShortcut =
-        relation('generateAdditionalFilesShortcut', track.additionalFiles);
-    }
+    sidebar:
+      relation('generateAlbumSidebar', track.album, track),
 
-    // Section: Other releases
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', track.additionalNames),
 
-    if (!empty(track.otherReleases)) {
-      const otherReleases = sections.otherReleases = {};
+    artworkColumn:
+      relation('generateTrackArtworkColumn', track),
 
-      otherReleases.heading =
-        relation('generateContentHeading');
+    contentHeading:
+      relation('generateContentHeading'),
 
-      otherReleases.colorStyles =
-        track.otherReleases
-          .map(track => relation('generateColorStyleAttribute', track.color));
+    contentContentHeading:
+      relation('generateContentContentHeading', track),
 
-      otherReleases.trackLinks =
-        track.otherReleases
-          .map(track => relation('linkTrack', track));
+    releaseInfo:
+      relation('generateTrackReleaseInfo', track),
 
-      otherReleases.albumLinks =
-        track.otherReleases
-          .map(track => relation('linkAlbum', track.album));
+    otherReleasesList:
+      relation('generateTrackInfoPageOtherReleasesList', track),
 
-      otherReleases.datetimestamps =
-        track.otherReleases.map(track2 =>
-          (track2.date
-            ? (track.date
-                ? relation('generateRelativeDatetimestamp',
-                    track2.date,
-                    track.date)
-                : relation('generateAbsoluteDatetimestamp',
-                    track2.date))
-            : null));
+    contributorContributionList:
+      relation('generateContributionList', track.contributorContribs),
 
-      otherReleases.items =
-        track.otherReleases.map(track => ({
-          trackLink: relation('linkTrack', track),
-          albumLink: relation('linkAlbum', track.album),
-        }));
-    }
+    referencedTracksList:
+      relation('generateTrackList', track.referencedTracks),
 
-    // Section: Contributors
+    sampledTracksList:
+      relation('generateTrackList', track.sampledTracks),
 
-    if (!empty(track.contributorContribs)) {
-      const contributors = sections.contributors = {};
+    referencedByTracksList:
+      relation('generateTrackListDividedByGroups',
+        query.mainReleaseTrack.referencedByTracks),
 
-      contributors.heading =
-        relation('generateContentHeading');
+    sampledByTracksList:
+      relation('generateTrackListDividedByGroups',
+        query.mainReleaseTrack.sampledByTracks),
 
-      contributors.list =
-        relation('generateContributionList', track.contributorContribs);
-    }
+    flashesThatFeatureList:
+      relation('generateTrackInfoPageFeaturedByFlashesList', track),
 
-    // Section: Referenced tracks
+    lyricsSection:
+      relation('generateLyricsSection', track.lyrics),
 
-    if (!empty(track.referencedTracks)) {
-      const references = sections.references = {};
+    sheetMusicFilesList:
+      relation('generateAdditionalFilesList', track.sheetMusicFiles),
 
-      references.heading =
-        relation('generateContentHeading');
+    midiProjectFilesList:
+      relation('generateAdditionalFilesList', track.midiProjectFiles),
 
-      references.list =
-        relation('generateTrackList', track.referencedTracks);
-    }
+    additionalFilesList:
+      relation('generateAdditionalFilesList', track.additionalFiles),
 
-    // Section: Sampled tracks
+    artistCommentarySection:
+      relation('generateTrackArtistCommentarySection', track),
 
-    if (!empty(track.sampledTracks)) {
-      const samples = sections.samples = {};
+    creditingSourceEntries:
+      track.creditingSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
 
-      samples.heading =
-        relation('generateContentHeading');
+    referencingSourceEntries:
+      track.referencingSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
+  }),
 
-      samples.list =
-        relation('generateTrackList', track.sampledTracks);
-    }
+  data: (_query, track) => ({
+    name:
+      track.name,
 
-    // Section: Tracks that reference
+    color:
+      track.color,
+  }),
 
-    if (!empty(track.referencedByTracks)) {
-      const referencedBy = sections.referencedBy = {};
-
-      referencedBy.heading =
-        relation('generateContentHeading');
-
-      referencedBy.list =
-        relation('generateTrackListDividedByGroups',
-          track.referencedByTracks,
-          sprawl.divideTrackListsByGroups);
-    }
-
-    // Section: Tracks that sample
-
-    if (!empty(track.sampledByTracks)) {
-      const sampledBy = sections.sampledBy = {};
-
-      sampledBy.heading =
-        relation('generateContentHeading');
-
-      sampledBy.list =
-        relation('generateTrackListDividedByGroups',
-          track.sampledByTracks,
-          sprawl.divideTrackListsByGroups);
-    }
-
-    // Section: Flashes that feature
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('trackPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            track: data.name,
+          }),
 
-    if (sprawl.enableFlashesAndGames) {
-      const sortedFeatures =
-        sortFlashesChronologically(
-          [track, ...track.otherReleases].flatMap(track =>
-            track.featuredInFlashes.map(flash => ({
-              // These aren't going to be exposed directly, they're processed
-              // into the appropriate relations after this sort.
-              flash, track,
+        headingMode: 'sticky',
 
-              // These properties are only used for the sort.
-              act: flash.act,
-              date: flash.date,
-            }))));
+        additionalNames: relations.additionalNamesBox,
 
-      if (!empty(sortedFeatures)) {
-        const flashesThatFeature = sections.flashesThatFeature = {};
+        color: data.color,
+        styleTags: relations.albumStyleTags,
 
-        flashesThatFeature.heading =
-          relation('generateContentHeading');
+        artworkColumnContent:
+          relations.artworkColumn,
 
-        flashesThatFeature.entries =
-          sortedFeatures.map(({flash, track: directlyFeaturedTrack}) =>
-            (directlyFeaturedTrack === track
-              ? {
-                  flashLink: relation('linkFlash', flash),
-                }
-              : {
-                  flashLink: relation('linkFlash', flash),
-                  trackLink: relation('linkTrack', directlyFeaturedTrack),
-                }));
-      }
-    }
+        mainContent: [
+          relations.releaseInfo,
 
-    // Section: Lyrics
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
 
-    if (track.lyrics) {
-      const lyrics = sections.lyrics = {};
+            language.encapsulate('releaseInfo', capsule => [
+              !html.isBlank(relations.sheetMusicFilesList) &&
+                language.encapsulate(capsule, 'sheetMusicFiles.shortcut', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#sheet-music-files'},
+                        language.$(capsule, 'link')),
+                  })),
 
-      lyrics.heading =
-        relation('generateContentHeading');
+              !html.isBlank(relations.midiProjectFilesList) &&
+                language.encapsulate(capsule, 'midiProjectFiles.shortcut', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#midi-project-files'},
+                        language.$(capsule, 'link')),
+                  })),
 
-      lyrics.content =
-        relation('transformContent', track.lyrics);
-    }
+              !html.isBlank(relations.additionalFilesList) &&
+                language.encapsulate(capsule, 'additionalFiles.shortcut', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#midi-project-files'},
+                        language.$(capsule, 'link')),
+                  })),
 
-    // Sections: Sheet music files, MIDI/proejct files, additional files
+              !html.isBlank(relations.artistCommentarySection) &&
+                language.encapsulate(capsule, 'readCommentary', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#artist-commentary'},
+                        language.$(capsule, 'link')),
+                  })),
 
-    if (!empty(track.sheetMusicFiles)) {
-      sections.sheetMusicFiles = additionalFilesSection(track.sheetMusicFiles);
-    }
+              !html.isBlank(relations.creditingSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditingSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#crediting-sources'},
+                        language.$(capsule, 'link')),
+                  })),
 
-    if (!empty(track.midiProjectFiles)) {
-      sections.midiProjectFiles = additionalFilesSection(track.midiProjectFiles);
-    }
+              !html.isBlank(relations.referencingSourceEntries) &&
+                language.encapsulate(capsule, 'readReferencingSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#referencing-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
 
-    if (!empty(track.additionalFiles)) {
-      sections.additionalFiles = additionalFilesSection(track.additionalFiles);
-    }
+          relations.otherReleasesList,
 
-    // Section: Artist commentary
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'contributors'},
+                title: language.$('releaseInfo.contributors'),
+              }),
 
-    if (track.commentary) {
-      sections.artistCommentary =
-        relation('generateCommentarySection', track.commentary);
-    }
+            relations.contributorContributionList.slots({
+              chronologyKind: 'trackContribution',
+            }),
+          ]),
+
+          html.tags([
+            language.encapsulate('releaseInfo.tracksReferenced', capsule =>
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'references'},
+
+                  title:
+                    language.$(capsule, {
+                      track:
+                        html.tag('i', data.name),
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                })),
 
-    return relations;
-  },
+            relations.referencedTracksList,
+          ]),
 
-  data(sprawl, track) {
-    return {
-      name: track.name,
-      color: track.color,
+          html.tags([
+            language.encapsulate('releaseInfo.tracksSampled', capsule =>
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'samples'},
 
-      hasTrackNumbers: track.album.hasTrackNumbers,
-      trackNumber: track.album.tracks.indexOf(track) + 1,
+                  title:
+                    language.$(capsule, {
+                      track:
+                        html.tag('i', data.name),
+                    }),
 
-      numAdditionalFiles: track.additionalFiles.length,
-    };
-  },
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                })),
 
-  generate(data, relations, {html, language}) {
-    const {sections: sec} = relations;
+            relations.sampledTracksList,
+          ]),
 
-    return relations.layout
-      .slots({
-        title: language.$('trackPage.title', {track: data.name}),
-        headingMode: 'sticky',
+          language.encapsulate('releaseInfo.tracksThatReference', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'referenced-by'},
 
-        additionalNames: relations.additionalNamesBox,
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
 
-        color: data.color,
-        styleRules: [relations.albumStyleRules],
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                }),
 
-        cover:
-          (relations.cover
-            ? relations.cover.slots({
-                alt: language.$('misc.alt.trackCover'),
-              })
-            : null),
+              relations.referencedByTracksList
+                .slots({
+                  headingString: capsule,
+                }),
+            ])),
 
-        mainContent: [
-          relations.releaseInfo,
+          language.encapsulate('releaseInfo.tracksThatSample', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'sampled-by'},
 
-          html.tag('p',
-            {[html.onlyIfContent]: true},
-            {[html.joinChildren]: html.tag('br')},
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
 
-            [
-              sec.sheetMusicFiles &&
-                language.$('releaseInfo.sheetMusicFiles.shortcut', {
-                  link: html.tag('a',
-                    {href: '#sheet-music-files'},
-                    language.$('releaseInfo.sheetMusicFiles.shortcut.link')),
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
                 }),
 
-              sec.midiProjectFiles &&
-                language.$('releaseInfo.midiProjectFiles.shortcut', {
-                  link: html.tag('a',
-                    {href: '#midi-project-files'},
-                    language.$('releaseInfo.midiProjectFiles.shortcut.link')),
+              relations.sampledByTracksList
+                .slots({
+                  headingString: capsule,
                 }),
+            ])),
 
-              sec.additionalFiles &&
-                sec.extra.additionalFilesShortcut,
+          html.tags([
+            language.encapsulate('releaseInfo.flashesThatFeature', capsule =>
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'featured-in'},
 
-              sec.artistCommentary &&
-                language.$('releaseInfo.readCommentary', {
-                  link: html.tag('a',
-                    {href: '#artist-commentary'},
-                    language.$('releaseInfo.readCommentary.link')),
-                }),
-            ]),
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
 
-          sec.otherReleases && [
-            sec.otherReleases.heading
-              .slots({
-                id: 'also-released-as',
-                title: language.$('releaseInfo.alsoReleasedAs'),
-              }),
-
-            html.tag('ul',
-              stitchArrays({
-                trackLink: sec.otherReleases.trackLinks,
-                albumLink: sec.otherReleases.albumLinks,
-                datetimestamp: sec.otherReleases.datetimestamps,
-                colorStyle: sec.otherReleases.colorStyles,
-              }).map(({
-                  trackLink,
-                  albumLink,
-                  datetimestamp,
-                  colorStyle,
-                }) => {
-                  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',
-                      colorStyle,
-                      language.$(...parts, options)));
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
                 })),
-          ],
-
-          sec.contributors && [
-            sec.contributors.heading
-              .slots({
-                id: 'contributors',
-                title: language.$('releaseInfo.contributors'),
-              }),
-
-            sec.contributors.list,
-          ],
 
-          sec.references && [
-            sec.references.heading
-              .slots({
-                id: 'references',
-                title:
-                  language.$('releaseInfo.tracksReferenced', {
-                    track: html.tag('i', data.name),
-                  }),
-              }),
+            relations.flashesThatFeatureList,
+          ]),
 
-            sec.references.list,
-          ],
+          relations.lyricsSection,
 
-          sec.samples && [
-            sec.samples.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
-                id: 'samples',
-                title:
-                  language.$('releaseInfo.tracksSampled', {
-                    track: html.tag('i', data.name),
-                  }),
+                attributes: {id: 'sheet-music-files'},
+                title: language.$('releaseInfo.sheetMusicFiles.heading'),
               }),
 
-            sec.samples.list,
-          ],
+            relations.sheetMusicFilesList,
+          ]),
 
-          sec.referencedBy && [
-            sec.referencedBy.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
-                id: 'referenced-by',
-                title:
-                  language.$('releaseInfo.tracksThatReference', {
-                    track: html.tag('i', data.name),
-                  }),
+                attributes: {id: 'midi-project-files'},
+                title: language.$('releaseInfo.midiProjectFiles.heading'),
               }),
 
-            sec.referencedBy.list,
-          ],
+            relations.midiProjectFilesList,
+          ]),
 
-          sec.sampledBy && [
-            sec.sampledBy.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
-                id: 'referenced-by',
-                title:
-                  language.$('releaseInfo.tracksThatSample', {
-                    track: html.tag('i', data.name),
-                  }),
+                attributes: {id: 'additional-files'},
+                title: language.$('releaseInfo.additionalFiles.heading'),
               }),
 
-            sec.sampledBy.list,
-          ],
+            relations.additionalFilesList,
+          ]),
 
-          sec.flashesThatFeature && [
-            sec.flashesThatFeature.heading
-              .slots({
-                id: 'featured-in',
-                title:
-                  language.$('releaseInfo.flashesThatFeature', {
-                    track: html.tag('i', data.name),
-                  }),
-              }),
+          relations.artistCommentarySection,
 
-            html.tag('ul', sec.flashesThatFeature.entries.map(({flashLink, trackLink}) =>
-              (trackLink
-                ? html.tag('li', {class: 'rerelease'},
-                    language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
-                      flash: flashLink,
-                      track: trackLink,
-                    }))
-                : html.tag('li',
-                    language.$('releaseInfo.flashesThatFeature.item', {
-                      flash: flashLink,
-                    }))))),
-          ],
-
-          sec.lyrics && [
-            sec.lyrics.heading
+          html.tags([
+            relations.contentContentHeading.clone()
               .slots({
-                id: 'lyrics',
-                title: language.$('releaseInfo.lyrics'),
+                attributes: {id: 'crediting-sources'},
+                string: 'misc.creditingSources',
               }),
 
-            html.tag('blockquote',
-              sec.lyrics.content
-                .slot('mode', 'lyrics')),
-          ],
+            relations.creditingSourceEntries,
+          ]),
 
-          sec.sheetMusicFiles && [
-            sec.sheetMusicFiles.heading
+          html.tags([
+            relations.contentContentHeading.clone()
               .slots({
-                id: 'sheet-music-files',
-                title: language.$('releaseInfo.sheetMusicFiles.heading'),
+                attributes: {id: 'referencing-sources'},
+                string: 'misc.referencingSources',
               }),
 
-            sec.sheetMusicFiles.list,
-          ],
-
-          sec.midiProjectFiles && [
-            sec.midiProjectFiles.heading
-              .slots({
-                id: 'midi-project-files',
-                title: language.$('releaseInfo.midiProjectFiles.heading'),
-              }),
-
-            sec.midiProjectFiles.list,
-          ],
-
-          sec.additionalFiles && [
-            sec.additionalFiles.heading
-              .slots({
-                id: 'additional-files',
-                title:
-                  language.$('releaseInfo.additionalFiles.heading', {
-                    additionalFiles:
-                      language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
-                  }),
-              }),
-
-            sec.additionalFiles.list,
-          ],
-
-          sec.artistCommentary,
+            relations.referencingSourceEntries,
+          ]),
         ],
 
         navLinkStyle: 'hierarchical',
-        navLinks: [
-          {auto: 'home'},
-          {html: relations.albumLink.slot('color', false)},
-          {
-            html:
-              (data.hasTrackNumbers
-                ? language.$('trackPage.nav.track.withNumber', {
-                    number: data.trackNumber,
-                    track: relations.trackLink
-                      .slot('attributes', {class: 'current'}),
-                  })
-                : language.$('trackPage.nav.track', {
-                    track: relations.trackLink
-                      .slot('attributes', {class: 'current'}),
-                  })),
-          },
-        ],
+        navLinks: html.resolve(relations.navLinks),
 
         navBottomRowContent:
           relations.albumNavAccent.slots({
@@ -581,25 +384,14 @@ export default {
             showExtraLinks: false,
           }),
 
-        navContent:
-          relations.chronologyLinks.slots({
-            chronologyInfoSets: [
-              {
-                headingString: 'misc.chronology.heading.track',
-                contributions: relations.artistChronologyContributions,
-              },
-              {
-                headingString: 'misc.chronology.heading.coverArt',
-                contributions: relations.coverArtistChronologyContributions,
-              },
-            ],
-          }),
+        secondaryNav:
+          relations.secondaryNav
+            .slot('mode', 'track'),
 
-        ...relations.sidebar,
+        leftSidebar: relations.sidebar,
 
         socialEmbed: relations.socialEmbed,
-      });
-  },
+      })),
 };
 
 /*
diff --git a/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js
new file mode 100644
index 00000000..61654512
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js
@@ -0,0 +1,63 @@
+import {sortFlashesChronologically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkFlash', 'linkTrack'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    enableFlashesAndGames:
+      wikiInfo.enableFlashesAndGames,
+  }),
+
+  query: (sprawl, track) => ({
+    sortedFeatures:
+      (sprawl.enableFlashesAndGames
+        ? sortFlashesChronologically(
+            track.allReleases.flatMap(track =>
+              track.featuredInFlashes.map(flash => ({
+                flash,
+                track,
+
+                // These properties are only used for the sort.
+                act: flash.act,
+                date: flash.date,
+              }))))
+        : []),
+  }),
+
+  relations: (relation, query, _sprawl, track) => ({
+    flashLinks:
+      query.sortedFeatures
+        .map(({flash}) => relation('linkFlash', flash)),
+
+    trackLinks:
+      query.sortedFeatures
+        .map(({track: directlyFeaturedTrack}) =>
+          (directlyFeaturedTrack === track
+            ? null
+         : directlyFeaturedTrack.name === track.name
+            ? null
+            : relation('linkTrack', directlyFeaturedTrack))),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tag('ul',
+      {[html.onlyIfContent]: true},
+
+      stitchArrays({
+        flashLink: relations.flashLinks,
+        trackLink: relations.trackLinks,
+      }).map(({flashLink, trackLink}) => {
+          const attributes = html.attributes();
+          const parts = ['releaseInfo.flashesThatFeature.item'];
+          const options = {flash: flashLink};
+
+          if (trackLink) {
+            parts.push('asDifferentRelease');
+            options.track = trackLink;
+          }
+
+          return html.tag('li', attributes, language.$(...parts, options));
+        })),
+};
diff --git a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js
new file mode 100644
index 00000000..ebd76577
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js
@@ -0,0 +1,42 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    trackLinks:
+      track.otherReleases
+        .map(track => relation('linkTrack', track)),
+  }),
+
+  data: (track) => ({
+    albumNames:
+      track.otherReleases
+        .map(track => track.album.name),
+
+    albumColors:
+      track.otherReleases
+        .map(track => track.album.color),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    html.tag('p',
+      {[html.onlyIfContent]: true},
+
+      language.$('releaseInfo.alsoReleasedOn', {
+        [language.onlyIfOptions]: ['albums'],
+
+        albums:
+          language.formatConjunctionList(
+            stitchArrays({
+              trackLink: relations.trackLinks,
+              albumName: data.albumNames,
+              albumColor: data.albumColors,
+            }).map(({trackLink, albumName, albumColor}) =>
+                trackLink.slots({
+                  content: language.sanitize(albumName),
+                  color: albumColor,
+                }))),
+      })),
+};
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
index 65f5552b..53a32536 100644
--- a/src/content/dependencies/generateTrackList.js
+++ b/src/content/dependencies/generateTrackList.js
@@ -1,58 +1,28 @@
-import {empty, stitchArrays} from '#sugar';
-
 export default {
-  contentDependencies: ['linkTrack', 'linkContribution'],
-
-  extraDependencies: ['html', 'language'],
+  contentDependencies: ['generateTrackListItem'],
+  extraDependencies: ['html'],
 
-  relations(relation, tracks) {
-    if (empty(tracks)) {
-      return {};
-    }
-
-    return {
-      trackLinks:
-        tracks
-          .map(track => relation('linkTrack', track)),
-
-      contributionLinks:
-        tracks
-          .map(track =>
-            (empty(track.artistContribs)
-              ? null
-              : track.artistContribs
-                  .map(contrib => relation('linkContribution', contrib)))),
-    };
-  },
+  relations: (relation, tracks) => ({
+    items:
+      tracks
+        .map(track => relation('generateTrackListItem', track, [])),
+  }),
 
   slots: {
-    showContribution: {type: 'boolean', default: false},
-    showIcons: {type: 'boolean', default: false},
+    colorMode: {
+      validate: v => v.is('none', 'track', 'line'),
+      default: 'track',
+    },
   },
 
-  generate(relations, slots, {html, language}) {
-    return (
-      html.tag('ul',
-        stitchArrays({
-          trackLink: relations.trackLinks,
-          contributionLinks: relations.contributionLinks,
-        }).map(({trackLink, contributionLinks}) =>
-            html.tag('li',
-              (empty(contributionLinks)
-                ? trackLink
-                : language.$('trackList.item.withArtists', {
-                    track: trackLink,
-                    by:
-                      html.tag('span', {class: 'by'},
-                        language.$('trackList.item.withArtists.by', {
-                          artists:
-                            language.formatConjunctionList(
-                              contributionLinks.map(link =>
-                                link.slots({
-                                  showContribution: slots.showContribution,
-                                  showIcons: slots.showIcons,
-                                }))),
-                        })),
-                  }))))));
-  },
+  generate: (relations, slots, {html}) =>
+    html.tag('ul',
+      {[html.onlyIfContent]: true},
+
+      relations.items.map(item =>
+        item.slots({
+          showArtists: true,
+          showDuration: false,
+          colorMode: slots.colorMode,
+        }))),
 };
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
index e070ac35..230868d6 100644
--- a/src/content/dependencies/generateTrackListDividedByGroups.js
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -1,53 +1,145 @@
-import {empty} from '#sugar';
-
-import groupTracksByGroup from '../util/groupTracksByGroup.js';
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
 
 export default {
-  contentDependencies: ['generateTrackList', 'linkGroup'],
-  extraDependencies: ['html', 'language'],
+  contentDependencies: [
+    'generateContentHeading',
+    'generateTrackList',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    divideTrackListsByGroups:
+      wikiInfo.divideTrackListsByGroups,
+  }),
 
-  relations(relation, tracks, groups) {
-    if (empty(tracks)) {
-      return {};
+  query(sprawl, tracks) {
+    const dividingGroups = sprawl.divideTrackListsByGroups;
+
+    const groupings = new Map();
+    const ungroupedTracks = [];
+
+    // Entry order matters! Add blank lists for each group
+    // in the order that those groups are provided.
+    for (const group of dividingGroups) {
+      groupings.set(group, []);
     }
 
-    if (empty(groups)) {
-      return {
-        flatList:
-          relation('generateTrackList', tracks),
-      };
+    for (const track of tracks) {
+      const firstMatchingGroup =
+        dividingGroups.find(group => group.albums.includes(track.album));
+
+      if (firstMatchingGroup) {
+        groupings.get(firstMatchingGroup).push(track);
+      } else {
+        ungroupedTracks.push(track);
+      }
     }
 
-    const lists = groupTracksByGroup(tracks, groups);
+    const groups = Array.from(groupings.keys());
+    const groupedTracks = Array.from(groupings.values());
 
-    return {
-      groupedLists:
-        Array.from(lists.entries()).map(([groupOrOther, tracks]) => ({
-          ...(groupOrOther === 'other'
-                ? {other: true}
-                : {groupLink: relation('linkGroup', groupOrOther)}),
+    // Drop the empty lists, so just the groups which
+    // at least a single track matched are left.
+    filterMultipleArrays(
+      groups,
+      groupedTracks,
+      (_group, tracks) => !empty(tracks));
 
-          list:
-            relation('generateTrackList', tracks),
-        })),
-    };
+    return {groups, groupedTracks, ungroupedTracks};
   },
 
-  generate(relations, {html, language}) {
-    if (relations.flatList) {
-      return relations.flatList;
-    }
+  relations: (relation, query, sprawl, tracks) => ({
+    flatList:
+      (empty(sprawl.divideTrackListsByGroups)
+        ? relation('generateTrackList', tracks)
+        : null),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    groupLinks:
+      query.groups
+        .map(group => relation('linkGroup', group)),
+
+    groupedTrackLists:
+      query.groupedTracks
+        .map(tracks => relation('generateTrackList', tracks)),
+
+    ungroupedTrackList:
+      (empty(query.ungroupedTracks)
+        ? null
+        : relation('generateTrackList', query.ungroupedTracks)),
+  }),
 
-    return html.tag('dl',
-      relations.groupedLists.map(({other, groupLink, list}) => [
-        html.tag('dt',
-          (other
-            ? language.$('trackList.group.fromOther')
-            : language.$('trackList.group', {
-                group: groupLink
-              }))),
-
-        html.tag('dd', list),
-      ]));
+  data: (query, _sprawl, _tracks) => ({
+    groupNames:
+      query.groups
+        .map(group => group.name),
+  }),
+
+  slots: {
+    headingString: {
+      type: 'string',
+    },
   },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.flatList ??
+
+    html.tag('dl',
+      {[html.onlyIfContent]: true},
+
+      language.encapsulate('trackList', listCapsule => [
+        stitchArrays({
+          groupName: data.groupNames,
+          groupLink: relations.groupLinks,
+          trackList: relations.groupedTrackLists,
+        }).map(({
+            groupName,
+            groupLink,
+            trackList,
+          }) => [
+            language.encapsulate(listCapsule, 'fromGroup', capsule =>
+              (slots.headingString
+                ? relations.contentHeading.clone().slots({
+                    tag: 'dt',
+
+                    title:
+                      language.$(capsule, {
+                        group: groupLink
+                      }),
+
+                    stickyTitle:
+                      language.$(slots.headingString, 'sticky', 'fromGroup', {
+                        group: groupName,
+                      }),
+                  })
+                : html.tag('dt',
+                    language.$(capsule, {
+                      group: groupLink
+                    })))),
+
+            html.tag('dd', trackList),
+          ]),
+
+        relations.ungroupedTrackList && [
+          language.encapsulate(listCapsule, 'fromOther', capsule =>
+            (slots.headingString
+              ? relations.contentHeading.clone().slots({
+                  tag: 'dt',
+
+                  title:
+                    language.$(capsule),
+
+                  stickyTitle:
+                    language.$(slots.headingString, 'sticky', 'fromOther'),
+                })
+              : html.tag('dt',
+                  language.$(capsule)))),
+
+          html.tag('dd', relations.ungroupedTrackList),
+        ],
+      ])),
 };
diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js
new file mode 100644
index 00000000..3c850a18
--- /dev/null
+++ b/src/content/dependencies/generateTrackListItem.js
@@ -0,0 +1,107 @@
+export default {
+  contentDependencies: [
+    'generateArtistCredit',
+    'generateColorStyleAttribute',
+    'generateTrackListMissingDuration',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track, contextContributions) => ({
+    trackLink:
+      relation('linkTrack', track),
+
+    credit:
+      relation('generateArtistCredit',
+        track.artistContribs,
+        contextContributions),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', track.color),
+
+    missingDuration:
+      (track.duration
+        ? null
+        : relation('generateTrackListMissingDuration')),
+  }),
+
+  data: (track, _contextContributions) => ({
+    duration:
+      track.duration ?? 0,
+
+    trackHasDuration:
+      !!track.duration,
+  }),
+
+  slots: {
+    // showArtists enables showing artists *at all.* It doesn't take precedence
+    // over behavior which automatically collapses (certain) artists because of
+    // provided context contributions.
+    showArtists: {
+      type: 'boolean',
+      default: true,
+    },
+
+    // If true and the track doesn't have a duration, a missing-duration cue
+    // will be displayed instead.
+    showDuration: {
+      type: 'boolean',
+      default: false,
+    },
+
+    colorMode: {
+      validate: v => v.is('none', 'track', 'line'),
+      default: 'track',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('trackList.item', itemCapsule =>
+      html.tag('li',
+        slots.colorMode === 'line' &&
+          relations.colorStyle.slot('context', 'primary-only'),
+
+        language.encapsulate(itemCapsule, workingCapsule => {
+          const workingOptions = {};
+
+          workingOptions.track =
+            relations.trackLink
+              .slot('color', slots.colorMode === 'track');
+
+          if (slots.showDuration) {
+            workingCapsule += '.withDuration';
+            workingOptions.duration =
+              (data.trackHasDuration
+                ? language.$(itemCapsule, 'withDuration.duration', {
+                    duration:
+                      language.formatDuration(data.duration),
+                  })
+                : relations.missingDuration);
+          }
+
+          const artistCapsule = language.encapsulate(itemCapsule, 'withArtists');
+
+          relations.credit.setSlots({
+            normalStringKey:
+              artistCapsule + '.by',
+
+            featuringStringKey:
+              artistCapsule + '.featuring',
+
+            normalFeaturingStringKey:
+              artistCapsule + '.by.featuring',
+          });
+
+          if (!html.isBlank(relations.credit)) {
+            workingCapsule += '.withArtists';
+            workingOptions.by =
+              html.tag('span', {class: 'by'},
+                // TODO: This is obviously evil.
+                html.metatag('chunkwrap', {split: /,| (?=and)/},
+                  html.resolve(relations.credit)));
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }))),
+};
diff --git a/src/content/dependencies/generateTrackListMissingDuration.js b/src/content/dependencies/generateTrackListMissingDuration.js
new file mode 100644
index 00000000..b5917982
--- /dev/null
+++ b/src/content/dependencies/generateTrackListMissingDuration.js
@@ -0,0 +1,35 @@
+export default {
+  contentDependencies: ['generateTextWithTooltip', 'generateTooltip'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateTooltip'),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('trackList.item.withDuration', itemCapsule =>
+      language.encapsulate(itemCapsule, 'duration', durationCapsule =>
+        relations.textWithTooltip.slots({
+          attributes: {class: 'missing-duration'},
+          customInteractionCue: true,
+
+          text:
+            language.$(durationCapsule, {
+              duration:
+                html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
+                  language.$(durationCapsule, 'missing')),
+            }),
+
+          tooltip:
+            relations.tooltip.slots({
+              attributes: {class: 'missing-duration-tooltip'},
+
+              content:
+                language.$(durationCapsule, 'missing.info'),
+            }),
+        }))),
+};
diff --git a/src/content/dependencies/generateTrackNavLinks.js b/src/content/dependencies/generateTrackNavLinks.js
new file mode 100644
index 00000000..6a8b7c64
--- /dev/null
+++ b/src/content/dependencies/generateTrackNavLinks.js
@@ -0,0 +1,64 @@
+export default {
+  contentDependencies: ['linkAlbum', 'linkTrack'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    albumLink:
+      relation('linkAlbum', track.album),
+
+    trackLink:
+      relation('linkTrack', track),
+  }),
+
+  data: (track) => ({
+    hasTrackNumbers:
+      track.album.hasTrackNumbers,
+
+    trackNumber:
+      track.trackNumber,
+  }),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('referenced-art', 'referencing-art'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('trackPage.nav', navCapsule => [
+      {auto: 'home'},
+
+      {html: relations.albumLink.slot('color', false)},
+
+      {
+        html:
+          language.encapsulate(navCapsule, 'track', workingCapsule => {
+            const workingOptions = {};
+
+            workingOptions.track =
+              relations.trackLink
+                .slot('attributes', {class: 'current'});
+
+            if (data.hasTrackNumbers) {
+              workingCapsule += '.withNumber';
+              workingOptions.number = data.trackNumber;
+            }
+
+            return language.$(workingCapsule, workingOptions);
+          }),
+
+        accent:
+          html.tag('a',
+            {[html.onlyIfContent]: true},
+
+            {href: ''},
+            {class: 'current'},
+
+            (slots.currentExtra === 'referenced-art'
+              ? language.$('referencedArtworksPage.subtitle')
+           : slots.currentExtra === 'referencing-art'
+              ? language.$('referencingArtworksPage.subtitle')
+              : null)),
+      },
+    ]),
+};
diff --git a/src/content/dependencies/generateTrackReferencedArtworksPage.js b/src/content/dependencies/generateTrackReferencedArtworksPage.js
new file mode 100644
index 00000000..7073409e
--- /dev/null
+++ b/src/content/dependencies/generateTrackReferencedArtworksPage.js
@@ -0,0 +1,47 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleTags',
+    'generateBackToTrackLink',
+    'generateReferencedArtworksPage',
+    'generateTrackNavLinks',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    page:
+      relation('generateReferencedArtworksPage', track.trackArtworks[0]),
+
+    albumStyleTags:
+      relation('generateAlbumStyleTags', track.album, track),
+
+    navLinks:
+      relation('generateTrackNavLinks', track),
+
+    backToTrackLink:
+      relation('generateBackToTrackLink', track),
+  }),
+
+  data: (track) => ({
+    name:
+      track.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.page.slots({
+      title:
+        language.$('trackPage.title', {
+          track:
+            data.name,
+        }),
+
+      styleTags: relations.albumStyleTags,
+
+      navLinks:
+        html.resolve(
+          relations.navLinks
+            .slot('currentExtra', 'referenced-art')),
+
+      navBottomRowContent: relations.backToTrackLink,
+    }),
+};
diff --git a/src/content/dependencies/generateTrackReferencingArtworksPage.js b/src/content/dependencies/generateTrackReferencingArtworksPage.js
new file mode 100644
index 00000000..a45144c8
--- /dev/null
+++ b/src/content/dependencies/generateTrackReferencingArtworksPage.js
@@ -0,0 +1,47 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleTags',
+    'generateBackToTrackLink',
+    'generateReferencingArtworksPage',
+    'generateTrackNavLinks',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    page:
+      relation('generateReferencingArtworksPage', track.trackArtworks[0]),
+
+    albumStyleTags:
+      relation('generateAlbumStyleTags', track.album, track),
+
+    navLinks:
+      relation('generateTrackNavLinks', track),
+
+    backToTrackLink:
+      relation('generateBackToTrackLink', track),
+  }),
+
+  data: (track) => ({
+    name:
+      track.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.page.slots({
+      title:
+        language.$('trackPage.title', {
+          track:
+            data.name,
+        }),
+
+      styleTags: relations.albumStyleTags,
+
+      navLinks:
+        html.resolve(
+          relations.navLinks
+            .slot('currentExtra', 'referencing-art')),
+
+      navBottomRowContent: relations.backToTrackLink,
+    }),
+};
diff --git a/src/content/dependencies/generateTrackReleaseBox.js b/src/content/dependencies/generateTrackReleaseBox.js
new file mode 100644
index 00000000..ef02e2b9
--- /dev/null
+++ b/src/content/dependencies/generateTrackReleaseBox.js
@@ -0,0 +1,46 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generatePageSidebarBox',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', track.album.color),
+
+    trackLink:
+      relation('linkTrack', track),
+  }),
+
+  data: (track) => ({
+    albumName:
+      track.album.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumSidebar.releaseBox', boxCapsule =>
+      relations.box.slots({
+        attributes: [
+          {class: 'track-release-sidebar-box'},
+          relations.colorStyle,
+        ],
+
+        content: [
+          html.tag('h1',
+            language.$(boxCapsule, 'title', {
+              album:
+                relations.trackLink.slots({
+                  color: false,
+                  content:
+                    language.sanitize(data.albumName),
+                }),
+            })),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 3bdeaa4f..3298dcc4 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -1,9 +1,7 @@
-import {empty} from '#sugar';
-
 export default {
   contentDependencies: [
     'generateReleaseInfoContributionsLine',
-    'linkExternal',
+    'generateReleaseInfoListenLine',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -11,19 +9,11 @@ export default {
   relations(relation, track) {
     const relations = {};
 
-    relations.artistContributionLinks =
+    relations.artistContributionsLine =
       relation('generateReleaseInfoContributionsLine', track.artistContribs);
 
-    if (track.hasUniqueCoverArt) {
-      relations.coverArtistContributionsLine =
-        relation('generateReleaseInfoContributionsLine', track.coverArtistContribs);
-    }
-
-    if (!empty(track.urls)) {
-      relations.externalLinks =
-        track.urls.map(url =>
-          relation('linkExternal', url));
-    }
+    relations.listenLine =
+      relation('generateReleaseInfoListenLine', track);
 
     return relations;
   },
@@ -37,7 +27,6 @@ export default {
 
     if (
       track.hasUniqueCoverArt &&
-      track.coverArtDate &&
       +track.coverArtDate !== +track.date
     ) {
       data.coverArtDate = track.coverArtDate;
@@ -47,44 +36,34 @@ export default {
   },
 
   generate: (data, relations, {html, language}) =>
-    html.tags([
-      html.tag('p',
-        {[html.onlyIfContent]: true},
-        {[html.joinChildren]: html.tag('br')},
-
-        [
-          relations.artistContributionLinks
-            .slots({stringKey: 'releaseInfo.by'}),
-
-          relations.coverArtistContributionsLine
-            ?.slots({stringKey: 'releaseInfo.coverArtBy'}),
-
-          data.date &&
-            language.$('releaseInfo.released', {
-              date: language.formatDate(data.date),
+    language.encapsulate('releaseInfo', capsule =>
+      html.tags([
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.artistContributionsLine.slots({
+              stringKey: capsule + '.by',
+              featuringStringKey: capsule + '.by.featuring',
+              chronologyKind: 'track',
             }),
 
-          data.coverArtDate &&
-            language.$('releaseInfo.artReleased', {
-              date: language.formatDate(data.coverArtDate),
+            language.$(capsule, 'released', {
+              [language.onlyIfOptions]: ['date'],
+              date: language.formatDate(data.date),
             }),
 
-          data.duration &&
-            language.$('releaseInfo.duration', {
+            language.$(capsule, 'duration', {
+              [language.onlyIfOptions]: ['duration'],
               duration: language.formatDuration(data.duration),
             }),
-        ]),
-
-      html.tag('p',
-        (relations.externalLinks
-          ? language.$('releaseInfo.listenOn', {
-              links:
-                language.formatDisjunctionList(
-                  relations.externalLinks
-                    .map(link => link.slot('context', 'track'))),
-            })
-          : language.$('releaseInfo.listenOn.noLinks', {
-              name: html.tag('i', data.name),
-            }))),
-    ]),
+          ]),
+
+        html.tag('p',
+          relations.listenLine.slots({
+            visibleWithoutLinks: true,
+            context: ['track'],
+          })),
+      ])),
 };
diff --git a/src/content/dependencies/generateTrackSocialEmbed.js b/src/content/dependencies/generateTrackSocialEmbed.js
index 0337fc46..310816f3 100644
--- a/src/content/dependencies/generateTrackSocialEmbed.js
+++ b/src/content/dependencies/generateTrackSocialEmbed.js
@@ -4,7 +4,7 @@ export default {
     'generateTrackSocialEmbedDescription',
   ],
 
-  extraDependencies: ['absoluteTo', 'language', 'urls'],
+  extraDependencies: ['absoluteTo', 'language'],
 
   relations(relation, track) {
     return {
@@ -26,61 +26,39 @@ export default {
     data.trackDirectory = track.directory;
     data.albumDirectory = album.directory;
 
+    data.hasImage = track.hasUniqueCoverArt || album.hasCoverArt;
+
     if (track.hasUniqueCoverArt) {
-      data.imageSource = 'track';
-      data.coverArtFileExtension = track.coverArtFileExtension;
+      data.imagePath = track.trackArtworks[0].path;
     } else if (album.hasCoverArt) {
-      data.imageSource = 'album';
-      data.coverArtFileExtension = album.coverArtFileExtension;
-    } else {
-      data.imageSource = 'none';
+      data.imagePath = album.coverArtworks[0].path;
     }
 
     return data;
   },
 
-  generate(data, relations, {absoluteTo, language, urls}) {
-    return relations.socialEmbed.slots({
-      title:
-        language.$('trackPage.socialEmbed.title', {
-          track: data.trackName,
-        }),
+  generate: (data, relations, {absoluteTo, language}) =>
+    language.encapsulate('trackPage.socialEmbed', embedCapsule =>
+      relations.socialEmbed.slots({
+        title:
+          language.$(embedCapsule, 'title', {
+            track: data.trackName,
+          }),
 
-      headingContent:
-        language.$('trackPage.socialEmbed.heading', {
-          album: data.albumName,
-        }),
+        description:
+          relations.description,
 
-      headingLink:
-        absoluteTo('localized.album', data.albumDirectory),
+        headingContent:
+          language.$(embedCapsule, 'heading', {
+            album: data.albumName,
+          }),
 
-      imagePath:
-        (data.imageSource === 'album'
-          ? '/' +
-            urls
-              .from('shared.root')
-              .to('media.albumCover', data.albumDirectory, data.coverArtFileExtension)
-       : data.imageSource === 'track'
-          ? '/' +
-            urls
-              .from('shared.root')
-              .to('media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension)
-          : null),
-    });
-  },
-};
+        headingLink:
+          absoluteTo('localized.album', data.albumDirectory),
 
-/*
-        socialEmbed: {
-          heading: language.$('trackPage.socialEmbed.heading', {
-            album: track.album.name,
-          }),
-          headingLink: absoluteTo('localized.album', album.directory),
-          title: language.$('trackPage.socialEmbed.title', {
-            track: track.name,
-          }),
-          description: getSocialEmbedDescription({getArtistString, language}),
-          image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}),
-          color: track.color,
-        },
-*/
+        imagePath:
+          (data.hasImage
+            ? data.imagePath
+            : null),
+      })),
+};
diff --git a/src/content/dependencies/generateTrackSocialEmbedDescription.js b/src/content/dependencies/generateTrackSocialEmbedDescription.js
index cf21eadf..4706aa26 100644
--- a/src/content/dependencies/generateTrackSocialEmbedDescription.js
+++ b/src/content/dependencies/generateTrackSocialEmbedDescription.js
@@ -1,38 +1,39 @@
+import {empty} from '#sugar';
+
 export default {
-  generate() {
-  },
-};
+  extraDependencies: ['html', 'language'],
+
+  data: (track) => ({
+    artistNames:
+      track.artistContribs
+        .map(contrib => contrib.artist.name),
+
+    coverArtistNames:
+      track.coverArtistContribs
+        .map(contrib => contrib.artist.name),
+  }),
 
-/*
-  const getSocialEmbedDescription = ({
-    getArtistString: _getArtistString,
-    language,
-  }) => {
-    const hasArtists = !empty(track.artistContribs);
-    const hasCoverArtists = !empty(track.coverArtistContribs);
-    const getArtistString = (contribs) =>
-      _getArtistString(contribs, {
-        // We don't want to put actual HTML tags in social embeds (sadly
-        // they don't get parsed and displayed, generally speaking), so
-        // override the link argument so that artist "links" just show
-        // their names.
-        link: {artist: (artist) => artist.name},
-      });
-    if (!hasArtists && !hasCoverArtists) return '';
-    return language.formatString(
-      'trackPage.socialEmbed.body' +
-        [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists']
-          .filter(Boolean)
-          .join(''),
-      Object.fromEntries(
-        [
-          hasArtists && ['artists', getArtistString(track.artistContribs)],
-          hasCoverArtists && [
-            'coverArtists',
-            getArtistString(track.coverArtistContribs),
-          ],
-        ].filter(Boolean)
-      )
-    );
-  };
-*/
+  generate: (data, {html, language}) =>
+    language.encapsulate('trackPage.socialEmbed.body', baseCapsule =>
+      language.encapsulate(baseCapsule, workingCapsule => {
+        const workingOptions = {};
+
+        if (!empty(data.artistNames)) {
+          workingCapsule += '.withArtists';
+          workingOptions.artists =
+            language.formatConjunctionList(data.artistNames);
+        }
+
+        if (!empty(data.coverArtistNames)) {
+          workingCapsule += '.withCoverArtists';
+          workingOptions.coverArtists =
+            language.formatConjunctionList(data.coverArtistNames);
+        }
+
+        if (workingCapsule === baseCapsule) {
+          return html.blank();
+        } else {
+          return language.$(workingCapsule, workingOptions);
+        }
+      })),
+};
diff --git a/src/content/dependencies/generateUnsafeMunchy.js b/src/content/dependencies/generateUnsafeMunchy.js
new file mode 100644
index 00000000..c11aadc7
--- /dev/null
+++ b/src/content/dependencies/generateUnsafeMunchy.js
@@ -0,0 +1,10 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    contentSource: {type: 'string'},
+  },
+
+  generate: (slots, {html}) =>
+    new html.Tag(null, null, slots.contentSource),
+};
diff --git a/src/content/dependencies/generateWallpaperStyleTag.js b/src/content/dependencies/generateWallpaperStyleTag.js
new file mode 100644
index 00000000..bf094300
--- /dev/null
+++ b/src/content/dependencies/generateWallpaperStyleTag.js
@@ -0,0 +1,80 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateStyleTag'],
+  extraDependencies: ['html', 'to'],
+
+  relations: (relation) => ({
+    styleTag:
+      relation('generateStyleTag'),
+  }),
+
+  slots: {
+    singleWallpaperPath: {
+      validate: v => v.strictArrayOf(v.isString),
+    },
+
+    singleWallpaperStyle: {
+      validate: v => v.isString,
+    },
+
+    wallpaperPartPaths: {
+      validate: v =>
+        v.strictArrayOf(v.optional(v.strictArrayOf(v.isString))),
+    },
+
+    wallpaperPartStyles: {
+      validate: v =>
+        v.strictArrayOf(v.optional(v.isString)),
+    },
+  },
+
+  generate(relations, slots, {html, to}) {
+    const attributes = html.attributes();
+    const rules = [];
+
+    attributes.add('class', 'wallpaper-style');
+
+    if (empty(slots.wallpaperPartPaths)) {
+      attributes.set('data-wallpaper-mode', 'one');
+
+      rules.push({
+        select: 'body::before',
+        declare: [
+          `background-image: url("${to(...slots.singleWallpaperPath)}");`,
+          slots.singleWallpaperStyle,
+        ],
+      });
+    } else {
+      attributes.set('data-wallpaper-mode', 'parts');
+      attributes.set('data-num-wallpaper-parts', slots.wallpaperPartPaths.length);
+
+      stitchArrays({
+        path: slots.wallpaperPartPaths,
+        style: slots.wallpaperPartStyles,
+      }).forEach(({path, style}, index) => {
+          rules.push({
+            select: `.wallpaper-part:nth-child(${index + 1})`,
+            declare: [
+              path && `background-image: url("${to(...path)}");`,
+              style,
+            ],
+          });
+        });
+
+      rules.push({
+        select: 'body::before',
+        declare: [
+          'display: none;',
+        ],
+      });
+    }
+
+    relations.styleTag.setSlots({
+      attributes,
+      rules,
+    });
+
+    return relations.styleTag;
+  },
+};
diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js
deleted file mode 100644
index a19f104c..00000000
--- a/src/content/dependencies/generateWikiHomeAlbumsRow.js
+++ /dev/null
@@ -1,150 +0,0 @@
-import {empty, stitchArrays} from '#sugar';
-import {getNewAdditions, getNewReleases} from '#wiki-data';
-
-export default {
-  contentDependencies: [
-    'generateWikiHomeContentRow',
-    'generateCoverCarousel',
-    'generateCoverGrid',
-    'image',
-    'linkAlbum',
-    'transformContent',
-  ],
-
-  extraDependencies: ['language', 'wikiData'],
-
-  sprawl({albumData}, row) {
-    const sprawl = {};
-
-    switch (row.sourceGroup) {
-      case 'new-releases':
-        sprawl.albums = getNewReleases(row.countAlbumsFromGroup, {albumData});
-        break;
-
-      case 'new-additions':
-        sprawl.albums = getNewAdditions(row.countAlbumsFromGroup, {albumData});
-        break;
-
-      default:
-        sprawl.albums =
-          (row.sourceGroup
-            ? row.sourceGroup.albums
-                .slice()
-                .reverse()
-                .filter(album => album.isListedOnHomepage)
-                .slice(0, row.countAlbumsFromGroup)
-            : []);
-    }
-
-    if (!empty(row.sourceAlbums)) {
-      sprawl.albums.push(...row.sourceAlbums);
-    }
-
-    return sprawl;
-  },
-
-  relations(relation, sprawl, row) {
-    const relations = {};
-
-    relations.contentRow =
-      relation('generateWikiHomeContentRow', row);
-
-    if (row.displayStyle === 'grid') {
-      relations.coverGrid =
-        relation('generateCoverGrid');
-    }
-
-    if (row.displayStyle === 'carousel') {
-      relations.coverCarousel =
-        relation('generateCoverCarousel');
-    }
-
-    relations.links =
-      sprawl.albums
-        .map(album => relation('linkAlbum', album));
-
-    relations.images =
-      sprawl.albums
-        .map(album => relation('image', album.artTags));
-
-    if (row.actionLinks) {
-      relations.actionLinks =
-        row.actionLinks
-          .map(content => relation('transformContent', content));
-    }
-
-    return relations;
-  },
-
-  data(sprawl, row) {
-    const data = {};
-
-    data.displayStyle = row.displayStyle;
-
-    if (row.displayStyle === 'grid') {
-      data.names =
-        sprawl.albums
-          .map(album => album.name);
-    }
-
-    data.paths =
-      sprawl.albums
-        .map(album =>
-          (album.hasCoverArt
-            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-            : null));
-
-    return data;
-  },
-
-  generate(data, relations, {language}) {
-    // Grids and carousels share some slots! Very convenient.
-    const commonSlots = {};
-
-    commonSlots.links =
-      relations.links;
-
-    commonSlots.images =
-      stitchArrays({
-        image: relations.images,
-        path: data.paths,
-        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
-        ? relations.actionLinks
-            .map(contents =>
-              contents
-                .slot('mode', 'single-link')
-                .content)
-        : null);
-
-    let content;
-
-    switch (data.displayStyle) {
-      case 'grid':
-        content =
-          relations.coverGrid.slots({
-            ...commonSlots,
-            names: data.names,
-          });
-        break;
-
-      case 'carousel':
-        content =
-          relations.coverCarousel.slots(commonSlots);
-        break;
-    }
-
-    return relations.contentRow.slots({content});
-  },
-};
diff --git a/src/content/dependencies/generateWikiHomeContentRow.js b/src/content/dependencies/generateWikiHomeContentRow.js
deleted file mode 100644
index 27b12e55..00000000
--- a/src/content/dependencies/generateWikiHomeContentRow.js
+++ /dev/null
@@ -1,28 +0,0 @@
-export default {
-  contentDependencies: ['generateColorStyleAttribute'],
-  extraDependencies: ['html'],
-
-  relations: (relation, row) => ({
-    colorStyle:
-      relation('generateColorStyleAttribute', row.color),
-  }),
-
-  data: (row) =>
-    ({name: row.name}),
-
-  slots: {
-    content: {
-      type: 'html',
-      mutable: false,
-    },
-  },
-
-  generate: (data, relations, slots, {html}) =>
-    html.tag('section', {class: 'row'},
-      relations.colorStyle,
-
-      [
-        html.tag('h2', data.name),
-        slots.content,
-      ]),
-};
diff --git a/src/content/dependencies/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js
deleted file mode 100644
index f592ab99..00000000
--- a/src/content/dependencies/generateWikiHomeNewsBox.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import {empty, stitchArrays} from '#sugar';
-
-export default {
-  contentDependencies: ['linkNewsEntry', 'transformContent'],
-  extraDependencies: ['html', 'language', 'wikiData'],
-
-  sprawl({newsData}) {
-    return {
-      entries: newsData.slice(0, 3),
-    };
-  },
-
-  relations(relation, sprawl) {
-    return {
-      entryContents:
-        sprawl.entries
-          .map(entry => relation('transformContent', entry.contentShort)),
-
-      entryMainLinks:
-        sprawl.entries
-          .map(entry => relation('linkNewsEntry', entry)),
-
-      entryReadMoreLinks:
-        sprawl.entries
-          .map(entry =>
-            entry.contentShort !== entry.content &&
-              relation('linkNewsEntry', entry)),
-    };
-  },
-
-  data(sprawl) {
-    return {
-      entryDates:
-        sprawl.entries
-          .map(entry => entry.date),
-    }
-  },
-
-  generate(data, relations, {html, language}) {
-    if (empty(relations.entryContents)) {
-      return html.blank();
-    }
-
-    return {
-      class: 'latest-news-sidebar-box',
-      content: [
-        html.tag('h1', language.$('homepage.news.title')),
-
-        stitchArrays({
-          date: data.entryDates,
-          content: relations.entryContents,
-          mainLink: relations.entryMainLinks,
-          readMoreLink: relations.entryReadMoreLinks,
-        }).map(({
-            date,
-            content,
-            mainLink,
-            readMoreLink,
-          }, index) =>
-            html.tag('article', {class: 'news-entry'},
-              index === 0 &&
-                {class: 'first-news-entry'},
-
-              [
-                html.tag('h2', [
-                  html.tag('time', language.formatDate(date)),
-                  mainLink,
-                ]),
-
-                content.slot('thumb', 'medium'),
-
-                html.tag('p',
-                  {[html.onlyIfContent]: true},
-                  readMoreLink
-                    ?.slots({
-                      content: language.$('homepage.news.entry.viewRest'),
-                    })),
-              ])),
-      ],
-    };
-  },
-};
diff --git a/src/content/dependencies/generateWikiHomePage.js b/src/content/dependencies/generateWikiHomePage.js
deleted file mode 100644
index 36fcc6f2..00000000
--- a/src/content/dependencies/generateWikiHomePage.js
+++ /dev/null
@@ -1,105 +0,0 @@
-export default {
-  contentDependencies: [
-    'generatePageLayout',
-    'generateWikiHomeAlbumsRow',
-    'generateWikiHomeNewsBox',
-    'transformContent',
-  ],
-
-  extraDependencies: ['wikiData'],
-
-  sprawl({wikiInfo}) {
-    return {
-      wikiName: wikiInfo.name,
-
-      enableNews: wikiInfo.enableNews,
-    };
-  },
-
-  relations(relation, sprawl, homepageLayout) {
-    const relations = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    if (homepageLayout.sidebarContent) {
-      relations.customSidebarContent =
-        relation('transformContent', homepageLayout.sidebarContent);
-    }
-
-    if (sprawl.enableNews) {
-      relations.newsSidebarBox =
-        relation('generateWikiHomeNewsBox');
-    }
-
-    if (homepageLayout.navbarLinks) {
-      relations.customNavLinkContents =
-        homepageLayout.navbarLinks
-          .map(content => relation('transformContent', content));
-    }
-
-    relations.contentRows =
-      homepageLayout.rows.map(row => {
-        switch (row.type) {
-          case 'albums':
-            return relation('generateWikiHomeAlbumsRow', row);
-          default:
-            return null;
-        }
-      });
-
-    return relations;
-  },
-
-  data(sprawl) {
-    return {
-      wikiName: sprawl.wikiName,
-    };
-  },
-
-  generate(data, relations) {
-    return relations.layout.slots({
-      title: data.wikiName,
-      showWikiNameInTitle: false,
-
-      mainClasses: ['top-index'],
-      headingMode: 'static',
-
-      mainContent: [
-        relations.contentRows,
-      ],
-
-      leftSidebarCollapse: false,
-      leftSidebarWide: true,
-
-      leftSidebarMultiple: [
-        (relations.customSidebarContent
-          ? {
-              class: 'custom-content-sidebar-box',
-              content:
-                relations.customSidebarContent
-                  .slot('mode', 'multiline'),
-            }
-          : null),
-
-        relations.newsSidebarBox ?? null,
-      ],
-
-      navLinkStyle: 'index',
-      navLinks: [
-        {auto: 'home', current: true},
-
-        ...(
-          relations.customNavLinkContents
-            ?.map(content => ({
-              html:
-                content.slots({
-                  mode: 'single-link',
-                  preferShortLinkNames: true,
-                }),
-            }))
-          ?? []),
-      ],
-    });
-  },
-};
diff --git a/src/content/dependencies/generateWikiHomepageActionsRow.js b/src/content/dependencies/generateWikiHomepageActionsRow.js
new file mode 100644
index 00000000..9f501099
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageActionsRow.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['generateGridActionLinks', 'transformContent'],
+
+  relations: (relation, row) => ({
+    template:
+      relation('generateGridActionLinks'),
+
+    links:
+      row.actionLinks
+        .map(content => relation('transformContent', content)),
+  }),
+
+  generate: (relations) =>
+    relations.template.slots({
+      actionLinks:
+        relations.links
+          .map(contents =>
+            contents
+              .slot('mode', 'single-link')
+              .content),
+    }),
+};
diff --git a/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js
new file mode 100644
index 00000000..b45bfc19
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['generateCoverCarousel', 'image', 'linkAlbum'],
+
+  relations: (relation, row) => ({
+    coverCarousel:
+      relation('generateCoverCarousel'),
+
+    links:
+      row.albums
+        .map(album => relation('linkAlbum', album)),
+
+    images:
+      row.albums
+        .map(album => relation('image', album.coverArtworks[0])),
+  }),
+
+  generate: (relations) =>
+    relations.coverCarousel.slots({
+      links: relations.links,
+      images: relations.images,
+    }),
+};
diff --git a/src/content/dependencies/generateWikiHomepageAlbumGridRow.js b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js
new file mode 100644
index 00000000..a00136ba
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js
@@ -0,0 +1,78 @@
+import {empty, stitchArrays} from '#sugar';
+import {getNewAdditions, getNewReleases} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateCoverGrid', 'image', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}, row) {
+    const sprawl = {};
+
+    switch (row.sourceGroup) {
+      case 'new-releases':
+        sprawl.albums = getNewReleases(row.countAlbumsFromGroup, {albumData});
+        break;
+
+      case 'new-additions':
+        sprawl.albums = getNewAdditions(row.countAlbumsFromGroup, {albumData});
+        break;
+
+      default:
+        sprawl.albums =
+          (row.sourceGroup
+            ? row.sourceGroup.albums
+                .slice()
+                .reverse()
+                .filter(album => album.isListedOnHomepage)
+                .slice(0, row.countAlbumsFromGroup)
+            : []);
+    }
+
+    if (!empty(row.sourceAlbums)) {
+      sprawl.albums.push(...row.sourceAlbums);
+    }
+
+    return sprawl;
+  },
+
+  relations: (relation, sprawl, _row) => ({
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      sprawl.albums
+        .map(album => relation('linkAlbum', album)),
+
+    images:
+      sprawl.albums
+        .map(album =>
+          relation('image',
+            (album.hasCoverArt
+              ? album.coverArtworks[0]
+              : null))),
+  }),
+
+  data: (sprawl, _row) => ({
+    names:
+      sprawl.albums
+        .map(album => album.name),
+  }),
+
+  generate: (data, relations, {language}) =>
+    relations.coverGrid.slots({
+      links: relations.links,
+      names: data.names,
+
+      images:
+        stitchArrays({
+          image: relations.images,
+          name: data.names,
+        }).map(({image, name}) =>
+            image.slots({
+              missingSourceContent:
+                language.$('misc.coverGrid.noCoverArt', {
+                  album: name,
+                }),
+              })),
+    }),
+};
diff --git a/src/content/dependencies/generateWikiHomepageNewsBox.js b/src/content/dependencies/generateWikiHomepageNewsBox.js
new file mode 100644
index 00000000..83a27695
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageNewsBox.js
@@ -0,0 +1,86 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkNewsEntry',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({newsData}) => ({
+    entries:
+      newsData.slice(0, 3),
+  }),
+
+  relations: (relation, sprawl) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    entryContents:
+      sprawl.entries
+        .map(entry => relation('transformContent', entry.contentShort)),
+
+    entryMainLinks:
+      sprawl.entries
+        .map(entry => relation('linkNewsEntry', entry)),
+
+    entryReadMoreLinks:
+      sprawl.entries
+        .map(entry =>
+          entry.contentShort !== entry.content &&
+            relation('linkNewsEntry', entry)),
+  }),
+
+  data: (sprawl) => ({
+    entryDates:
+      sprawl.entries
+        .map(entry => entry.date),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('homepage.news', boxCapsule =>
+      relations.box.slots({
+        attributes: {class: 'latest-news-sidebar-box'},
+        collapsible: false,
+
+        content: [
+          html.tag('h1',
+            {[html.onlyIfSiblings]: true},
+            language.$(boxCapsule, 'title')),
+
+          stitchArrays({
+            date: data.entryDates,
+            content: relations.entryContents,
+            mainLink: relations.entryMainLinks,
+            readMoreLink: relations.entryReadMoreLinks,
+          }).map(({
+              date,
+              content,
+              mainLink,
+              readMoreLink,
+            }, index) =>
+              language.encapsulate(boxCapsule, 'entry', entryCapsule =>
+                html.tag('article', {class: 'news-entry'},
+                  index === 0 &&
+                    {class: 'first-news-entry'},
+
+                  [
+                    html.tag('h2', [
+                      html.tag('time', language.formatDate(date)),
+                      mainLink,
+                    ]),
+
+                    content.slot('thumb', 'medium'),
+
+                    html.tag('p',
+                      {[html.onlyIfContent]: true},
+                      readMoreLink
+                        ?.slots({
+                          content: language.$(entryCapsule, 'viewRest'),
+                        })),
+                  ]))),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateWikiHomepagePage.js b/src/content/dependencies/generateWikiHomepagePage.js
new file mode 100644
index 00000000..8c09a007
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepagePage.js
@@ -0,0 +1,97 @@
+export default {
+  contentDependencies: [
+    'generatePageLayout',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'generateWikiHomepageNewsBox',
+    'generateWikiHomepageSection',
+    'transformContent',
+  ],
+
+  extraDependencies: ['wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    wikiName:
+      wikiInfo.name,
+
+    enableNews:
+      wikiInfo.enableNews,
+  }),
+
+  relations: (relation, sprawl, homepageLayout) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    sidebar:
+      relation('generatePageSidebar'),
+
+    customSidebarBox:
+      relation('generatePageSidebarBox'),
+
+    customSidebarContent:
+      relation('transformContent', homepageLayout.sidebarContent),
+
+    newsSidebarBox:
+      (sprawl.enableNews
+        ? relation('generateWikiHomepageNewsBox')
+        : null),
+
+    customNavLinkContents:
+      homepageLayout.navbarLinks
+        .map(content => relation('transformContent', content)),
+
+    sections:
+      homepageLayout.sections
+        .map(section => relation('generateWikiHomepageSection', section)),
+  }),
+
+  data: (sprawl) => ({
+    wikiName:
+      sprawl.wikiName,
+  }),
+
+  generate: (data, relations) =>
+    relations.layout.slots({
+      title: data.wikiName,
+      showWikiNameInTitle: false,
+
+      mainClasses: ['top-index'],
+      headingMode: 'static',
+
+      mainContent: [
+        relations.sections,
+      ],
+
+      leftSidebar:
+        relations.sidebar.slots({
+          wide: true,
+
+          boxes: [
+            relations.customSidebarBox.slots({
+              attributes: {class: 'custom-content-sidebar-box'},
+              collapsible: false,
+
+              content:
+                relations.customSidebarContent
+                  .slot('mode', 'multiline'),
+            }),
+
+            relations.newsSidebarBox,
+          ],
+        }),
+
+      navLinkStyle: 'index',
+      navLinks: [
+        {auto: 'home', current: true},
+
+        ...
+          relations.customNavLinkContents.map(content => ({
+            html:
+              content.slots({
+                mode: 'single-link',
+                preferShortLinkNames: true,
+              }),
+          })),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateWikiHomepageSection.js b/src/content/dependencies/generateWikiHomepageSection.js
new file mode 100644
index 00000000..49a474da
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageSection.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateWikiHomepageActionsRow',
+    'generateWikiHomepageAlbumCarouselRow',
+    'generateWikiHomepageAlbumGridRow',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, homepageSection) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute', homepageSection.color),
+
+    rows:
+      homepageSection.rows.map(row =>
+        (row.type === 'actions'
+          ? relation('generateWikiHomepageActionsRow', row)
+       : row.type === 'album carousel'
+          ? relation('generateWikiHomepageAlbumCarouselRow', row)
+       : row.type === 'album grid'
+          ? relation('generateWikiHomepageAlbumGridRow', row)
+          : null)),
+  }),
+
+  data: (homepageSection) => ({
+    name:
+      homepageSection.name,
+  }),
+
+  generate: (data, relations, {html}) =>
+    html.tag('section',
+      relations.colorStyle,
+
+      [
+        html.tag('h2', data.name),
+        relations.rows,
+      ]),
+};
diff --git a/src/content/dependencies/generateWikiWallpaperStyleTag.js b/src/content/dependencies/generateWikiWallpaperStyleTag.js
new file mode 100644
index 00000000..12d27304
--- /dev/null
+++ b/src/content/dependencies/generateWikiWallpaperStyleTag.js
@@ -0,0 +1,38 @@
+export default {
+  contentDependencies: ['generateWallpaperStyleTag'],
+  extraDependencies: ['wikiData'],
+
+  sprawl: ({wikiInfo}) => ({wikiInfo}),
+
+  relations: (relation) => ({
+    wallpaperStyleTag:
+      relation('generateWallpaperStyleTag'),
+  }),
+
+  data: ({wikiInfo}) => ({
+    singleWallpaperPath: [
+      'media.path',
+      'bg.' + wikiInfo.wikiWallpaperFileExtension,
+    ],
+
+    singleWallpaperStyle:
+      wikiInfo.wikiWallpaperStyle,
+
+    wallpaperPartPaths:
+      wikiInfo.wikiWallpaperParts.map(part =>
+        (part.asset
+          ? ['media.path', part.asset]
+          : null)),
+
+    wallpaperPartStyles:
+      wikiInfo.wikiWallpaperParts.map(part => part.style),
+  }),
+
+  generate: (data, relations) =>
+    relations.wallpaperStyleTag.slots({
+      singleWallpaperPath: data.singleWallpaperPath,
+      singleWallpaperStyle: data.singleWallpaperStyle,
+      wallpaperPartPaths: data.wallpaperPartPaths,
+      wallpaperPartStyles: data.wallpaperPartStyles,
+    }),
+};
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index db307a6b..bf47b14f 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -1,12 +1,11 @@
-import {logInfo, logWarn} from '#cli';
+import {logWarn} from '#cli';
 import {empty} from '#sugar';
 
 export default {
   extraDependencies: [
-    'cachebust',
     'checkIfImagePathHasCachedThumbnails',
     'getDimensionsOfImagePath',
-    'getSizeOfImagePath',
+    'getSizeOfMediaFile',
     'getThumbnailEqualOrSmaller',
     'getThumbnailsAvailableForDimensions',
     'html',
@@ -17,72 +16,83 @@ export default {
 
   contentDependencies: ['generateColorStyleAttribute'],
 
-  relations: (relation) => ({
+  relations: (relation, _artwork) => ({
     colorStyle:
       relation('generateColorStyleAttribute'),
   }),
 
-  data(artTags) {
-    const data = {};
-
-    if (artTags) {
-      data.contentWarnings =
-        artTags
-          .filter(tag => tag.isContentWarning)
-          .map(tag => tag.name);
-    } else {
-      data.contentWarnings = null;
-    }
-
-    return data;
-  },
+  data: (artwork) => ({
+    path:
+      (artwork
+        ? artwork.path
+        : null),
+
+    warnings:
+      (artwork
+        ? artwork.artTags
+            .filter(artTag => artTag.isContentWarning)
+            .map(artTag => artTag.name)
+        : null),
+
+    dimensions:
+      (artwork
+        ? artwork.dimensions
+        : null),
+  }),
 
   slots: {
-    src: {type: 'string'},
-
-    path: {
-      validate: v => v.validateArrayItems(v.isString),
-    },
-
     thumb: {type: 'string'},
 
+    reveal: {type: 'boolean', default: true},
+    lazy: {type: 'boolean', default: false},
+    square: {type: 'boolean', default: false},
+
     link: {
       validate: v => v.anyOf(v.isBoolean, v.isString),
       default: false,
     },
 
-    color: {
-      validate: v => v.isColor,
-    },
+    color: {validate: v => v.isColor},
 
-    warnings: {
-      validate: v => v.looseArrayOf(v.isString),
+    // Added to the .image-container.
+    attributes: {
+      type: 'attributes',
+      mutable: false,
     },
 
-    reveal: {type: 'boolean', default: true},
-    lazy: {type: 'boolean', default: false},
-    square: {type: 'boolean', default: false},
-
+    // Added to the <img> itself.
     alt: {type: 'string'},
-    width: {type: 'number'},
-    height: {type: 'number'},
 
-    attributes: {
-      type: 'attributes',
-      mutable: false,
+    // Specify 'src' or 'path', or the path will be used from the artwork.
+    // If none of the above is present, the message in missingSourceContent
+    // will be displayed instead.
+
+    src: {type: 'string'},
+
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
     },
 
     missingSourceContent: {
       type: 'html',
       mutable: false,
     },
+
+    // These will also be used from the artwork if not specified as slots.
+
+    warnings: {
+      validate: v => v.looseArrayOf(v.isString),
+    },
+
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
   },
 
   generate(data, relations, slots, {
-    cachebust,
     checkIfImagePathHasCachedThumbnails,
     getDimensionsOfImagePath,
-    getSizeOfImagePath,
+    getSizeOfMediaFile,
     getThumbnailEqualOrSmaller,
     getThumbnailsAvailableForDimensions,
     html,
@@ -90,15 +100,14 @@ export default {
     missingImagePaths,
     to,
   }) {
-    let originalSrc;
-
-    if (slots.src) {
-      originalSrc = slots.src;
-    } else if (!empty(slots.path)) {
-      originalSrc = to(...slots.path);
-    } else {
-      originalSrc = '';
-    }
+    const originalSrc =
+      (slots.src
+        ? slots.src
+     : slots.path
+        ? to(...slots.path)
+     : data.path
+        ? to(...data.path)
+        : '');
 
     // TODO: This feels janky. It's necessary to deal with static content that
     // includes strings like <img src="media/misc/foo.png">, but processing the
@@ -116,32 +125,31 @@ export default {
     const isMissingImageFile =
       missingImagePaths.includes(mediaSrc);
 
-    if (isMissingImageFile) {
-      logInfo`No image file for ${mediaSrc} - build again for list of missing images.`;
-    }
-
     const willLink =
       !isMissingImageFile &&
       (typeof slots.link === 'string' || slots.link);
 
-    const contentWarnings =
-      slots.warnings ??
-      data.contentWarnings;
+    const warnings = slots.warnings ?? data.warnings;
+    const dimensions = slots.dimensions ?? data.dimensions;
 
     const willReveal =
       slots.reveal &&
       originalSrc &&
       !isMissingImageFile &&
-      !empty(contentWarnings);
-
-    const willSquare = slots.square;
+      !empty(warnings);
 
     const imgAttributes = html.attributes([
       {class: 'image'},
 
       slots.alt && {alt: slots.alt},
-      slots.width && {width: slots.width},
-      slots.height && {height: slots.height},
+
+      dimensions &&
+      dimensions[0] &&
+        {width: dimensions[0]},
+
+      dimensions &&
+      dimensions[1] &&
+        {height: dimensions[1]},
     ]);
 
     const isPlaceholder =
@@ -161,13 +169,13 @@ export default {
     if (willReveal) {
       reveal = [
         html.tag('img', {class: 'reveal-symbol'},
-          {src: to('shared.staticFile', 'warning.svg', cachebust)}),
+          {src: to('staticMisc.path', 'warning.svg')}),
 
         html.tag('br'),
 
         html.tag('span', {class: 'reveal-warnings'},
           language.$('misc.contentWarnings.warnings', {
-            warnings: language.formatUnitList(contentWarnings),
+            warnings: language.formatUnitList(warnings),
           })),
 
         html.tag('br'),
@@ -220,23 +228,19 @@ export default {
           to('thumb.path', mediaSrcJpeg);
       }
 
-      const dimensions = getDimensionsOfImagePath(mediaSrc);
-      const availableThumbs = getThumbnailsAvailableForDimensions(dimensions);
-
-      const [width, height] = dimensions;
-      const originalLength = Math.max(width, height)
+      const originalDimensions = getDimensionsOfImagePath(mediaSrc);
+      const availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions);
 
       const fileSize =
         (willLink && mediaSrc
-          ? getSizeOfImagePath(mediaSrc)
+          ? getSizeOfMediaFile(mediaSrc)
           : null);
 
       imgAttributes.add([
         fileSize &&
           {'data-original-size': fileSize},
 
-        originalLength &&
-          {'data-original-length': originalLength},
+        {'data-dimensions': originalDimensions.join('x')},
 
         !empty(availableThumbs) &&
           {'data-thumbs':
@@ -325,14 +329,14 @@ export default {
 
       wrapped =
         html.tag('div', {class: 'image-outer-area'},
-          willSquare &&
+          slots.square &&
             {class: 'square-content'},
 
           wrapped);
 
       wrapped =
         html.tag('div', {class: 'image-container'},
-          willSquare &&
+          slots.square &&
             {class: 'square'},
 
           typeof slots.link === 'string' &&
diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js
index a5009804..25d7324f 100644
--- a/src/content/dependencies/index.js
+++ b/src/content/dependencies/index.js
@@ -11,6 +11,11 @@ import {colors, logWarn} from '#cli';
 import contentFunction, {ContentFunctionSpecError} from '#content-function';
 import {annotateFunction} from '#sugar';
 
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const codeSrcPath = path.resolve(__dirname, '..');
+const codeRootPath = path.resolve(codeSrcPath, '..');
+
 function cachebust(filePath) {
   if (filePath in cachebust.cache) {
     cachebust.cache[filePath] += 1;
@@ -42,7 +47,9 @@ export function watchContentDependencies({
     close,
   });
 
-  const eslint = new ESLint();
+  const eslint = new ESLint({
+    cwd: codeRootPath,
+  });
 
   const metaPath = fileURLToPath(import.meta.url);
   const metaDirname = path.dirname(metaPath);
diff --git a/src/content/dependencies/linkAdditionalFile.js b/src/content/dependencies/linkAdditionalFile.js
new file mode 100644
index 00000000..a8a940b1
--- /dev/null
+++ b/src/content/dependencies/linkAdditionalFile.js
@@ -0,0 +1,29 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+
+  query: (file, filename) => ({
+    index:
+      file.filenames.indexOf(filename),
+  }),
+
+  relations: (relation, _query, _file, _filename) => ({
+    linkTemplate:
+      relation('linkTemplate'),
+  }),
+
+  data: (query, file, filename) => ({
+    filename,
+
+    // Kinda jank, but eh.
+    path:
+      (query.index >= 0
+        ? file.paths.at(query.index)
+        : null),
+  }),
+
+  generate: (data, relations) =>
+    relations.linkTemplate.slots({
+      path: data.path,
+      content: data.filename,
+    }),
+};
diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js
deleted file mode 100644
index 39e7111e..00000000
--- a/src/content/dependencies/linkAlbumAdditionalFile.js
+++ /dev/null
@@ -1,24 +0,0 @@
-export default {
-  contentDependencies: ['linkTemplate'],
-
-  relations(relation) {
-    return {
-      linkTemplate: relation('linkTemplate'),
-    };
-  },
-
-  data(album, file) {
-    return {
-      albumDirectory: album.directory,
-      file,
-    };
-  },
-
-  generate(data, relations) {
-    return relations.linkTemplate
-      .slots({
-        path: ['media.albumAdditionalFile', data.albumDirectory, data.file],
-        content: data.file,
-      });
-  },
-};
diff --git a/src/content/dependencies/linkAlbumDynamically.js b/src/content/dependencies/linkAlbumDynamically.js
index 3adc64df..45f8c2a9 100644
--- a/src/content/dependencies/linkAlbumDynamically.js
+++ b/src/content/dependencies/linkAlbumDynamically.js
@@ -1,14 +1,61 @@
+import {empty} from '#sugar';
+
 export default {
-  contentDependencies: ['linkAlbumGallery', 'linkAlbum'],
-  extraDependencies: ['pagePath'],
+  contentDependencies: [
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'pagePath'],
 
   relations: (relation, album) => ({
-    galleryLink: relation('linkAlbumGallery', album),
-    infoLink: relation('linkAlbum', album),
+    galleryLink:
+      relation('linkAlbumGallery', album),
+
+    infoLink:
+      relation('linkAlbum', album),
+
+    commentaryLink:
+      relation('linkAlbumCommentary', album),
   }),
 
-  generate: (relations, {pagePath}) =>
-    (pagePath[0] === 'albumGallery'
+  data: (album) => ({
+    albumDirectory:
+      album.directory,
+
+    albumHasCommentary:
+      !empty(album.commentary),
+  }),
+
+  slots: {
+    linkCommentaryPages: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots, {pagePath}) =>
+     // When linking to an album *from* an album commentary page,
+     // if the link is to the *same* album, then the effective target
+     // of the link is really the album's commentary, so scroll to it.
+    (pagePath[0] === 'albumCommentary' &&
+     pagePath[1] === data.albumDirectory &&
+     data.albumHasCommentary
+      ? relations.infoLink.slots({
+          anchor: true,
+          hash: 'album-commentary',
+        })
+
+     // When linking to *another* album from an album commentary page,
+     // the target is (by default) still just the album (its info page).
+     // But this can be customized per-link!
+   : pagePath[0] === 'albumCommentary' &&
+     slots.linkCommentaryPages
+      ? relations.commentaryLink
+
+   : pagePath[0] === 'albumGallery'
       ? relations.galleryLink
+
       : relations.infoLink),
 };
diff --git a/src/content/dependencies/linkAlbumReferencedArtworks.js b/src/content/dependencies/linkAlbumReferencedArtworks.js
new file mode 100644
index 00000000..ba51b5e3
--- /dev/null
+++ b/src/content/dependencies/linkAlbumReferencedArtworks.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumReferencedArtworks', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAlbumReferencingArtworks.js b/src/content/dependencies/linkAlbumReferencingArtworks.js
new file mode 100644
index 00000000..4d5e799d
--- /dev/null
+++ b/src/content/dependencies/linkAlbumReferencingArtworks.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumReferencingArtworks', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAnythingMan.js b/src/content/dependencies/linkAnythingMan.js
new file mode 100644
index 00000000..e408c1b2
--- /dev/null
+++ b/src/content/dependencies/linkAnythingMan.js
@@ -0,0 +1,28 @@
+export default {
+  contentDependencies: [
+    'linkAlbum',
+    'linkArtwork',
+    'linkFlash',
+    'linkTrack',
+  ],
+
+  query: (thing) => ({
+    referenceType: thing.constructor[Symbol.for('Thing.referenceType')],
+  }),
+
+  relations: (relation, query, thing) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbum', thing)
+     : query.referenceType === 'artwork'
+        ? relation('linkArtwork', thing)
+     : query.referenceType === 'flash'
+        ? relation('linkFlash', thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrack', thing)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.link,
+};
diff --git a/src/content/dependencies/linkArtTagDynamically.js b/src/content/dependencies/linkArtTagDynamically.js
new file mode 100644
index 00000000..964258e1
--- /dev/null
+++ b/src/content/dependencies/linkArtTagDynamically.js
@@ -0,0 +1,14 @@
+export default {
+  contentDependencies: ['linkArtTagGallery', 'linkArtTagInfo'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, artTag) => ({
+    galleryLink: relation('linkArtTagGallery', artTag),
+    infoLink: relation('linkArtTagInfo', artTag),
+  }),
+
+  generate: (relations, {pagePath}) =>
+    (pagePath[0] === 'artTagInfo'
+      ? relations.infoLink
+      : relations.galleryLink),
+};
diff --git a/src/content/dependencies/linkArtTagGallery.js b/src/content/dependencies/linkArtTagGallery.js
new file mode 100644
index 00000000..a92b69c1
--- /dev/null
+++ b/src/content/dependencies/linkArtTagGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artTag) =>
+    ({link: relation('linkThing', 'localized.artTagGallery', artTag)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtTag.js b/src/content/dependencies/linkArtTagInfo.js
index 7ddb7786..409cb3c0 100644
--- a/src/content/dependencies/linkArtTag.js
+++ b/src/content/dependencies/linkArtTagInfo.js
@@ -2,7 +2,7 @@ export default {
   contentDependencies: ['linkThing'],
 
   relations: (relation, artTag) =>
-    ({link: relation('linkThing', 'localized.tag', artTag)}),
+    ({link: relation('linkThing', 'localized.artTagInfo', artTag)}),
 
   generate: (relations) => relations.link,
 };
diff --git a/src/content/dependencies/linkArtwork.js b/src/content/dependencies/linkArtwork.js
new file mode 100644
index 00000000..8cd6f359
--- /dev/null
+++ b/src/content/dependencies/linkArtwork.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: ['linkAlbum', 'linkTrack'],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Symbol.for('Thing.referenceType')],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbum', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrack', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.link,
+};
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index cb57aa47..c658d461 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -1,120 +1,85 @@
-import {empty} from '#sugar';
-
 export default {
   contentDependencies: [
+    'generateContributionTooltip',
     'generateTextWithTooltip',
-    'generateTooltip',
     'linkArtist',
-    'linkExternalAsIcon',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  relations(relation, contribution) {
-    const relations = {};
+  relations: (relation, contribution) => ({
+    artistLink:
+      relation('linkArtist', contribution.artist),
 
-    relations.artistLink =
-      relation('linkArtist', contribution.who);
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
 
-    relations.textWithTooltip =
-      relation('generateTextWithTooltip');
+    tooltip:
+      relation('generateContributionTooltip', contribution),
+  }),
 
-    relations.tooltip =
-      relation('generateTooltip');
+  data: (contribution) => ({
+    annotation: contribution.annotation,
+    urls: contribution.artist.urls,
+  }),
 
-    if (!empty(contribution.who.urls)) {
-      relations.artistIcons =
-        contribution.who.urls
-          .map(url => relation('linkExternalAsIcon', url));
-    }
+  slots: {
+    showAnnotation: {type: 'boolean', default: false},
+    showExternalLinks: {type: 'boolean', default: false},
+    showChronology: {type: 'boolean', default: false},
 
-    return relations;
-  },
+    trimAnnotation: {type: 'boolean', default: false},
 
-  data(contribution) {
-    return {
-      what: contribution.what,
-    };
-  },
-
-  slots: {
-    showContribution: {type: 'boolean', default: false},
-    showIcons: {type: 'boolean', default: false},
     preventWrapping: {type: 'boolean', default: true},
+    preventTooltip: {type: 'boolean', default: false},
 
-    iconMode: {
-      validate: v => v.is('inline', 'tooltip'),
-      default: 'inline'
-    },
+    chronologyKind: {type: 'string'},
   },
 
-  generate(data, relations, slots, {html, language}) {
-    const hasContribution = !!(slots.showContribution && data.what);
-    const hasExternalIcons = !!(slots.showIcons && relations.artistIcons);
-
-    const parts = ['misc.artistLink'];
-    const options = {};
-
-    options.artist =
-      (hasExternalIcons && slots.iconMode === 'tooltip'
-        ? relations.textWithTooltip.slots({
-            customInteractionCue: true,
-
-            text:
-              relations.artistLink.slots({
-                attributes: {class: 'text-with-tooltip-interaction-cue'},
-              }),
-
-            tooltip:
-              relations.tooltip.slots({
-                attributes:
-                  {class: ['icons', 'icons-tooltip']},
-
-                contentAttributes:
-                  {[html.joinChildren]: ''},
-
-                content:
-                  relations.artistIcons
-                    .map(icon =>
-                      icon.slots({
-                        context: 'artist',
-                        withText: true,
-                      })),
-              }),
-          })
-        : relations.artistLink);
-
-    if (hasContribution) {
-      parts.push('withContribution');
-      options.contrib = data.what;
-    }
-
-    if (hasExternalIcons && slots.iconMode === 'inline') {
-      parts.push('withExternalLinks');
-      options.links =
-        html.tag('span', {class: ['icons', 'icons-inline']},
-          {[html.noEdgeWhitespace]: true},
-          language.formatUnitList(
-            relations.artistIcons
-              .slice(0, 4)
-              .map(icon => icon.slot('context', 'artist'))));
-    }
-
-    const contributionPart =
-      language.formatString(...parts, options);
-
-    if (!hasContribution && !hasExternalIcons) {
-      return contributionPart;
-    }
-
-    return (
-      html.tag('span', {class: 'contribution'},
-        {[html.noEdgeWhitespace]: true},
-
-        parts.length > 1 &&
-        slots.preventWrapping &&
-          {class: 'nowrap'},
-
-        contributionPart));
-  },
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('span', {class: 'contribution'},
+      {[html.noEdgeWhitespace]: true},
+
+      slots.preventWrapping &&
+        {class: 'nowrap'},
+
+      language.encapsulate('misc.artistLink', workingCapsule => {
+        const workingOptions = {};
+
+        // Filling slots early is necessary to actually give the tooltip
+        // content. Otherwise, the coming-up html.isBlank() always reports
+        // the tooltip as blank!
+        relations.tooltip.setSlots({
+          showExternalLinks: slots.showExternalLinks,
+          showChronology: slots.showChronology,
+          chronologyKind: slots.chronologyKind,
+        });
+
+        workingOptions.artist =
+          (html.isBlank(relations.tooltip) || slots.preventTooltip
+            ? relations.artistLink
+            : relations.textWithTooltip.slots({
+                customInteractionCue: true,
+
+                text:
+                  relations.artistLink.slots({
+                    attributes: {class: 'text-with-tooltip-interaction-cue'},
+                  }),
+
+                tooltip:
+                  relations.tooltip,
+              }));
+
+        const annotation =
+          (slots.trimAnnotation
+            ? data.annotation?.replace(/^edits for wiki(: )?/, '')
+            : data.annotation);
+
+        if (slots.showAnnotation && annotation) {
+          workingCapsule += '.withContribution';
+          workingOptions.contrib = annotation;
+        }
+
+        return language.formatString(workingCapsule, workingOptions);
+      })),
 };
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index ba2dbf21..45c08a08 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -6,12 +6,22 @@ export default {
   data: (url) => ({url}),
 
   slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    suffixNormalContent: {
+      type: 'html',
+      mutable: false,
+    },
+
     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',
+      default: 'platform',
     },
 
     context: {
@@ -19,22 +29,130 @@ export default {
       default: 'generic',
     },
 
+    fromContent: {
+      type: 'boolean',
+      default: false,
+    },
+
+    indicateExternal: {
+      type: 'boolean',
+      default: false,
+    },
+
+    disableBrowserTooltip: {
+      type: 'boolean',
+      default: false,
+    },
+
     tab: {
       validate: v => v.is('default', 'separate'),
       default: 'default',
     },
   },
 
-  generate: (data, slots, {html, language}) =>
-    html.tag('a',
-      {href: data.url},
-      {class: 'nowrap'},
+  generate(data, slots, {html, language}) {
+    let urlIsValid;
+    try {
+      new URL(data.url);
+      urlIsValid = true;
+    } catch {
+      urlIsValid = false;
+    }
+
+    let formattedLink;
+    if (urlIsValid) {
+      formattedLink =
+        language.formatExternalLink(data.url, {
+          style: slots.style,
+          context: slots.context,
+        });
 
-      slots.tab === 'separate' &&
-        {target: '_blank'},
+      // Fall back to platform if nothing matched the desired style.
+      if (html.isBlank(formattedLink) && slots.style !== 'platform') {
+        formattedLink =
+          language.formatExternalLink(data.url, {
+            style: 'platform',
+            context: slots.context,
+          });
+      }
+    } else {
+      formattedLink = null;
+    }
 
-      language.formatExternalLink(data.url, {
-        style: slots.style,
-        context: slots.context,
-      })),
+    const linkAttributes = html.attributes({
+      class: 'external-link',
+    });
+
+    let linkContent;
+    if (urlIsValid) {
+      linkAttributes.set('href', data.url);
+
+      if (html.isBlank(slots.content)) {
+        linkContent = formattedLink;
+      } else {
+        linkContent = slots.content;
+      }
+    } else {
+      if (html.isBlank(slots.content)) {
+        linkContent =
+          html.tag('i',
+            language.$('misc.external.invalidURL.annotation'));
+      } else {
+        linkContent =
+          language.$('misc.external.invalidURL', {
+            link: slots.content,
+            annotation:
+              html.tag('i',
+                language.$('misc.external.invalidURL.annotation')),
+          });
+      }
+    }
+
+    if (slots.fromContent) {
+      linkAttributes.add('class', 'from-content');
+    }
+
+    if (urlIsValid && slots.indicateExternal) {
+      linkAttributes.add('class', 'indicate-external');
+
+      let titleText;
+      if (slots.disableBrowserTooltip) {
+        titleText = null;
+      } else if (slots.tab === 'separate') {
+        if (html.isBlank(slots.content)) {
+          titleText =
+            language.$('misc.external.opensInNewTab.annotation');
+        } else {
+          titleText =
+            language.$('misc.external.opensInNewTab', {
+              link: formattedLink,
+              annotation:
+                language.$('misc.external.opensInNewTab.annotation'),
+            });
+        }
+      } else if (!html.isBlank(slots.content)) {
+        titleText = formattedLink;
+      }
+
+      if (titleText) {
+        linkAttributes.set('title', titleText.toString());
+      }
+    }
+
+    if (urlIsValid && slots.tab === 'separate') {
+      linkAttributes.set('target', '_blank');
+    }
+
+    if (!html.isBlank(slots.suffixNormalContent)) {
+      linkContent =
+        html.tags([
+          linkContent,
+
+          html.tag('span', {class: 'normal-content'},
+            slots.suffixNormalContent),
+        ], {[html.joinChildren]: ''});
+    }
+
+    return html.tag('a', linkAttributes, linkContent);
+  },
 };
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
deleted file mode 100644
index 3eb355a9..00000000
--- a/src/content/dependencies/linkExternalAsIcon.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import {isExternalLinkContext} from '#external-links';
-
-export default {
-  extraDependencies: ['html', 'language', 'to'],
-
-  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, 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', {class: 'icon'},
-      {href: data.url},
-
-      slots.withText &&
-        {class: '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/linkFlashAct.js b/src/content/dependencies/linkFlashAct.js
index fbb819ed..82c23325 100644
--- a/src/content/dependencies/linkFlashAct.js
+++ b/src/content/dependencies/linkFlashAct.js
@@ -1,14 +1,22 @@
 export default {
-  contentDependencies: ['linkThing'],
-  extraDependencies: ['html'],
+  contentDependencies: ['generateUnsafeMunchy', 'linkThing'],
 
-  relations: (relation, flashAct) =>
-    ({link: relation('linkThing', 'localized.flashActGallery', flashAct)}),
+  relations: (relation, flashAct) => ({
+    unsafeMunchy:
+      relation('generateUnsafeMunchy'),
 
-  data: (flashAct) =>
-    ({name: flashAct.name}),
+    link:
+      relation('linkThing', 'localized.flashActGallery', flashAct),
+  }),
 
-  generate: (data, relations, {html}) =>
-    relations.link
-      .slot('content', new html.Tag(null, null, data.name)),
+  data: (flashAct) => ({
+    name: flashAct.name,
+  }),
+
+  generate: (data, relations) =>
+    relations.link.slots({
+      content:
+        relations.unsafeMunchy
+          .slot('contentSource', data.name),
+    }),
 };
diff --git a/src/content/dependencies/linkFlashSide.js b/src/content/dependencies/linkFlashSide.js
new file mode 100644
index 00000000..b77ca65a
--- /dev/null
+++ b/src/content/dependencies/linkFlashSide.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['linkFlashAct'],
+
+  relations: (relation, flashSide) => ({
+    link:
+      relation('linkFlashAct', flashSide.acts[0]),
+  }),
+
+  data: (flashSide) => ({
+    name:
+      flashSide.name,
+
+    color:
+      flashSide.color,
+  }),
+
+  generate: (data, relations) =>
+    relations.link.slots({
+      content: data.name,
+      color: data.color,
+    }),
+};
diff --git a/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js
new file mode 100644
index 00000000..ec856631
--- /dev/null
+++ b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js
@@ -0,0 +1,62 @@
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {chunkArtistTrackContributions} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateColorStyleAttribute'],
+  extraDependencies: ['html', 'language'],
+
+  query(track, artist) {
+    const relevantInfoPageChunkingContributions =
+      track.allReleases
+        .flatMap(release => [
+          ...release.artistContribs,
+          ...release.contributorContribs,
+        ])
+        .filter(c => c.artist === artist);
+
+    sortContributionsChronologically(
+      relevantInfoPageChunkingContributions,
+      sortAlbumsTracksChronologically);
+
+    const contributionChunks =
+      chunkArtistTrackContributions(relevantInfoPageChunkingContributions);
+
+    const trackChunks =
+      contributionChunks
+        .map(chunksInAlbum => chunksInAlbum
+          .map(chunksInTrack => chunksInTrack[0].thing));
+
+    const trackChunksForThisAlbum =
+      trackChunks
+        .filter(tracks => tracks[0].album === track.album);
+
+    const containingChunkIndex =
+      trackChunksForThisAlbum
+        .findIndex(tracks => tracks.includes(track));
+
+    return {containingChunkIndex};
+  },
+
+  relations: (relation, _query, track, _artist) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute', track.album.color),
+  }),
+
+  data: (query, track, _artist) => ({
+    albumName:
+      track.album.name,
+
+    albumDirectory:
+      track.album.directory,
+
+    containingChunkIndex:
+      query.containingChunkIndex,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    html.tag('a',
+      {href: `#tracks-${data.albumDirectory}-${data.containingChunkIndex}`},
+      relations.colorStyle.slot('context', 'primary-only'),
+      language.sanitize(data.albumName)),
+};
diff --git a/src/content/dependencies/linkPathFromMedia.js b/src/content/dependencies/linkPathFromMedia.js
index 34a2b857..d71c69f8 100644
--- a/src/content/dependencies/linkPathFromMedia.js
+++ b/src/content/dependencies/linkPathFromMedia.js
@@ -1,13 +1,64 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: ['linkTemplate'],
 
+  extraDependencies: [
+    'checkIfImagePathHasCachedThumbnails',
+    'getDimensionsOfImagePath',
+    'getSizeOfMediaFile',
+    'getThumbnailsAvailableForDimensions',
+    'html',
+    'to',
+  ],
+
   relations: (relation) =>
     ({link: relation('linkTemplate')}),
 
   data: (path) =>
     ({path}),
 
-  generate: (data, relations) =>
-    relations.link
-      .slot('path', ['media.path', data.path]),
+  generate(data, relations, {
+    checkIfImagePathHasCachedThumbnails,
+    getDimensionsOfImagePath,
+    getSizeOfMediaFile,
+    getThumbnailsAvailableForDimensions,
+    html,
+    to,
+  }) {
+    const attributes = html.attributes();
+
+    if (checkIfImagePathHasCachedThumbnails(data.path)) {
+      const dimensions = getDimensionsOfImagePath(data.path);
+      const availableThumbs = getThumbnailsAvailableForDimensions(dimensions);
+      const fileSize = getSizeOfMediaFile(data.path);
+
+      const embedSrc =
+        to('thumb.path', data.path.replace(/\.(png|jpg)$/, '.tack.jpg'));
+
+      attributes.add([
+        {class: 'image-media-link'},
+
+        {'data-embed-src': embedSrc},
+
+        fileSize &&
+          {'data-original-size': fileSize},
+
+        {'data-dimensions': dimensions.join('x')},
+
+        !empty(availableThumbs) &&
+          {'data-thumbs':
+              availableThumbs
+                .map(([name, size]) => `${name}:${size}`)
+                .join(' ')},
+      ]);
+    }
+
+    relations.link.setSlots({
+      attributes,
+      path: ['media.path', data.path],
+    });
+
+    return relations.link;
+  },
 };
diff --git a/src/content/dependencies/linkReferencedArtworks.js b/src/content/dependencies/linkReferencedArtworks.js
new file mode 100644
index 00000000..c456b808
--- /dev/null
+++ b/src/content/dependencies/linkReferencedArtworks.js
@@ -0,0 +1,24 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'linkAlbumReferencedArtworks',
+    'linkTrackReferencedArtworks',
+  ],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbumReferencedArtworks', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrackReferencedArtworks', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkReferencingArtworks.js b/src/content/dependencies/linkReferencingArtworks.js
new file mode 100644
index 00000000..0cfca4db
--- /dev/null
+++ b/src/content/dependencies/linkReferencingArtworks.js
@@ -0,0 +1,24 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'linkAlbumReferencingArtworks',
+    'linkTrackReferencingArtworks',
+  ],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbumReferencingArtworks', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrackReferencingArtworks', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js
index 63cc82e8..4f853dc4 100644
--- a/src/content/dependencies/linkTemplate.js
+++ b/src/content/dependencies/linkTemplate.js
@@ -26,6 +26,11 @@ export default {
       type: 'html',
       mutable: false,
     },
+
+    suffixNormalContent: {
+      type: 'html',
+      mutable: false,
+    },
   },
 
   generate(slots, {
@@ -61,13 +66,22 @@ export default {
       attributes.set('title', slots.tooltip);
     }
 
-    const content =
+    const mainContent =
       (html.isBlank(slots.content)
         ? language.$('misc.missingLinkContent')
-        : striptags(html.resolve(slots.content, {normalize: 'string'}), {
-            disallowedTags: new Set(['a']),
-          }));
+        : striptags(
+            html.resolve(slots.content, {normalize: 'string'}),
+            {disallowedTags: new Set(['a'])}));
+
+    const allContent =
+      (html.isBlank(slots.suffixNormalContent)
+        ? mainContent
+        : html.tags([
+            mainContent,
+            html.tag('span', {class: 'normal-content'},
+              slots.suffixNormalContent),
+          ], {[html.joinChildren]: ''}));
 
-    return html.tag('a', attributes, content);
+    return html.tag('a', attributes, allContent);
   },
 }
diff --git a/src/content/dependencies/linkTrackDynamically.js b/src/content/dependencies/linkTrackDynamically.js
index 242cd4cb..bbcf1c34 100644
--- a/src/content/dependencies/linkTrackDynamically.js
+++ b/src/content/dependencies/linkTrackDynamically.js
@@ -1,3 +1,5 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: ['linkTrack'],
   extraDependencies: ['pagePath'],
@@ -14,7 +16,7 @@ export default {
       track.album.directory,
 
     trackHasCommentary:
-      !!track.commentary,
+      !empty(track.commentary),
   }),
 
   generate(data, relations, {pagePath}) {
diff --git a/src/content/dependencies/linkTrackReferencedArtworks.js b/src/content/dependencies/linkTrackReferencedArtworks.js
new file mode 100644
index 00000000..b4cb08fe
--- /dev/null
+++ b/src/content/dependencies/linkTrackReferencedArtworks.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, track) =>
+    ({link: relation('linkThing', 'localized.trackReferencedArtworks', track)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkTrackReferencingArtworks.js b/src/content/dependencies/linkTrackReferencingArtworks.js
new file mode 100644
index 00000000..c9c9f4d1
--- /dev/null
+++ b/src/content/dependencies/linkTrackReferencingArtworks.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, track) =>
+    ({link: relation('linkThing', 'localized.trackReferencingArtworks', track)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkWikiHome.js b/src/content/dependencies/linkWikiHomepage.js
index d8d3d0a0..d8d3d0a0 100644
--- a/src/content/dependencies/linkWikiHome.js
+++ b/src/content/dependencies/linkWikiHomepage.js
diff --git a/src/content/dependencies/listAllAdditionalFilesTemplate.js b/src/content/dependencies/listAllAdditionalFilesTemplate.js
index bf48c966..8ec69f1d 100644
--- a/src/content/dependencies/listAllAdditionalFilesTemplate.js
+++ b/src/content/dependencies/listAllAdditionalFilesTemplate.js
@@ -1,209 +1,44 @@
 import {sortChronologically} from '#sort';
-import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
     'generateListingPage',
-    'generateListAllAdditionalFilesChunk',
-    'linkAlbum',
-    'linkTrack',
-    'linkAlbumAdditionalFile',
+    'generateListAllAdditionalFilesAlbumSection',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
+  extraDependencies: ['html', 'wikiData'],
 
   sprawl: ({albumData}) => ({albumData}),
 
-  query(sprawl, spec, property) {
-    const albums =
-      sortChronologically(sprawl.albumData.slice());
+  query: (sprawl, spec, property) => ({
+    spec,
+    property,
 
-    const tracks =
-      albums
-        .map(album => album.tracks.slice());
-
-    // Get additional file objects from albums and their tracks.
-    // There's a possibility that albums and tracks don't both implement
-    // the same additional file fields - in this case, just treat them
-    // as though they do implement those fields, but don't have any
-    // additional files of that type.
-
-    const albumAdditionalFileObjects =
-      albums
-        .map(album => album[property] ?? []);
-
-    const trackAdditionalFileObjects =
-      tracks
-        .map(byAlbum => byAlbum
-          .map(track => track[property] ?? []));
-
-    // Filter out tracks that don't have any additional files.
-
-    stitchArrays({tracks, trackAdditionalFileObjects})
-      .forEach(({tracks, trackAdditionalFileObjects}) => {
-        filterMultipleArrays(tracks, trackAdditionalFileObjects,
-          (track, trackAdditionalFileObjects) => !empty(trackAdditionalFileObjects));
-      });
-
-    // Filter out albums that don't have any tracks,
-    // nor any additional files of their own.
-
-    filterMultipleArrays(albums, albumAdditionalFileObjects, tracks, trackAdditionalFileObjects,
-      (album, albumAdditionalFileObjects, tracks, trackAdditionalFileObjects) =>
-        !empty(albumAdditionalFileObjects) ||
-        !empty(trackAdditionalFileObjects));
-
-    // Map additional file objects into titles and lists of file names.
-
-    const albumAdditionalFileTitles =
-      albumAdditionalFileObjects
-        .map(byAlbum => byAlbum
-          .map(({title}) => title));
-
-    const albumAdditionalFileFiles =
-      albumAdditionalFileObjects
-        .map(byAlbum => byAlbum
-          .map(({files}) => files));
-
-    const trackAdditionalFileTitles =
-      trackAdditionalFileObjects
-        .map(byAlbum => byAlbum
-          .map(byTrack => byTrack
-            .map(({title}) => title)));
-
-    const trackAdditionalFileFiles =
-      trackAdditionalFileObjects
-        .map(byAlbum => byAlbum
-          .map(byTrack => byTrack
-            .map(({files}) => files)));
-
-    return {
-      spec,
-      albums,
-      tracks,
-      albumAdditionalFileTitles,
-      albumAdditionalFileFiles,
-      trackAdditionalFileTitles,
-      trackAdditionalFileFiles,
-    };
-  },
+    albums:
+      sortChronologically(sprawl.albumData.slice()),
+  }),
 
   relations: (relation, query) => ({
     page:
       relation('generateListingPage', query.spec),
 
-    albumLinks:
-      query.albums
-        .map(album => relation('linkAlbum', album)),
-
-    trackLinks:
-      query.tracks
-        .map(byAlbum => byAlbum
-          .map(track => relation('linkTrack', track))),
-
-    albumChunks:
-      query.albums
-        .map(() => relation('generateListAllAdditionalFilesChunk')),
-
-    trackChunks:
-      query.tracks
-        .map(byAlbum => byAlbum
-          .map(() => relation('generateListAllAdditionalFilesChunk'))),
-
-    albumAdditionalFileLinks:
-      stitchArrays({
-        album: query.albums,
-        files: query.albumAdditionalFileFiles,
-      }).map(({album, files: byAlbum}) =>
-          byAlbum.map(files => files
-            .map(file =>
-              relation('linkAlbumAdditionalFile', album, file)))),
-
-    trackAdditionalFileLinks:
-      stitchArrays({
-        album: query.albums,
-        files: query.trackAdditionalFileFiles,
-      }).map(({album, files: byAlbum}) =>
-          byAlbum
-            .map(byTrack => byTrack
-              .map(files => files
-                .map(file => relation('linkAlbumAdditionalFile', album, file))))),
-  }),
-
-  data: (query) => ({
-    albumAdditionalFileTitles: query.albumAdditionalFileTitles,
-    trackAdditionalFileTitles: query.trackAdditionalFileTitles,
-    albumAdditionalFileFiles: query.albumAdditionalFileFiles,
-    trackAdditionalFileFiles: query.trackAdditionalFileFiles,
+    albumSections:
+      query.albums.map(album =>
+        relation('generateListAllAdditionalFilesAlbumSection',
+          album,
+          query.property)),
   }),
 
   slots: {
     stringsKey: {type: 'string'},
   },
 
-  generate: (data, relations, slots, {html, language}) =>
+  generate: (relations, slots) =>
     relations.page.slots({
       type: 'custom',
 
       content:
-        stitchArrays({
-          albumLink: relations.albumLinks,
-          trackLinks: relations.trackLinks,
-          albumChunk: relations.albumChunks,
-          trackChunks: relations.trackChunks,
-          albumAdditionalFileTitles: data.albumAdditionalFileTitles,
-          trackAdditionalFileTitles: data.trackAdditionalFileTitles,
-          albumAdditionalFileLinks: relations.albumAdditionalFileLinks,
-          trackAdditionalFileLinks: relations.trackAdditionalFileLinks,
-          albumAdditionalFileFiles: data.albumAdditionalFileFiles,
-          trackAdditionalFileFiles: data.trackAdditionalFileFiles,
-        }).map(({
-            albumLink,
-            trackLinks,
-            albumChunk,
-            trackChunks,
-            albumAdditionalFileTitles,
-            trackAdditionalFileTitles,
-            albumAdditionalFileLinks,
-            trackAdditionalFileLinks,
-            albumAdditionalFileFiles,
-            trackAdditionalFileFiles,
-          }) => [
-            html.tag('h3', {class: 'content-heading'}, albumLink),
-
-            html.tag('dl', [
-              albumChunk.slots({
-                title:
-                  language.$('listingPage', slots.stringsKey, 'albumFiles'),
-
-                additionalFileTitles: albumAdditionalFileTitles,
-                additionalFileLinks: albumAdditionalFileLinks,
-                additionalFileFiles: albumAdditionalFileFiles,
-
-                stringsKey: slots.stringsKey,
-              }),
-
-              stitchArrays({
-                trackLink: trackLinks,
-                trackChunk: trackChunks,
-                trackAdditionalFileTitles,
-                trackAdditionalFileLinks,
-                trackAdditionalFileFiles,
-              }).map(({
-                  trackLink,
-                  trackChunk,
-                  trackAdditionalFileTitles,
-                  trackAdditionalFileLinks,
-                  trackAdditionalFileFiles,
-                }) =>
-                  trackChunk.slots({
-                    title: trackLink,
-                    additionalFileTitles: trackAdditionalFileTitles,
-                    additionalFileLinks: trackAdditionalFileLinks,
-                    additionalFileFiles: trackAdditionalFileFiles,
-                    stringsKey: slots.stringsKey,
-                  })),
-            ]),
-          ]),
+        relations.albumSections.map(section =>
+          section.slot('stringsKey', slots.stringsKey)),
     }),
 };
diff --git a/src/content/dependencies/listArtTagNetwork.js b/src/content/dependencies/listArtTagNetwork.js
index b3a54747..93dd4ce8 100644
--- a/src/content/dependencies/listArtTagNetwork.js
+++ b/src/content/dependencies/listArtTagNetwork.js
@@ -1 +1,366 @@
-export default {generate() {}};
+import {sortAlphabetically} from '#sort';
+import {empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtTagInfo'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({artTagData}) {
+    return {artTagData};
+  },
+
+  query(sprawl, spec) {
+    const artTags =
+      sprawl.artTagData.filter(artTag => !artTag.isContentWarning);
+
+    const rootArtTags =
+      artTags
+        .filter(artTag => !empty(artTag.directDescendantArtTags))
+        .filter(artTag =>
+          empty(artTag.directAncestorArtTags) ||
+          artTag.directAncestorArtTags.length >= 2);
+
+    sortAlphabetically(rootArtTags);
+
+    rootArtTags.sort(
+      ({directAncestorArtTags: ancestorsA},
+       {directAncestorArtTags: ancestorsB}) =>
+        ancestorsA.length - ancestorsB.length);
+
+    const getStats = (artTag) => ({
+      directUses:
+        artTag.directlyFeaturedInArtworks.length,
+
+      // Not currently displayed
+      directAndIndirectUses:
+        unique([
+          ...artTag.indirectlyFeaturedInArtworks,
+          ...artTag.directlyFeaturedInArtworks,
+        ]).length,
+
+      totalUses:
+        [
+          ...artTag.directlyFeaturedInArtworks,
+          ...
+            artTag.allDescendantArtTags
+              .flatMap(artTag => artTag.directlyFeaturedInArtworks),
+        ].length,
+
+      descendants:
+        artTag.allDescendantArtTags.length,
+
+      leaves:
+        (empty(artTag.directDescendantArtTags)
+          ? null
+          : artTag.allDescendantArtTags
+              .filter(artTag => empty(artTag.directDescendantArtTags))
+              .length),
+    });
+
+    const recursive = (artTag, depth) => {
+      const descendantNodes =
+        (empty(artTag.directDescendantArtTags)
+          ? null
+       : depth > 0 && artTag.directAncestorArtTags.length >= 2
+          ? null
+          : artTag.directDescendantArtTags
+              .map(artTag => recursive(artTag, depth + 1)));
+
+      descendantNodes?.sort(
+        ({descendantNodes: descendantNodesA},
+         {descendantNodes: descendantNodesB}) =>
+            (descendantNodesA ? 1 : 0)
+          - (descendantNodesB ? 1 : 0));
+
+      const recursiveGetRootAncestor = ancestorArtTag =>
+        (ancestorArtTag.directAncestorArtTags.length === 1
+          ? recursiveGetRootAncestor(ancestorArtTag.directAncestorArtTags[0])
+          : ancestorArtTag);
+
+      const ancestorRootArtTags =
+        (depth === 0 && !empty(artTag.directAncestorArtTags)
+          ? unique(artTag.directAncestorArtTags.map(recursiveGetRootAncestor))
+          : null);
+
+      const stats = getStats(artTag);
+
+      return {
+        artTag,
+        stats,
+        descendantNodes,
+        ancestorRootArtTags,
+      };
+    };
+
+    const uppermostRootTags =
+      artTags
+        .filter(artTag => !empty(artTag.directDescendantArtTags))
+        .filter(artTag => empty(artTag.directAncestorArtTags));
+
+    const orphanArtTags =
+      artTags
+        .filter(artTag => empty(artTag.directDescendantArtTags))
+        .filter(artTag => empty(artTag.directAncestorArtTags));
+
+    return {
+      spec,
+
+      rootNodes:
+        rootArtTags
+          .map(artTag => recursive(artTag, 0)),
+
+      uppermostRootTags,
+      orphanArtTags,
+    };
+  },
+
+  relations(relation, query) {
+    const recursive = queryNode => ({
+      artTagLink:
+        relation('linkArtTagInfo', queryNode.artTag),
+
+      ancestorTagLinks:
+        queryNode.ancestorRootArtTags
+          ?.map(artTag => relation('linkArtTagInfo', artTag))
+          ?? null,
+
+      descendantNodes:
+        queryNode.descendantNodes
+          ?.map(recursive)
+          ?? null,
+    });
+
+    return {
+      page:
+        relation('generateListingPage', query.spec),
+
+      rootNodes:
+        query.rootNodes.map(recursive),
+
+      uppermostRootTagLinks:
+        query.uppermostRootTags
+          .map(artTag => relation('linkArtTagInfo', artTag)),
+
+      orphanArtTagLinks:
+        query.orphanArtTags
+          .map(artTag => relation('linkArtTagInfo', artTag)),
+    };
+  },
+
+  data(query) {
+    const rootArtTags = query.rootNodes.map(({artTag}) => artTag);
+
+    const recursive = queryNode => ({
+      directory:
+        queryNode.artTag.directory,
+
+      directUses:
+        queryNode.stats.directUses,
+
+      totalUses:
+        queryNode.stats.totalUses,
+
+      descendants:
+        queryNode.stats.descendants,
+
+      leaves:
+        queryNode.stats.leaves,
+
+      representsRoot:
+        rootArtTags.includes(queryNode.artTag),
+
+      ancestorTagDirectories:
+        queryNode.ancestorRootArtTags
+          ?.map(artTag => artTag.directory)
+          ?? null,
+
+      descendantNodes:
+        queryNode.descendantNodes
+          ?.map(recursive)
+          ?? null,
+    });
+
+    return {
+      rootNodes:
+        query.rootNodes.map(recursive),
+
+      uppermostRootTagDirectories:
+        query.uppermostRootTags
+          .map(artTag => artTag.directory),
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    const prefix = `listingPage.listArtTags.network`;
+
+    const wrapTagWithJumpTo = (dataNode, relationsNode, depth) =>
+      (depth === 0
+        ? relationsNode.artTagLink
+     : dataNode.representsRoot
+        ? language.$(prefix, 'tag.jumpToRoot', {
+            tag:
+              relationsNode.artTagLink.slots({
+                anchor: true,
+                hash: dataNode.directory,
+              }),
+          })
+        : relationsNode.artTagLink);
+
+    const wrapTagWithStats = (dataNode, relationsNode, depth) => [
+      html.tag('span', {class: 'network-tag'},
+        language.$(prefix, 'tag', {
+          tag:
+            wrapTagWithJumpTo(dataNode, relationsNode, depth),
+        })),
+
+      html.tag('span', {class: 'network-tag'},
+        {class: 'with-stat'},
+        {style: 'display: none'},
+
+        language.$(prefix, 'tag.withStat', {
+          tag:
+            wrapTagWithJumpTo(dataNode, relationsNode, depth),
+
+          stat:
+            html.tag('span', {class: 'network-tag-stat'},
+              language.$(prefix, 'tag.withStat.stat', {
+                stat: [
+                  html.tag('span', {class: 'network-tag-direct-uses-stat'},
+                    dataNode.directUses.toString()),
+
+                  html.tag('span', {class: 'network-tag-total-uses-stat'},
+                    dataNode.totalUses.toString()),
+
+                  html.tag('span', {class: 'network-tag-descendants-stat'},
+                    dataNode.descendants.toString()),
+
+                  html.tag('span', {class: 'network-tag-leaves-stat'},
+                    (dataNode.leaves === null
+                      ? language.$(prefix, 'tag.withStat.notApplicable')
+                      : dataNode.leaves.toString())),
+                ],
+              })),
+        }))
+    ];
+
+    const recursive = (dataNode, relationsNode, depth) => [
+      html.tag('dt',
+        {
+          id: depth === 0 && dataNode.directory,
+          class: depth % 2 === 0 ? 'even' : 'odd',
+        },
+
+        (depth === 0
+          ? (relationsNode.ancestorTagLinks
+              ? language.$(prefix, 'root.withAncestors', {
+                  tag:
+                    wrapTagWithStats(dataNode, relationsNode, depth),
+
+                  ancestors:
+                    language.formatUnitList(
+                      stitchArrays({
+                        link: relationsNode.ancestorTagLinks,
+                        directory: dataNode.ancestorTagDirectories,
+                      }).map(({link, directory}) =>
+                          link.slots({
+                            anchor: true,
+                            hash: directory,
+                          }))),
+                })
+              : language.$(prefix, 'root.jumpToTop', {
+                  tag:
+                    wrapTagWithStats(dataNode, relationsNode, depth),
+
+                  link:
+                    html.tag('a', {href: '#top'},
+                      language.$(prefix, 'root.jumpToTop.link')),
+                }))
+          : wrapTagWithStats(dataNode, relationsNode, depth))),
+
+      dataNode.descendantNodes &&
+      relationsNode.descendantNodes &&
+        html.tag('dd',
+          {class: depth % 2 === 0 ? 'even' : 'odd'},
+          html.tag('dl',
+            stitchArrays({
+              dataNode: dataNode.descendantNodes,
+              relationsNode: relationsNode.descendantNodes,
+            }).map(({dataNode, relationsNode}) =>
+                recursive(dataNode, relationsNode, depth + 1)))),
+    ];
+
+    return relations.page.slots({
+      type: 'custom',
+
+      content: [
+        html.tag('p', {id: 'network-stat-line'},
+          language.$(prefix, 'statLine', {
+            stat: [
+              html.tag('a', {id: 'network-stat-none'},
+                {href: '#'},
+                language.$(prefix, 'statLine.none')),
+
+              html.tag('a', {id: 'network-stat-total-uses'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.totalUses')),
+
+              html.tag('a', {id: 'network-stat-direct-uses'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.directUses')),
+
+              html.tag('a', {id: 'network-stat-descendants'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.descendants')),
+
+              html.tag('a', {id: 'network-stat-leaves'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.leaves')),
+            ],
+          })),
+
+        html.tag('dl', {id: 'network-top-dl'}, [
+          html.tag('dt', {id: 'top'},
+            language.$(prefix, 'jumpToRoot.title')),
+
+          html.tag('dd',
+            html.tag('ul',
+              stitchArrays({
+                link: relations.uppermostRootTagLinks,
+                directory: data.uppermostRootTagDirectories,
+              }).map(({link, directory}) =>
+                  html.tag('li',
+                    language.$(prefix, 'jumpToRoot.item', {
+                      tag:
+                        link.slots({
+                          anchor: true,
+                          hash: directory,
+                        }),
+                    }))))),
+
+          stitchArrays({
+            dataNode: data.rootNodes,
+            relationsNode: relations.rootNodes,
+          }).map(({dataNode, relationsNode}) =>
+              recursive(dataNode, relationsNode, 0)),
+
+          !empty(relations.orphanArtTagLinks) && [
+            html.tag('dt',
+              language.$(prefix, 'orphanArtTags.title')),
+
+            html.tag('dd',
+              html.tag('ul',
+                relations.orphanArtTagLinks.map(orphanArtTagLink =>
+                  html.tag('li',
+                    language.$(prefix, 'orphanArtTags.item', {
+                      tag: orphanArtTagLink,
+                    }))))),
+          ],
+        ]),
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/listTagsByName.js b/src/content/dependencies/listArtTagsByName.js
index d7022a55..1df9dfff 100644
--- a/src/content/dependencies/listTagsByName.js
+++ b/src/content/dependencies/listArtTagsByName.js
@@ -1,8 +1,8 @@
 import {sortAlphabetically} from '#sort';
-import {stitchArrays} from '#sugar';
+import {stitchArrays, unique} from '#sugar';
 
 export default {
-  contentDependencies: ['generateListingPage', 'linkArtTag'],
+  contentDependencies: ['generateListingPage', 'linkArtTagGallery'],
   extraDependencies: ['language', 'wikiData'],
 
   sprawl({artTagData}) {
@@ -16,7 +16,7 @@ export default {
       artTags:
         sortAlphabetically(
           artTagData
-            .filter(tag => !tag.isContentWarning)),
+            .filter(artTag => !artTag.isContentWarning)),
     };
   },
 
@@ -26,15 +26,18 @@ export default {
 
       artTagLinks:
         query.artTags
-          .map(tag => relation('linkArtTag', tag)),
+          .map(artTag => relation('linkArtTagGallery', artTag)),
     };
   },
 
   data(query) {
     return {
       counts:
-        query.artTags
-          .map(tag => tag.taggedInThings.length),
+        query.artTags.map(artTag =>
+          unique([
+            ...artTag.indirectlyFeaturedInArtworks,
+            ...artTag.directlyFeaturedInArtworks,
+          ]).length),
     };
   },
 
diff --git a/src/content/dependencies/listArtTagsByUses.js b/src/content/dependencies/listArtTagsByUses.js
new file mode 100644
index 00000000..eca7f1c6
--- /dev/null
+++ b/src/content/dependencies/listArtTagsByUses.js
@@ -0,0 +1,54 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtTagGallery'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl: ({artTagData}) =>
+    ({artTagData}),
+
+  query({artTagData}, spec) {
+    const artTags =
+      sortAlphabetically(
+        artTagData
+          .filter(artTag => !artTag.isContentWarning));
+
+    const counts =
+      artTags.map(artTag =>
+        unique([
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
+        ]).length);
+
+    filterByCount(artTags, counts);
+    sortByCount(artTags, counts, {greatestFirst: true});
+
+    return {spec, artTags, counts};
+  },
+
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    artTagLinks:
+      query.artTags
+        .map(artTag => relation('linkArtTagGallery', artTag)),
+  }),
+
+  data: (query) =>
+    ({counts: query.counts}),
+
+  generate: (data, relations, {language}) =>
+    relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artTagLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            tag: link,
+            timesUsed: language.countTimesUsed(count, {unit: true}),
+          })),
+    }),
+};
diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js
index 0af586cd..99f19764 100644
--- a/src/content/dependencies/listArtistsByContributions.js
+++ b/src/content/dependencies/listArtistsByContributions.js
@@ -1,5 +1,5 @@
 import {sortAlphabetically, sortByCount} from '#sort';
-import {empty, filterByCount, filterMultipleArrays, stitchArrays, unique}
+import {empty, filterByCount, filterMultipleArrays, stitchArrays}
   from '#sugar';
 
 export default {
@@ -34,30 +34,46 @@ export default {
       query[countsKey] = counts;
     };
 
+    const countContributions = (artist, keys) => {
+      const contribs =
+        keys
+          .flatMap(key => artist[key])
+          .filter(contrib => contrib.countInContributionTotals);
+
+      const things =
+        new Set(contribs.map(contrib => contrib.thing));
+
+      return things.size;
+    };
+
     queryContributionInfo(
       'artistsByTrackContributions',
       'countsByTrackContributions',
       artist =>
-        unique([
-          ...artist.tracksAsContributor,
-          ...artist.tracksAsArtist,
-        ]).length);
+        countContributions(artist, [
+          'trackArtistContributions',
+          'trackContributorContributions',
+        ]));
 
     queryContributionInfo(
       'artistsByArtworkContributions',
       'countsByArtworkContributions',
       artist =>
-        artist.tracksAsCoverArtist.length +
-        artist.albumsAsCoverArtist.length +
-        artist.albumsAsWallpaperArtist.length +
-        artist.albumsAsBannerArtist.length);
+        countContributions(artist, [
+          'albumCoverArtistContributions',
+          'albumWallpaperArtistContributions',
+          'albumBannerArtistContributions',
+          'trackCoverArtistContributions',
+        ]));
 
     if (sprawl.enableFlashesAndGames) {
       queryContributionInfo(
         'artistsByFlashContributions',
         'countsByFlashContributions',
         artist =>
-          artist.flashesAsContributor.length);
+          countContributions(artist, [
+            'flashContributorContributions',
+          ]));
     }
 
     return query;
diff --git a/src/content/dependencies/listArtistsByDuration.js b/src/content/dependencies/listArtistsByDuration.js
index f677d82c..6b2a18a0 100644
--- a/src/content/dependencies/listArtistsByDuration.js
+++ b/src/content/dependencies/listArtistsByDuration.js
@@ -1,6 +1,5 @@
 import {sortAlphabetically, sortByCount} from '#sort';
 import {filterByCount, stitchArrays} from '#sugar';
-import {getTotalDuration} from '#wiki-data';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist'],
@@ -16,11 +15,7 @@ export default {
         artistData.filter(artist => !artist.isAlias));
 
     const durations =
-      artists.map(artist =>
-        getTotalDuration([
-          ...(artist.tracksAsArtist ?? []),
-          ...(artist.tracksAsContributor ?? []),
-        ], {originalReleasesOnly: true}));
+      artists.map(artist => artist.totalDuration);
 
     filterByCount(artists, durations);
     sortByCount(artists, durations, {greatestFirst: true});
diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js
index 30884d24..17096cfc 100644
--- a/src/content/dependencies/listArtistsByGroup.js
+++ b/src/content/dependencies/listArtistsByGroup.js
@@ -1,6 +1,13 @@
 import {sortAlphabetically} from '#sort';
-import {empty, filterMultipleArrays, stitchArrays, unique} from '#sugar';
-import {getArtistNumContributions} from '#wiki-data';
+
+import {
+  empty,
+  filterByCount,
+  filterMultipleArrays,
+  stitchArrays,
+  transposeArrays,
+  unique,
+} from '#sugar';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
@@ -15,29 +22,74 @@ export default {
       sortAlphabetically(
         sprawl.artistData.filter(artist => !artist.isAlias));
 
-    const groups =
+    const interestingGroups =
       sprawl.wikiInfo.divideTrackListsByGroups;
 
-    if (empty(groups)) {
-      return {spec, artists};
+    if (empty(interestingGroups)) {
+      return {spec};
     }
 
-    const artistGroups =
+    // We don't actually care about *which* things belong to each group, only
+    // how many belong to each group. So we'll just compute a list of all the
+    // (interesting) groups that each of each artists' things belongs to.
+    const artistThingGroups =
       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};
+        ([
+          (unique(
+            ([
+              artist.albumArtistContributions
+                .map(contrib => contrib.thing),
+              artist.albumCoverArtistContributions
+                .map(contrib => contrib.thing.thing),
+              artist.albumWallpaperArtistContributions
+                .map(contrib => contrib.thing.thing),
+              artist.albumBannerArtistContributions
+                .map(contrib => contrib.thing.thing),
+            ]).flat()
+          )).map(album => album.groups),
+          (unique(
+            ([
+              artist.trackArtistContributions
+                .map(contrib => contrib.thing),
+              artist.trackContributorContributions
+                .map(contrib => contrib.thing),
+              artist.trackCoverArtistContributions
+                .map(contrib => contrib.thing.thing),
+            ]).flat()
+          )).map(track => track.album.groups),
+        ]).flat()
+          .map(groups => groups
+            .filter(group => interestingGroups.includes(group))));
+
+    const [artistsByGroup, countsByGroup] =
+      transposeArrays(interestingGroups.map(group => {
+        const counts =
+          artistThingGroups
+            .map(thingGroups => thingGroups
+              .filter(thingGroups => thingGroups.includes(group))
+              .length);
+
+        const filteredArtists = artists.slice();
+
+        filterByCount(filteredArtists, counts);
+
+        return [filteredArtists, counts];
+      }));
+
+    const groups = interestingGroups;
+
+    filterMultipleArrays(
+      groups,
+      artistsByGroup,
+      countsByGroup,
+      (_group, artists, _counts) => !empty(artists));
+
+    return {
+      spec,
+      groups,
+      artistsByGroup,
+      countsByGroup,
+    };
   },
 
   relations(relation, query) {
@@ -46,12 +98,6 @@ export default {
     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
@@ -69,65 +115,43 @@ export default {
   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)));
+        query.countsByGroup;
     }
 
     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}),
-                })),
-          })));
-  },
+  generate: (data, relations, {language}) =>
+    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}),
+              }))),
+    }),
 };
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
index 0f709577..2a8d1b4c 100644
--- a/src/content/dependencies/listArtistsByLatestContribution.js
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -83,7 +83,8 @@ export default {
       });
     };
 
-    const getArtists = (thing, key) => thing[key].map(({who}) => who);
+    const getArtists = (thing, key) =>
+      thing[key].map(({artist}) => artist);
 
     const albumsLatestFirst = sortAlbumsTracksChronologically(sprawl.albumData.slice());
     const tracksLatestFirst = sortAlbumsTracksChronologically(sprawl.trackData.slice());
@@ -97,13 +98,16 @@ export default {
       ])) {
         // Might combine later with 'track' of the same album and date.
         considerDate(artist, album.coverArtDate ?? album.date, album, 'artwork');
+        // '?? album.date' is kept here because wallpaper and banner may
+        // technically be present for an album w/o cover art, therefore
+        // also no cover art date.
       }
     }
 
     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');
+        considerDate(artist, track.coverArtDate, track.album, 'artwork');
       }
 
       for (const artist of new Set([
diff --git a/src/content/dependencies/listGroupsByDuration.js b/src/content/dependencies/listGroupsByDuration.js
index da2f26db..c79e1bc4 100644
--- a/src/content/dependencies/listGroupsByDuration.js
+++ b/src/content/dependencies/listGroupsByDuration.js
@@ -16,7 +16,7 @@ export default {
       groups.map(group =>
         getTotalDuration(
           group.albums.flatMap(album => album.tracks),
-          {originalReleasesOnly: true}));
+          {mainReleasesOnly: true}));
 
     filterByCount(groups, durations);
     sortByCount(groups, durations, {greatestFirst: true});
diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js
index ab2eca93..79bba441 100644
--- a/src/content/dependencies/listRandomPageLinks.js
+++ b/src/content/dependencies/listRandomPageLinks.js
@@ -74,20 +74,22 @@ export default {
   },
 
   generate(data, relations, {html, language}) {
+    const capsule = language.encapsulate('listingPage.other.randomPages');
+
     const miscellaneousChunkRows = [
-      {
+      language.encapsulate(capsule, 'chunk.item.randomArtist', capsule => ({
         stringsKey: 'randomArtist',
 
         mainLink:
           html.tag('a',
             {href: '#', 'data-random': 'artist'},
-            language.$('listingPage.other.randomPages.chunk.item.randomArtist.mainLink')),
+            language.$(capsule, 'mainLink')),
 
         atLeastTwoContributions:
           html.tag('a',
             {href: '#', 'data-random': 'artist-more-than-one-contrib'},
-            language.$('listingPage.other.randomPages.chunk.item.randomArtist.atLeastTwoContributions')),
-      },
+            language.$(capsule, 'atLeastTwoContributions')),
+      })),
 
       {stringsKey: 'randomAlbumWholeSite'},
       {stringsKey: 'randomTrackWholeSite'},
@@ -104,24 +106,25 @@ export default {
 
       content: [
         html.tag('p',
-          language.$('listingPage.other.randomPages.chooseLinkLine', {
-            fromPart:
-              (relations.groupLinks
-                ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups')
-                : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups')),
+          language.encapsulate(capsule, 'chooseLinkLine', capsule =>
+            language.$(capsule, {
+              fromPart:
+                (relations.groupLinks
+                  ? language.$(capsule, 'fromPart.dividedByGroups')
+                  : language.$(capsule, 'fromPart.notDividedByGroups')),
 
-            browserSupportPart:
-              language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'),
-          })),
+              browserSupportPart:
+                language.$(capsule, 'browserSupportPart'),
+            }))),
 
         html.tag('p', {id: 'data-loading-line'},
-          language.$('listingPage.other.randomPages.dataLoadingLine')),
+          language.$(capsule, 'dataLoadingLine')),
 
         html.tag('p', {id: 'data-loaded-line'},
-          language.$('listingPage.other.randomPages.dataLoadedLine')),
+          language.$(capsule, 'dataLoadedLine')),
 
         html.tag('p', {id: 'data-error-line'},
-          language.$('listingPage.other.randomPages.dataErrorLine')),
+          language.$(capsule, 'dataErrorLine')),
       ],
 
       showSkipToSection: true,
@@ -148,17 +151,18 @@ export default {
 
         ...
           (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')),
-              }))
+            ? relations.groupLinks.map(() =>
+                language.encapsulate(capsule, 'chunk.title.fromGroup.accent', capsule => ({
+                  randomAlbum:
+                    html.tag('a',
+                      {href: '#', 'data-random': 'album-in-group-dl'},
+                      language.$(capsule, 'randomAlbum')),
+
+                  randomTrack:
+                    html.tag('a',
+                      {href: '#', 'data-random': 'track-in-group-dl'},
+                      language.$(capsule, 'randomTrack')),
+                })))
             : [null]),
       ],
 
diff --git a/src/content/dependencies/listTagsByUses.js b/src/content/dependencies/listTagsByUses.js
deleted file mode 100644
index 00c700a5..00000000
--- a/src/content/dependencies/listTagsByUses.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import {sortAlphabetically, sortByCount} from '#sort';
-import {filterByCount, stitchArrays} from '#sugar';
-
-export default {
-  contentDependencies: ['generateListingPage', 'linkArtTag'],
-  extraDependencies: ['language', 'wikiData'],
-
-  sprawl({artTagData}) {
-    return {artTagData};
-  },
-
-  query({artTagData}, spec) {
-    const artTags =
-      sortAlphabetically(
-        artTagData
-          .filter(tag => !tag.isContentWarning));
-
-    const counts =
-      artTags
-        .map(tag => tag.taggedInThings.length);
-
-    filterByCount(artTags, counts);
-    sortByCount(artTags, counts, {greatestFirst: true});
-
-    return {spec, artTags, counts};
-  },
-
-  relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
-
-      artTagLinks:
-        query.artTags
-          .map(tag => relation('linkArtTag', tag)),
-    };
-  },
-
-  data(query) {
-    return {
-      counts:
-        query.artTags
-          .map(tag => tag.taggedInThings.length),
-    };
-  },
-
-  generate(data, relations, {language}) {
-    return relations.page.slots({
-      type: 'rows',
-      rows:
-        stitchArrays({
-          link: relations.artTagLinks,
-          count: data.counts,
-        }).map(({link, count}) => ({
-            tag: link,
-            timesUsed: language.countTimesUsed(count, {unit: true}),
-          })),
-    });
-  },
-};
diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js
index 01ce4e2d..dcfaeaf0 100644
--- a/src/content/dependencies/listTracksByDate.js
+++ b/src/content/dependencies/listTracksByDate.js
@@ -5,48 +5,54 @@ export default {
   contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
   extraDependencies: ['language', 'wikiData'],
 
-  sprawl({trackData}) {
-    return {trackData};
-  },
+  sprawl: ({trackData}) => ({trackData}),
 
   query({trackData}, spec) {
-    return {
-      spec,
+    const query = {spec};
+
+    query.tracks =
+      sortAlbumsTracksChronologically(
+        trackData.filter(track => track.date));
+
+    query.chunks =
+      chunkByProperties(query.tracks, ['album', 'date']);
 
-      chunks:
-        chunkByProperties(
-          sortAlbumsTracksChronologically(trackData.slice()),
-          ['album', 'date']),
-    };
+    return query;
   },
 
-  relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
 
-      albumLinks:
-        query.chunks
-          .map(({album}) => relation('linkAlbum', album)),
+    albumLinks:
+      query.chunks
+        .map(({album}) => relation('linkAlbum', album)),
 
-      trackLinks:
-        query.chunks
-          .map(({chunk}) => chunk
-            .map(track => relation('linkTrack', track))),
-    };
-  },
+    trackLinks:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(track => relation('linkTrack', track))),
+  }),
 
-  data(query) {
-    return {
-      dates:
-        query.chunks
-          .map(({date}) => date),
+  data: (query) => ({
+    dates:
+      query.chunks
+        .map(({date}) => date),
 
-      rereleases:
-        query.chunks.map(({chunk}) =>
-          chunk.map(track =>
-            track.originalReleaseTrack !== null)),
-    };
-  },
+    rereleases:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(track =>
+            // Check if the index of this track...
+            query.tracks.indexOf(track) >
+            // ...is greater than the *smallest* index
+            // of any of this track's *other* releases.
+            // (It won't be greater than its own index,
+            // so we can use otherReleases here, rather
+            // than allReleases.)
+            Math.min(...
+              track.otherReleases.map(t => query.tracks.indexOf(t))))),
+  }),
 
   generate(data, relations, {language}) {
     return relations.page.slots({
@@ -78,7 +84,7 @@ export default {
         data.rereleases.map(rereleases =>
           rereleases.map(rerelease =>
             (rerelease
-              ? {class: 'rerelease'}
+              ? {class: 'rerelease-line'}
               : null))),
     });
   },
diff --git a/src/content/dependencies/listTracksWithLyrics.js b/src/content/dependencies/listTracksWithLyrics.js
index a13a76f0..e6ab9d7d 100644
--- a/src/content/dependencies/listTracksWithLyrics.js
+++ b/src/content/dependencies/listTracksWithLyrics.js
@@ -2,7 +2,7 @@ export default {
   contentDependencies: ['listTracksWithExtra'],
 
   relations: (relation, spec) =>
-    ({page: relation('listTracksWithExtra', spec, 'lyrics', 'truthy')}),
+    ({page: relation('listTracksWithExtra', spec, 'lyrics', 'array')}),
 
   generate: (relations) =>
     relations.page,
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index faae35aa..e9a75744 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -1,11 +1,30 @@
+import {basename} from 'node:path';
+
+import {logWarn} from '#cli';
 import {bindFind} from '#find';
-import {replacerSpec, parseInput} from '#replacer';
+import {replacerSpec, parseContentNodes} from '#replacer';
 
 import {Marked} from 'marked';
+import striptags from 'striptags';
 
 const commonMarkedOptions = {
   headerIds: false,
   mangle: false,
+
+  tokenizer: {
+    url(src) {
+      // Don't link emails
+      const cap = this.rules.inline.url.exec(src);
+      if (cap?.[2] === '@') return;
+
+      // Use normal tokenizer url behavior otherwise
+      // Note that super.url doesn't work here because marked is binding or
+      // applying this function on the tokenizer instance - super.prop would
+      // just read the prototype of the containing object literal, not the
+      // rebound tokenizer. (Thanks MDN.)
+      return Object.getPrototypeOf(this).url.call(this, src);
+    },
+  },
 };
 
 const multilineMarked = new Marked({
@@ -30,23 +49,44 @@ function getPlaceholder(node, content) {
   return {type: 'text', data: content.slice(node.i, node.iEnd)};
 }
 
+function getArg(node, argKey) {
+  return (
+    node.data.args
+      ?.find(({key}) => key.data === argKey)
+      ?.value ??
+    null);
+}
+
 export default {
   contentDependencies: [
     ...(
       Object.values(replacerSpec)
         .map(description => description.link)
         .filter(Boolean)),
+
     'image',
+    'generateTextWithTooltip',
+    'generateTooltip',
+    'linkExternal',
   ],
 
-  extraDependencies: ['html', 'language', 'to', 'wikiData'],
+  extraDependencies: [
+    'html',
+    'language',
+    'niceShowAggregate',
+    'to',
+    'wikiData',
+  ],
 
   sprawl(wikiData, content) {
-    const find = bindFind(wikiData);
+    const find = bindFind(wikiData, {mode: 'quiet'});
 
-    const parsedNodes = parseInput(content);
+    const {result: parsedNodes, error} =
+      parseContentNodes(content ?? '', {errorMode: 'return'});
 
     return {
+      error,
+
       nodes: parsedNodes
         .map(node => {
           if (node.type !== 'tag') {
@@ -114,7 +154,31 @@ export default {
 
             data.hash = enteredHash ?? null;
 
-            return {i: node.i, iEnd: node.iEnd, type: 'link', data};
+            return {i: node.i, iEnd: node.iEnd, type: 'internal-link', data};
+          }
+
+          if (replacerKey === 'tooltip') {
+            // TODO: Again, no recursive nodes. Sorry!
+            // const enteredLabel = node.data.label && transformNode(node.data.label, opts);
+            const enteredLabel = node.data.label?.data;
+
+            return {
+              i: node.i,
+              iEnd: node.iEnd,
+              type: 'tooltip',
+              data: {
+                tooltip:
+                  replacerValue ?? '(empty tooltip...)',
+
+                label:
+                  enteredLabel ?? '(tooltip without label)',
+
+                link:
+                  (getArg(node, 'link')
+                    ? getArg(node, 'link')[0].data
+                    : null),
+              },
+            };
           }
 
           // This will be another {type: 'tag'} node which gets processed in
@@ -136,14 +200,22 @@ export default {
     return {
       content,
 
+      error:
+        sprawl.error,
+
       nodes:
         sprawl.nodes
           .map(node => {
             switch (node.type) {
-              // Replace link nodes with a stub. It'll be replaced (by position)
-              // with an item from relations.
-              case 'link':
-                return {type: 'link'};
+              // Replace internal link nodes with a stub. It'll be replaced
+              // (by position) with an item from relations.
+              //
+              // TODO: This should be where label and hash get passed through,
+              // rather than in relations... (in which case there's no need to
+              // handle it specially here, and we can really just return
+              // data.nodes = sprawl.nodes)
+              case 'internal-link':
+                return {type: 'internal-link'};
 
               // Other nodes will get processed in generate.
               default:
@@ -163,13 +235,21 @@ export default {
               link: relation(name, arg),
               label: node.data.label,
               hash: node.data.hash,
+              name: arg?.name,
+              shortName: arg?.shortName ?? arg?.nameShort,
             }
           : getPlaceholder(node, content));
 
     return {
-      links:
+      textWithTooltip:
+        relation('generateTextWithTooltip'),
+
+      tooltip:
+        relation('generateTooltip'),
+
+      internalLinks:
         nodes
-          .filter(({type}) => type === 'link')
+          .filter(({type}) => type === 'internal-link')
           .map(node => {
             const {link, thing, value} = node.data;
 
@@ -182,6 +262,19 @@ export default {
             }
           }),
 
+      externalLinks:
+        nodes
+          .filter(({type}) => type === 'external-link')
+          .map(({data: {href}}) =>
+            relation('linkExternal', href)),
+
+      externalLinksForTooltipNodes:
+        nodes
+          .filter(({type}) => type === 'tooltip')
+          .filter(({data}) => data.link)
+          .map(({data: {link: href}}) =>
+            relation('linkExternal', href)),
+
       images:
         nodes
           .filter(({type}) => type === 'image')
@@ -201,24 +294,66 @@ export default {
       default: false,
     },
 
+    indicateExternalLinks: {
+      type: 'boolean',
+      default: true,
+    },
+
+    absorbPunctuationFollowingExternalLinks: {
+      type: 'boolean',
+      default: true,
+    },
+
+    textOnly: {
+      type: 'boolean',
+      default: false,
+    },
+
     thumb: {
       validate: v => v.is('small', 'medium', 'large'),
       default: 'large',
     },
   },
 
-  generate(data, relations, slots, {html, language, to}) {
-    let linkIndex = 0;
+  generate(data, relations, slots, {html, language, niceShowAggregate, to}) {
+    if (data.error) {
+      logWarn`Error in content text.`;
+      niceShowAggregate(data.error);
+    }
+
     let imageIndex = 0;
+    let internalLinkIndex = 0;
+    let externalLinkIndex = 0;
+    let externalLinkForTooltipNodeIndex = 0;
+
+    let offsetTextNode = 0;
 
-    // This array contains only straight text and link nodes, which are directly
-    // representable in html (so no further processing is needed on the level of
-    // individual nodes).
     const contentFromNodes =
-      data.nodes.map(node => {
+      data.nodes.map((node, index) => {
+        const nextNode = data.nodes[index + 1];
+
+        const absorbFollowingPunctuation = template => {
+          if (nextNode?.type !== 'text') {
+            return;
+          }
+
+          const text = nextNode.data;
+          const match = text.match(/^[.,;:?!…]+(?=[^\n]*[a-z])/i);
+          const suffix = match?.[0];
+          if (suffix) {
+            template.setSlot('suffixNormalContent', suffix);
+            offsetTextNode = suffix.length;
+          }
+        };
+
         switch (node.type) {
-          case 'text':
-            return {type: 'text', data: node.data};
+          case 'text': {
+            const text = node.data.slice(offsetTextNode);
+
+            offsetTextNode = 0;
+
+            return {type: 'text', data: text};
+          }
 
           case 'image': {
             const src =
@@ -237,57 +372,185 @@ export default {
             } = node;
 
             if (node.inline) {
+              let content =
+                html.tag('img',
+                  src && {src},
+                  width && {width},
+                  height && {height},
+                  style && {style},
+
+                  align && !link &&
+                    {class: 'align-' + align},
+
+                  pixelate &&
+                    {class: 'pixelate'});
+
+              if (link) {
+                content =
+                  html.tag('a',
+                    {href: link},
+                    {target: '_blank'},
+
+                    align &&
+                      {class: 'align-' + align},
+
+                    {title:
+                      language.encapsulate('misc.external.opensInNewTab', capsule =>
+                        language.$(capsule, {
+                          link:
+                            language.formatExternalLink(link, {
+                              style: 'platform',
+                            }),
+
+                          annotation:
+                            language.$(capsule, 'annotation'),
+                        }).toString())},
+
+                    content);
+              }
+
               return {
-                type: 'image',
+                type: 'processed-image',
                 inline: true,
-                data:
-                  html.tag('img',
-                    src && {src},
-                    width && {width},
-                    height && {height},
-                    style && {style},
-
-                    pixelate &&
-                      {class: 'pixelate'}),
+                data: content,
               };
             }
 
             const image = relations.images[imageIndex++];
 
+            image.setSlots({
+              src,
+
+              link: link ?? true,
+              warnings: warnings ?? null,
+              thumb: slots.thumb,
+            });
+
+            if (width || height) {
+              image.setSlot('dimensions', [width ?? null, height ?? null]);
+            }
+
+            image.setSlot('attributes', [
+              {class: 'content-image'},
+
+              pixelate &&
+                {class: 'pixelate'},
+            ]);
+
             return {
-              type: 'image',
+              type: 'processed-image',
               inline: false,
               data:
                 html.tag('div', {class: 'content-image-container'},
-                  align === 'center' &&
-                    {class: 'align-center'},
+                  align &&
+                    {class: 'align-' + align},
 
-                  image.slots({
-                    src,
+                  image),
+            };
+          }
 
-                    link: link ?? true,
-                    width: width ?? null,
-                    height: height ?? null,
-                    warnings: warnings ?? null,
-                    thumb: slots.thumb,
+          case 'video': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {width, height, align, inline, pixelate} = node;
+
+            const video =
+              html.tag('video',
+                src && {src},
+                width && {width},
+                height && {height},
+
+                {controls: true},
+
+                align && inline &&
+                  {class: 'align-' + align},
+
+                pixelate &&
+                  {class: 'pixelate'});
+
+            const content =
+              (inline
+                ? video
+                : html.tag('div', {class: 'content-video-container'},
+                    align &&
+                      {class: 'align-' + align},
+
+                    video));
 
-                    attributes: [
-                      {class: 'content-image'},
 
-                      pixelate &&
-                        {class: 'pixelate'},
-                    ],
-                  })),
+            return {
+              type: 'processed-video',
+              data: content,
             };
           }
 
-          case 'link': {
-            const linkNode = relations.links[linkIndex++];
-            if (linkNode.type === 'text') {
-              return {type: 'text', data: linkNode.data};
+          case 'audio': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {align, inline, nameless} = node;
+
+            const audio =
+              html.tag('audio',
+                src && {src},
+
+                align && inline &&
+                  {class: 'align-' + align},
+
+                {controls: true});
+
+            const content =
+              (inline
+                ? audio
+                : html.tag('div', {class: 'content-audio-container'},
+                    align &&
+                      {class: 'align-' + align},
+
+                    [
+                      !nameless &&
+                        html.tag('a', {class: 'filename'},
+                          src && {href: src},
+                          language.sanitize(basename(node.src))),
+
+                      audio,
+                    ]));
+
+            return {
+              type: 'processed-audio',
+              data: content,
+            };
+          }
+
+          case 'internal-link': {
+            const nodeFromRelations = relations.internalLinks[internalLinkIndex++];
+            if (nodeFromRelations.type === 'text') {
+              return {type: 'text', data: nodeFromRelations.data};
             }
 
-            const {link, label, hash} = linkNode;
+            // TODO: This is a bit hacky, like the stuff below,
+            // but since we dressed it up in a utility function
+            // maybe it's okay...
+            const link =
+              html.resolve(
+                nodeFromRelations.link,
+                {slots: ['content', 'hash']});
+
+            const {label, hash, shortName, name} = nodeFromRelations;
+
+            if (slots.textOnly) {
+              if (label) {
+                return {type: 'text', data: label};
+              } else if (slots.preferShortLinkNames) {
+                return {type: 'text', data: shortName ?? name};
+              } else {
+                return {type: 'text', data: name};
+              }
+            }
 
             // These are removed from the typical combined slots({})-style
             // because we don't want to override slots that were already set
@@ -301,7 +564,7 @@ export default {
             try {
               link.getSlotDescription('preferShortName');
               hasPreferShortNameSlot = true;
-            } catch (error) {
+            } catch {
               hasPreferShortNameSlot = false;
             }
 
@@ -314,7 +577,7 @@ export default {
             try {
               link.getSlotDescription('tooltipStyle');
               hasTooltipStyleSlot = true;
-            } catch (error) {
+            } catch {
               hasTooltipStyleSlot = false;
             }
 
@@ -322,7 +585,93 @@ export default {
               link.setSlot('tooltipStyle', 'none');
             }
 
-            return {type: 'link', data: link};
+            let doTheAbsorbyThing = false;
+
+            // TODO: This is just silly.
+            try {
+              const tag = html.resolve(link, {normalize: 'tag'});
+              doTheAbsorbyThing ||= tag.attributes.has('class', 'image-media-link');
+            } catch {}
+
+            if (doTheAbsorbyThing) {
+              absorbFollowingPunctuation(link);
+            }
+
+            return {type: 'processed-internal-link', data: link};
+          }
+
+          case 'external-link': {
+            const {label} = node.data;
+            const externalLink = relations.externalLinks[externalLinkIndex++];
+
+            if (slots.textOnly) {
+              return {type: 'text', data: label};
+            }
+
+            externalLink.setSlots({
+              content: label,
+              fromContent: true,
+            });
+
+            if (slots.absorbPunctuationFollowingExternalLinks) {
+              absorbFollowingPunctuation(externalLink);
+            }
+
+            if (slots.indicateExternalLinks) {
+              externalLink.setSlots({
+                indicateExternal: true,
+                tab: 'separate',
+                style: 'platform',
+              });
+            }
+
+            return {type: 'processed-external-link', data: externalLink};
+          }
+
+          case 'tooltip': {
+            const {label, link, tooltip: tooltipContent} = node.data;
+
+            const externalLink =
+              (link
+                ? relations.externalLinksForTooltipNodes
+                    .at(externalLinkForTooltipNodeIndex++)
+                : null);
+
+            if (externalLink) {
+              externalLink.setSlots({
+                content: label,
+                fromContent: true,
+              });
+
+              if (slots.indicateExternalLinks) {
+                externalLink.setSlots({
+                  indicateExternal: true,
+                  disableBrowserTooltip: true,
+                  tab: 'separate',
+                  style: 'platform',
+                });
+              }
+            }
+
+            const textWithTooltip = relations.textWithTooltip.clone();
+            const tooltip = relations.tooltip.clone();
+
+            tooltip.setSlots({
+              attributes: {class: 'content-tooltip'},
+              content: tooltipContent, // Not sanitized!
+            });
+
+            textWithTooltip.setSlots({
+              attributes: [
+                {class: 'content-tooltip-guy'},
+                externalLink && {class: 'has-link'},
+              ],
+
+              text: externalLink ?? label,
+              tooltip,
+            });
+
+            return {type: 'processed-tooltip', data: textWithTooltip};
           }
 
           case 'tag': {
@@ -341,12 +690,19 @@ export default {
                 ? valueFn(replacerValue)
                 : replacerValue);
 
-            const contents =
+            const content =
               (htmlFn
                 ? htmlFn(value, {html, language})
                 : value);
 
-            return {type: 'text', data: contents.toString()};
+            const contentText =
+              html.resolve(content, {normalize: 'string'});
+
+            if (slots.textOnly) {
+              return {type: 'text', data: striptags(contentText)};
+            } else {
+              return {type: 'text', data: contentText};
+            }
           }
 
           default:
@@ -358,7 +714,10 @@ export default {
     // access to its slots.
 
     if (slots.mode === 'single-link') {
-      const link = contentFromNodes.find(node => node.type === 'link');
+      const link =
+        contentFromNodes.find(node =>
+          node.type === 'processed-internal-link' ||
+          node.type === 'processed-external-link');
 
       if (!link) {
         return html.blank();
@@ -385,13 +744,10 @@ export default {
             return getTextNodeContents(node, index);
           }
 
-          const attributes = html.attributes({
-            class: 'INSERT-NON-TEXT',
-            'data-type': node.type,
-          });
+          let attributes = `class="INSERT-NON-TEXT" data-type="${node.type}"`;
 
-          if (node.type === 'image') {
-            attributes.set('data-inline', node.inline);
+          if (node.type === 'processed-image' && node.inline) {
+            attributes += ` data-inline`;
           }
 
           return `<span ${attributes}>${index}</span>`;
@@ -422,15 +778,19 @@ export default {
 
         const attributes = html.parseAttributes(match[1]);
 
-        // Images that were all on their own line need to be removed from
-        // the surrounding <p> tag that marked generates. The HTML parser
-        // treats a <div> that starts inside a <p> as a Crocker-class
-        // misgiving, and will treat you very badly if you feed it that.
-        if (attributes.get('data-type') === 'image') {
-          if (!attributes.get('data-inline')) {
-            tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, '');
-            deleteParagraph = true;
-          }
+        // Images (or videos) that were all on their own line need to be
+        // removed from the surrounding <p> tag that marked generates.
+        // The HTML parser treats a <div> that starts inside a <p> as a
+        // Crocker-class misgiving, and will treat you very badly if you
+        // feed it that.
+        if (
+          (attributes.get('data-type') === 'processed-image' &&
+          !attributes.get('data-inline')) ||
+          attributes.get('data-type') === 'processed-video' ||
+          attributes.get('data-type') === 'processed-audio'
+        ) {
+          tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, '');
+          deleteParagraph = true;
         }
 
         const nonTextNodeIndex = match[2];
@@ -441,7 +801,11 @@ export default {
         addText(markedOutput.slice(parseFrom));
       }
 
-      return html.tags(tags, {[html.joinChildren]: ''});
+      return (
+        html.tags(tags, {
+          [html.joinChildren]: '',
+          [html.onlyIfContent]: true,
+        }));
     };
 
     if (slots.mode === 'inline') {
@@ -466,9 +830,9 @@ export default {
           // Expand line breaks which don't follow a list, quote,
           // 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 */
+          .replace(/(?<!^ *(?:-|\d+\.).*|^>.*|^  .*\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')
+          .replace(/(?<=^ *(?:-|\d+\.).*)\n+(?!^ *(?:-|\d+\.))/gm, '\n\n')
           // Expand line breaks which are at the end of a quote.
           .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n');
 
@@ -500,25 +864,12 @@ export default {
 
       const markedInput =
         extractNonTextNodes({
-          getTextNodeContents(node, index) {
-            // First, replace line breaks that follow text content with
-            // <br> tags.
-            let content = node.data.replace(/(?!^)\n/gm, '<br>\n');
-
-            // Scrap line breaks that are at the end of a verse.
-            content = content.replace(/<br>$(?=\n\n)/gm, '');
-
-            // If the node started with a line break, and it's not the
-            // very first node, then whatever came before it was inline.
-            // (This is an assumption based on text links being basically
-            // the only tag that shows up in lyrics.) Since this text is
-            // following content that was already inline, restore that
-            // initial line break.
-            if (node.data[0] === '\n' && index !== 0) {
-              content = '<br>' + content;
-            }
-
-            return content;
+          getTextNodeContents(node) {
+            // Just insert <br> before every line break. The resulting
+            // text will appear all in one paragraph - this is expected
+            // for lyrics, and allows for multiple lines of proportional
+            // space between stanzas.
+            return node.data.replace(/\n/g, '<br>\n');
           },
         });