« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/content
diff options
context:
space:
mode:
Diffstat (limited to 'src/content')
-rw-r--r--src/content/dependencies/generateAbsoluteDatetimestamp.js53
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js26
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunk.js46
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunkItem.js30
-rw-r--r--src/content/dependencies/generateAdditionalNamesBox.js28
-rw-r--r--src/content/dependencies/generateAdditionalNamesBoxItem.js48
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js96
-rw-r--r--src/content/dependencies/generateAlbumArtInfoBox.js39
-rw-r--r--src/content/dependencies/generateAlbumArtworkColumn.js38
-rw-r--r--src/content/dependencies/generateAlbumBanner.js37
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js306
-rw-r--r--src/content/dependencies/generateAlbumCommentarySidebar.js73
-rw-r--r--src/content/dependencies/generateAlbumGalleryAlbumGrid.js90
-rw-r--r--src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js20
-rw-r--r--src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js7
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js167
-rw-r--r--src/content/dependencies/generateAlbumGalleryStatsLine.js38
-rw-r--r--src/content/dependencies/generateAlbumGalleryTrackGrid.js122
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js238
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js142
-rw-r--r--src/content/dependencies/generateAlbumReferencedArtworksPage.js58
-rw-r--r--src/content/dependencies/generateAlbumReferencingArtworksPage.js58
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js107
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNav.js127
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNavGroupPart.js94
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js94
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js171
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js126
-rw-r--r--src/content/dependencies/generateAlbumSidebarSeriesBox.js102
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackListBox.js31
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js167
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbed.js70
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbedDescription.js41
-rw-r--r--src/content/dependencies/generateAlbumStyleRules.js107
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js206
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js62
-rw-r--r--src/content/dependencies/generateArtTagAncestorDescendantMapList.js153
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js222
-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.js180
-rw-r--r--src/content/dependencies/generateArtistCreditWikiEditsPart.js55
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js108
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js234
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js401
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunk.js50
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js72
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js72
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunk.js114
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkItem.js91
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkedList.js20
-rw-r--r--src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js280
-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.js62
-rw-r--r--src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js30
-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.js81
-rw-r--r--src/content/dependencies/generateArtistNavLinks.js94
-rw-r--r--src/content/dependencies/generateBackToAlbumLink.js15
-rw-r--r--src/content/dependencies/generateBackToTrackLink.js15
-rw-r--r--src/content/dependencies/generateBanner.js33
-rw-r--r--src/content/dependencies/generateColorStyleAttribute.js37
-rw-r--r--src/content/dependencies/generateColorStyleRules.js42
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js91
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js112
-rw-r--r--src/content/dependencies/generateCommentaryEntryDate.js93
-rw-r--r--src/content/dependencies/generateCommentaryIndexPage.js104
-rw-r--r--src/content/dependencies/generateContentHeading.js61
-rw-r--r--src/content/dependencies/generateContributionList.js29
-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.js121
-rw-r--r--src/content/dependencies/generateCoverArtworkArtTagDetails.js50
-rw-r--r--src/content/dependencies/generateCoverArtworkArtistDetails.js25
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js98
-rw-r--r--src/content/dependencies/generateCoverArtworkReferenceDetails.js60
-rw-r--r--src/content/dependencies/generateCoverCarousel.js55
-rw-r--r--src/content/dependencies/generateCoverGrid.js90
-rw-r--r--src/content/dependencies/generateDatetimestampTemplate.js40
-rw-r--r--src/content/dependencies/generateDotSwitcherTemplate.js41
-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.js85
-rw-r--r--src/content/dependencies/generateFlashActNavAccent.js64
-rw-r--r--src/content/dependencies/generateFlashActSidebar.js30
-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/generateFlashIndexPage.js144
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js202
-rw-r--r--src/content/dependencies/generateFlashNavAccent.js66
-rw-r--r--src/content/dependencies/generateFooterLocalizationLinks.js59
-rw-r--r--src/content/dependencies/generateGridActionLinks.js16
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js182
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js179
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js47
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js87
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListItem.js136
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsSection.js93
-rw-r--r--src/content/dependencies/generateGroupNavAccent.js53
-rw-r--r--src/content/dependencies/generateGroupNavLinks.js59
-rw-r--r--src/content/dependencies/generateGroupSecondaryNav.js20
-rw-r--r--src/content/dependencies/generateGroupSecondaryNavCategoryPart.js79
-rw-r--r--src/content/dependencies/generateGroupSidebar.js46
-rw-r--r--src/content/dependencies/generateGroupSidebarCategoryDetails.js81
-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/generateListAllAdditionalFilesChunk.js90
-rw-r--r--src/content/dependencies/generateListRandomPageLinksAlbumLink.js18
-rw-r--r--src/content/dependencies/generateListingIndexList.js131
-rw-r--r--src/content/dependencies/generateListingPage.js288
-rw-r--r--src/content/dependencies/generateListingSidebar.js37
-rw-r--r--src/content/dependencies/generateListingsIndexPage.js89
-rw-r--r--src/content/dependencies/generateLyricsEntry.js25
-rw-r--r--src/content/dependencies/generateLyricsSection.js81
-rw-r--r--src/content/dependencies/generateNewsEntryNavAccent.js40
-rw-r--r--src/content/dependencies/generateNewsEntryPage.js105
-rw-r--r--src/content/dependencies/generateNewsEntryReadAnotherLinks.js97
-rw-r--r--src/content/dependencies/generateNewsIndexPage.js94
-rw-r--r--src/content/dependencies/generateNextLink.js13
-rw-r--r--src/content/dependencies/generatePageLayout.js790
-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/generateQuickDescription.js134
-rw-r--r--src/content/dependencies/generateReferencedArtworksPage.js100
-rw-r--r--src/content/dependencies/generateReferencingArtworksPage.js100
-rw-r--r--src/content/dependencies/generateRelativeDatetimestamp.js69
-rw-r--r--src/content/dependencies/generateReleaseInfoContributionsLine.js31
-rw-r--r--src/content/dependencies/generateSearchSidebarBox.js62
-rw-r--r--src/content/dependencies/generateSecondaryNav.js30
-rw-r--r--src/content/dependencies/generateSecondaryNavParentSiblingsPart.js115
-rw-r--r--src/content/dependencies/generateSocialEmbed.js70
-rw-r--r--src/content/dependencies/generateStaticPage.js46
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js59
-rw-r--r--src/content/dependencies/generateTextWithTooltip.js71
-rw-r--r--src/content/dependencies/generateTooltip.js34
-rw-r--r--src/content/dependencies/generateTrackArtistCommentarySection.js157
-rw-r--r--src/content/dependencies/generateTrackArtworkColumn.js33
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js435
-rw-r--r--src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js63
-rw-r--r--src/content/dependencies/generateTrackInfoPageOtherReleasesList.js42
-rw-r--r--src/content/dependencies/generateTrackList.js28
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js145
-rw-r--r--src/content/dependencies/generateTrackListItem.js106
-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.js82
-rw-r--r--src/content/dependencies/generateTrackSocialEmbed.js68
-rw-r--r--src/content/dependencies/generateTrackSocialEmbedDescription.js39
-rw-r--r--src/content/dependencies/generateUnsafeMunchy.js10
-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/image.js374
-rw-r--r--src/content/dependencies/index.js274
-rw-r--r--src/content/dependencies/linkAlbum.js8
-rw-r--r--src/content/dependencies/linkAlbumAdditionalFile.js24
-rw-r--r--src/content/dependencies/linkAlbumCommentary.js8
-rw-r--r--src/content/dependencies/linkAlbumDynamically.js61
-rw-r--r--src/content/dependencies/linkAlbumGallery.js8
-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.js8
-rw-r--r--src/content/dependencies/linkArtist.js8
-rw-r--r--src/content/dependencies/linkArtistGallery.js8
-rw-r--r--src/content/dependencies/linkArtwork.js20
-rw-r--r--src/content/dependencies/linkCommentaryIndex.js12
-rw-r--r--src/content/dependencies/linkContribution.js85
-rw-r--r--src/content/dependencies/linkExternal.js151
-rw-r--r--src/content/dependencies/linkFlash.js8
-rw-r--r--src/content/dependencies/linkFlashAct.js22
-rw-r--r--src/content/dependencies/linkFlashIndex.js12
-rw-r--r--src/content/dependencies/linkFlashSide.js22
-rw-r--r--src/content/dependencies/linkGroup.js8
-rw-r--r--src/content/dependencies/linkGroupDynamically.js14
-rw-r--r--src/content/dependencies/linkGroupExtra.js34
-rw-r--r--src/content/dependencies/linkGroupGallery.js8
-rw-r--r--src/content/dependencies/linkListing.js15
-rw-r--r--src/content/dependencies/linkListingIndex.js12
-rw-r--r--src/content/dependencies/linkNewsEntry.js8
-rw-r--r--src/content/dependencies/linkNewsIndex.js12
-rw-r--r--src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js62
-rw-r--r--src/content/dependencies/linkPathFromMedia.js64
-rw-r--r--src/content/dependencies/linkPathFromRoot.js13
-rw-r--r--src/content/dependencies/linkPathFromSite.js13
-rw-r--r--src/content/dependencies/linkReferencedArtworks.js24
-rw-r--r--src/content/dependencies/linkReferencingArtworks.js24
-rw-r--r--src/content/dependencies/linkStaticPage.js8
-rw-r--r--src/content/dependencies/linkStationaryIndex.js24
-rw-r--r--src/content/dependencies/linkTemplate.js87
-rw-r--r--src/content/dependencies/linkThing.js154
-rw-r--r--src/content/dependencies/linkTrack.js8
-rw-r--r--src/content/dependencies/linkTrackDynamically.js36
-rw-r--r--src/content/dependencies/linkTrackReferencedArtworks.js8
-rw-r--r--src/content/dependencies/linkTrackReferencingArtworks.js8
-rw-r--r--src/content/dependencies/linkWikiHomepage.js20
-rw-r--r--src/content/dependencies/listAlbumsByDate.js52
-rw-r--r--src/content/dependencies/listAlbumsByDateAdded.js60
-rw-r--r--src/content/dependencies/listAlbumsByDuration.js52
-rw-r--r--src/content/dependencies/listAlbumsByName.js50
-rw-r--r--src/content/dependencies/listAlbumsByTracks.js51
-rw-r--r--src/content/dependencies/listAllAdditionalFiles.js9
-rw-r--r--src/content/dependencies/listAllAdditionalFilesTemplate.js209
-rw-r--r--src/content/dependencies/listAllMidiProjectFiles.js9
-rw-r--r--src/content/dependencies/listAllSheetMusicFiles.js9
-rw-r--r--src/content/dependencies/listArtTagNetwork.js366
-rw-r--r--src/content/dependencies/listArtTagsByName.js57
-rw-r--r--src/content/dependencies/listArtTagsByUses.js54
-rw-r--r--src/content/dependencies/listArtistsByCommentaryEntries.js58
-rw-r--r--src/content/dependencies/listArtistsByContributions.js174
-rw-r--r--src/content/dependencies/listArtistsByDuration.js55
-rw-r--r--src/content/dependencies/listArtistsByGroup.js157
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js323
-rw-r--r--src/content/dependencies/listArtistsByName.js48
-rw-r--r--src/content/dependencies/listGroupsByAlbums.js51
-rw-r--r--src/content/dependencies/listGroupsByCategory.js76
-rw-r--r--src/content/dependencies/listGroupsByDuration.js56
-rw-r--r--src/content/dependencies/listGroupsByLatestAlbum.js72
-rw-r--r--src/content/dependencies/listGroupsByName.js49
-rw-r--r--src/content/dependencies/listGroupsByTracks.js55
-rw-r--r--src/content/dependencies/listRandomPageLinks.js197
-rw-r--r--src/content/dependencies/listTracksByAlbum.js48
-rw-r--r--src/content/dependencies/listTracksByDate.js91
-rw-r--r--src/content/dependencies/listTracksByDuration.js51
-rw-r--r--src/content/dependencies/listTracksByDurationInAlbum.js87
-rw-r--r--src/content/dependencies/listTracksByName.js36
-rw-r--r--src/content/dependencies/listTracksByTimesReferenced.js52
-rw-r--r--src/content/dependencies/listTracksInFlashesByAlbum.js82
-rw-r--r--src/content/dependencies/listTracksInFlashesByFlash.js69
-rw-r--r--src/content/dependencies/listTracksWithExtra.js85
-rw-r--r--src/content/dependencies/listTracksWithLyrics.js9
-rw-r--r--src/content/dependencies/listTracksWithMidiProjectFiles.js9
-rw-r--r--src/content/dependencies/listTracksWithSheetMusicFiles.js9
-rw-r--r--src/content/dependencies/transformContent.js756
257 files changed, 20503 insertions, 0 deletions
diff --git a/src/content/dependencies/generateAbsoluteDatetimestamp.js b/src/content/dependencies/generateAbsoluteDatetimestamp.js
new file mode 100644
index 00000000..930b6f13
--- /dev/null
+++ b/src/content/dependencies/generateAbsoluteDatetimestamp.js
@@ -0,0 +1,53 @@
+export default {
+  contentDependencies: [
+    'generateDatetimestampTemplate',
+    'generateTooltip',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  data: (date) =>
+    ({date}),
+
+  relations: (relation) => ({
+    template:
+      relation('generateDatetimestampTemplate'),
+
+    tooltip:
+      relation('generateTooltip'),
+  }),
+
+  slots: {
+    style: {
+      validate: v => v.is('full', 'year'),
+      default: 'full',
+    },
+
+    // Only has an effect for 'year' style.
+    tooltip: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots, {language}) =>
+    relations.template.slots({
+      mainContent:
+        (slots.style === 'full'
+          ? language.formatDate(data.date)
+       : slots.style === 'year'
+          ? data.date.getFullYear().toString()
+          : null),
+
+      tooltip:
+        slots.tooltip &&
+        slots.style === 'year' &&
+          relations.tooltip.slots({
+            content:
+              language.formatDate(data.date),
+          }),
+
+      datetime:
+        data.date.toISOString(),
+    }),
+};
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js
new file mode 100644
index 00000000..68120b23
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -0,0 +1,26 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    chunks: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    chunkItems: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('ul', {class: 'additional-files-list'},
+      {[html.onlyIfContent]: true},
+
+      stitchArrays({
+        chunk: slots.chunks,
+        items: slots.chunkItems,
+      }).map(({chunk, items}) =>
+          chunk.clone()
+            .slot('items', items))),
+};
diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js
new file mode 100644
index 00000000..507b2329
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesListChunk.js
@@ -0,0 +1,46 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    description: {
+      type: 'html',
+      mutable: false,
+    },
+
+    items: {
+      validate: v => v.looseArrayOf(v.isHTML),
+    },
+  },
+
+  generate: (slots, {html, language}) =>
+    language.encapsulate('releaseInfo.additionalFiles.entry', capsule =>
+      html.tag('li',
+        html.tag('details',
+          html.isBlank(slots.items) &&
+            {open: true},
+
+          [
+            html.tag('summary',
+              html.tag('span',
+                language.$(capsule, {
+                  title:
+                    html.tag('b', slots.title),
+                }))),
+
+            html.tag('ul', [
+              html.tag('li', {class: 'entry-description'},
+                {[html.onlyIfContent]: true},
+                slots.description),
+
+              (html.isBlank(slots.items)
+                ? html.tag('li',
+                    language.$(capsule, 'noFilesAvailable'))
+                : slots.items),
+            ]),
+          ]))),
+};
diff --git a/src/content/dependencies/generateAdditionalFilesListChunkItem.js b/src/content/dependencies/generateAdditionalFilesListChunkItem.js
new file mode 100644
index 00000000..c37d6bb2
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesListChunkItem.js
@@ -0,0 +1,30 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    fileLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    fileSize: {
+      validate: v => v.isWholeNumber,
+    },
+  },
+
+  generate(slots, {html, language}) {
+    const itemParts = ['releaseInfo.additionalFiles.file'];
+    const itemOptions = {file: slots.fileLink};
+
+    if (slots.fileSize) {
+      itemParts.push('withSize');
+      itemOptions.size = language.formatFileSize(slots.fileSize);
+    }
+
+    const li =
+      html.tag('li',
+        language.$(...itemParts, itemOptions));
+
+    return li;
+  },
+};
diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js
new file mode 100644
index 00000000..b7392dfd
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalNamesBox.js
@@ -0,0 +1,28 @@
+export default {
+  contentDependencies: ['generateAdditionalNamesBoxItem'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, additionalNames) => ({
+    items:
+      additionalNames
+        .map(entry => relation('generateAdditionalNamesBoxItem', entry)),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tag('div', {id: 'additional-names-box'},
+      {class: 'drop'},
+      {[html.onlyIfContent]: true},
+
+      [
+        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
new file mode 100644
index 00000000..e3e59a34
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalNamesBoxItem.js
@@ -0,0 +1,48 @@
+export default {
+  contentDependencies: ['transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    nameContent:
+      relation('transformContent', entry.name),
+
+    annotationContent:
+      (entry.annotation
+        ? relation('transformContent', entry.annotation)
+        : null),
+  }),
+
+  generate: (relations, {html, language}) => {
+    const prefix = 'misc.additionalNames.item';
+
+    const itemParts = [prefix];
+    const itemOptions = {};
+
+    itemOptions.name =
+      html.tag('span', {class: 'additional-name'},
+        relations.nameContent.slot('mode', 'inline'));
+
+    const accentParts = [prefix, 'accent'];
+    const accentOptions = {};
+
+    if (relations.annotationContent) {
+      accentParts.push('withAnnotation');
+      accentOptions.annotation =
+        relations.annotationContent.slots({
+          mode: 'inline',
+          absorbPunctuationFollowingExternalLinks: false,
+        });
+    }
+
+    if (accentParts.length > 2) {
+      itemParts.push('withAccent');
+      itemOptions.accent =
+        html.tag('span', {class: 'accent'},
+          html.metatag('chunkwrap', {split: ','},
+            html.resolve(
+              language.$(...accentParts, accentOptions))));
+    }
+
+    return language.$(...itemParts, itemOptions);
+  },
+};
diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js
new file mode 100644
index 00000000..ad17206f
--- /dev/null
+++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js
@@ -0,0 +1,96 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalFilesList',
+    'generateAdditionalFilesListChunk',
+    'generateAdditionalFilesListChunkItem',
+    'linkAlbumAdditionalFile',
+    'transformContent',
+  ],
+
+  extraDependencies: ['getSizeOfMediaFile', 'html', 'urls'],
+
+  relations: (relation, album, additionalFiles) => ({
+    list:
+      relation('generateAdditionalFilesList', additionalFiles),
+
+    chunks:
+      additionalFiles
+        .map(() => relation('generateAdditionalFilesListChunk')),
+
+    chunkDescriptions:
+      additionalFiles
+        .map(({description}) =>
+          (description
+            ? relation('transformContent', description)
+            : null)),
+
+    chunkItems:
+      additionalFiles
+        .map(({files}) =>
+          (files ?? [])
+            .map(() => relation('generateAdditionalFilesListChunkItem'))),
+
+    chunkItemFileLinks:
+      additionalFiles
+        .map(({files}) =>
+          (files ?? [])
+            .map(file => relation('linkAlbumAdditionalFile', album, file))),
+  }),
+
+  data: (album, additionalFiles) => ({
+    albumDirectory: album.directory,
+
+    chunkTitles:
+      additionalFiles
+        .map(({title}) => title),
+
+    chunkItemLocations:
+      additionalFiles
+        .map(({files}) => files ?? []),
+  }),
+
+  slots: {
+    showFileSizes: {type: 'boolean', default: true},
+  },
+
+  generate: (data, relations, slots, {getSizeOfMediaFile, urls}) =>
+    relations.list.slots({
+      chunks:
+        stitchArrays({
+          chunk: relations.chunks,
+          description: relations.chunkDescriptions,
+          title: data.chunkTitles,
+        }).map(({chunk, title, description}) =>
+            chunk.slots({
+              title,
+              description:
+                (description
+                  ? description.slot('mode', 'inline')
+                  : null),
+            })),
+
+      chunkItems:
+        stitchArrays({
+          items: relations.chunkItems,
+          fileLinks: relations.chunkItemFileLinks,
+          locations: data.chunkItemLocations,
+        }).map(({items, fileLinks, locations}) =>
+            stitchArrays({
+              item: items,
+              fileLink: fileLinks,
+              location: locations,
+            }).map(({item, fileLink, location}) =>
+                item.slots({
+                  fileLink: fileLink,
+                  fileSize:
+                    (slots.showFileSizes
+                      ? getSizeOfMediaFile(
+                          urls
+                            .from('media.root')
+                            .to('media.albumAdditionalFile', data.albumDirectory, location))
+                      : 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/generateAlbumBanner.js b/src/content/dependencies/generateAlbumBanner.js
new file mode 100644
index 00000000..3cc141bc
--- /dev/null
+++ b/src/content/dependencies/generateAlbumBanner.js
@@ -0,0 +1,37 @@
+export default {
+  contentDependencies: ['generateBanner'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album) {
+    if (!album.hasBannerArt) {
+      return {};
+    }
+
+    return {
+      banner: relation('generateBanner'),
+    };
+  },
+
+  data(album) {
+    if (!album.hasBannerArt) {
+      return {};
+    }
+
+    return {
+      path: ['media.albumBanner', album.directory, album.bannerFileExtension],
+      dimensions: album.bannerDimensions,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    if (!relations.banner) {
+      return html.blank();
+    }
+
+    return relations.banner.slots({
+      path: data.path,
+      dimensions: data.dimensions,
+      alt: language.$('misc.alt.albumBanner'),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
new file mode 100644
index 00000000..1e39b47d
--- /dev/null
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -0,0 +1,306 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumCommentarySidebar',
+    'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
+    'generateAlbumStyleRules',
+    'generateCommentaryEntry',
+    'generateContentHeading',
+    'generateCoverArtwork',
+    'generatePageLayout',
+    'linkAlbum',
+    'linkExternal',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  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.secondaryNav =
+      relation('generateAlbumSecondaryNav', album);
+
+    relations.sidebar =
+      relation('generateAlbumCommentarySidebar', album);
+
+    relations.albumStyleRules =
+      relation('generateAlbumStyleRules', album, null);
+
+    relations.albumLink =
+      relation('linkAlbum', album);
+
+    relations.albumNavAccent =
+      relation('generateAlbumNavAccent', album, null);
+
+    if (!empty(album.commentary)) {
+      relations.albumCommentaryHeading =
+        relation('generateContentHeading');
+
+      relations.albumCommentaryLink =
+        relation('linkAlbum', album);
+
+      relations.albumCommentaryListeningLinks =
+        album.urls.map(url => relation('linkExternal', url));
+
+      if (album.hasCoverArt) {
+        relations.albumCommentaryCover =
+          relation('generateCoverArtwork', album.coverArtworks[0]);
+      }
+
+      relations.albumCommentaryEntries =
+        album.commentary
+          .map(entry => relation('generateCommentaryEntry', entry));
+    }
+
+    relations.trackCommentaryHeadings =
+      query.tracksWithCommentary
+        .map(() => relation('generateContentHeading'));
+
+    relations.trackCommentaryLinks =
+      query.tracksWithCommentary
+        .map(track => relation('linkTrack', track));
+
+    relations.trackCommentaryListeningLinks =
+      query.tracksWithCommentary
+        .map(track =>
+          track.urls.map(url => relation('linkExternal', url)));
+
+    relations.trackCommentaryCovers =
+      query.tracksWithCommentary
+        .map(track =>
+          (track.hasUniqueCoverArt
+            ? relation('generateCoverArtwork', track.trackArtworks[0])
+            : null));
+
+    relations.trackCommentaryEntries =
+      query.tracksWithCommentary
+        .map(track =>
+          track.commentary
+            .map(entry => relation('generateCommentaryEntry', entry)));
+
+    return relations;
+  },
+
+  data(query, album) {
+    const data = {};
+
+    data.name = album.name;
+    data.color = album.color;
+    data.date = album.date;
+
+    data.entryCount =
+      query.thingsWithCommentary
+        .flatMap(({commentary}) => commentary)
+        .length;
+
+    data.wordCount =
+      query.thingsWithCommentary
+        .flatMap(({commentary}) => commentary)
+        .map(({body}) => body)
+        .join(' ')
+        .split(' ')
+        .length;
+
+    data.trackCommentaryTrackDates =
+      query.tracksWithCommentary
+        .map(track => track.dateFirstReleased);
+
+    data.trackCommentaryDirectories =
+      query.tracksWithCommentary
+        .map(track => track.directory);
+
+    data.trackCommentaryColors =
+      query.tracksWithCommentary
+        .map(track =>
+          (track.color === album.color
+            ? null
+            : track.color));
+
+    return data;
+  },
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumCommentaryPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            album: data.name,
+          }),
+
+        headingMode: 'sticky',
+
+        color: data.color,
+        styleRules: [relations.albumStyleRules],
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          html.tag('p',
+            {[html.joinChildren]: html.tag('br')},
+
+            [
+              data.date &&
+              data.entryCount >= 1 &&
+                language.$('releaseInfo.albumReleased', {
+                  date:
+                    html.tag('b',
+                      language.formatDate(data.date)),
+                }),
+
+              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,
+            link: relations.trackCommentaryLinks,
+            listeningLinks: relations.trackCommentaryListeningLinks,
+            directory: data.trackCommentaryDirectories,
+            cover: relations.trackCommentaryCovers,
+            entries: relations.trackCommentaryEntries,
+            color: data.trackCommentaryColors,
+            trackDate: data.trackCommentaryTrackDates,
+          }).map(({
+              heading,
+              link,
+              listeningLinks,
+              directory,
+              cover,
+              entries,
+              color,
+              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'}),
+
+              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',
+        navLinks: [
+          {auto: 'home'},
+          {
+            html:
+              relations.albumLink
+                .slot('attributes', {class: 'current'}),
+
+            accent:
+              relations.albumNavAccent.slots({
+                showTrackNavigation: false,
+                showExtraLinks: true,
+                currentExtra: '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/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/generateAlbumGalleryCoverArtistsLine.js b/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js
new file mode 100644
index 00000000..7dcdf6de
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: ['linkArtistGallery'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, coverArtists) {
+    return {
+      coverArtistLinks:
+        coverArtists
+          .map(artist => relation('linkArtistGallery', artist)),
+    };
+  },
+
+  generate(relations, {html, language}) {
+    return (
+      html.tag('p', {class: 'quick-info'},
+        language.$('albumGalleryPage.coverArtistsLine', {
+          artists: language.formatConjunctionList(relations.coverArtistLinks),
+        })));
+  },
+};
diff --git a/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js b/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js
new file mode 100644
index 00000000..ad99cb87
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js
@@ -0,0 +1,7 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  generate: ({html, language}) =>
+    html.tag('p', {class: 'quick-info'},
+      language.$('albumGalleryPage.noTrackArtworksLine')),
+};
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
new file mode 100644
index 00000000..2ba3b272
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -0,0 +1,167 @@
+import {stitchArrays, unique} from '#sugar';
+import {getKebabCase} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateAlbumGalleryAlbumGrid',
+    'generateAlbumGalleryNoTrackArtworksLine',
+    'generateAlbumGalleryStatsLine',
+    'generateAlbumGalleryTrackGrid',
+    'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
+    'generateAlbumStyleRules',
+    'generateIntrapageDotSwitcher',
+    'generatePageLayout',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album) {
+    const query = {};
+
+    const trackArtworkLabels =
+      album.tracks
+        .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) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', album, null),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    albumNavAccent:
+      relation('generateAlbumNavAccent', album, null),
+
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', album),
+
+    statsLine:
+      relation('generateAlbumGalleryStatsLine', album),
+
+    noTrackArtworksLine:
+      (album.tracks.every(track => !track.hasUniqueCoverArt)
+        ? relation('generateAlbumGalleryNoTrackArtworksLine')
+        : null),
+
+    setSwitcher:
+      relation('generateIntrapageDotSwitcher'),
+
+    albumGrid:
+      relation('generateAlbumGalleryAlbumGrid', album),
+
+    trackGrids:
+      query.recurringTrackArtworkLabels.map(label =>
+        relation('generateAlbumGalleryTrackGrid', album, label)),
+  }),
+
+  data: (query, album) => ({
+    trackGridLabels:
+      query.recurringTrackArtworkLabels,
+
+    trackGridIDs:
+      query.recurringTrackArtworkLabels.map(label =>
+        'track-grid-' +
+          (label
+            ? getKebabCase(label)
+            : 'no-label')),
+
+    name:
+      album.name,
+
+    color:
+      album.color,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumGalleryPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            album: data.name,
+          }),
+
+        headingMode: 'static',
+
+        color: data.color,
+        styleRules: [relations.albumStyleRules],
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          relations.statsLine,
+
+          relations.albumGrid,
+
+          relations.noTrackArtworksLine,
+
+          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',
+        navLinks: [
+          {auto: 'home'},
+          {
+            html:
+              relations.albumLink
+                .slot('attributes', {class: 'current'}),
+            accent:
+              relations.albumNavAccent.slots({
+                showTrackNavigation: false,
+                showExtraLinks: true,
+                currentExtra: 'gallery',
+              }),
+          },
+        ],
+
+        secondaryNav: relations.secondaryNav,
+      })),
+};
diff --git a/src/content/dependencies/generateAlbumGalleryStatsLine.js b/src/content/dependencies/generateAlbumGalleryStatsLine.js
new file mode 100644
index 00000000..75bffb36
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryStatsLine.js
@@ -0,0 +1,38 @@
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data(album) {
+    return {
+      name: album.name,
+      date: album.date,
+      duration: getTotalDuration(album.tracks),
+      numTracks: album.tracks.length,
+    };
+  },
+
+  generate(data, {html, language}) {
+    const parts = ['albumGalleryPage.statsLine'];
+    const options = {};
+
+    options.tracks =
+      html.tag('b',
+        language.countTracks(data.numTracks, {unit: true}));
+
+    options.duration =
+      html.tag('b',
+        language.formatDuration(data.duration, {unit: true}));
+
+    if (data.date) {
+      parts.push('withDate');
+      options.date =
+        html.tag('b',
+          language.formatDate(data.date));
+    }
+
+    return (
+      html.tag('p', {class: 'quick-info'},
+        language.formatString(...parts, options)));
+  },
+};
diff --git a/src/content/dependencies/generateAlbumGalleryTrackGrid.js b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
new file mode 100644
index 00000000..85e7576c
--- /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),
+
+    trackArtworkArtists:
+      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.trackArtworkArtists.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
new file mode 100644
index 00000000..d0788523
--- /dev/null
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -0,0 +1,238 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateAlbumAdditionalFilesList',
+    'generateAlbumArtworkColumn',
+    'generateAlbumBanner',
+    'generateAlbumNavAccent',
+    'generateAlbumReleaseInfo',
+    'generateAlbumSecondaryNav',
+    'generateAlbumSidebar',
+    'generateAlbumSocialEmbed',
+    'generateAlbumStyleRules',
+    'generateAlbumTrackList',
+    'generateCommentaryEntry',
+    'generateContentHeading',
+    'generatePageLayout',
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', album, null),
+
+    socialEmbed:
+      relation('generateAlbumSocialEmbed', album),
+
+    albumNavAccent:
+      relation('generateAlbumNavAccent', album, null),
+
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', album),
+
+    sidebar:
+      relation('generateAlbumSidebar', album, null),
+
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', album.additionalNames),
+
+    artworkColumn:
+      relation('generateAlbumArtworkColumn', album),
+
+    banner:
+      (album.hasBannerArt
+        ? relation('generateAlbumBanner', album)
+        : null),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    releaseInfo:
+      relation('generateAlbumReleaseInfo', album),
+
+    galleryLink:
+      (album.tracks.some(t => t.hasUniqueCoverArt)
+        ? relation('linkAlbumGallery', album)
+        : null),
+
+    commentaryLink:
+      ([album, ...album.tracks].some(({commentary}) => !empty(commentary))
+        ? relation('linkAlbumCommentary', album)
+        : null),
+
+    trackList:
+      relation('generateAlbumTrackList', album),
+
+    additionalFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        album,
+        album.additionalFiles),
+
+    artistCommentaryEntries:
+      album.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
+
+    creditSourceEntries:
+      album.creditSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
+  }),
+
+  data: (album) => ({
+    name:
+      album.name,
+
+    color:
+      album.color,
+
+    dateAddedToWiki:
+      album.dateAddedToWiki,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            album: data.name,
+          }),
+
+        color: data.color,
+        headingMode: 'sticky',
+        styleRules: [relations.albumStyleRules],
+
+        additionalNames: relations.additionalNamesBox,
+
+        artworkColumnContent:
+          relations.artworkColumn,
+
+        mainContent: [
+          relations.releaseInfo,
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate('releaseInfo', capsule => [
+              !html.isBlank(relations.additionalFilesList) &&
+                language.$(capsule, 'additionalFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#additional-files'},
+                    language.$(capsule, 'additionalFiles.shortcut.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, 'readCreditSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#credit-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
+
+          relations.trackList,
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate('releaseInfo', capsule => [
+              language.$(capsule, 'addedToWiki', {
+                [language.onlyIfOptions]: ['date'],
+                date: language.formatDate(data.dateAddedToWiki),
+              }),
+            ])),
+
+          language.encapsulate('releaseInfo.additionalFiles', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'additional-files'},
+                  title: language.$(capsule, 'heading'),
+                }),
+
+              relations.additionalFilesList,
+            ])),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'artist-commentary'},
+                title: language.$('misc.artistCommentary'),
+              }),
+
+            relations.artistCommentaryEntries,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'credit-sources'},
+                title: language.$('misc.creditSources'),
+              }),
+
+            relations.creditSourceEntries,
+          ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {
+            auto: 'current',
+            accent:
+              relations.albumNavAccent.slots({
+                showTrackNavigation: true,
+                showExtraLinks: true,
+              }),
+          },
+        ],
+
+        banner: relations.banner ?? null,
+        bannerPosition: 'top',
+
+        secondaryNav: relations.secondaryNav,
+
+        leftSidebar: relations.sidebar,
+
+        socialEmbed: relations.socialEmbed,
+      })),
+};
diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js
new file mode 100644
index 00000000..432c5f3d
--- /dev/null
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -0,0 +1,142 @@
+import {atOffset, empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
+    'linkTrack',
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album, track) {
+    const query = {};
+
+    const index =
+      (track
+        ? album.tracks.indexOf(track)
+        : null);
+
+    query.previousTrack =
+      (track
+        ? atOffset(album.tracks, index, -1)
+        : null);
+
+    query.nextTrack =
+      (track
+        ? atOffset(album.tracks, index, +1)
+        : null);
+
+    return query;
+  },
+
+  relations: (relation, query, album, _track) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+
+    previousTrackLink:
+      (query.previousTrack
+        ? relation('linkTrack', query.previousTrack)
+        : null),
+
+    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},
+    showExtraLinks: {type: 'boolean', default: false},
+
+    currentExtra: {
+      validate: v => v.is('gallery', 'commentary'),
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const albumNavCapsule = language.encapsulate('albumPage.nav');
+    const trackNavCapsule = language.encapsulate('trackPage.nav');
+
+    const previousLink =
+      data.isTrackPage &&
+        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 =
+      data.hasMultipleTracks &&
+        html.tag('a',
+          {id: 'random-button'},
+          {href: '#', 'data-random': 'track-in-sidebar'},
+
+          (data.isTrackPage
+            ? language.$(trackNavCapsule, 'random')
+            : language.$(albumNavCapsule, 'randomTrack')));
+
+    return relations.switcher.slots({
+      links: [
+        slots.showTrackNavigation &&
+          previousLink,
+
+        slots.showTrackNavigation &&
+          nextLink,
+
+        slots.showExtraLinks &&
+          galleryLink,
+
+        slots.showExtraLinks &&
+          commentaryLink,
+
+        slots.showTrackNavigation &&
+          randomLink,
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumReferencedArtworksPage.js b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
new file mode 100644
index 00000000..7586393c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
@@ -0,0 +1,58 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleRules',
+    'generateBackToAlbumLink',
+    'generateReferencedArtworksPage',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    page:
+      relation('generateReferencedArtworksPage', album.coverArtworks[0]),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', 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,
+        }),
+
+      styleRules: [relations.albumStyleRules],
+
+      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..d072d2f6
--- /dev/null
+++ b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
@@ -0,0 +1,58 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleRules',
+    'generateBackToAlbumLink',
+    'generateReferencingArtworksPage',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    page:
+      relation('generateReferencingArtworksPage', album.coverArtworks[0]),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', 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,
+        }),
+
+      styleRules: [relations.albumStyleRules],
+
+      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
new file mode 100644
index 00000000..0abb412c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -0,0 +1,107 @@
+import {accumulateSum, empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateReleaseInfoContributionsLine',
+    'linkExternal',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album) {
+    const relations = {};
+
+    relations.artistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.artistContribs);
+
+    relations.wallpaperArtistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs);
+
+    relations.bannerArtistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
+
+    relations.externalLinks =
+      album.urls.map(url =>
+        relation('linkExternal', url));
+
+    return relations;
+  },
+
+  data(album) {
+    const data = {};
+
+    if (album.date) {
+      data.date = album.date;
+    }
+
+    if (album.coverArtDate && +album.coverArtDate !== +album.date) {
+      data.coverArtDate = album.coverArtDate;
+    }
+
+    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}) =>
+    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',
+            }),
+
+            language.$(capsule, 'released', {
+              [language.onlyIfOptions]: ['date'],
+              date: language.formatDate(data.date),
+            }),
+
+            language.$(capsule, 'duration', {
+              [language.onlyIfOptions]: ['duration'],
+              duration:
+                language.formatDuration(data.duration, {
+                  approximate: data.durationApproximate,
+                }),
+            }),
+          ]),
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+
+          language.$(capsule, 'listenOn', {
+            [language.onlyIfOptions]: ['links'],
+
+            links:
+              language.formatDisjunctionList(
+                relations.externalLinks
+                  .map(link =>
+                    link.slot('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
new file mode 100644
index 00000000..bfa48f03
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSecondaryNav.js
@@ -0,0 +1,127 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumSecondaryNavGroupPart',
+    'generateAlbumSecondaryNavSeriesPart',
+    'generateDotSwitcherTemplate',
+    'generateSecondaryNav',
+  ],
+
+  extraDependencies: ['html', 'wikiData'],
+
+  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;
+
+    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, _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}) {
+    const groupConnectedParts =
+      stitchArrays({
+        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,
+    ];
+
+    return relations.secondaryNav.slots({
+      alwaysVisible: slots.alwaysVisible,
+
+      attributes: [
+        {class: 'album-secondary-nav'},
+
+        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
new file mode 100644
index 00000000..7cf689cc
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -0,0 +1,171 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import {stitchArrays, transposeArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarGroupBox',
+    'generateAlbumSidebarSeriesBox',
+    'generateAlbumSidebarTrackListBox',
+    'generatePageSidebar',
+    'generatePageSidebarConjoinedBox',
+    'generateTrackReleaseBox',
+  ],
+
+  extraDependencies: ['html', 'wikiData'],
+
+  sprawl: ({groupData}) => ({
+    // TODO: Series aren't their own things, so we access them weirdly.
+    seriesData:
+      groupData.flatMap(group => group.serieses),
+  }),
+
+  query(sprawl, album, track) {
+    const query = {};
+
+    query.groups =
+      album.groups;
+
+    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));
+
+    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);
+
+      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}) {
+    for (const box of [
+      ...relations.groupBoxes,
+      ...relations.seriesBoxes.flat(),
+      ...relations.disconnectedSeriesBoxes,
+    ]) {
+      box.setSlot('mode',
+        data.isAlbumPage ? 'album' : 'track');
+    }
+
+    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
new file mode 100644
index 00000000..f3be74f7
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -0,0 +1,126 @@
+import {sortChronologically} from '#sort';
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkAlbum',
+    'linkExternal',
+    'linkGroup',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album, group) {
+    const query = {};
+
+    if (album.date) {
+      const albums =
+        group.albums.filter(album => 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.
+      sortChronologically(albums, {latestFirst: true});
+
+      const index =
+        albums.indexOf(album);
+
+      query.previousAlbum =
+        atOffset(albums, index, +1);
+
+      query.nextAlbum =
+        atOffset(albums, index, -1);
+    }
+
+    return query;
+  },
+
+  relations(relation, query, album, group) {
+    const relations = {};
+
+    relations.box =
+      relation('generatePageSidebarBox');
+
+    relations.groupLink =
+      relation('linkGroup', group);
+
+    relations.externalLinks =
+      group.urls.map(url =>
+        relation('linkExternal', url));
+
+    if (group.descriptionShort) {
+      relations.description =
+        relation('transformContent', group.descriptionShort);
+    }
+
+    if (query.previousAlbum) {
+      relations.previousAlbumLink =
+        relation('linkAlbum', query.previousAlbum);
+    }
+
+    if (query.nextAlbum) {
+      relations.nextAlbumLink =
+        relation('linkAlbum', query.nextAlbum);
+    }
+
+    return relations;
+  },
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'track',
+    },
+  },
+
+  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
new file mode 100644
index 00000000..dae5fa03
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -0,0 +1,167 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['getColors', 'html', 'language'],
+
+  relations(relation, album, track, trackSection) {
+    const relations = {};
+
+    relations.trackLinks =
+      trackSection.tracks.map(track =>
+        relation('linkTrack', track));
+
+    return relations;
+  },
+
+  data(album, track, trackSection) {
+    const data = {};
+
+    data.hasTrackNumbers =
+      album.hasTrackNumbers &&
+      !empty(trackSection.tracks);
+
+    data.isTrackPage = !!track;
+
+    data.name = trackSection.name;
+    data.color = trackSection.color;
+    data.isDefaultTrackSection = trackSection.isDefaultTrackSection;
+
+    data.firstTrackNumber =
+      (data.hasTrackNumbers
+        ? trackSection.tracks.at(0).trackNumber
+        : null);
+
+    data.lastTrackNumber =
+      (data.hasTrackNumbers
+        ? trackSection.tracks.at(-1).trackNumber
+        : null);
+
+    data.trackDirectories =
+      trackSection.tracks
+        .map(track => track.directory);
+
+    data.tracksAreMissingCommentary =
+      trackSection.tracks
+        .map(track => empty(track.commentary));
+
+    data.tracksAreCurrentTrack =
+      trackSection.tracks
+        .map(traaaaaaaack => traaaaaaaack === track);
+
+    data.includesCurrentTrack =
+      data.tracksAreCurrentTrack.includes(true);
+
+    return data;
+  },
+
+  slots: {
+    anchor: {type: 'boolean'},
+    open: {type: 'boolean'},
+
+    mode: {
+      validate: v => v.is('info', 'commentary'),
+      default: 'info',
+    },
+  },
+
+  generate(data, relations, slots, {getColors, html, language}) {
+    const capsule = language.encapsulate('albumSidebar.trackList');
+
+    const sectionName =
+      html.tag('b',
+        (data.isDefaultTrackSection
+          ? language.$(capsule, 'fallbackSectionName')
+          : data.name));
+
+    let colorStyle;
+    if (data.color) {
+      const {primary} = getColors(data.color);
+      colorStyle = {style: `--primary-color: ${primary}`};
+    }
+
+    const trackListItems =
+      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 &&
+        {class: 'current'},
+
+      // Allow forcing open via a template slot.
+      // This isn't exactly janky, but the rest of this function
+      // kind of is when you contextualize it in a template...
+      slots.open &&
+        {open: true},
+
+      // Leave sidebar track sections collapsed on album info page,
+      // since there's already a view of the full track listing
+      // in the main content area.
+      data.isTrackPage &&
+
+      // Only expand the track section which includes the track
+      // currently being viewed by default.
+      data.includesCurrentTrack &&
+        {open: true},
+
+      [
+        html.tag('summary',
+          colorStyle,
+
+          html.tag('span',
+            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',
+              {start: data.firstTrackNumber},
+              trackListItems)
+          : html.tag('ul', trackListItems)),
+      ]);
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js
new file mode 100644
index 00000000..e28a3fd0
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSocialEmbed.js
@@ -0,0 +1,70 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateSocialEmbed',
+    'generateAlbumSocialEmbedDescription',
+  ],
+
+  extraDependencies: ['absoluteTo', 'language'],
+
+  relations(relation, album) {
+    return {
+      socialEmbed:
+        relation('generateSocialEmbed'),
+
+      description:
+        relation('generateAlbumSocialEmbedDescription', album),
+    };
+  },
+
+  data(album) {
+    const data = {};
+
+    data.hasHeading = !empty(album.groups);
+
+    if (data.hasHeading) {
+      const firstGroup = album.groups[0];
+      data.headingGroupName = firstGroup.name;
+      data.headingGroupDirectory = firstGroup.directory;
+    }
+
+    data.hasImage = album.hasCoverArt;
+
+    if (data.hasImage) {
+      data.imagePath = album.coverArtworks[0].path;
+    }
+
+    data.albumName = album.name;
+
+    return data;
+  },
+
+  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
new file mode 100644
index 00000000..69c39c3a
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js
@@ -0,0 +1,41 @@
+import {accumulateSum} from '#sugar';
+
+export default {
+  extraDependencies: ['language'],
+
+  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
new file mode 100644
index 00000000..6bfcc62e
--- /dev/null
+++ b/src/content/dependencies/generateAlbumStyleRules.js
@@ -0,0 +1,107 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  extraDependencies: ['to'],
+
+  data(album, track) {
+    const data = {};
+
+    data.hasWallpaper = !empty(album.wallpaperArtistContribs);
+    data.hasBanner = !empty(album.bannerArtistContribs);
+
+    if (data.hasWallpaper) {
+      if (!empty(album.wallpaperParts)) {
+        data.wallpaperMode = 'parts';
+
+        data.wallpaperPaths =
+          album.wallpaperParts.map(part =>
+            (part.asset
+              ? ['media.albumWallpaperPart', album.directory, part.asset]
+              : null));
+
+        data.wallpaperStyles =
+          album.wallpaperParts.map(part => part.style);
+      } else {
+        data.wallpaperMode = 'one';
+        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 oneWallpaperRule =
+      data.wallpaperMode === 'one' &&
+        rule(`body::before`, [
+          `background-image: url("${to(...data.wallpaperPath)}");`,
+          data.wallpaperStyle,
+        ]);
+
+    const wallpaperPartRules =
+      data.wallpaperMode === 'parts' &&
+        stitchArrays({
+          path: data.wallpaperPaths,
+          style: data.wallpaperStyles,
+        }).map(({path, style}, index) =>
+            rule(`.wallpaper-part:nth-child(${index + 1})`, [
+              path && `background-image: url("${to(...path)}");`,
+              style,
+            ]));
+
+    const nukeBasicWallpaperRule =
+      data.wallpaperMode === 'parts' &&
+        rule(`body::before`, ['display: none']);
+
+    const wallpaperRules = [
+      oneWallpaperRule,
+      ...wallpaperPartRules || [],
+      nukeBasicWallpaperRule,
+    ];
+
+    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 (
+      [...wallpaperRules, bannerRule, dataRule]
+        .filter(Boolean)
+        .flat()
+        .join('\n'));
+  },
+};
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
new file mode 100644
index 00000000..0a949ded
--- /dev/null
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -0,0 +1,206 @@
+import {accumulateSum, empty, stitchArrays} from '#sugar';
+
+function displayTrackSections(album) {
+  if (empty(album.trackSections)) {
+    return false;
+  }
+
+  if (album.trackSections.length > 1) {
+    return true;
+  }
+
+  if (!album.trackSections[0].isDefaultTrackSection) {
+    return true;
+  }
+
+  return false;
+}
+
+function displayTracks(album) {
+  if (empty(album.tracks)) {
+    return false;
+  }
+
+  return true;
+}
+
+function getDisplayMode(album) {
+  if (displayTrackSections(album)) {
+    return 'trackSections';
+  } else if (displayTracks(album)) {
+    return 'tracks';
+  } else {
+    return 'none';
+  }
+}
+
+export default {
+  contentDependencies: [
+    'generateAlbumTrackListItem',
+    'generateContentHeading',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album) {
+    return {
+      displayMode: getDisplayMode(album),
+    };
+  },
+
+  relations(relation, query, album) {
+    const relations = {};
+
+    switch (query.displayMode) {
+      case 'trackSections':
+        relations.trackSectionHeadings =
+          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 =>
+              relation('generateAlbumTrackListItem', track, album)));
+
+        break;
+
+      case 'tracks':
+        relations.items =
+          album.tracks.map(track =>
+            relation('generateAlbumTrackListItem', track, album));
+
+        break;
+    }
+
+    return relations;
+  },
+
+  data(query, album) {
+    const data = {};
+
+    data.displayMode = query.displayMode;
+    data.hasTrackNumbers = album.hasTrackNumbers;
+
+    switch (query.displayMode) {
+      case 'trackSections':
+        data.trackSectionNames =
+          album.trackSections
+            .map(section => section.name);
+
+        data.trackSectionDurations =
+          album.trackSections
+            .map(section =>
+              accumulateSum(section.tracks, track => track.duration));
+
+        data.trackSectionDurationsApproximate =
+          album.trackSections
+            .map(section => section.tracks.length > 1);
+
+        if (album.hasTrackNumbers) {
+          data.trackSectionsStartCountingFrom =
+            album.trackSections
+              .map(section => section.startCountingFrom);
+        } else {
+          data.trackSectionsStartCountingFrom =
+            album.trackSections
+              .map(() => null);
+        }
+
+        break;
+    }
+
+    return data;
+  },
+
+  slots: {
+    collapseDurationScope: {
+      validate: v =>
+        v.is('never', 'track', 'section', 'album'),
+
+      default: 'album',
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const listTag = (data.hasTrackNumbers ? 'ol' : 'ul');
+
+    const slotItems = items =>
+      items.map(item =>
+        item.slots({
+          collapseDurationScope:
+            slots.collapseDurationScope,
+        }));
+
+    switch (data.displayMode) {
+      case 'trackSections':
+        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,
+            startCountingFrom: data.trackSectionsStartCountingFrom,
+          }).map(({
+              heading,
+              description,
+              items,
+
+              name,
+              duration,
+              durationApproximate,
+              startCountingFrom,
+            }) => [
+              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(listTag,
+                  data.hasTrackNumbers &&
+                    {start: startCountingFrom},
+
+                  slotItems(items)),
+              ]),
+            ]));
+
+      case 'tracks':
+        return html.tag(listTag, slotItems(relations.items));
+
+      default:
+        return html.blank();
+    }
+  }
+};
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
new file mode 100644
index 00000000..44297c15
--- /dev/null
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -0,0 +1,62 @@
+export default {
+  contentDependencies: ['generateTrackListItem'],
+  extraDependencies: ['html'],
+
+  query: (track, album) => ({
+    trackHasDuration:
+      !!track.duration,
+
+    sectionHasDuration:
+      !album.trackSections
+        .some(section =>
+          section.tracks.every(track => !track.duration) &&
+          section.tracks.includes(track)),
+
+    albumHasDuration:
+      album.tracks.some(track => track.duration),
+  }),
+
+  relations: (relation, query, track) => ({
+    item:
+      relation('generateTrackListItem',
+        track,
+        track.album.artistContribs),
+  }),
+
+  data: (query, track, album) => ({
+    trackHasDuration: query.trackHasDuration,
+    sectionHasDuration: query.sectionHasDuration,
+    albumHasDuration: query.albumHasDuration,
+
+    colorize:
+      track.color !== album.color,
+  }),
+
+  slots: {
+    collapseDurationScope: {
+      validate: v =>
+        v.is('never', 'track', 'section', 'album'),
+
+      default: 'album',
+    },
+  },
+
+  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/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
new file mode 100644
index 00000000..344e7bda
--- /dev/null
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -0,0 +1,222 @@
+import {sortArtworksChronologically} from '#sort';
+import {empty, unique} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateArtTagGalleryPageFeaturedLine',
+    'generateArtTagGalleryPageShowingLine',
+    'generateArtTagNavLinks',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'generateQuickDescription',
+    'image',
+    'linkAnythingMan',
+    'linkArtTagGallery',
+    'linkExternal',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      enableListings: wikiInfo.enableListings,
+    };
+  },
+
+  query(sprawl, artTag) {
+    const directArtworks = artTag.directlyFeaturedInArtworks;
+    const indirectArtworks = artTag.indirectlyFeaturedInArtworks;
+    const allArtworks = unique([...directArtworks, ...indirectArtworks]);
+
+    sortArtworksChronologically(allArtworks, {latestFirst: true});
+
+    return {directArtworks, indirectArtworks, allArtworks};
+  },
+
+  relations(relation, query, sprawl, artTag) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    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.allArtworks
+        .map(artwork => relation('linkAnythingMan', artwork.thing));
+
+    relations.images =
+      query.allArtworks
+        .map(artwork => relation('image', artwork));
+
+    return relations;
+  },
+
+  data(query, sprawl, artTag) {
+    const data = {};
+
+    data.enableListings = sprawl.enableListings;
+
+    data.name = artTag.name;
+    data.color = artTag.color;
+
+    data.numArtworksIndirectly = query.indirectArtworks.length;
+    data.numArtworksDirectly = query.directArtworks.length;
+    data.numArtworksTotal = query.allArtworks.length;
+
+    data.names =
+      query.allArtworks
+        .map(artwork => artwork.thing.name);
+
+    data.coverArtists =
+      query.allArtworks
+        .map(artwork => artwork.artistContribs
+          .map(contrib => contrib.artist.name));
+
+    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}) =>
+    language.encapsulate('artTagGalleryPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            tag: data.name,
+          }),
+
+        headingMode: 'static',
+        color: data.color,
+
+        additionalNames: relations.additionalNamesBox,
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          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,
+              lazy: 12,
+
+              classes:
+                data.onlyFeaturedIndirectly.map(onlyFeaturedIndirectly =>
+                  (onlyFeaturedIndirectly ? 'featured-indirectly' : '')),
+
+              info:
+                data.coverArtists.map(names =>
+                  (names === null
+                    ? null
+                    : language.$('misc.coverGrid.details.coverArtists', {
+                        artists: language.formatUnitList(names),
+                      }))),
+            }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        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..6bdbeb23
--- /dev/null
+++ b/src/content/dependencies/generateArtistCredit.js
@@ -0,0 +1,180 @@
+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.normalContributionsDifferFromContext =
+      !compareArrays(
+        query.normalContributions.map(({artist}) => artist),
+        contextNormalContributions.map(({artist}) => artist),
+        {checkOrder: false});
+
+    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) => ({
+    normalContributionsDifferFromContext:
+      query.normalContributionsDifferFromContext,
+
+    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,
+      ]);
+
+    if (empty(relations.featuringContributionLinks)) {
+      if (data.normalContributionsDifferFromContext) {
+        return language.$(slots.normalStringKey, {
+          ...slots.additionalStringOptions,
+          artists: artistsList,
+        });
+      } else {
+        return html.blank();
+      }
+    }
+
+    if (data.normalContributionsDifferFromContext && 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
new file mode 100644
index 00000000..6a24275e
--- /dev/null
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -0,0 +1,108 @@
+import {sortArtworksChronologically} from '#sort';
+
+export default {
+  contentDependencies: [
+    'generateArtistNavLinks',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkAnythingMan',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  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.$(pageCapsule, 'title', {
+            artist: data.name,
+          }),
+
+        headingMode: 'static',
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          html.tag('p', {class: 'quick-info'},
+            language.$(pageCapsule, 'infoLine', {
+              coverArts:
+                language.countArtworks(data.numArtworks, {
+                  unit: true,
+                }),
+            })),
+
+          relations.coverGrid
+            .slots({
+              links: relations.links,
+              images: relations.images,
+              names: data.names,
+
+              info:
+                data.otherCoverArtists.map(names =>
+                  language.$('misc.coverGrid.details.otherCoverArtists', {
+                    [language.onlyIfOptions]: ['artists'],
+
+                    artists: language.formatUnitList(names),
+                  })),
+            }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks:
+          relations.artistNavLinks
+            .slots({
+              showExtraLinks: true,
+              currentExtra: 'gallery',
+            })
+            .content,
+      })),
+}
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
new file mode 100644
index 00000000..3e0cd1d2
--- /dev/null
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -0,0 +1,234 @@
+import {empty, filterProperties, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: ['linkGroup'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({groupCategoryData}) {
+    return {
+      groupOrder: groupCategoryData.flatMap(category => category.groups),
+    }
+  },
+
+  query(sprawl, tracksAndAlbums) {
+    const filteredAlbums = tracksAndAlbums.filter(thing => !thing.album);
+    const filteredTracks = tracksAndAlbums.filter(thing => thing.album);
+
+    const allAlbums = unique([
+      ...filteredAlbums,
+      ...filteredTracks.map(track => track.album),
+    ]);
+
+    const allGroupsUnordered = new Set(Array.from(allAlbums).flatMap(album => album.groups));
+    const allGroupsOrdered = sprawl.groupOrder.filter(group => allGroupsUnordered.has(group));
+
+    const mapTemplate = allGroupsOrdered.map(group => [group, 0]);
+    const groupToCountMap = new Map(mapTemplate);
+    const groupToDurationMap = new Map(mapTemplate);
+    const groupToDurationCountMap = new Map(mapTemplate);
+
+    for (const album of filteredAlbums) {
+      for (const group of album.groups) {
+        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
+      }
+    }
+
+    for (const track of filteredTracks) {
+      for (const group of track.album.groups) {
+        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
+        if (track.duration && track.mainReleaseTrack === null) {
+          groupToDurationMap.set(group, groupToDurationMap.get(group) + track.duration);
+          groupToDurationCountMap.set(group, groupToDurationCountMap.get(group) + 1);
+        }
+      }
+    }
+
+    const groupsSortedByCount =
+      allGroupsOrdered
+        .slice()
+        .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.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));
+
+    const groupCountsSortedByCount =
+      groupsSortedByCount
+        .map(group => groupToCountMap.get(group));
+
+    const groupDurationsSortedByCount =
+      groupsSortedByCount
+        .map(group => groupToDurationMap.get(group));
+
+    const groupDurationsApproximateSortedByCount =
+      groupsSortedByCount
+        .map(group => groupToDurationCountMap.get(group) > 1);
+
+    const groupCountsSortedByDuration =
+      groupsSortedByDuration
+        .map(group => groupToCountMap.get(group));
+
+    const groupDurationsSortedByDuration =
+      groupsSortedByDuration
+        .map(group => groupToDurationMap.get(group));
+
+    const groupDurationsApproximateSortedByDuration =
+      groupsSortedByDuration
+        .map(group => groupToDurationCountMap.get(group) > 1);
+
+    return {
+      groupsSortedByCount,
+      groupsSortedByDuration,
+
+      groupCountsSortedByCount,
+      groupDurationsSortedByCount,
+      groupDurationsApproximateSortedByCount,
+
+      groupCountsSortedByDuration,
+      groupDurationsSortedByDuration,
+      groupDurationsApproximateSortedByDuration,
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      groupLinksSortedByCount:
+        query.groupsSortedByCount
+          .map(group => relation('linkGroup', group)),
+
+      groupLinksSortedByDuration:
+        query.groupsSortedByDuration
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return filterProperties(query, [
+      'groupCountsSortedByCount',
+      'groupDurationsSortedByCount',
+      'groupDurationsApproximateSortedByCount',
+
+      'groupCountsSortedByDuration',
+      'groupDurationsSortedByDuration',
+      'groupDurationsApproximateSortedByDuration',
+    ]);
+  },
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    showBothColumns: {type: 'boolean'},
+    showSortButton: {type: 'boolean'},
+    visible: {type: 'boolean', default: true},
+
+    sort: {validate: v => v.is('count', 'duration')},
+    countUnit: {validate: v => v.is('tracks', 'artworks')},
+  },
+
+  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',
+      ];
+
+      // 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
new file mode 100644
index 00000000..3a3cf8b7
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -0,0 +1,401 @@
+import {empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistArtworkColumn',
+    'generateArtistGroupContributionsInfo',
+    'generateArtistInfoPageArtworksChunkedList',
+    'generateArtistInfoPageCommentaryChunkedList',
+    'generateArtistInfoPageFlashesChunkedList',
+    'generateArtistInfoPageTracksChunkedList',
+    'generateArtistNavLinks',
+    'generateContentHeading',
+    'generatePageLayout',
+    'linkArtistGallery',
+    'linkExternal',
+    'linkGroup',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (artist) => ({
+    // 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 in the list.
+    allTracks:
+      unique(
+        ([
+          artist.trackArtistContributions,
+          artist.trackContributorContributions,
+        ]).flat()
+          .map(({thing}) => thing)),
+
+    // 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.
+    allArtworkThings:
+      ([
+        artist.albumCoverArtistContributions,
+        artist.albumWallpaperArtistContributions,
+        artist.albumBannerArtistContributions,
+        artist.trackCoverArtistContributions,
+      ]).flat()
+        .filter(({annotation}) => !annotation?.startsWith('edits for wiki'))
+        .map(({thing}) => thing.thing),
+
+    // 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.allTracks),
+
+    artworksChunkedList:
+      relation('generateArtistInfoPageArtworksChunkedList', artist, false),
+
+    editsForWikiArtworksChunkedList:
+      relation('generateArtistInfoPageArtworksChunkedList', artist, true),
+
+    artworksGroupInfo:
+      relation('generateArtistGroupContributionsInfo', query.allArtworkThings),
+
+    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:
+      query.allTracks.length,
+
+    totalDuration:
+      artist.totalDuration,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistPage', pageCapsule =>
+      relations.layout.slots({
+        title: data.name,
+        headingMode: 'sticky',
+
+        artworkColumnContent:
+          relations.artworkColumn,
+
+        mainContent: [
+          html.tags([
+            html.tag('p',
+              {[html.onlyIfSiblings]: true},
+              language.$('releaseInfo.note')),
+
+            html.tag('blockquote',
+              {[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);
+                          }))),
+                });
+              }),
+
+              language.$(capsule, 'alias', {
+                [language.onlyIfOptions]: ['groups'],
+
+                groups:
+                  language.formatConjunctionList(relations.aliasGroupLinks),
+              }),
+            ])),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              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'],
+
+              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',
+                attributes: {id: 'tracks'},
+                title: language.$(pageCapsule, 'trackList.title'),
+              }),
+
+            data.totalDuration > 0 &&
+              html.tag('p',
+                {[html.onlyIfSiblings]: true},
+
+                language.$(pageCapsule, 'contributedDurationLine', {
+                  artist: data.name,
+                  duration:
+                    language.formatDuration(data.totalDuration, {
+                      approximate: data.totalTrackCount > 1,
+                      unit: true,
+                    }),
+                })),
+
+            relations.tracksChunkedList.slots({
+              groupInfo:
+                language.encapsulate(pageCapsule, 'groupContributions', capsule => [
+                  relations.tracksGroupInfo.clone()
+                    .slots({
+                      title: language.$(capsule, 'title.music'),
+                      showSortButton: true,
+                      sort: 'count',
+                      countUnit: 'tracks',
+                      visible: true,
+                    }),
+
+                  relations.tracksGroupInfo.clone()
+                    .slots({
+                      title: language.$(capsule, 'title.music'),
+                      showSortButton: true,
+                      sort: 'duration',
+                      countUnit: 'tracks',
+                      visible: false,
+                    }),
+                ]),
+            }),
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
+                attributes: {id: 'art'},
+                title: language.$(pageCapsule, 'artList.title'),
+              }),
+
+            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'),
+                    }),
+                }))),
+
+            relations.artworksChunkedList
+              .slots({
+                groupInfo:
+                  language.encapsulate(pageCapsule, 'groupContributions', capsule =>
+                    relations.artworksGroupInfo
+                      .slots({
+                        title: language.$(capsule, 'title.artworks'),
+                        showBothColumns: false,
+                        sort: 'count',
+                        countUnit: 'artworks',
+                      })),
+              }),
+
+            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',
+                attributes: {id: 'flashes'},
+                title: language.$(pageCapsule, 'flashList.title'),
+              }),
+
+            relations.flashesChunkedList,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
+                attributes: {id: 'commentary'},
+                title: language.$(pageCapsule, 'commentaryList.title'),
+              }),
+
+            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',
+        navLinks:
+          relations.artistNavLinks
+            .slots({
+              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..2f2fe0c5
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
@@ -0,0 +1,72 @@
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageOtherArtistLinks',
+    'linkTrack',
+  ],
+
+  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]),
+  }),
+
+  data: (query, contrib) => ({
+    kind:
+      query.kind,
+
+    annotation:
+      contrib.annotation,
+  }),
+
+  slots: {
+    filterEditsForWiki: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.template.slots({
+      otherArtistLinks: relations.otherArtistLinks,
+
+      annotation:
+        (slots.filterEditsForWiki
+          ? data.annotation?.replace(/^edits for wiki(: )?/, '')
+          : data.annotation),
+
+      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')))))),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
new file mode 100644
index 00000000..75a4aa5a
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -0,0 +1,72 @@
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {chunkByConditions, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkedList',
+    'generateArtistInfoPageArtworksChunk',
+  ],
+
+  query(artist, filterEditsForWiki) {
+    const query = {};
+
+    const allContributions = [
+      ...artist.albumCoverArtistContributions,
+      ...artist.albumWallpaperArtistContributions,
+      ...artist.albumBannerArtistContributions,
+      ...artist.trackCoverArtistContributions,
+    ];
+
+    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;
+  },
+
+  relations: (relation, query, _artist, _filterEditsForWiki) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
+
+    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:
+        relations.chunks.map(chunk =>
+          chunk.slot('filterEditsForWiki', data.filterEditsForWiki)),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js
new file mode 100644
index 00000000..fce68a7d
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageChunk.js
@@ -0,0 +1,114 @@
+import {empty} from '#sugar';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    mode: {
+      validate: v => v.is('flash', 'album'),
+    },
+
+    id: {type: 'string'},
+
+    albumLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    flashActLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    items: {
+      type: 'html',
+      mutable: false,
+    },
+
+    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: {
+      switch (slots.mode) {
+        case 'album': {
+          accentedLink = slots.albumLink;
+
+          const options = {album: accentedLink};
+          const parts = ['artistPage.creditList.album'];
+
+          if (onlyDate) {
+            parts.push('withDate');
+            options.date = language.formatDate(onlyDate);
+          }
+
+          if (slots.duration) {
+            parts.push('withDuration');
+            options.duration =
+              language.formatDuration(slots.duration, {
+                approximate: slots.durationApproximate,
+              });
+          }
+
+          accentedLink = language.formatString(...parts, options);
+          break;
+        }
+
+        case 'flash': {
+          accentedLink = slots.flashActLink;
+
+          const options = {act: accentedLink};
+          const parts = ['artistPage.creditList.flashAct'];
+
+          if (onlyDate) {
+            parts.push('withDate');
+            options.date = language.formatDate(onlyDate);
+          } else if (earliestDate && latestDate) {
+            parts.push('withDateRange');
+            options.dateRange =
+              language.formatDateRange(earliestDate, latestDate);
+          }
+
+          accentedLink = language.formatString(...parts, options);
+          break;
+        }
+      }
+    }
+
+    return html.tags([
+      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
new file mode 100644
index 00000000..7987b642
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js
@@ -0,0 +1,91 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['generateTextWithTooltip'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+  }),
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    annotation: {
+      type: 'html',
+      mutable: false,
+    },
+
+    otherArtistLinks: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    rereleaseTooltip: {
+      type: 'html',
+      mutable: false,
+    },
+
+    firstReleaseTooltip: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry', entryCapsule =>
+      html.tag('li',
+        slots.rerelease && {class: 'rerelease'},
+
+        language.encapsulate(entryCapsule, workingCapsule => {
+          const workingOptions = {entry: slots.content};
+
+          if (!html.isBlank(slots.rereleaseTooltip)) {
+            workingCapsule += '.rerelease';
+            workingOptions.rerelease =
+              relations.textWithTooltip.slots({
+                attributes: {class: 'rerelease'},
+                text: language.$(entryCapsule, 'rerelease.term'),
+                tooltip: slots.rereleaseTooltip,
+              });
+
+            return language.$(workingCapsule, workingOptions);
+          }
+
+          if (!html.isBlank(slots.firstReleaseTooltip)) {
+            workingCapsule += '.firstRelease';
+            workingOptions.firstRelease =
+              relations.textWithTooltip.slots({
+                attributes: {class: 'first-release'},
+                text: language.$(entryCapsule, 'firstRelease.term'),
+                tooltip: slots.firstReleaseTooltip,
+              });
+
+            return language.$(workingCapsule, workingOptions);
+          }
+
+          let anyAccent = false;
+
+          if (!empty(slots.otherArtistLinks)) {
+            anyAccent = true;
+            workingCapsule += '.withArtists';
+            workingOptions.artists =
+              language.formatConjunctionList(slots.otherArtistLinks);
+          }
+
+          if (!html.isBlank(slots.annotation)) {
+            anyAccent = true;
+            workingCapsule += '.withAnnotation';
+            workingOptions.annotation = slots.annotation;
+          }
+
+          if (anyAccent) {
+            return language.$(workingCapsule, workingOptions);
+          } else {
+            return slots.content;
+          }
+        }))),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageChunkedList.js b/src/content/dependencies/generateArtistInfoPageChunkedList.js
new file mode 100644
index 00000000..e7915ab7
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageChunkedList.js
@@ -0,0 +1,20 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    groupInfo: {
+      type: 'html',
+      mutable: false,
+    },
+
+    chunks: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  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
new file mode 100644
index 00000000..d0c5e14e
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
@@ -0,0 +1,280 @@
+import {chunkByProperties, stitchArrays} from '#sugar';
+
+import {
+  sortAlbumsTracksChronologically,
+  sortByDate,
+  sortEntryThingPairs,
+} from '#sort';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageChunkItem',
+    'linkAlbum',
+    'linkFlash',
+    'linkFlashAct',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(artist, filterWikiEditorCommentary) {
+    const processEntry = ({
+      thing,
+      entry,
+
+      chunkType,
+      itemType,
+
+      album = null,
+      track = null,
+      flashAct = null,
+      flash = null,
+    }) => ({
+      thing: thing,
+      entry: {
+        chunkType,
+        itemType,
+
+        album,
+        track,
+        flashAct,
+        flash,
+
+        annotation: entry.annotation,
+      },
+    });
+
+    const processAlbumEntry = ({thing: album, entry}) =>
+      processEntry({
+        thing: album,
+        entry: entry,
+
+        chunkType: 'album',
+        itemType: 'album',
+
+        album: album,
+        track: null,
+      });
+
+    const processTrackEntry = ({thing: track, entry}) =>
+      processEntry({
+        thing: track,
+        entry: entry,
+
+        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(({annotation}) =>
+              (filterWikiEditorCommentary
+                ? annotation?.match(/^wiki editor/i)
+                : !annotation?.match(/^wiki editor/i)))
+
+            .map(entry => processEntry({thing, entry})));
+
+    const processAlbumEntries = ({albums}) =>
+      processEntries({
+        things: albums,
+        processEntry: processAlbumEntry,
+      });
+
+    const processTrackEntries = ({tracks}) =>
+      processEntries({
+        things: tracks,
+        processEntry: processTrackEntry,
+      });
+
+    const processFlashEntries = ({flashes}) =>
+      processEntries({
+        things: flashes,
+        processEntry: processFlashEntry,
+      });
+
+    const {
+      albumsAsCommentator,
+      tracksAsCommentator,
+      flashesAsCommentator,
+    } = artist;
+
+    const albumEntries =
+      processAlbumEntries({
+        albums: albumsAsCommentator,
+      });
+
+    const trackEntries =
+      processTrackEntries({
+        tracks: tracksAsCommentator,
+      });
+
+    const flashEntries =
+      processFlashEntries({
+        flashes: flashesAsCommentator,
+      })
+
+    const albumTrackEntries =
+      sortEntryThingPairs(
+        [...albumEntries, ...trackEntries],
+        sortAlbumsTracksChronologically);
+
+    const allEntries =
+      sortEntryThingPairs(
+        [...albumTrackEntries, ...flashEntries],
+        sortByDate);
+
+    const chunks =
+      chunkByProperties(
+        allEntries.map(({entry}) => entry),
+        ['chunkType', 'album', 'flashAct']);
+
+    return {chunks};
+  },
+
+  relations: (relation, query, _artist, filterWikiEditorCommentary) => ({
+    chunks:
+      query.chunks
+        .map(() => relation('generateArtistInfoPageChunk')),
+
+    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'))),
+
+    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}) =>
+            relation('transformContent',
+              (filterWikiEditorCommentary
+                ? annotation?.replace(/^wiki editor(, )?/i, '')
+                : annotation)))),
+  }),
+
+  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},
+
+      stitchArrays({
+        chunk: relations.chunks,
+        chunkLink: relations.chunkLinks,
+        chunkType: data.chunkTypes,
+
+        items: relations.items,
+        itemLinks: relations.itemLinks,
+        itemAnnotations: relations.itemAnnotations,
+        itemTypes: data.itemTypes,
+      }).map(({
+          chunk,
+          chunkLink,
+          chunkType,
+
+          items,
+          itemLinks,
+          itemAnnotations,
+          itemTypes,
+        }) =>
+          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
new file mode 100644
index 00000000..b347faf5
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
@@ -0,0 +1,62 @@
+import {sortContributionsChronologically, sortFlashesChronologically}
+  from '#sort';
+import {chunkByConditions, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkedList',
+    'generateArtistInfoPageFlashesChunk',
+  ],
+
+  extraDependencies: ['wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    enableFlashesAndGames:
+      wikiInfo.enableFlashesAndGames,
+  }),
+
+  query(sprawl, artist) {
+    const query = {};
+
+    const allContributions =
+      (sprawl.enableFlashesAndGames
+        ? [
+            ...artist.flashContributorContributions,
+          ]
+      : []);
+
+    sortContributionsChronologically(
+      allContributions,
+      sortFlashesChronologically);
+
+    query.contribs =
+      chunkByConditions(allContributions, [
+        ({thing: flash1}, {thing: flash2}) =>
+          flash1.act !== flash2.act,
+      ]);
+
+    query.flashActs =
+      query.contribs
+        .map(contribs => contribs[0].thing)
+        .map(thing => thing.act);
+
+    return query;
+  },
+
+  relations: (relation, query, _sprawl, _artist) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
+
+    chunks:
+      stitchArrays({
+        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
new file mode 100644
index 00000000..dcee9c00
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
@@ -0,0 +1,30 @@
+import {unique} from '#sugar';
+
+export default {
+  contentDependencies: ['linkArtist'],
+
+  query(contribs) {
+    const associatedContributionsByOtherArtists =
+      contribs
+        .flatMap(ownContrib =>
+          ownContrib.associatedContributions
+            .filter(associatedContrib =>
+              associatedContrib.artist !== ownContrib.artist));
+
+    const otherArtists =
+      unique(
+        associatedContributionsByOtherArtists
+          .map(contrib => contrib.artist));
+
+    return {otherArtists};
+  },
+
+  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
new file mode 100644
index 00000000..84eb29ac
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -0,0 +1,81 @@
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {stitchArrays} from '#sugar';
+import {chunkArtistTrackContributions} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkedList',
+    'generateArtistInfoPageTracksChunk',
+  ],
+
+  query(artist) {
+    const query = {};
+
+    const allContributions = [
+      ...artist.trackArtistContributions,
+      ...artist.trackContributorContributions,
+    ];
+
+    sortContributionsChronologically(
+      allContributions,
+      sortAlbumsTracksChronologically);
+
+    query.contribs =
+      chunkArtistTrackContributions(allContributions);
+
+    query.albums =
+      query.contribs
+        .map(contribs =>
+          contribs[0][0].thing.album);
+
+    return query;
+  },
+
+  relations: (relation, query, artist) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
+
+    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,
+          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
new file mode 100644
index 00000000..1b4b6eca
--- /dev/null
+++ b/src/content/dependencies/generateArtistNavLinks.js
@@ -0,0 +1,94 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'linkArtist',
+    'linkArtistGallery',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    enableListings:
+      wikiInfo.enableListings,
+  }),
+
+  query: (_sprawl, artist) => ({
+    hasGallery:
+      !empty(artist.albumCoverArtistContributions) ||
+      !empty(artist.trackCoverArtistContributions),
+  }),
+
+  relations: (relation, query, _sprawl, artist) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    artistMainLink:
+      relation('linkArtist', artist),
+
+    artistInfoLink:
+      relation('linkArtist', artist),
+
+    artistGalleryLink:
+      (query.hasGallery
+        ? relation('linkArtistGallery', artist)
+        : null),
+  }),
+
+  data: (_query, sprawl) => ({
+    enableListings:
+      sprawl.enableListings,
+  }),
+
+  slots: {
+    showExtraLinks: {type: 'boolean', default: false},
+
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) => [
+    {auto: 'home'},
+
+    data.enableListings &&
+      {
+        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/generateBanner.js b/src/content/dependencies/generateBanner.js
new file mode 100644
index 00000000..15eb08eb
--- /dev/null
+++ b/src/content/dependencies/generateBanner.js
@@ -0,0 +1,33 @@
+export default {
+  extraDependencies: ['html', 'to'],
+
+  slots: {
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
+    },
+
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
+
+    alt: {
+      type: 'string',
+    },
+  },
+
+  generate: (slots, {html, to}) =>
+    html.tag('div', {id: 'banner'},
+      html.tag('img',
+        {src: to(...slots.path)},
+
+        (slots.dimensions
+          ? {width: slots.dimensions[0]}
+          : {width: 1100}),
+
+        (slots.dimensions
+          ? {height: slots.dimensions[1]}
+          : {height: 200}),
+
+        slots.alt &&
+          {alt: slots.alt})),
+};
diff --git a/src/content/dependencies/generateColorStyleAttribute.js b/src/content/dependencies/generateColorStyleAttribute.js
new file mode 100644
index 00000000..03d95ac5
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleAttribute.js
@@ -0,0 +1,37 @@
+export default {
+  contentDependencies: ['generateColorStyleVariables'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    colorVariables:
+      relation('generateColorStyleVariables'),
+  }),
+
+  data: (color) => ({
+    color:
+      color ?? null,
+  }),
+
+  slots: {
+    color: {
+      validate: v => v.isColor,
+    },
+
+    context: {
+      validate: v => v.is(
+        'any-content',
+        'image-box',
+        'primary-only'),
+
+      default: 'any-content',
+    },
+  },
+
+  generate: (data, relations, slots) => ({
+    style:
+      relations.colorVariables.slots({
+        color: slots.color ?? data.color,
+        context: slots.context,
+      }).content,
+  }),
+};
diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js
new file mode 100644
index 00000000..c412b8f2
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleRules.js
@@ -0,0 +1,42 @@
+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/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js
new file mode 100644
index 00000000..5270dbe4
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -0,0 +1,91 @@
+export default {
+  extraDependencies: ['html', 'getColors'],
+
+  slots: {
+    color: {
+      validate: v => v.isColor,
+    },
+
+    context: {
+      validate: v => v.is(
+        'any-content',
+        'image-box',
+        'page-root',
+        'image-box',
+        'primary-only'),
+
+      default: 'any-content',
+    },
+
+    mode: {
+      validate: v => v.is('style', 'property-list'),
+      default: 'style',
+    },
+  },
+
+  generate(slots, {getColors}) {
+    if (!slots.color) return [];
+
+    const {
+      primary,
+      dark,
+      dim,
+      deep,
+      deepGhost,
+      lightGhost,
+      bg,
+      bgBlack,
+      shadow,
+    } = getColors(slots.color);
+
+    let anyContent = [
+      `--primary-color: ${primary}`,
+      `--dark-color: ${dark}`,
+      `--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;
+
+    switch (slots.context) {
+      case 'any-content':
+        selectedProperties = anyContent;
+        break;
+
+      case 'image-box':
+        selectedProperties = [
+          `--primary-color: ${primary}`,
+          `--dim-color: ${dim}`,
+          `--deep-color: ${deep}`,
+          `--bg-black-color: ${bgBlack}`,
+        ];
+        break;
+
+      case 'page-root':
+        selectedProperties = [
+          ...anyContent,
+          `--page-primary-color: ${primary}`,
+        ];
+        break;
+
+      case 'primary-only':
+        selectedProperties = [
+          `--primary-color: ${primary}`,
+        ];
+        break;
+    }
+
+    switch (slots.mode) {
+      case 'style':
+        return selectedProperties.join('; ');
+
+      case 'property-list':
+        return selectedProperties;
+    }
+  },
+};
diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
new file mode 100644
index 00000000..c93020f3
--- /dev/null
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -0,0 +1,112 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCommentaryEntryDate',
+    'generateColorStyleAttribute',
+    'linkArtist',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    artistLinks:
+      (!empty(entry.artists) && !entry.artistDisplayText
+        ? entry.artists
+            .map(artist => relation('linkArtist', artist))
+        : null),
+
+    artistsContent:
+      (entry.artistDisplayText
+        ? relation('transformContent', entry.artistDisplayText)
+        : null),
+
+    annotationContent:
+      (entry.annotation
+        ? relation('transformContent', entry.annotation)
+        : null),
+
+    bodyContent:
+      (entry.body
+        ? relation('transformContent', entry.body)
+        : null),
+
+    colorStyle:
+      relation('generateColorStyleAttribute'),
+
+    date:
+      relation('generateCommentaryEntryDate', entry),
+  }),
+
+  slots: {
+    color: {validate: v => v.isColor},
+  },
+
+  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
new file mode 100644
index 00000000..d68ba42e
--- /dev/null
+++ b/src/content/dependencies/generateCommentaryIndexPage.js
@@ -0,0 +1,104 @@
+import {sortChronologically} from '#sort';
+import {accumulateSum, filterMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generatePageLayout', 'linkAlbumCommentary'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query(sprawl) {
+    const query = {};
+
+    query.albums =
+      sortChronologically(sprawl.albumData.slice());
+
+    const entries =
+      query.albums.map(album =>
+        [album, ...album.tracks]
+          .filter(({commentary}) => commentary)
+          .flatMap(({commentary}) => commentary));
+
+    query.wordCounts =
+      entries.map(entries =>
+        accumulateSum(
+          entries,
+          entry => entry.body.split(' ').length));
+
+    query.entryCounts =
+      entries.map(entries => entries.length);
+
+    filterMultipleArrays(query.albums, query.wordCounts, query.entryCounts,
+      (album, wordCount, entryCount) => entryCount >= 1);
+
+    return query;
+  },
+
+  relations(relation, query) {
+    return {
+      layout:
+        relation('generatePageLayout'),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbumCommentary', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      wordCounts: query.wordCounts,
+      entryCounts: query.entryCounts,
+
+      totalWordCount: accumulateSum(query.wordCounts),
+      totalEntryCount: accumulateSum(query.entryCounts),
+    };
+  },
+
+  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/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
new file mode 100644
index 00000000..f52bc043
--- /dev/null
+++ b/src/content/dependencies/generateContentHeading.js
@@ -0,0 +1,61 @@
+export default {
+  extraDependencies: ['html'],
+  contentDependencies: ['generateColorStyleAttribute'],
+
+  relations: (relation) => ({
+    colorStyle: relation('generateColorStyleAttribute'),
+  }),
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    stickyTitle: {
+      type: 'html',
+      mutable: false,
+    },
+
+    accent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    color: {validate: v => v.isColor},
+
+    tag: {
+      type: 'string',
+      default: 'p',
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag(slots.tag, {class: 'content-heading'},
+      {tabindex: '0'},
+      {[html.onlyIfSiblings]: true},
+
+      slots.attributes,
+
+      slots.color &&
+        relations.colorStyle.slot('color', slots.color),
+
+      [
+        html.tag('span', {class: 'content-heading-main-title'},
+          {[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
new file mode 100644
index 00000000..d1c3de0f
--- /dev/null
+++ b/src/content/dependencies/generateContributionList.js
@@ -0,0 +1,29 @@
+export default {
+  contentDependencies: ['linkContribution'],
+  extraDependencies: ['html'],
+
+  relations: (relation, contributions) => ({
+    contributionLinks:
+      contributions
+        .map(contrib => relation('linkContribution', contrib)),
+  }),
+
+  slots: {
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('ul',
+      {[html.onlyIfContent]: true},
+
+      relations.contributionLinks
+        .map(contributionLink =>
+          html.tag('li',
+            contributionLink.slots({
+              showAnnotation: true,
+              showExternalLinks: true,
+              showChronology: true,
+              preventWrapping: false,
+              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
new file mode 100644
index 00000000..3a10ab20
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -0,0 +1,121 @@
+export default {
+  contentDependencies: [
+    'generateCoverArtworkArtTagDetails',
+    'generateCoverArtworkArtistDetails',
+    'generateCoverArtworkOriginDetails',
+    'generateCoverArtworkReferenceDetails',
+    'image',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, artwork) => ({
+    image:
+      relation('image', artwork),
+
+    originDetails:
+      relation('generateCoverArtworkOriginDetails', artwork),
+
+    artTagDetails:
+      relation('generateCoverArtworkArtTagDetails', artwork),
+
+    artistDetails:
+      relation('generateCoverArtworkArtistDetails', artwork),
+
+    referenceDetails:
+      relation('generateCoverArtworkReferenceDetails', artwork),
+  }),
+
+  data: (artwork) => ({
+    color:
+      artwork.thing.color ?? null,
+
+    dimensions:
+      artwork.dimensions,
+  }),
+
+  slots: {
+    alt: {type: 'string'},
+
+    color: {
+      validate: v => v.isColor,
+    },
+
+    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}) {
+    const {image} = relations;
+
+    image.setSlots({
+      color: slots.color ?? data.color,
+      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);
+    }
+
+    return (
+      html.tag('div', {class: 'cover-artwork'},
+        slots.mode === 'commentary' &&
+          {class: 'commentary-art'},
+
+        (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..b20f599b
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkArtTagDetails.js
@@ -0,0 +1,50 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkArtTagGallery'],
+  extraDependencies: ['html'],
+
+  query: (artwork) => ({
+    linkableArtTags:
+      artwork.artTags
+        .filter(tag => !tag.isContentWarning),
+  }),
+
+  relations: (relation, query, _artwork) => ({
+    artTagLinks:
+      query.linkableArtTags
+        .map(tag => relation('linkArtTagGallery', tag)),
+  }),
+
+  data: (query, _artwork) => {
+    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);
+      }
+    }
+
+    const preferShortName =
+      query.linkableArtTags
+        .map(artTag => !duplicateShortNames.has(artTag.nameShort));
+
+    return {preferShortName};
+  },
+
+  generate: (data, relations, {html}) =>
+    html.tag('ul', {class: 'image-details'},
+      {[html.onlyIfContent]: true},
+
+      {class: 'art-tag-details'},
+
+      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..08a01cfe
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -0,0 +1,98 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'generateArtistCredit',
+    'generateAbsoluteDatetimestamp',
+    'linkAlbum',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'pagePath'],
+
+  query: (artwork) => ({
+    artworkThingType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    credit:
+      relation('generateArtistCredit', artwork.artistContribs, []),
+
+    source:
+      relation('transformContent', artwork.source),
+
+    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'},
+
+        [
+          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.slots({
+                  style: 'year',
+                  tooltip: true,
+                });
+            }
+
+            return relations.credit.slots({
+              showAnnotation: true,
+              showExternalLinks: true,
+              showChronology: true,
+              showWikiEdits: true,
+
+              trimAnnotation: false,
+
+              chronologyKind: 'coverArt',
+
+              normalStringKey: workingCapsule,
+              additionalStringOptions: workingOptions,
+            });
+          }),
+
+          pagePath[0] === 'track' &&
+          data.artworkThingType === 'album' &&
+            language.$(capsule, 'trackArtFromAlbum', {
+              album:
+                relations.albumLink.slot('color', false),
+            }),
+
+          language.$(capsule, 'source', {
+            [language.onlyIfOptions]: ['source'],
+            source: relations.source.slot('mode', 'inline'),
+          }),
+        ])),
+};
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
new file mode 100644
index 00000000..430f651e
--- /dev/null
+++ b/src/content/dependencies/generateCoverCarousel.js
@@ -0,0 +1,55 @@
+import {empty, repeat, stitchArrays} from '#sugar';
+import {getCarouselLayoutForNumberOfItems} from '#wiki-data';
+
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    images: {validate: v => v.strictArrayOf(v.isHTML)},
+    links: {validate: v => v.strictArrayOf(v.isHTML)},
+
+    lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)},
+  },
+
+  generate(slots, {html}) {
+    const stitched =
+      stitchArrays({
+        image: slots.images,
+        link: slots.links,
+      });
+
+    if (empty(stitched)) {
+      return;
+    }
+
+    const layout = getCarouselLayoutForNumberOfItems(stitched.length);
+
+    return html.tags([
+      html.tag('div', {class: 'carousel-container'},
+        {'data-carousel-rows': layout.rows},
+        {'data-carousel-columns': layout.columns},
+
+        repeat(3, [
+          html.tag('div', {class: 'carousel-grid'},
+            {'aria-hidden': 'true'},
+
+            stitched.map(({image, link}, index) =>
+              html.tag('div', {class: 'carousel-item'},
+                link.slots({
+                  attributes: {tabindex: '-1'},
+                  content:
+                    image.slots({
+                      thumb: 'small',
+                      square: true,
+                      lazy:
+                        (typeof slots.lazy === 'number'
+                          ? index >= slots.lazy
+                       : typeof slots.lazy === 'boolean'
+                          ? slots.lazy
+                          : false),
+                    }),
+                })))),
+        ])),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
new file mode 100644
index 00000000..29ac08b7
--- /dev/null
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -0,0 +1,90 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateGridActionLinks'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation) {
+    return {
+      actionLinks: relation('generateGridActionLinks'),
+    };
+  },
+
+  slots: {
+    images: {validate: v => v.strictArrayOf(v.isHTML)},
+    links: {validate: v => v.strictArrayOf(v.isHTML)},
+    names: {validate: v => v.strictArrayOf(v.isHTML)},
+    info: {validate: v => v.strictArrayOf(v.isHTML)},
+
+    // 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}) =>
+    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(({classes, image, link, name, info}, index) =>
+            link.slots({
+              attributes: [
+                {class: ['grid-item', 'box']},
+
+                (classes
+                  ? {class: classes}
+                  : null),
+              ],
+
+              colorContext: 'image-box',
+
+              content: [
+                image.slots({
+                  thumb: 'medium',
+                  square: true,
+                  lazy:
+                    (typeof slots.lazy === 'number'
+                      ? index >= slots.lazy
+                   : typeof slots.lazy === 'boolean'
+                      ? slots.lazy
+                      : false),
+                }),
+
+                html.tag('span',
+                  {[html.onlyIfContent]: true},
+
+                  language.sanitize(name)),
+
+                html.tag('span',
+                  {[html.onlyIfContent]: true},
+
+                  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
new file mode 100644
index 00000000..a92d15fc
--- /dev/null
+++ b/src/content/dependencies/generateDatetimestampTemplate.js
@@ -0,0 +1,40 @@
+export default {
+  contentDependencies: ['generateTextWithTooltip'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+  }),
+
+  slots: {
+    mainContent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    tooltip: {
+      type: 'html',
+      mutable: true,
+    },
+
+    datetime: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    relations.textWithTooltip.slots({
+      attributes: {class: 'datetimestamp'},
+
+      text:
+        html.tag('time',
+          {datetime: slots.datetime},
+          slots.mainContent),
+
+      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/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
new file mode 100644
index 00000000..84ab549d
--- /dev/null
+++ b/src/content/dependencies/generateFlashActGalleryPage.js
@@ -0,0 +1,85 @@
+import striptags from 'striptags';
+
+export default {
+  contentDependencies: [
+    'generateCoverGrid',
+    'generateFlashActNavAccent',
+    'generateFlashActSidebar',
+    'generatePageLayout',
+    'image',
+    'linkFlash',
+    'linkFlashAct',
+    'linkFlashIndex',
+  ],
+
+  extraDependencies: ['language'],
+
+  relations: (relation, act) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    flashIndexLink:
+      relation('linkFlashIndex'),
+
+    flashActNavLink:
+      relation('linkFlashAct', act),
+
+    flashActNavAccent:
+      relation('generateFlashActNavAccent', act),
+
+    sidebar:
+      relation('generateFlashActSidebar', act, null),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    coverGridImages:
+      act.flashes
+        .map(flash => relation('image', flash.coverArtwork)),
+
+    flashLinks:
+      act.flashes
+        .map(flash => relation('linkFlash', flash)),
+  }),
+
+  data: (act) => ({
+    name: act.name,
+    color: act.color,
+
+    flashNames:
+      act.flashes.map(flash => flash.name),
+  }),
+
+  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
new file mode 100644
index 00000000..c4ec77b8
--- /dev/null
+++ b/src/content/dependencies/generateFlashActNavAccent.js
@@ -0,0 +1,64 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
+    'linkFlashAct',
+  ],
+
+  extraDependencies: ['wikiData'],
+
+  sprawl: ({flashActData}) =>
+    ({flashActData}),
+
+  query(sprawl, flashAct) {
+    // Like with generateFlashNavAccent, don't sort chronologically here.
+    const flashActs =
+      sprawl.flashActData;
+
+    const index =
+      flashActs.indexOf(flashAct);
+
+    const previousFlashAct =
+      atOffset(flashActs, index, -1);
+
+    const nextFlashAct =
+      atOffset(flashActs, index, +1);
+
+    return {previousFlashAct, nextFlashAct};
+  },
+
+  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
new file mode 100644
index 00000000..1421dde9
--- /dev/null
+++ b/src/content/dependencies/generateFlashActSidebar.js
@@ -0,0 +1,30 @@
+export default {
+  contentDependencies: [
+    'generateFlashActSidebarCurrentActBox',
+    'generateFlashActSidebarSideMapBox',
+    'generatePageSidebar',
+  ],
+
+  relations: (relation, act, flash) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    currentActBox:
+      relation('generateFlashActSidebarCurrentActBox', act, flash),
+
+    sideMapBox:
+      relation('generateFlashActSidebarSideMapBox', act, flash),
+  }),
+
+  data: (_act, flash) => ({
+    isFlashActPage: !flash,
+  }),
+
+  generate: (data, relations) =>
+    relations.sidebar.slots({
+      boxes:
+        (data.isFlashActPage
+          ? [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/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
new file mode 100644
index 00000000..2788406c
--- /dev/null
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -0,0 +1,144 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkFlash',
+    'linkFlashAct',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({flashActData}) => ({flashActData}),
+
+  query(sprawl) {
+    const flashActs =
+      sprawl.flashActData.slice();
+
+    const jumpActs =
+      flashActs
+        .filter(act => act.side.acts.indexOf(act) === 0);
+
+    return {flashActs, jumpActs};
+  },
+
+  relations: (relation, query) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    jumpLinkColorStyles:
+      query.jumpActs
+        .map(act => relation('generateColorStyleAttribute', act.side.color)),
+
+    actColorStyles:
+      query.flashActs
+        .map(act => relation('generateColorStyleAttribute', act.color)),
+
+    actLinks:
+      query.flashActs
+        .map(act => relation('linkFlashAct', act)),
+
+    actCoverGrids:
+      query.flashActs
+        .map(() => relation('generateCoverGrid')),
+
+    actCoverGridLinks:
+      query.flashActs
+        .map(act => act.flashes
+          .map(flash => relation('linkFlash', flash))),
+
+    actCoverGridImages:
+      query.flashActs
+        .map(act => act.flashes
+          .map(flash => relation('image', flash.coverArtwork))),
+  }),
+
+  data: (query) => ({
+    jumpLinkAnchors:
+      query.jumpActs
+        .map(act => act.directory),
+
+    jumpLinkLabels:
+      query.jumpActs
+        .map(act => act.side.name),
+
+    actAnchors:
+      query.flashActs
+        .map(act => act.directory),
+
+    actCoverGridNames:
+      query.flashActs
+        .map(act => act.flashes
+          .map(flash => flash.name)),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    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,
+          }).map(({
+              colorStyle,
+              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
new file mode 100644
index 00000000..095e43c4
--- /dev/null
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -0,0 +1,202 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateCommentaryEntry',
+    'generateContentHeading',
+    'generateContributionList',
+    'generateFlashActSidebar',
+    'generateFlashArtworkColumn',
+    'generateFlashNavAccent',
+    'generatePageLayout',
+    'generateTrackList',
+    'linkExternal',
+    'linkFlashAct',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(flash) {
+    const query = {};
+
+    query.urls = [];
+
+    if (flash.page) {
+      query.urls.push(`https://homestuck.com/story/${flash.page}`);
+    }
+
+    if (!empty(flash.urls)) {
+      query.urls.push(...flash.urls);
+    }
+
+    return query;
+  },
+
+  relations: (relation, query, flash) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    sidebar:
+      relation('generateFlashActSidebar', flash.act, flash),
+
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', flash.additionalNames),
+
+    externalLinks:
+      query.urls
+        .map(url => relation('linkExternal', url)),
+
+    artworkColumn:
+      relation('generateFlashArtworkColumn', flash),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    flashActLink:
+      relation('linkFlashAct', flash.act),
+
+    flashNavAccent:
+      relation('generateFlashNavAccent', flash),
+
+    featuredTracksList:
+      relation('generateTrackList', flash.featuredTracks),
+
+    contributorContributionList:
+      relation('generateContributionList', flash.contributorContribs),
+
+    artistCommentaryEntries:
+      flash.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
+
+    creditSourceEntries:
+      flash.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
+  }),
+
+  data: (_query, flash) => ({
+    name:
+      flash.name,
+
+    color:
+      flash.color,
+
+    date:
+      flash.date,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('flashPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            flash: data.name,
+          }),
+
+        color: data.color,
+        headingMode: 'sticky',
+
+        additionalNames: relations.additionalNamesBox,
+
+        artworkColumnContent: relations.artworkColumn,
+
+        mainContent: [
+          html.tag('p',
+            language.$('releaseInfo.released', {
+              date: language.formatDate(data.date),
+            })),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$('releaseInfo.playOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'flash'))),
+            })),
+
+          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, 'readCreditSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#credit-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',
+            }),
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'artist-commentary'},
+                title: language.$('misc.artistCommentary'),
+              }),
+
+            relations.artistCommentaryEntries,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'credit-sources'},
+                title: language.$('misc.creditSources'),
+              }),
+
+            relations.creditSourceEntries,
+          ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.flashActLink.slot('color', false)},
+          {auto: 'current'},
+        ],
+
+        navBottomRowContent: relations.flashNavAccent,
+
+        leftSidebar: relations.sidebar,
+      })),
+};
diff --git a/src/content/dependencies/generateFlashNavAccent.js b/src/content/dependencies/generateFlashNavAccent.js
new file mode 100644
index 00000000..0f5d2d6b
--- /dev/null
+++ b/src/content/dependencies/generateFlashNavAccent.js
@@ -0,0 +1,66 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
+    'linkFlash',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({flashActData}) =>
+    ({flashActData}),
+
+  query(sprawl, flash) {
+    // Don't sort chronologically here. The previous/next buttons should match
+    // the order in the sidebar, by act rather than date.
+    const flashes =
+      sprawl.flashActData
+        .flatMap(act => act.flashes);
+
+    const index =
+      flashes.indexOf(flash);
+
+    const previousFlash =
+      atOffset(flashes, index, -1);
+
+    const nextFlash =
+      atOffset(flashes, index, +1);
+
+    return {previousFlash, nextFlash};
+  },
+
+  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/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js
new file mode 100644
index 00000000..dfd83aef
--- /dev/null
+++ b/src/content/dependencies/generateFooterLocalizationLinks.js
@@ -0,0 +1,59 @@
+import {sortByName} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  extraDependencies: [
+    'defaultLanguage',
+    'html',
+    'language',
+    'languages',
+    'pagePath',
+    'to',
+  ],
+
+  generate({
+    defaultLanguage,
+    html,
+    language,
+    languages,
+    pagePath,
+    to,
+  }) {
+    const switchableLanguages =
+      Object.entries(languages)
+        .filter(([code, language]) => code !== 'default' && !language.hidden)
+        .map(([code, language]) => language);
+
+    if (switchableLanguages.length <= 1) {
+      return html.blank();
+    }
+
+    sortByName(switchableLanguages);
+
+    const [pagePathSubkey, ...pagePathArgs] = pagePath;
+
+    const linkPaths =
+      switchableLanguages.map(language =>
+        (language === defaultLanguage
+          ? (['localizedDefaultLanguage.' + pagePathSubkey,
+              ...pagePathArgs])
+          : (['localizedWithBaseDirectory.' + pagePathSubkey,
+              language.code,
+              ...pagePathArgs])));
+
+    const links =
+      stitchArrays({
+        language: switchableLanguages,
+        linkPath: linkPaths,
+      }).map(({language, linkPath}) =>
+          html.tag('span',
+            html.tag('a',
+              {href: to(...linkPath)},
+              language.name)));
+
+    return html.tag('div', {class: 'footer-localization-links'},
+      language.$('misc.uiLanguage', {
+        languages: language.formatListWithoutSeparator(links),
+      }));
+  },
+};
diff --git a/src/content/dependencies/generateGridActionLinks.js b/src/content/dependencies/generateGridActionLinks.js
new file mode 100644
index 00000000..585a02b9
--- /dev/null
+++ b/src/content/dependencies/generateGridActionLinks.js
@@ -0,0 +1,16 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('div', {class: 'grid-actions'},
+      {[html.onlyIfContent]: true},
+
+      (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
new file mode 100644
index 00000000..d51366ca
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -0,0 +1,182 @@
+import {sortChronologically} from '#sort';
+import {empty, stitchArrays} from '#sugar';
+import {filterItemsForCarousel, getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateCoverCarousel',
+    'generateCoverGrid',
+    'generateGroupNavLinks',
+    'generateGroupSecondaryNav',
+    'generateGroupSidebar',
+    'generatePageLayout',
+    'generateQuickDescription',
+    'image',
+    'linkAlbum',
+    'linkListing',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) =>
+    ({enableGroupUI: wikiInfo.enableGroupUI}),
+
+  relations(relation, sprawl, group) {
+    const relations = {};
+
+    const albums =
+      sortChronologically(group.albums.slice(), {latestFirst: true});
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.navLinks =
+      relation('generateGroupNavLinks', group);
+
+    if (sprawl.enableGroupUI) {
+      relations.secondaryNav =
+        relation('generateGroupSecondaryNav', group);
+
+      relations.sidebar =
+        relation('generateGroupSidebar', group);
+    }
+
+    const carouselAlbums = filterItemsForCarousel(group.featuredAlbums);
+
+    if (!empty(carouselAlbums)) {
+      relations.coverCarousel =
+        relation('generateCoverCarousel');
+
+      relations.carouselLinks =
+        carouselAlbums
+          .map(album => relation('linkAlbum', album));
+
+      relations.carouselImages =
+        carouselAlbums
+          .map(album => relation('image', album.coverArtworks[0]));
+    }
+
+    relations.quickDescription =
+      relation('generateQuickDescription', group);
+
+    relations.coverGrid =
+      relation('generateCoverGrid');
+
+    relations.gridLinks =
+      albums
+        .map(album => relation('linkAlbum', album));
+
+    relations.gridImages =
+      albums.map(album =>
+        (album.hasCoverArt
+          ? relation('image', album.coverArtworks[0])
+          : relation('image')));
+
+    return relations;
+  },
+
+  data(sprawl, group) {
+    const data = {};
+
+    data.name = group.name;
+    data.color = group.color;
+
+    const albums = sortChronologically(group.albums.slice(), {latestFirst: true});
+    const tracks = albums.flatMap((album) => album.tracks);
+
+    data.numAlbums = albums.length;
+    data.numTracks = tracks.length;
+    data.totalDuration = getTotalDuration(tracks, {mainReleasesOnly: true});
+
+    data.gridNames = albums.map(album => album.name);
+    data.gridDurations = albums.map(album => getTotalDuration(album.tracks));
+    data.gridNumTracks = albums.map(album => album.tracks.length);
+
+    return data;
+  },
+
+  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: relations.carouselImages,
+            }),
+
+          relations.quickDescription,
+
+          html.tag('p', {class: 'quick-info'},
+            language.$(pageCapsule, 'infoLine', {
+              tracks:
+                html.tag('b',
+                  language.countTracks(data.numTracks, {
+                    unit: true,
+                  })),
+
+              albums:
+                html.tag('b',
+                  language.countAlbums(data.numAlbums, {
+                    unit: true,
+                  })),
+
+              time:
+                html.tag('b',
+                  language.formatDuration(data.totalDuration, {
+                    unit: true,
+                  })),
+            })),
+
+          relations.coverGrid
+            .slots({
+              links: relations.gridLinks,
+              names: data.gridNames,
+
+              images:
+                stitchArrays({
+                  image: relations.gridImages,
+                  name: data.gridNames,
+                }).map(({image, name}) =>
+                    image.slots({
+                      missingSourceContent:
+                        language.$('misc.coverGrid.noCoverArt', {
+                          album: name,
+                        }),
+                    })),
+
+              info:
+                stitchArrays({
+                  numTracks: data.gridNumTracks,
+                  duration: data.gridDurations,
+                }).map(({numTracks, duration}) =>
+                    language.$('misc.coverGrid.details.albumLength', {
+                      tracks: language.countTracks(numTracks, {unit: true}),
+                      time: language.formatDuration(duration),
+                    })),
+            }),
+        ],
+
+        leftSidebar:
+          (relations.sidebar
+            ? relations.sidebar
+                .slot('currentExtra', 'gallery')
+                .content /* TODO: Kludge. */
+            : null),
+
+        navLinkStyle: 'hierarchical',
+        navLinks:
+          relations.navLinks
+            .slot('currentExtra', 'gallery')
+            .content,
+
+        secondaryNav:
+          relations.secondaryNav ?? null,
+      })),
+};
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
new file mode 100644
index 00000000..7b9c2afa
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -0,0 +1,179 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateGroupInfoPageAlbumsSection',
+    'generateGroupNavLinks',
+    'generateGroupSecondaryNav',
+    'generateGroupSidebar',
+    'generatePageLayout',
+    'linkArtist',
+    'linkExternal',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  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,
+
+        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);
+                          }))),
+                });
+              }),
+
+              language.$(capsule, 'aliases', {
+                [language.onlyIfOptions]: ['aliases'],
+
+                aliases:
+                  language.formatConjunctionList(
+                    relations.aliasArtistLinks.map(link =>
+                      link.slots({
+                        attributes: [relations.wikiColorAttribute],
+                      }))),
+              }),
+            ])),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatDisjunctionList(
+                  relations.visitLinks
+                    .map(link => link.slot('context', 'group'))),
+            })),
+
+          html.tag('blockquote',
+            {[html.onlyIfContent]: true},
+            relations.description.slot('mode', 'multiline')),
+
+          relations.albumSection,
+        ],
+
+        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..99e7e8ff
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
@@ -0,0 +1,136 @@
+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'},
+                html.metatag('chunkwrap', {split: ','},
+                  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
new file mode 100644
index 00000000..bdc3ee4c
--- /dev/null
+++ b/src/content/dependencies/generateGroupNavLinks.js
@@ -0,0 +1,59 @@
+export default {
+  contentDependencies: ['generateGroupNavAccent', 'linkGroup'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({groupCategoryData, wikiInfo}) => ({
+    groupCategoryData,
+    enableGroupUI: wikiInfo.enableGroupUI,
+    enableListings: wikiInfo.enableListings,
+  }),
+
+  relations: (relation, _sprawl, group) => ({
+    mainLink:
+      relation('linkGroup', group),
+
+    accent:
+      relation('generateGroupNavAccent', group),
+  }),
+
+  data: (sprawl, _group) => ({
+    enableGroupUI: sprawl.enableGroupUI,
+    enableListings: sprawl.enableListings,
+  }),
+
+  slots: {
+    showExtraLinks: {type: 'boolean', default: false},
+
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  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
new file mode 100644
index 00000000..c48f3142
--- /dev/null
+++ b/src/content/dependencies/generateGroupSecondaryNav.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: [
+    'generateSecondaryNav',
+    'generateGroupSecondaryNavCategoryPart',
+  ],
+
+  relations: (relation, group) => ({
+    secondaryNav:
+      relation('generateSecondaryNav'),
+
+    categoryPart:
+      relation('generateGroupSecondaryNavCategoryPart', group.category, group),
+  }),
+
+  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
new file mode 100644
index 00000000..0888cbbe
--- /dev/null
+++ b/src/content/dependencies/generateGroupSidebar.js
@@ -0,0 +1,46 @@
+export default {
+  contentDependencies: [
+    'generateGroupSidebarCategoryDetails',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({groupCategoryData}) => ({groupCategoryData}),
+
+  relations: (relation, sprawl, group) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    categoryDetails:
+      sprawl.groupCategoryData.map(category =>
+        relation('generateGroupSidebarCategoryDetails', category, group)),
+  }),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  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)),
+          ],
+        }),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateGroupSidebarCategoryDetails.js b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
new file mode 100644
index 00000000..208ccd07
--- /dev/null
+++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
@@ -0,0 +1,81 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'linkGroup',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, category) {
+    return {
+      colorStyle:
+        relation('generateColorStyleAttribute', category.color),
+
+      groupInfoLinks:
+        category.groups.map(group =>
+          relation('linkGroup', group)),
+
+      groupGalleryLinks:
+        category.groups.map(group =>
+          (empty(group.albums)
+            ? null
+            : relation('linkGroupGallery', group))),
+    };
+  },
+
+  data(category, group) {
+    const data = {};
+
+    data.name = category.name;
+
+    data.isCurrentCategory = category === group.category;
+
+    if (data.isCurrentCategory) {
+      data.currentGroupIndex = category.groups.indexOf(group);
+    }
+
+    return data;
+  },
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  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/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
new file mode 100644
index 00000000..deb8c4ea
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
@@ -0,0 +1,90 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    additionalFileTitles: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    additionalFileLinks: {
+      validate: v => v.strictArrayOf(v.strictArrayOf(v.isHTML)),
+    },
+
+    additionalFileFiles: {
+      validate: v => v.strictArrayOf(v.strictArrayOf(v.isString)),
+    },
+
+    stringsKey: {type: 'string'},
+  },
+
+  generate(slots, {html, language}) {
+    if (empty(slots.additionalFileLinks)) {
+      return html.blank();
+    }
+
+    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,
+            }) =>
+              language.encapsulate('listingPage', slots.stringsKey, 'file', capsule =>
+                (additionalFileLinks.length === 1
+                  ? html.tag('li',
+                      additionalFileLinks[0].slots({
+                        content:
+                          language.$(capsule, {
+                            title: additionalFileTitle,
+                          }),
+                      }))
+
+               : additionalFileLinks.length === 0
+                  ? html.tag('li',
+                      language.$(capsule, 'withNoFiles', {
+                        title: additionalFileTitle,
+                      }))
+
+                  : html.tag('li', {class: 'has-details'},
+                      html.tag('details', [
+                        html.tag('summary',
+                          html.tag('span',
+                            language.$(capsule, 'withMultipleFiles', {
+                              title:
+                                html.tag('b', additionalFileTitle),
+
+                              files:
+                                language.countAdditionalFiles(
+                                  additionalFileLinks.length,
+                                  {unit: true}),
+                            }))),
+
+                        html.tag('ul',
+                          stitchArrays({
+                            additionalFileLink: additionalFileLinks,
+                            additionalFileFile: additionalFileFiles,
+                          }).map(({additionalFileLink, additionalFileFile}) =>
+                              html.tag('li',
+                                additionalFileLink.slots({
+                                  content:
+                                    language.$(capsule, {
+                                      title: additionalFileFile,
+                                    }),
+                                })))),
+                      ]))))))),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateListRandomPageLinksAlbumLink.js b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js
new file mode 100644
index 00000000..b3560aca
--- /dev/null
+++ b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js
@@ -0,0 +1,18 @@
+export default {
+  contentDependencies: ['linkAlbum'],
+
+  data: (album) =>
+    ({directory: album.directory}),
+
+  relations: (relation, album) =>
+    ({albumLink: relation('linkAlbum', album)}),
+
+  generate: (data, relations) =>
+    relations.albumLink.slots({
+      anchor: true,
+      attributes: {
+        'data-random': 'track-in-album',
+        'style': `--album-directory: ${data.directory}`,
+      },
+    }),
+};
diff --git a/src/content/dependencies/generateListingIndexList.js b/src/content/dependencies/generateListingIndexList.js
new file mode 100644
index 00000000..78622e6e
--- /dev/null
+++ b/src/content/dependencies/generateListingIndexList.js
@@ -0,0 +1,131 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkListing'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({listingTargetSpec, wikiInfo}) {
+    return {listingTargetSpec, wikiInfo};
+  },
+
+  query(sprawl) {
+    const query = {};
+
+    const targetListings =
+      sprawl.listingTargetSpec
+        .map(({listings}) =>
+          listings
+            .filter(listing =>
+              !listing.featureFlag ||
+              sprawl.wikiInfo[listing.featureFlag]));
+
+    query.targets =
+      sprawl.listingTargetSpec
+        .filter((target, index) => !empty(targetListings[index]));
+
+    query.targetListings =
+      targetListings
+        .filter(listings => !empty(listings))
+
+    return query;
+  },
+
+  relations(relation, query) {
+    return {
+      listingLinks:
+        query.targetListings
+          .map(listings =>
+            listings.map(listing => relation('linkListing', listing))),
+    };
+  },
+
+  data(query, sprawl, currentListing) {
+    const data = {};
+
+    data.targetStringsKeys =
+      query.targets
+        .map(({stringsKey}) => stringsKey);
+
+    data.listingStringsKeys =
+      query.targetListings
+        .map(listings =>
+          listings.map(({stringsKey}) => stringsKey));
+
+    if (currentListing) {
+      data.currentTargetIndex =
+        query.targets
+          .indexOf(currentListing.target);
+
+      data.currentListingIndex =
+        query.targetListings
+          .find(listings => listings.includes(currentListing))
+          .indexOf(currentListing);
+    }
+
+    return data;
+  },
+
+  slots: {
+    mode: {validate: v => v.is('content', 'sidebar')},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const listingLinkLists =
+      stitchArrays({
+        listingLinks: relations.listingLinks,
+        listingStringsKeys: data.listingStringsKeys,
+      }).map(({listingLinks, listingStringsKeys}, targetIndex) =>
+          html.tag('ul',
+            stitchArrays({
+              listingLink: listingLinks,
+              listingStringsKey: listingStringsKeys,
+            }).map(({listingLink, listingStringsKey}, listingIndex) =>
+                html.tag('li',
+                  targetIndex === data.currentTargetIndex &&
+                  listingIndex === data.currentListingIndex &&
+                    {class: 'current'},
+
+                  listingLink.slots({
+                    content:
+                      language.$('listingPage', listingStringsKey, 'title.short'),
+                  })))));
+
+    const targetTitles =
+      data.targetStringsKeys
+        .map(stringsKey => language.$('listingPage.target', stringsKey));
+
+    switch (slots.mode) {
+      case 'sidebar':
+        return html.tags(
+          stitchArrays({
+            targetTitle: targetTitles,
+            listingLinkList: listingLinkLists,
+          }).map(({targetTitle, listingLinkList}, targetIndex) =>
+              html.tag('details',
+                targetIndex === data.currentTargetIndex &&
+                  {class: 'current', open: true},
+
+                [
+                  html.tag('summary',
+                    html.tag('span',
+                      html.tag('b', targetTitle))),
+
+                  listingLinkList,
+                ])));
+
+      case 'content':
+        return (
+          html.tag('dl',
+            stitchArrays({
+              targetTitle: targetTitles,
+              listingLinkList: listingLinkLists,
+            }).map(({targetTitle, listingLinkList}) => [
+                html.tag('dt', {class: 'content-heading'},
+                  targetTitle),
+
+                html.tag('dd',
+                  listingLinkList),
+              ])));
+    }
+  },
+};
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
new file mode 100644
index 00000000..5f9a99a9
--- /dev/null
+++ b/src/content/dependencies/generateListingPage.js
@@ -0,0 +1,288 @@
+import {bindOpts, empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateListingSidebar',
+    'generatePageLayout',
+    'linkListing',
+    'linkListingIndex',
+    'linkTemplate',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  relations(relation, listing) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.sidebar =
+      relation('generateListingSidebar', listing);
+
+    relations.listingsIndexLink =
+      relation('linkListingIndex');
+
+    relations.chunkHeading =
+      relation('generateContentHeading');
+
+    relations.showSkipToSectionLinkTemplate =
+      relation('linkTemplate');
+
+    if (listing.target.listings.length > 1) {
+      relations.sameTargetListingLinks =
+        listing.target.listings
+          .map(listing => relation('linkListing', listing));
+    } else {
+      relations.sameTargetListingLinks = [];
+    }
+
+    relations.seeAlsoLinks =
+      (!empty(listing.seeAlso)
+        ? listing.seeAlso
+            .map(listing => relation('linkListing', listing))
+        : []);
+
+    return relations;
+  },
+
+  data(listing) {
+    return {
+      stringsKey: listing.stringsKey,
+
+      targetStringsKey: listing.target.stringsKey,
+
+      sameTargetListingStringsKeys:
+        listing.target.listings
+          .map(listing => listing.stringsKey),
+
+      sameTargetListingsCurrentIndex:
+        listing.target.listings
+          .indexOf(listing),
+    };
+  },
+
+  slots: {
+    type: {
+      validate: v => v.is('rows', 'chunks', 'custom'),
+    },
+
+    rows: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
+
+    rowAttributes: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject))
+    },
+
+    chunkTitles: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
+
+    chunkTitleAccents: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject)),
+    },
+
+    chunkRows: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
+
+    chunkRowAttributes: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject)),
+    },
+
+    showSkipToSection: {
+      type: 'boolean',
+      default: false,
+    },
+
+    chunkIDs: {
+      validate: v => v.strictArrayOf(v.optional(v.isString)),
+    },
+
+    listStyle: {
+      validate: v => v.is('ordered', 'unordered'),
+      default: 'unordered',
+    },
+
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    function formatListingString({
+      context,
+      provided = {},
+    }) {
+      const parts = ['listingPage', data.stringsKey];
+
+      if (Array.isArray(context)) {
+        parts.push(...context);
+      } else {
+        parts.push(context);
+      }
+
+      if (provided.stringsKey) {
+        parts.push(provided.stringsKey);
+      }
+
+      const options = {...provided};
+      delete options.stringsKey;
+
+      return language.formatString(...parts, options);
+    }
+
+    const formatRow = ({context, row, attributes}) =>
+      (attributes?.href
+        ? html.tag('li',
+            html.tag('a',
+              attributes,
+              formatListingString({
+                context,
+                provided: row,
+              })))
+        : html.tag('li',
+            attributes,
+            formatListingString({
+              context,
+              provided: row,
+            })));
+
+    const formatRowList = ({context, rows, rowAttributes}) =>
+      html.tag(
+        (slots.listStyle === 'ordered' ? 'ol' : 'ul'),
+        stitchArrays({
+          row: rows,
+          attributes: rowAttributes ?? rows.map(() => null),
+        }).map(
+          bindOpts(formatRow, {
+            [bindOpts.bindIndex]: 0,
+            context,
+          })));
+
+    return relations.layout.slots({
+      title: formatListingString({context: 'title'}),
+
+      headingMode: 'sticky',
+
+      mainContent: [
+        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,
+
+        slots.type === 'rows' &&
+          formatRowList({
+            context: 'item',
+            rows: slots.rows,
+            rowAttributes: slots.rowAttributes,
+          }),
+
+        slots.type === 'chunks' &&
+          html.tag('dl', [
+            slots.showSkipToSection && [
+              html.tag('dt',
+                language.$('listingPage.skipToSection')),
+
+              html.tag('dd',
+                html.tag('ul',
+                  stitchArrays({
+                    title: slots.chunkTitles,
+                    id: slots.chunkIDs,
+                  }).filter(({id}) => id)
+                    .map(({title, id}) =>
+                      html.tag('li',
+                        relations.showSkipToSectionLinkTemplate
+                          .clone()
+                          .slots({
+                            hash: id,
+                            content:
+                              html.normalize(
+                                formatListingString({
+                                  context: 'chunk.title',
+                                  provided: title,
+                                }).toString()
+                                  .replace(/:$/, '')),
+                          }))))),
+            ],
+
+            stitchArrays({
+              title: slots.chunkTitles,
+              titleAccent: slots.chunkTitleAccents,
+              id: slots.chunkIDs,
+              rows: slots.chunkRows,
+              rowAttributes: slots.chunkRowAttributes,
+            }).map(({title, titleAccent, id, rows, rowAttributes}) => [
+                relations.chunkHeading
+                  .clone()
+                  .slots({
+                    tag: 'dt',
+                    attributes: [id && {id}],
+
+                    title:
+                      formatListingString({
+                        context: 'chunk.title',
+                        provided: title,
+                      }),
+
+                    accent:
+                      titleAccent &&
+                        formatListingString({
+                          context: ['chunk.title', title.stringsKey, 'accent'],
+                          provided: titleAccent,
+                        }),
+                  }),
+
+                html.tag('dd',
+                  formatRowList({
+                    context: 'chunk.item',
+                    rows,
+                    rowAttributes,
+                  })),
+              ]),
+          ]),
+      ],
+
+      navLinkStyle: 'hierarchical',
+      navLinks: [
+        {auto: 'home'},
+        {html: relations.listingsIndexLink},
+        {auto: 'current'},
+      ],
+
+      leftSidebar: relations.sidebar,
+    });
+  },
+};
diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js
new file mode 100644
index 00000000..aeac05cf
--- /dev/null
+++ b/src/content/dependencies/generateListingSidebar.js
@@ -0,0 +1,37 @@
+export default {
+  contentDependencies: [
+    'generateListingIndexList',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'linkListingIndex',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, currentListing) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    listingIndexLink:
+      relation('linkListingIndex'),
+
+    listingIndexList:
+      relation('generateListingIndexList', currentListing),
+  }),
+
+  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
new file mode 100644
index 00000000..b57ebe15
--- /dev/null
+++ b/src/content/dependencies/generateListingsIndexPage.js
@@ -0,0 +1,89 @@
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateListingIndexList',
+    'generateListingSidebar',
+    'generatePageLayout',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({albumData, trackData, wikiInfo}) {
+    return {
+      wikiName: wikiInfo.name,
+      numTracks: trackData.length,
+      numAlbums: albumData.length,
+      totalDuration: getTotalDuration(trackData),
+    };
+  },
+
+  relations(relation) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.sidebar =
+      relation('generateListingSidebar', null);
+
+    relations.list =
+      relation('generateListingIndexList', null);
+
+    return relations;
+  },
+
+  data(sprawl) {
+    return {
+      wikiName: sprawl.wikiName,
+      numTracks: sprawl.numTracks,
+      numAlbums: sprawl.numAlbums,
+      totalDuration: sprawl.totalDuration,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.layout.slots({
+      title: language.$('listingIndex.title'),
+
+      headingMode: 'static',
+
+      mainContent: [
+        html.tag('p',
+          language.$('listingIndex.infoLine', {
+            wiki: data.wikiName,
+
+            tracks:
+              html.tag('b',
+                language.countTracks(data.numTracks, {unit: true})),
+
+            albums:
+              html.tag('b',
+                language.countAlbums(data.numAlbums, {unit: true})),
+
+            duration:
+              html.tag('b',
+                language.formatDuration(data.totalDuration, {
+                  approximate: true,
+                  unit: true,
+                })),
+          })),
+
+        html.tag('hr'),
+
+        html.tag('p',
+          language.$('listingIndex.exploreList')),
+
+        relations.list.slot('mode', 'content'),
+      ],
+
+      navLinkStyle: 'hierarchical',
+      navLinks: [
+        {auto: 'home'},
+        {auto: 'current'},
+      ],
+
+      leftSidebar: relations.sidebar,
+    });
+  },
+};
diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js
new file mode 100644
index 00000000..4f9c22f1
--- /dev/null
+++ b/src/content/dependencies/generateLyricsEntry.js
@@ -0,0 +1,25 @@
+export default {
+  contentDependencies: [
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    content:
+      relation('transformContent', entry.body),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('div', {class: 'lyrics-entry'},
+      slots.attributes,
+
+      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
new file mode 100644
index 00000000..4abd87d1
--- /dev/null
+++ b/src/content/dependencies/generateNewsEntryPage.js
@@ -0,0 +1,105 @@
+import {sortChronologically} from '#sort';
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateNewsEntryNavAccent',
+    'generateNewsEntryReadAnotherLinks',
+    'generatePageLayout',
+    'linkNewsIndex',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({newsData}) {
+    return {newsData};
+  },
+
+  query({newsData}, newsEntry) {
+    const entries = sortChronologically(newsData.slice());
+
+    const index = entries.indexOf(newsEntry);
+
+    const previousEntry =
+      atOffset(entries, index, -1);
+
+    const nextEntry =
+      atOffset(entries, index, +1);
+
+    return {previousEntry, nextEntry};
+  },
+
+  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/generateNewsEntryReadAnotherLinks.js b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js
new file mode 100644
index 00000000..d978b0e4
--- /dev/null
+++ b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js
@@ -0,0 +1,97 @@
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateRelativeDatetimestamp',
+    'linkNewsEntry',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, currentEntry, previousEntry, nextEntry) {
+    const relations = {};
+
+    if (previousEntry) {
+      relations.previousEntryLink =
+        relation('linkNewsEntry', previousEntry);
+
+      if (previousEntry.date) {
+        relations.previousEntryDatetimestamp =
+          (currentEntry.date
+            ? relation('generateRelativeDatetimestamp',
+                previousEntry.date,
+                currentEntry.date)
+            : relation('generateAbsoluteDatetimestamp',
+                previousEntry.date));
+      }
+    }
+
+    if (nextEntry) {
+      relations.nextEntryLink =
+        relation('linkNewsEntry', nextEntry);
+
+      if (nextEntry.date) {
+        relations.nextEntryDatetimestamp =
+          (currentEntry.date
+            ? relation('generateRelativeDatetimestamp',
+                nextEntry.date,
+                currentEntry.date)
+            : relation('generateAbsoluteDatetimestamp',
+                nextEntry.date));
+      }
+    }
+
+    return relations;
+  },
+
+  generate(relations, {html, language}) {
+    const prefix = `newsEntryPage.readAnother`;
+
+    const entryLines = [];
+
+    if (relations.previousEntryLink) {
+      const parts = [prefix, `previous`];
+      const options = {};
+
+      options.entry = relations.previousEntryLink;
+
+      if (relations.previousEntryDatetimestamp) {
+        parts.push('withDate');
+        options.date =
+          relations.previousEntryDatetimestamp.slots({
+            style: 'full',
+            tooltip: true,
+          });
+      }
+
+      entryLines.push(language.$(...parts, options));
+    }
+
+    if (relations.nextEntryLink) {
+      const parts = [prefix, `next`];
+      const options = {};
+
+      options.entry = relations.nextEntryLink;
+
+      if (relations.nextEntryDatetimestamp) {
+        parts.push('withDate');
+        options.date =
+          relations.nextEntryDatetimestamp.slots({
+            style: 'full',
+            tooltip: true,
+          });
+      }
+
+      entryLines.push(language.$(...parts, options));
+    }
+
+    return (
+      html.tag('p', {class: 'read-another-links'},
+        {[html.onlyIfContent]: true},
+        {[html.joinChildren]: html.tag('br')},
+
+        entryLines.length > 1 &&
+          {class: 'offset-tooltips'},
+
+        entryLines));
+  },
+};
diff --git a/src/content/dependencies/generateNewsIndexPage.js b/src/content/dependencies/generateNewsIndexPage.js
new file mode 100644
index 00000000..02964ce8
--- /dev/null
+++ b/src/content/dependencies/generateNewsIndexPage.js
@@ -0,0 +1,94 @@
+import {sortChronologically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageLayout',
+    'linkNewsEntry',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({newsData}) {
+    return {newsData};
+  },
+
+  query({newsData}) {
+    return {
+      entries:
+        sortChronologically(
+          newsData.slice(),
+          {latestFirst: true}),
+    };
+  },
+
+  relations(relation, query) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.entryLinks =
+      query.entries
+        .map(entry => relation('linkNewsEntry', entry));
+
+    relations.viewRestLinks =
+      query.entries
+        .map(entry =>
+          (entry.content === entry.contentShort
+            ? null
+            : relation('linkNewsEntry', entry)));
+
+    relations.entryContents =
+      query.entries
+        .map(entry => relation('transformContent', entry.contentShort));
+
+    return relations;
+  },
+
+  data(query) {
+    return {
+      entryDates:
+        query.entries.map(entry => entry.date),
+
+      entryDirectories:
+        query.entries.map(entry => entry.directory),
+    };
+  },
+
+  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
new file mode 100644
index 00000000..070c7c82
--- /dev/null
+++ b/src/content/dependencies/generatePageLayout.js
@@ -0,0 +1,790 @@
+import {openAggregate} from '#aggregate';
+import {atOffset, empty, repeat} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleRules',
+    'generateFooterLocalizationLinks',
+    'generateImageOverlay',
+    'generatePageSidebar',
+    'generateSearchSidebarBox',
+    'generateStickyHeadingContainer',
+    'transformContent',
+  ],
+
+  extraDependencies: [
+    'getColors',
+    'html',
+    'language',
+    'pagePath',
+    'pagePathStringFromRoot',
+    'to',
+    'wikiData',
+  ],
+
+  sprawl: ({wikiInfo}) => ({
+    enableSearch: wikiInfo.enableSearch,
+    footerContent: wikiInfo.footerContent,
+    wikiColor: wikiInfo.color,
+    wikiName: wikiInfo.nameShort,
+    canonicalBase: wikiInfo.canonicalBase,
+  }),
+
+  data: (sprawl) => ({
+    wikiColor: sprawl.wikiColor,
+    wikiName: sprawl.wikiName,
+    canonicalBase: sprawl.canonicalBase,
+  }),
+
+  relations(relation, sprawl) {
+    const relations = {};
+
+    relations.footerLocalizationLinks =
+      relation('generateFooterLocalizationLinks');
+
+    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.imageOverlay =
+      relation('generateImageOverlay');
+
+    return relations;
+  },
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    showWikiNameInTitle: {
+      validate: v => v.is(true, false, 'auto'),
+      default: 'auto',
+    },
+
+    subtitle: {
+      type: 'html',
+      mutable: false,
+    },
+
+    showSearch: {
+      type: 'boolean',
+      default: true,
+    },
+
+    additionalNames: {
+      type: 'html',
+      mutable: false,
+    },
+
+    artworkColumnContent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    // Strictly speaking we clone this each time we use it, so it doesn't
+    // need to be marked as mutable here.
+    socialEmbed: {
+      type: 'html',
+      mutable: true,
+    },
+
+    color: {validate: v => v.isColor},
+
+    styleRules: {
+      validate: v => v.sparseArrayOf(v.isHTML),
+      default: [],
+    },
+
+    mainClasses: {
+      validate: v => v.sparseArrayOf(v.isString),
+      default: [],
+    },
+
+    // Main
+
+    mainContent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    headingMode: {
+      validate: v => v.is('sticky', 'static'),
+      default: 'static',
+    },
+
+    // Sidebars
+
+    leftSidebar: {
+      type: 'html',
+      mutable: true,
+    },
+
+    rightSidebar: {
+      type: 'html',
+      mutable: true,
+    },
+
+    // Banner
+
+    banner: {
+      type: 'html',
+      mutable: false,
+    },
+
+    bannerPosition: {
+      validate: v => v.is('top', 'bottom'),
+      default: 'top',
+    },
+
+    // Nav & Footer
+
+    navContent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    navBottomRowContent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    navLinkStyle: {
+      validate: v => v.is('hierarchical', 'index'),
+      default: 'index',
+    },
+
+    navLinks: {
+      validate: v =>
+        v.sparseArrayOf(object => {
+          v.isObject(object);
+
+          const aggregate = openAggregate({message: `Errors validating navigation link`});
+
+          aggregate.call(v.validateProperties({
+            auto: () => true,
+            html: () => true,
+
+            path: () => true,
+            title: () => true,
+            accent: () => true,
+
+            current: () => true,
+          }), object);
+
+          if (object.current !== undefined) {
+            aggregate.call(v.isBoolean, object.current);
+          }
+
+          if (object.auto || object.html) {
+            if (object.auto && object.html) {
+              aggregate.push(new TypeError(`Don't specify both auto and html`));
+            } else if (object.auto) {
+              aggregate.call(v.is('home', 'current'), object.auto);
+            } else {
+              aggregate.call(v.isHTML, object.html);
+            }
+
+            if (object.path || object.title) {
+              aggregate.push(new TypeError(`Don't specify path or title along with auto or html`));
+            }
+          } else {
+            aggregate.call(v.validateProperties({
+              path: v.strictArrayOf(v.isString),
+              title: v.isHTML,
+            }), {
+              path: object.path,
+              title: object.title,
+            });
+          }
+
+          aggregate.close();
+
+          return true;
+        })
+    },
+
+    secondaryNav: {
+      type: 'html',
+      mutable: false,
+    },
+
+    footerContent: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate(data, relations, slots, {
+    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 firstItemInArtworkColumn =
+      html.smooth(slots.artworkColumnContent)
+        .content[0];
+
+    const primaryCover =
+      (firstItemInArtworkColumn &&
+       html.resolve(firstItemInArtworkColumn, {normalize: 'tag'})
+         .attributes.has('class', 'cover-artwork')
+        ? firstItemInArtworkColumn
+        : null);
+
+    const titleContentsHTML =
+      (html.isBlank(slots.title)
+        ? null
+     : html.isBlank(slots.additionalNames)
+        ? language.sanitize(slots.title)
+        : html.tag('a', {
+            href: '#additional-names-box',
+            title: language.$('misc.additionalNames.tooltip').toString(),
+          }, language.sanitize(slots.title)));
+
+    const titleHTML =
+      (html.isBlank(slots.title)
+        ? null
+     : slots.headingMode === 'sticky'
+        ? [
+            relations.stickyHeadingContainer.slots({
+              title: titleContentsHTML,
+              cover: 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.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: 'artwork-column'},
+            {[html.onlyIfContent]: true},
+            {class: 'isolate-tooltip-z-indexing'},
+
+            slots.artworkColumnContent),
+
+          subtitleHTML,
+
+          slots.additionalNames,
+
+          html.tag('div', {class: 'main-content-container'},
+            {[html.onlyIfContent]: true},
+            mainContentHTML),
+        ]);
+
+    const footerHTML =
+      html.tag('footer', {id: 'footer'},
+        {[html.onlyIfContent]: true},
+
+        [
+          html.tag('div', {class: 'footer-content'},
+            {[html.onlyIfContent]: true},
+            footerContent),
+
+          relations.footerLocalizationLinks,
+        ]);
+
+    const navHTML =
+      html.tag('nav', {id: 'header'},
+        {[html.onlyIfContent]: true},
+
+        !empty(slots.navLinks) &&
+          {class: 'nav-has-main-links'},
+
+        !html.isBlank(slots.navContent) &&
+          {class: 'nav-has-content'},
+
+        !html.isBlank(slots.navBottomRowContent) &&
+          {class: 'nav-has-bottom-row'},
+
+        [
+          html.tag('div', {class: 'nav-main-links'},
+            {[html.onlyIfContent]: true},
+            {class: 'nav-links-' + slots.navLinkStyle},
+
+            slots.navLinks
+              ?.filter(Boolean)
+              ?.map((cur, i, entries) => {
+                let content;
+
+                if (cur.html) {
+                  content = cur.html;
+                } else {
+                  const attributes = html.attributes();
+                  let title;
+
+                  switch (cur.auto) {
+                    case 'home':
+                      title = data.wikiName;
+                      attributes.set('href', to('localized.home'));
+                      break;
+                    case 'current':
+                      title = slots.title;
+                      attributes.set('href', '');
+                      break;
+                    case null:
+                    case undefined:
+                      title = cur.title;
+                      attributes.set('href', to(...cur.path));
+                      break;
+                  }
+
+                  content = html.tag('a', attributes, title);
+                }
+
+                const showAsCurrent =
+                  cur.current ||
+                  cur.auto === 'current' ||
+                  (slots.navLinkStyle === 'hierarchical' &&
+                    i === slots.navLinks.length - 1);
+
+                const navLink =
+                  html.tag('span', {class: 'nav-link'},
+                    showAsCurrent &&
+                      {class: 'current'},
+
+                    [
+                      html.tag('span', {class: 'nav-link-content'},
+                        content),
+
+                      html.tag('span', {class: 'nav-link-accent'},
+                        {[html.noEdgeWhitespace]: true},
+                        {[html.onlyIfContent]: true},
+
+                        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},
+
+            language.$('misc.navAccent', {
+              [language.onlyIfOptions]: ['links'],
+              links: slots.navBottomRowContent,
+            })),
+
+          html.tag('div', {class: 'nav-content'},
+            {[html.onlyIfContent]: true},
+            slots.navContent),
+        ]);
+
+    const getSidebar = (side, id, needed) => {
+      const sidebar =
+        (html.isBlank(slots[side])
+          ? (needed
+              ? relations.sidebar.clone()
+              : html.blank())
+          : slots[side]);
+
+      if (html.isBlank(sidebar) && !needed) {
+        return sidebar;
+      }
+
+      return sidebar.slots({
+        attributes:
+          sidebar
+            .getSlotValue('attributes')
+            .with({id}),
+      });
+    }
+
+    const willShowSearch =
+      slots.showSearch && relations.searchBox;
+
+    let showingSidebarLeft;
+    let showingSidebarRight;
+    let sidebarsInContentColumn = false;
+
+    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left', willShowSearch);
+    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right', false);
+
+    if (willShowSearch) {
+      if (html.isBlank(leftSidebar)) {
+        sidebarsInContentColumn = true;
+        showingSidebarLeft = true;
+      }
+
+      leftSidebar.setSlot(
+        'boxes',
+        html.tags([
+          relations.searchBox,
+          leftSidebar.getSlotValue('boxes'),
+        ]));
+    }
+
+    const hasSidebarLeft = !html.isBlank(html.resolve(leftSidebar));
+    const hasSidebarRight = !html.isBlank(html.resolve(rightSidebar));
+
+    showingSidebarLeft ??= hasSidebarLeft;
+    showingSidebarRight ??= hasSidebarRight;
+
+    const processSkippers = skipperList =>
+      skipperList
+        .filter(({condition, id}) =>
+          (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))));
+
+    const skippersHTML =
+      mainHTML &&
+        html.tag('div', {id: 'skippers'}, [
+          html.tag('span', language.$('misc.skippers.skipTo')),
+          html.tag('div', {class: 'skipper-list'},
+            processSkippers([
+              {condition: true, id: 'content', string: 'content'},
+              {
+                condition: hasSidebarLeft,
+                id: 'sidebar-left',
+                string:
+                  (hasSidebarRight
+                    ? 'sidebar.left'
+                    : 'sidebar'),
+              },
+              {
+                condition: hasSidebarRight,
+                id: 'sidebar-right',
+                string:
+                  (hasSidebarLeft
+                    ? 'sidebar.right'
+                    : 'sidebar'),
+              },
+              {condition: navHTML, id: 'header', string: 'header'},
+              {condition: footerHTML, id: 'footer', string: 'footer'},
+            ])),
+
+          html.tag('div', {class: 'skipper-list'},
+            {[html.onlyIfContent]: true},
+            processSkippers([
+              {id: 'tracks', string: 'tracks'},
+              {id: 'art', string: 'artworks'},
+              {id: 'flashes', string: 'flashes'},
+              {id: 'contributors', string: 'contributors'},
+              {id: 'references', string: 'references'},
+              {id: 'referenced-by', string: 'referencedBy'},
+              {id: 'samples', string: 'samples'},
+              {id: 'sampled-by', string: 'sampledBy'},
+              {id: 'features', string: 'features'},
+              {id: 'featured-in', string: 'featuredIn'},
+              {id: 'sheet-music-files', string: 'sheetMusicFiles'},
+              {id: 'midi-project-files', string: 'midiProjectFiles'},
+              {id: 'additional-files', string: 'additionalFiles'},
+              {id: 'commentary', string: 'commentary'},
+              {id: 'artist-commentary', string: 'artistCommentary'},
+              {id: 'credit-sources', string: 'creditSources'},
+            ])),
+        ]);
+
+    const styleRulesCSS =
+      html.resolve(slots.styleRules, {normalize: 'string'});
+
+    const fallbackBackgroundStyleRule =
+      (styleRulesCSS.match(/body::before[^}]*background-image:/)
+        ? ''
+        : `body::before {\n` +
+          `    background-image: url("${to('media.path', 'bg.jpg')}");\n` +
+          `}`);
+
+    const numWallpaperParts =
+      html.resolve(slots.styleRules, {normalize: 'string'})
+        .match(/\.wallpaper-part:nth-child/g)
+        ?.length ?? 0;
+
+    const wallpaperPartsHTML =
+      html.tag('div', {class: 'wallpaper-parts'},
+        {[html.onlyIfContent]: true},
+
+        repeat(numWallpaperParts, () =>
+          html.tag('div', {class: 'wallpaper-part'})));
+
+    const layoutHTML = [
+      navHTML,
+
+      slots.bannerPosition === 'top' &&
+        slots.banner,
+
+      slots.secondaryNav,
+
+      html.tag('div', {class: 'layout-columns'}, [
+        leftSidebar,
+        mainHTML,
+        rightSidebar,
+      ]),
+
+      slots.bannerPosition === 'bottom' &&
+        slots.banner,
+
+      footerHTML,
+    ];
+
+    const pageHTML = html.tags([
+      `<!DOCTYPE html>`,
+      html.tag('html',
+        {lang: language.intlCode},
+        {'data-language-code': language.code},
+
+        {'data-url-key': 'localized.' + pagePath[0]},
+        Object.fromEntries(
+          pagePath
+            .slice(1)
+            .map((v, i) => [['data-url-value' + i], v])),
+
+        {'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')},
+
+        [
+          // developersComment,
+
+          html.tag('head', [
+            html.tag('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', {
+              name: 'viewport',
+              content: 'width=device-width, initial-scale=1',
+            }),
+
+            slots.color && [
+              html.tag('meta', {
+                name: 'theme-color',
+                content: colors.dark,
+                media: '(prefers-color-scheme: dark)',
+              }),
+
+              html.tag('meta', {
+                name: 'theme-color',
+                content: colors.light,
+                media: '(prefers-color-scheme: light)',
+              }),
+
+              html.tag('meta', {
+                name: 'theme-color',
+                content: colors.primary,
+              }),
+            ],
+
+            /*
+            ...(
+              Object.entries(meta)
+                .filter(([key, value]) => value)
+                .map(([key, value]) => html.tag('meta', {[key]: value}))),
+            */
+
+            canonicalHref &&
+              html.tag('link', {
+                rel: 'canonical',
+                href: canonicalHref,
+              }),
+
+            /*
+            ...(
+              localizedCanonical
+                .map(({lang, href}) => html.tag('link', {
+                  rel: 'alternate',
+                  hreflang: lang,
+                  href,
+                }))),
+            */
+
+            hasSocialEmbed &&
+              slots.socialEmbed
+                .clone()
+                .slot('mode', 'html'),
+
+            oEmbedJSONHref &&
+              html.tag('link', {
+                type: 'application/json+oembed',
+                href: oEmbedJSONHref,
+              }),
+
+            html.tag('link', {
+              rel: 'stylesheet',
+              href: to('staticCSS.path', 'site.css'),
+            }),
+
+            html.tag('style', [
+              relations.colorStyleRules
+                .slot('color', slots.color ?? data.wikiColor),
+
+              fallbackBackgroundStyleRule,
+              slots.styleRules,
+            ]),
+
+            html.tag('script', {
+              src: to('staticLib.path', 'chroma-js/chroma.min.js'),
+            }),
+
+            html.tag('script', {
+              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',
+            [
+              wallpaperPartsHTML,
+
+              html.tag('div', {id: 'page-container'},
+                showingSidebarLeft &&
+                  {class: 'showing-sidebar-left'},
+
+                showingSidebarRight &&
+                  {class: 'showing-sidebar-right'},
+
+                sidebarsInContentColumn &&
+                  {class: 'sidebars-in-content-column'},
+
+                [
+                  skippersHTML,
+                  layoutHTML,
+                ]),
+
+              // infoCardHTML,
+              relations.imageOverlay,
+            ]),
+        ])
+    ]).toString();
+
+    const oEmbedJSON =
+      (hasSocialEmbed
+        ? slots.socialEmbed
+            .clone()
+            .slot('mode', 'json')
+            .content
+        : null);
+
+    return {pageHTML, oEmbedJSON};
+  },
+};
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/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..154b4762
--- /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: {
+    styleRules: {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,
+        styleRules: slots.styleRules,
+
+        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..55977b37
--- /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: {
+    styleRules: {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,
+        styleRules: slots.styleRules,
+
+        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/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js
new file mode 100644
index 00000000..a997de0e
--- /dev/null
+++ b/src/content/dependencies/generateRelativeDatetimestamp.js
@@ -0,0 +1,69 @@
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateDatetimestampTemplate',
+    'generateTooltip',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  data: (currentDate, referenceDate) =>
+    (currentDate.getTime() === referenceDate.getTime()
+      ? {equal: true, date: currentDate}
+      : {equal: false, currentDate, referenceDate}),
+
+  relations: (relation, currentDate) => ({
+    template:
+      relation('generateDatetimestampTemplate'),
+
+    fallback:
+      relation('generateAbsoluteDatetimestamp', currentDate),
+
+    tooltip:
+      relation('generateTooltip'),
+  }),
+
+  slots: {
+    style: {
+      validate: v => v.is('full', 'year'),
+      default: 'full',
+    },
+
+    tooltip: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate(data, relations, slots, {language}) {
+    if (data.equal) {
+      return relations.fallback.slots({
+        style: slots.style,
+        tooltip: slots.tooltip,
+      });
+    }
+
+    return relations.template.slots({
+      mainContent:
+        (slots.style === 'full'
+          ? language.formatDate(data.currentDate)
+       : slots.style === 'year'
+          ? data.currentDate.getFullYear().toString()
+          : null),
+
+      tooltip:
+        slots.tooltip &&
+          relations.tooltip.slots({
+            content:
+              language.formatRelativeDate(data.currentDate, data.referenceDate, {
+                considerRoundingDays: true,
+                approximate: true,
+                absolute: slots.style === 'year',
+              }),
+          }),
+
+      datetime:
+        data.currentDate.toISOString(),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js
new file mode 100644
index 00000000..016e0a2c
--- /dev/null
+++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js
@@ -0,0 +1,31 @@
+export default {
+  contentDependencies: ['generateArtistCredit'],
+  extraDependencies: ['html'],
+
+  relations: (relation, contributions) => ({
+    credit:
+      relation('generateArtistCredit', contributions, []),
+  }),
+
+  slots: {
+    stringKey: {type: 'string'},
+    featuringStringKey: {type: 'string'},
+
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (relations, slots) =>
+    relations.credit.slots({
+      showAnnotation: true,
+      showExternalLinks: true,
+      showChronology: true,
+      showWikiEdits: true,
+
+      trimAnnotation: false,
+
+      chronologyKind: slots.chronologyKind,
+
+      normalStringKey: slots.stringKey,
+      normalFeaturingStringKey: slots.featuringStringKey,
+    }),
+};
diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js
new file mode 100644
index 00000000..188a678f
--- /dev/null
+++ b/src/content/dependencies/generateSearchSidebarBox.js
@@ -0,0 +1,62 @@
+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')),
+          ]),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateSecondaryNav.js b/src/content/dependencies/generateSecondaryNav.js
new file mode 100644
index 00000000..9ce7ce9b
--- /dev/null
+++ b/src/content/dependencies/generateSecondaryNav.js
@@ -0,0 +1,30 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    alwaysVisible: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('nav', {id: 'secondary-nav'},
+      {[html.onlyIfContent]: true},
+      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
new file mode 100644
index 00000000..513ea518
--- /dev/null
+++ b/src/content/dependencies/generateSocialEmbed.js
@@ -0,0 +1,70 @@
+export default {
+  extraDependencies: ['absoluteTo', 'html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      canonicalBase: wikiInfo.canonicalBase,
+      shortWikiName: wikiInfo.nameShort,
+    };
+  },
+
+  data(sprawl) {
+    return {
+      canonicalBase: sprawl.canonicalBase,
+      shortWikiName: sprawl.shortWikiName,
+    };
+  },
+
+  slots: {
+    mode: {validate: v => v.is('html', 'json')},
+
+    title: {type: 'string'},
+    description: {type: 'string'},
+
+    headingContent: {type: 'string'},
+    headingLink: {type: 'string'},
+    imagePath: {validate: v => v.strictArrayOf(v.isString)},
+  },
+
+  generate(data, slots, {absoluteTo, html, language}) {
+    switch (slots.mode) {
+      case 'html':
+        return html.tags([
+          slots.title &&
+            html.tag('meta', {property: 'og:title', content: slots.title}),
+
+          slots.description &&
+            html.tag('meta', {
+              property: 'og:description',
+              content: slots.description,
+            }),
+
+          slots.imagePath &&
+            html.tag('meta', {
+              property: 'og:image',
+              content: absoluteTo(...slots.imagePath),
+            }),
+        ]);
+
+      case 'json':
+        return JSON.stringify({
+          author_name:
+            (slots.headingContent
+              ? html.resolve(
+                  language.$('misc.socialEmbed.heading', {
+                    wikiName: data.shortWikiName,
+                    heading: slots.headingContent,
+                  }),
+                  {normalize: 'string'})
+              : undefined),
+
+          author_url:
+            (slots.headingLink && data.canonicalBase
+              ? data.canonicalBase.replace(/\/$/, '') +
+                '/' +
+                slots.headingLink.replace(/^\//, '')
+              : undefined),
+        });
+    }
+  },
+};
diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js
new file mode 100644
index 00000000..226152c7
--- /dev/null
+++ b/src/content/dependencies/generateStaticPage.js
@@ -0,0 +1,46 @@
+export default {
+  contentDependencies: ['generatePageLayout', 'transformContent'],
+  extraDependencies: ['html'],
+
+  relations(relation, staticPage) {
+    return {
+      layout: relation('generatePageLayout'),
+      content: relation('transformContent', staticPage.content),
+    };
+  },
+
+  data(staticPage) {
+    return {
+      name: staticPage.name,
+      stylesheet: staticPage.stylesheet,
+      script: staticPage.script,
+    };
+  },
+
+  generate(data, relations, {html}) {
+    return relations.layout
+      .slots({
+        title: data.name,
+        headingMode: 'sticky',
+
+        styleRules:
+          (data.stylesheet
+            ? [data.stylesheet]
+            : []),
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          relations.content,
+
+          data.script &&
+            html.tag('script', data.script),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      });
+  },
+};
diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js
new file mode 100644
index 00000000..ec3062a3
--- /dev/null
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -0,0 +1,59 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    rootAttributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    cover: {
+      type: 'html',
+      mutable: true,
+    },
+  },
+
+  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-anchor'},
+        html.tag('div', {class: 'content-sticky-heading-container'},
+          !html.isBlank(slots.cover) &&
+            {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/generateTextWithTooltip.js b/src/content/dependencies/generateTextWithTooltip.js
new file mode 100644
index 00000000..49ce1f61
--- /dev/null
+++ b/src/content/dependencies/generateTextWithTooltip.js
@@ -0,0 +1,71 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    customInteractionCue: {
+      type: 'boolean',
+      default: false,
+    },
+
+    text: {
+      type: 'html',
+      mutable: false,
+    },
+
+    tooltip: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate(slots, {html}) {
+    const hasTooltip =
+      !html.isBlank(slots.tooltip);
+
+    if (slots.attributes.blank && !hasTooltip) {
+      return slots.text;
+    }
+
+    let {attributes} = slots;
+
+    if (hasTooltip) {
+      attributes = attributes.clone();
+      attributes.add({
+        [html.onlyIfContent]: true,
+        [html.joinChildren]: '',
+        [html.noEdgeWhitespace]: true,
+        class: 'text-with-tooltip',
+      });
+    }
+
+    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 =
+      (hasTooltip
+        ? [textPart, slots.tooltip]
+        : textPart);
+
+    return html.tag('span', attributes, content);
+  },
+};
diff --git a/src/content/dependencies/generateTooltip.js b/src/content/dependencies/generateTooltip.js
new file mode 100644
index 00000000..b09ee230
--- /dev/null
+++ b/src/content/dependencies/generateTooltip.js
@@ -0,0 +1,34 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    contentAttributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  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/generateTrackArtistCommentarySection.js b/src/content/dependencies/generateTrackArtistCommentarySection.js
new file mode 100644
index 00000000..e3041d3a
--- /dev/null
+++ b/src/content/dependencies/generateTrackArtistCommentarySection.js
@@ -0,0 +1,157 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCommentaryEntry',
+    'generateContentHeading',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    otherSecondaryReleasesWithCommentary:
+      track.otherReleases
+        .filter(track => !track.isMainRelease)
+        .filter(track => !empty(track.commentary)),
+  }),
+
+  relations: (relation, query, track) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+
+    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.contentHeading.clone()
+          .slots({
+            attributes: {id: 'artist-commentary'},
+            title: language.$('misc.artistCommentary'),
+          }),
+
+        data.isSecondaryRelease &&
+          html.tags([
+            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.tags([
+          data.isSecondaryRelease &&
+          !html.isBlank(relations.mainReleaseArtistCommentaryEntries) &&
+            html.tag('p', {class: ['drop', 'commentary-drop']},
+              {[html.onlyIfSiblings]: true},
+
+              language.$(capsule, 'info.releaseSpecific', {
+                album:
+                  relations.thisReleaseAlbumLink,
+              })),
+
+          relations.artistCommentaryEntries,
+        ]),
+
+        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/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
new file mode 100644
index 00000000..ca6f82b9
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -0,0 +1,435 @@
+export default {
+  contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateAlbumAdditionalFilesList',
+    'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
+    'generateAlbumSidebar',
+    'generateAlbumStyleRules',
+    'generateCommentaryEntry',
+    'generateContentHeading',
+    'generateContributionList',
+    'generateLyricsSection',
+    'generatePageLayout',
+    'generateTrackArtistCommentarySection',
+    'generateTrackArtworkColumn',
+    'generateTrackInfoPageFeaturedByFlashesList',
+    'generateTrackInfoPageOtherReleasesList',
+    'generateTrackList',
+    'generateTrackListDividedByGroups',
+    'generateTrackNavLinks',
+    'generateTrackReleaseInfo',
+    'generateTrackSocialEmbed',
+    'linkAlbum',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    mainReleaseTrack:
+      (track.isMainRelease
+        ? track
+        : track.mainReleaseTrack),
+  }),
+
+  relations: (relation, query, track) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', track.album, track),
+
+    socialEmbed:
+      relation('generateTrackSocialEmbed', track),
+
+    navLinks:
+      relation('generateTrackNavLinks', track),
+
+    albumNavAccent:
+      relation('generateAlbumNavAccent', track.album, track),
+
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', track.album),
+
+    sidebar:
+      relation('generateAlbumSidebar', track.album, track),
+
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', track.additionalNames),
+
+    artworkColumn:
+      relation('generateTrackArtworkColumn', track),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    releaseInfo:
+      relation('generateTrackReleaseInfo', track),
+
+    otherReleasesList:
+      relation('generateTrackInfoPageOtherReleasesList', track),
+
+    contributorContributionList:
+      relation('generateContributionList', track.contributorContribs),
+
+    referencedTracksList:
+      relation('generateTrackList', track.referencedTracks),
+
+    sampledTracksList:
+      relation('generateTrackList', track.sampledTracks),
+
+    referencedByTracksList:
+      relation('generateTrackListDividedByGroups',
+        query.mainReleaseTrack.referencedByTracks),
+
+    sampledByTracksList:
+      relation('generateTrackListDividedByGroups',
+        query.mainReleaseTrack.sampledByTracks),
+
+    flashesThatFeatureList:
+      relation('generateTrackInfoPageFeaturedByFlashesList', track),
+
+    lyricsSection:
+      relation('generateLyricsSection', track.lyrics),
+
+    sheetMusicFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        track.album,
+        track.sheetMusicFiles),
+
+    midiProjectFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        track.album,
+        track.midiProjectFiles),
+
+    additionalFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        track.album,
+        track.additionalFiles),
+
+    artistCommentarySection:
+      relation('generateTrackArtistCommentarySection', track),
+
+    creditSourceEntries:
+      track.creditSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
+  }),
+
+  data: (_query, track) => ({
+    name:
+      track.name,
+
+    color:
+      track.color,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('trackPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            track: data.name,
+          }),
+
+        headingMode: 'sticky',
+
+        additionalNames: relations.additionalNamesBox,
+
+        color: data.color,
+        styleRules: [relations.albumStyleRules],
+
+        artworkColumnContent:
+          relations.artworkColumn,
+
+        mainContent: [
+          relations.releaseInfo,
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            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')),
+                  })),
+
+              !html.isBlank(relations.midiProjectFilesList) &&
+                language.encapsulate(capsule, 'midiProjectFiles.shortcut', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#midi-project-files'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.additionalFilesList) &&
+                language.encapsulate(capsule, 'additionalFiles.shortcut', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#midi-project-files'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.artistCommentarySection) &&
+                language.encapsulate(capsule, 'readCommentary', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#artist-commentary'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.creditSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#credit-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
+
+          relations.otherReleasesList,
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'contributors'},
+                title: language.$('releaseInfo.contributors'),
+              }),
+
+            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'),
+                })),
+
+            relations.referencedTracksList,
+          ]),
+
+          html.tags([
+            language.encapsulate('releaseInfo.tracksSampled', capsule =>
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'samples'},
+
+                  title:
+                    language.$(capsule, {
+                      track:
+                        html.tag('i', data.name),
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                })),
+
+            relations.sampledTracksList,
+          ]),
+
+          language.encapsulate('releaseInfo.tracksThatReference', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'referenced-by'},
+
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                }),
+
+              relations.referencedByTracksList
+                .slots({
+                  headingString: capsule,
+                }),
+            ])),
+
+          language.encapsulate('releaseInfo.tracksThatSample', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'sampled-by'},
+
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                }),
+
+              relations.sampledByTracksList
+                .slots({
+                  headingString: capsule,
+                }),
+            ])),
+
+          html.tags([
+            language.encapsulate('releaseInfo.flashesThatFeature', capsule =>
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'featured-in'},
+
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                })),
+
+            relations.flashesThatFeatureList,
+          ]),
+
+          relations.lyricsSection,
+
+          // html.tags([
+          //   relations.contentHeading.clone()
+          //     .slots({
+          //       attributes: {id: 'lyrics'},
+          //       title: language.$('releaseInfo.lyrics'),
+          //     }),
+
+          //   html.tag('blockquote',
+          //     {[html.onlyIfContent]: true},
+          //     relations.lyrics.slot('mode', 'lyrics')),
+          // ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'sheet-music-files'},
+                title: language.$('releaseInfo.sheetMusicFiles.heading'),
+              }),
+
+            relations.sheetMusicFilesList,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'midi-project-files'},
+                title: language.$('releaseInfo.midiProjectFiles.heading'),
+              }),
+
+            relations.midiProjectFilesList,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'additional-files'},
+                title: language.$('releaseInfo.additionalFiles.heading'),
+              }),
+
+            relations.additionalFilesList,
+          ]),
+
+          relations.artistCommentarySection,
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'credit-sources'},
+                title: language.$('misc.creditSources'),
+              }),
+
+            relations.creditSourceEntries,
+          ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: html.resolve(relations.navLinks),
+
+        navBottomRowContent:
+          relations.albumNavAccent.slots({
+            showTrackNavigation: true,
+            showExtraLinks: false,
+          }),
+
+        secondaryNav:
+          relations.secondaryNav
+            .slot('mode', 'track'),
+
+        leftSidebar: relations.sidebar,
+
+        socialEmbed: relations.socialEmbed,
+      })),
+};
+
+/*
+  const data = {
+    type: 'data',
+    path: ['track', track.directory],
+    data: ({
+      serializeContribs,
+      serializeCover,
+      serializeGroupsForTrack,
+      serializeLink,
+    }) => ({
+      name: track.name,
+      directory: track.directory,
+      dates: {
+        released: track.date,
+        originallyReleased: track.originalDate,
+        coverArtAdded: track.coverArtDate,
+      },
+      duration: track.duration,
+      color: track.color,
+      cover: serializeCover(track, getTrackCover),
+      artistsContribs: serializeContribs(track.artistContribs),
+      contributorContribs: serializeContribs(track.contributorContribs),
+      coverArtistContribs: serializeContribs(track.coverArtistContribs || []),
+      album: serializeLink(track.album),
+      groups: serializeGroupsForTrack(track),
+      references: track.references.map(serializeLink),
+      referencedBy: track.referencedBy.map(serializeLink),
+      alsoReleasedAs: otherReleases.map((track) => ({
+        track: serializeLink(track),
+        album: serializeLink(track.album),
+      })),
+    }),
+  };
+
+  const page = {
+    page: () => {
+      return {
+        theme:
+          getThemeString(track.color, {
+            additionalVariables: [
+              `--album-directory: ${album.directory}`,
+              `--track-directory: ${track.directory}`,
+            ]
+          }),
+      };
+    },
+  };
+*/
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
new file mode 100644
index 00000000..53a32536
--- /dev/null
+++ b/src/content/dependencies/generateTrackList.js
@@ -0,0 +1,28 @@
+export default {
+  contentDependencies: ['generateTrackListItem'],
+  extraDependencies: ['html'],
+
+  relations: (relation, tracks) => ({
+    items:
+      tracks
+        .map(track => relation('generateTrackListItem', track, [])),
+  }),
+
+  slots: {
+    colorMode: {
+      validate: v => v.is('none', 'track', 'line'),
+      default: 'track',
+    },
+  },
+
+  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
new file mode 100644
index 00000000..230868d6
--- /dev/null
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -0,0 +1,145 @@
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateTrackList',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    divideTrackListsByGroups:
+      wikiInfo.divideTrackListsByGroups,
+  }),
+
+  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, []);
+    }
+
+    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 groups = Array.from(groupings.keys());
+    const groupedTracks = Array.from(groupings.values());
+
+    // Drop the empty lists, so just the groups which
+    // at least a single track matched are left.
+    filterMultipleArrays(
+      groups,
+      groupedTracks,
+      (_group, tracks) => !empty(tracks));
+
+    return {groups, groupedTracks, ungroupedTracks};
+  },
+
+  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)),
+  }),
+
+  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..887b6f03
--- /dev/null
+++ b/src/content/dependencies/generateTrackListItem.js
@@ -0,0 +1,106 @@
+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'},
+                html.metatag('chunkwrap', {split: ','},
+                  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..93438c5b
--- /dev/null
+++ b/src/content/dependencies/generateTrackReferencedArtworksPage.js
@@ -0,0 +1,47 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleRules',
+    'generateBackToTrackLink',
+    'generateReferencedArtworksPage',
+    'generateTrackNavLinks',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    page:
+      relation('generateReferencedArtworksPage', track.trackArtworks[0]),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', 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,
+        }),
+
+      styleRules: [relations.albumStyleRules],
+
+      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..e9818bad
--- /dev/null
+++ b/src/content/dependencies/generateTrackReferencingArtworksPage.js
@@ -0,0 +1,47 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleRules',
+    'generateBackToTrackLink',
+    'generateReferencingArtworksPage',
+    'generateTrackNavLinks',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    page:
+      relation('generateReferencingArtworksPage', track.trackArtworks[0]),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', 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,
+        }),
+
+      styleRules: [relations.albumStyleRules],
+
+      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
new file mode 100644
index 00000000..54e462c7
--- /dev/null
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -0,0 +1,82 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateReleaseInfoContributionsLine',
+    'linkExternal',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, track) {
+    const relations = {};
+
+    relations.artistContributionLinks =
+      relation('generateReleaseInfoContributionsLine', track.artistContribs);
+
+    if (!empty(track.urls)) {
+      relations.externalLinks =
+        track.urls.map(url =>
+          relation('linkExternal', url));
+    }
+
+    return relations;
+  },
+
+  data(track) {
+    const data = {};
+
+    data.name = track.name;
+    data.date = track.date;
+    data.duration = track.duration;
+
+    if (
+      track.hasUniqueCoverArt &&
+      +track.coverArtDate !== +track.date
+    ) {
+      data.coverArtDate = track.coverArtDate;
+    }
+
+    return data;
+  },
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule =>
+      html.tags([
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.artistContributionLinks.slots({
+              stringKey: capsule + '.by',
+              featuringStringKey: capsule + '.by.featuring',
+              chronologyKind: 'track',
+            }),
+
+            language.$(capsule, 'released', {
+              [language.onlyIfOptions]: ['date'],
+              date: language.formatDate(data.date),
+            }),
+
+            language.$(capsule, 'duration', {
+              [language.onlyIfOptions]: ['duration'],
+              duration: language.formatDuration(data.duration),
+            }),
+          ]),
+
+        html.tag('p',
+          language.encapsulate(capsule, 'listenOn', capsule =>
+            (relations.externalLinks
+              ? language.$(capsule, {
+                  links:
+                    language.formatDisjunctionList(
+                      relations.externalLinks
+                        .map(link => link.slot('context', 'track'))),
+                })
+              : language.$(capsule, 'noLinks', {
+                  name:
+                    html.tag('i', data.name),
+                })))),
+      ])),
+};
diff --git a/src/content/dependencies/generateTrackSocialEmbed.js b/src/content/dependencies/generateTrackSocialEmbed.js
new file mode 100644
index 00000000..7cb37af2
--- /dev/null
+++ b/src/content/dependencies/generateTrackSocialEmbed.js
@@ -0,0 +1,68 @@
+export default {
+  contentDependencies: [
+    'generateSocialEmbed',
+    'generateTrackSocialEmbedDescription',
+  ],
+
+  extraDependencies: ['absoluteTo', 'language'],
+
+  relations(relation, track) {
+    return {
+      socialEmbed:
+        relation('generateSocialEmbed'),
+
+      description:
+        relation('generateTrackSocialEmbedDescription', track),
+    };
+  },
+
+  data(track) {
+    const {album} = track;
+    const data = {};
+
+    data.trackName = track.name;
+    data.albumName = album.name;
+
+    data.trackDirectory = track.directory;
+    data.albumDirectory = album.directory;
+
+    if (track.hasUniqueCoverArt) {
+      data.imageSource = 'track';
+      data.coverArtFileExtension = track.coverArtFileExtension;
+    } else if (album.hasCoverArt) {
+      data.imageSource = 'album';
+      data.coverArtFileExtension = album.coverArtFileExtension;
+    } else {
+      data.imageSource = 'none';
+    }
+
+    return data;
+  },
+
+  generate: (data, relations, {absoluteTo, language}) =>
+    language.encapsulate('trackPage.socialEmbed', embedCapsule =>
+      relations.socialEmbed.slots({
+        title:
+          language.$(embedCapsule, 'title', {
+            track: data.trackName,
+          }),
+
+        description:
+          relations.description,
+
+        headingContent:
+          language.$(embedCapsule, 'heading', {
+            album: data.albumName,
+          }),
+
+        headingLink:
+          absoluteTo('localized.album', data.albumDirectory),
+
+        imagePath:
+          (data.imageSource === 'album'
+            ? ['media.albumCover', data.albumDirectory, data.coverArtFileExtension]
+         : data.imageSource === 'track'
+            ? ['media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension]
+            : null),
+      })),
+};
diff --git a/src/content/dependencies/generateTrackSocialEmbedDescription.js b/src/content/dependencies/generateTrackSocialEmbedDescription.js
new file mode 100644
index 00000000..4706aa26
--- /dev/null
+++ b/src/content/dependencies/generateTrackSocialEmbedDescription.js
@@ -0,0 +1,39 @@
+import {empty} from '#sugar';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data: (track) => ({
+    artistNames:
+      track.artistContribs
+        .map(contrib => contrib.artist.name),
+
+    coverArtistNames:
+      track.coverArtistContribs
+        .map(contrib => contrib.artist.name),
+  }),
+
+  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/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/image.js b/src/content/dependencies/image.js
new file mode 100644
index 00000000..bf47b14f
--- /dev/null
+++ b/src/content/dependencies/image.js
@@ -0,0 +1,374 @@
+import {logWarn} from '#cli';
+import {empty} from '#sugar';
+
+export default {
+  extraDependencies: [
+    'checkIfImagePathHasCachedThumbnails',
+    'getDimensionsOfImagePath',
+    'getSizeOfMediaFile',
+    'getThumbnailEqualOrSmaller',
+    'getThumbnailsAvailableForDimensions',
+    'html',
+    'language',
+    'missingImagePaths',
+    'to',
+  ],
+
+  contentDependencies: ['generateColorStyleAttribute'],
+
+  relations: (relation, _artwork) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute'),
+  }),
+
+  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: {
+    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},
+
+    // Added to the .image-container.
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    // Added to the <img> itself.
+    alt: {type: 'string'},
+
+    // 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, {
+    checkIfImagePathHasCachedThumbnails,
+    getDimensionsOfImagePath,
+    getSizeOfMediaFile,
+    getThumbnailEqualOrSmaller,
+    getThumbnailsAvailableForDimensions,
+    html,
+    language,
+    missingImagePaths,
+    to,
+  }) {
+    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
+    // src string directly when a parts-formed path *is* available seems wrong.
+    // It should be possible to do urls.from(slots.path[0]).to(...slots.path),
+    // for example, but will require reworking the control flow here a little.
+    let mediaSrc = null;
+    if (originalSrc.startsWith(to('media.root'))) {
+      mediaSrc =
+        originalSrc
+          .slice(to('media.root').length)
+          .replace(/^\//, '');
+    }
+
+    const isMissingImageFile =
+      missingImagePaths.includes(mediaSrc);
+
+    const willLink =
+      !isMissingImageFile &&
+      (typeof slots.link === 'string' || slots.link);
+
+    const warnings = slots.warnings ?? data.warnings;
+    const dimensions = slots.dimensions ?? data.dimensions;
+
+    const willReveal =
+      slots.reveal &&
+      originalSrc &&
+      !isMissingImageFile &&
+      !empty(warnings);
+
+    const imgAttributes = html.attributes([
+      {class: 'image'},
+
+      slots.alt && {alt: slots.alt},
+
+      dimensions &&
+      dimensions[0] &&
+        {width: dimensions[0]},
+
+      dimensions &&
+      dimensions[1] &&
+        {height: dimensions[1]},
+    ]);
+
+    const isPlaceholder =
+      !originalSrc || isMissingImageFile;
+
+    if (isPlaceholder) {
+      return (
+        prepare(
+          html.tag('div', {class: 'image-text-area'},
+            (html.isBlank(slots.missingSourceContent)
+              ? language.$('misc.missingImage')
+              : slots.missingSourceContent)),
+          'visible'));
+    }
+
+    let reveal = null;
+    if (willReveal) {
+      reveal = [
+        html.tag('img', {class: 'reveal-symbol'},
+          {src: to('staticMisc.path', 'warning.svg')}),
+
+        html.tag('br'),
+
+        html.tag('span', {class: 'reveal-warnings'},
+          language.$('misc.contentWarnings.warnings', {
+            warnings: language.formatUnitList(warnings),
+          })),
+
+        html.tag('br'),
+
+        html.tag('span', {class: 'reveal-interaction'},
+          language.$('misc.contentWarnings.reveal')),
+      ];
+    }
+
+    const hasThumbnails =
+      mediaSrc &&
+      checkIfImagePathHasCachedThumbnails(mediaSrc);
+
+    // Warn for images that *should* have cached thumbnail information but are
+    // missing from the thumbs cache.
+    if (
+      slots.thumb &&
+      !hasThumbnails &&
+      !mediaSrc.endsWith('.gif')
+    ) {
+      logWarn`No thumbnail info cached: ${mediaSrc} - displaying original image here (instead of ${slots.thumb})`;
+    }
+
+    let displaySrc = originalSrc;
+
+    // This is only distinguished from displaySrc by being a thumbnail,
+    // so it won't be set if thumbnails aren't available.
+    let revealSrc = null;
+
+    // If thumbnails are available *and* being used, calculate thumbSrc,
+    // and provide some attributes relevant to the large image overlay.
+    if (hasThumbnails && slots.thumb) {
+      const selectedSize =
+        getThumbnailEqualOrSmaller(slots.thumb, mediaSrc);
+
+      const mediaSrcJpeg =
+        mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`);
+
+      displaySrc =
+        to('thumb.path', mediaSrcJpeg);
+
+      if (willReveal) {
+        const miniSize =
+          getThumbnailEqualOrSmaller('mini', mediaSrc);
+
+        const mediaSrcJpeg =
+          mediaSrc.replace(/\.(png|jpg)$/, `.${miniSize}.jpg`);
+
+        revealSrc =
+          to('thumb.path', mediaSrcJpeg);
+      }
+
+      const originalDimensions = getDimensionsOfImagePath(mediaSrc);
+      const availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions);
+
+      const fileSize =
+        (willLink && mediaSrc
+          ? getSizeOfMediaFile(mediaSrc)
+          : null);
+
+      imgAttributes.add([
+        fileSize &&
+          {'data-original-size': fileSize},
+
+        {'data-dimensions': originalDimensions.join('x')},
+
+        !empty(availableThumbs) &&
+          {'data-thumbs':
+              availableThumbs
+                .map(([name, size]) => `${name}:${size}`)
+                .join(' ')},
+      ]);
+    }
+
+    if (!displaySrc) {
+      return (
+        prepare(
+          html.tag('img', imgAttributes),
+          'visible'));
+    }
+
+    const images = {
+      displayStatic:
+        html.tag('img',
+          imgAttributes,
+          {src: displaySrc}),
+
+      displayLazy:
+        slots.lazy &&
+          html.tag('img',
+            imgAttributes,
+            {class: 'lazy', 'data-original': displaySrc}),
+
+      revealStatic:
+        revealSrc &&
+          html.tag('img', {class: 'reveal-thumbnail'},
+            imgAttributes,
+            {src: revealSrc}),
+
+      revealLazy:
+        slots.lazy &&
+        revealSrc &&
+          html.tag('img', {class: 'reveal-thumbnail'},
+            imgAttributes,
+            {class: 'lazy', 'data-original': revealSrc}),
+    };
+
+    const staticImageContent =
+      html.tags([images.displayStatic, images.revealStatic]);
+
+    if (slots.lazy) {
+      const lazyImageContent =
+        html.tags([images.displayLazy, images.revealLazy]);
+
+      return html.tags([
+        html.tag('noscript',
+          prepare(staticImageContent, 'visible')),
+
+        prepare(lazyImageContent, 'hidden'),
+      ]);
+    } else {
+      return prepare(staticImageContent, 'visible');
+    }
+
+    function prepare(imageContent, visibility) {
+      let wrapped = imageContent;
+
+      if (willReveal) {
+        wrapped =
+          html.tags([
+            wrapped,
+            html.tag('span', {class: 'reveal-text-container'},
+              html.tag('span', {class: 'reveal-text'},
+                reveal)),
+          ]);
+      }
+
+      wrapped =
+        html.tag('div', {class: 'image-inner-area'},
+          wrapped);
+
+      if (willLink) {
+        wrapped =
+          html.tag('a', {class: 'image-link'},
+            (typeof slots.link === 'string'
+              ? {href: slots.link}
+              : {href: originalSrc}),
+
+            wrapped);
+      }
+
+      wrapped =
+        html.tag('div', {class: 'image-outer-area'},
+          slots.square &&
+            {class: 'square-content'},
+
+          wrapped);
+
+      wrapped =
+        html.tag('div', {class: 'image-container'},
+          slots.square &&
+            {class: 'square'},
+
+          typeof slots.link === 'string' &&
+            {class: 'no-image-preview'},
+
+          (isPlaceholder
+            ? {class: 'placeholder-image'}
+            : [
+                willLink &&
+                  {class: 'has-link'},
+
+                willReveal &&
+                  {class: 'reveal'},
+
+                revealSrc &&
+                  {class: 'has-reveal-thumbnail'},
+              ]),
+
+          visibility === 'hidden' &&
+            {class: 'js-hide'},
+
+          slots.color &&
+            relations.colorStyle.slots({
+              color: slots.color,
+              context: 'image-box',
+            }),
+
+          slots.attributes,
+
+          wrapped);
+
+      return wrapped;
+    }
+  },
+};
diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js
new file mode 100644
index 00000000..a5009804
--- /dev/null
+++ b/src/content/dependencies/index.js
@@ -0,0 +1,274 @@
+import EventEmitter from 'node:events';
+import {readdir} from 'node:fs/promises';
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+import chokidar from 'chokidar';
+import {ESLint} from 'eslint';
+
+import {showAggregate as _showAggregate} from '#aggregate';
+import {colors, logWarn} from '#cli';
+import contentFunction, {ContentFunctionSpecError} from '#content-function';
+import {annotateFunction} from '#sugar';
+
+function cachebust(filePath) {
+  if (filePath in cachebust.cache) {
+    cachebust.cache[filePath] += 1;
+    return `${filePath}?cachebust${cachebust.cache[filePath]}`;
+  } else {
+    cachebust.cache[filePath] = 0;
+    return filePath;
+  }
+}
+
+cachebust.cache = Object.create(null);
+
+export function watchContentDependencies({
+  mock = null,
+  logging = true,
+  showAggregate = _showAggregate,
+} = {}) {
+  const events = new EventEmitter();
+  const contentDependencies = {};
+
+  let emittedReady = false;
+  let emittedErrorForFunctions = new Set();
+  let closed = false;
+
+  let _close = () => {};
+
+  Object.assign(events, {
+    contentDependencies,
+    close,
+  });
+
+  const eslint = new ESLint();
+
+  const metaPath = fileURLToPath(import.meta.url);
+  const metaDirname = path.dirname(metaPath);
+  const watchPath = metaDirname;
+
+  const mockKeys = new Set();
+  if (mock) {
+    const errors = [];
+
+    for (const [functionName, spec] of Object.entries(mock)) {
+      mockKeys.add(functionName);
+      try {
+        const fn = processFunctionSpec(functionName, spec);
+        contentDependencies[functionName] = fn;
+      } catch (error) {
+        error.message = `(${functionName}) ${error.message}`;
+        errors.push(error);
+      }
+    }
+
+    if (errors.length) {
+      throw new AggregateError(errors, `Errors processing mocked content functions`);
+    }
+  }
+
+  // Chokidar's 'ready' event is supposed to only fire once an 'add' event
+  // has been fired for everything in the watched directory, but it's not
+  // totally reliable. https://github.com/paulmillr/chokidar/issues/1011
+  //
+  // Workaround here is to readdir for the names of all dependencies ourselves,
+  // and enter null for each into the contentDependencies object. We'll emit
+  // 'ready' ourselves only once no nulls remain. And we won't actually start
+  // watching until the readdir is done and nulls are entered (so we don't
+  // prematurely find out there aren't any nulls - before the nulls have
+  // been entered at all!).
+
+  readdir(watchPath).then(files => {
+    if (closed) {
+      return;
+    }
+
+    const filePaths = files.map(file => path.join(watchPath, file));
+    for (const filePath of filePaths) {
+      if (filePath === metaPath) continue;
+      const functionName = getFunctionName(filePath);
+      if (!isMocked(functionName)) {
+        contentDependencies[functionName] = null;
+      }
+    }
+
+    const watcher = chokidar.watch(watchPath);
+
+    watcher.on('all', (event, filePath) => {
+      if (!['add', 'change'].includes(event)) return;
+      if (filePath === metaPath) return;
+      handlePathUpdated(filePath);
+
+    });
+
+    watcher.on('unlink', (filePath) => {
+      if (filePath === metaPath) {
+        console.error(`Yeowzers content dependencies just got nuked.`);
+        return;
+      }
+
+      handlePathRemoved(filePath);
+    });
+
+    _close = () => watcher.close();
+  });
+
+  return events;
+
+  async function close() {
+    closed = true;
+    return _close();
+  }
+
+  function checkReadyConditions() {
+    if (emittedReady) return;
+    if (Object.values(contentDependencies).includes(null)) return;
+
+    events.emit('ready');
+    emittedReady = true;
+  }
+
+  function getFunctionName(filePath) {
+    const shortPath = path.basename(filePath);
+    const functionName = shortPath.slice(0, -path.extname(shortPath).length);
+    return functionName;
+  }
+
+  function isMocked(functionName) {
+    return mockKeys.has(functionName);
+  }
+
+  async function handlePathRemoved(filePath) {
+    const functionName = getFunctionName(filePath);
+    if (isMocked(functionName)) return;
+
+    delete contentDependencies[functionName];
+  }
+
+  async function handlePathUpdated(filePath) {
+    const functionName = getFunctionName(filePath);
+    if (isMocked(functionName)) return;
+
+    let error = null;
+
+    main: {
+      const eslintResults = await eslint.lintFiles([filePath]);
+      const eslintFormatter = await eslint.loadFormatter('stylish');
+      const eslintResultText = eslintFormatter.format(eslintResults);
+      if (eslintResultText.trim().length) {
+        console.log(eslintResultText);
+      }
+
+      let spec;
+      try {
+        const module =
+          await import(
+            cachebust(
+              './' +
+              path
+                .relative(metaDirname, filePath)
+                .split(path.sep)
+                .join('/')));
+        spec = module.default;
+      } catch (caughtError) {
+        error = caughtError;
+        error.message = `Error importing: ${error.message}`;
+        break main;
+      }
+
+      // Just skip newly created files. They'll be processed again when
+      // written.
+      if (spec === undefined) {
+        // For practical purposes the file is treated as though it doesn't
+        // even exist (undefined), rather than not being ready yet (null).
+        // Apart from if existing contents of the file were erased (but not
+        // the file itself), this value might already be set (to null!) by
+        // the readdir performed at the beginning to evaluate which files
+        // should be read and processed at least once before reporting all
+        // dependencies as ready.
+        delete contentDependencies[functionName];
+        return;
+      }
+
+      let fn;
+      try {
+        fn = processFunctionSpec(functionName, spec);
+      } catch (caughtError) {
+        error = caughtError;
+        break main;
+      }
+
+      const emittedError = emittedErrorForFunctions.has(functionName);
+      if (logging && (emittedReady || emittedError)) {
+        const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'});
+        console.log(colors.green(`[${timestamp}] Updated ${functionName}`));
+      }
+
+      contentDependencies[functionName] = fn;
+
+      events.emit('update', functionName);
+      checkReadyConditions();
+    }
+
+    if (!error) {
+      return true;
+    }
+
+    if (!(functionName in contentDependencies)) {
+      contentDependencies[functionName] = null;
+    }
+
+    events.emit('error', functionName, error);
+    emittedErrorForFunctions.add(functionName);
+
+    if (logging) {
+      if (contentDependencies[functionName]) {
+        logWarn`Failed to import ${functionName} - using existing version`;
+      } else {
+        logWarn`Failed to import ${functionName} - no prior version loaded`;
+      }
+
+      if (typeof error === 'string') {
+        console.error(colors.yellow(error));
+      } else if (error instanceof ContentFunctionSpecError) {
+        console.error(colors.yellow(error.message));
+      } else {
+        showAggregate(error);
+      }
+    }
+
+    return false;
+  }
+
+  function processFunctionSpec(functionName, spec) {
+    if (typeof spec?.data === 'function') {
+      annotateFunction(spec.data, {name: functionName, description: 'data'});
+    }
+
+    if (typeof spec?.generate === 'function') {
+      annotateFunction(spec.generate, {name: functionName});
+    }
+
+    return contentFunction(spec);
+  }
+}
+
+export function quickLoadContentDependencies(opts) {
+  return new Promise((resolve, reject) => {
+    const watcher = watchContentDependencies(opts);
+
+    watcher.on('error', (name, error) => {
+      watcher.close().then(() => {
+        error.message = `Error loading dependency ${name}: ${error}`;
+        reject(error);
+      });
+    });
+
+    watcher.on('ready', () => {
+      watcher.close().then(() => {
+        resolve(watcher.contentDependencies);
+      });
+    });
+  });
+}
diff --git a/src/content/dependencies/linkAlbum.js b/src/content/dependencies/linkAlbum.js
new file mode 100644
index 00000000..36b0d13a
--- /dev/null
+++ b/src/content/dependencies/linkAlbum.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.album', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js
new file mode 100644
index 00000000..39e7111e
--- /dev/null
+++ b/src/content/dependencies/linkAlbumAdditionalFile.js
@@ -0,0 +1,24 @@
+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/linkAlbumCommentary.js b/src/content/dependencies/linkAlbumCommentary.js
new file mode 100644
index 00000000..ab519fd6
--- /dev/null
+++ b/src/content/dependencies/linkAlbumCommentary.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumCommentary', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAlbumDynamically.js b/src/content/dependencies/linkAlbumDynamically.js
new file mode 100644
index 00000000..45f8c2a9
--- /dev/null
+++ b/src/content/dependencies/linkAlbumDynamically.js
@@ -0,0 +1,61 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'pagePath'],
+
+  relations: (relation, album) => ({
+    galleryLink:
+      relation('linkAlbumGallery', album),
+
+    infoLink:
+      relation('linkAlbum', album),
+
+    commentaryLink:
+      relation('linkAlbumCommentary', album),
+  }),
+
+  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/linkAlbumGallery.js b/src/content/dependencies/linkAlbumGallery.js
new file mode 100644
index 00000000..e3f30a29
--- /dev/null
+++ b/src/content/dependencies/linkAlbumGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumGallery', album)}),
+
+  generate: (relations) => relations.link,
+};
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/linkArtTagInfo.js b/src/content/dependencies/linkArtTagInfo.js
new file mode 100644
index 00000000..409cb3c0
--- /dev/null
+++ b/src/content/dependencies/linkArtTagInfo.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artTag) =>
+    ({link: relation('linkThing', 'localized.artTagInfo', artTag)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtist.js b/src/content/dependencies/linkArtist.js
new file mode 100644
index 00000000..718ee6fa
--- /dev/null
+++ b/src/content/dependencies/linkArtist.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artist) =>
+    ({link: relation('linkThing', 'localized.artist', artist)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtistGallery.js b/src/content/dependencies/linkArtistGallery.js
new file mode 100644
index 00000000..66dc172d
--- /dev/null
+++ b/src/content/dependencies/linkArtistGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artist) =>
+    ({link: relation('linkThing', 'localized.artistGallery', artist)}),
+
+  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/linkCommentaryIndex.js b/src/content/dependencies/linkCommentaryIndex.js
new file mode 100644
index 00000000..5568ff84
--- /dev/null
+++ b/src/content/dependencies/linkCommentaryIndex.js
@@ -0,0 +1,12 @@
+export default {
+  contentDependencies: ['linkStationaryIndex'],
+
+  relations: (relation) =>
+    ({link:
+        relation(
+          'linkStationaryIndex',
+          'localized.commentaryIndex',
+          'commentaryIndex.title')}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
new file mode 100644
index 00000000..c658d461
--- /dev/null
+++ b/src/content/dependencies/linkContribution.js
@@ -0,0 +1,85 @@
+export default {
+  contentDependencies: [
+    'generateContributionTooltip',
+    'generateTextWithTooltip',
+    'linkArtist',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, contribution) => ({
+    artistLink:
+      relation('linkArtist', contribution.artist),
+
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateContributionTooltip', contribution),
+  }),
+
+  data: (contribution) => ({
+    annotation: contribution.annotation,
+    urls: contribution.artist.urls,
+  }),
+
+  slots: {
+    showAnnotation: {type: 'boolean', default: false},
+    showExternalLinks: {type: 'boolean', default: false},
+    showChronology: {type: 'boolean', default: false},
+
+    trimAnnotation: {type: 'boolean', default: false},
+
+    preventWrapping: {type: 'boolean', default: true},
+    preventTooltip: {type: 'boolean', default: false},
+
+    chronologyKind: {type: 'string'},
+  },
+
+  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
new file mode 100644
index 00000000..073c821e
--- /dev/null
+++ b/src/content/dependencies/linkExternal.js
@@ -0,0 +1,151 @@
+import {isExternalLinkContext, isExternalLinkStyle} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  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: 'platform',
+    },
+
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+
+    fromContent: {
+      type: 'boolean',
+      default: false,
+    },
+
+    indicateExternal: {
+      type: 'boolean',
+      default: false,
+    },
+
+    tab: {
+      validate: v => v.is('default', 'separate'),
+      default: 'default',
+    },
+  },
+
+  generate(data, slots, {html, language}) {
+    let urlIsValid;
+    try {
+      new URL(data.url);
+      urlIsValid = true;
+    } catch (error) {
+      urlIsValid = false;
+    }
+
+    let formattedLink;
+    if (urlIsValid) {
+      formattedLink =
+        language.formatExternalLink(data.url, {
+          style: slots.style,
+          context: slots.context,
+        });
+
+      // 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;
+    }
+
+    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.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/linkFlash.js b/src/content/dependencies/linkFlash.js
new file mode 100644
index 00000000..93dd5a28
--- /dev/null
+++ b/src/content/dependencies/linkFlash.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, flash) =>
+    ({link: relation('linkThing', 'localized.flash', flash)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkFlashAct.js b/src/content/dependencies/linkFlashAct.js
new file mode 100644
index 00000000..82c23325
--- /dev/null
+++ b/src/content/dependencies/linkFlashAct.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['generateUnsafeMunchy', 'linkThing'],
+
+  relations: (relation, flashAct) => ({
+    unsafeMunchy:
+      relation('generateUnsafeMunchy'),
+
+    link:
+      relation('linkThing', 'localized.flashActGallery', flashAct),
+  }),
+
+  data: (flashAct) => ({
+    name: flashAct.name,
+  }),
+
+  generate: (data, relations) =>
+    relations.link.slots({
+      content:
+        relations.unsafeMunchy
+          .slot('contentSource', data.name),
+    }),
+};
diff --git a/src/content/dependencies/linkFlashIndex.js b/src/content/dependencies/linkFlashIndex.js
new file mode 100644
index 00000000..6dd0710e
--- /dev/null
+++ b/src/content/dependencies/linkFlashIndex.js
@@ -0,0 +1,12 @@
+export default {
+  contentDependencies: ['linkStationaryIndex'],
+
+  relations: (relation) =>
+    ({link:
+        relation(
+          'linkStationaryIndex',
+          'localized.flashIndex',
+          'flashIndex.title')}),
+
+  generate: (relations) => relations.link,
+};
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/linkGroup.js b/src/content/dependencies/linkGroup.js
new file mode 100644
index 00000000..ebab1b5b
--- /dev/null
+++ b/src/content/dependencies/linkGroup.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, group) =>
+    ({link: relation('linkThing', 'localized.groupInfo', group)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkGroupDynamically.js b/src/content/dependencies/linkGroupDynamically.js
new file mode 100644
index 00000000..90303ed1
--- /dev/null
+++ b/src/content/dependencies/linkGroupDynamically.js
@@ -0,0 +1,14 @@
+export default {
+  contentDependencies: ['linkGroupGallery', 'linkGroup'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, group) => ({
+    galleryLink: relation('linkGroupGallery', group),
+    infoLink: relation('linkGroup', group),
+  }),
+
+  generate: (relations, {pagePath}) =>
+    (pagePath[0] === 'groupGallery'
+      ? relations.galleryLink
+      : relations.infoLink),
+};
diff --git a/src/content/dependencies/linkGroupExtra.js b/src/content/dependencies/linkGroupExtra.js
new file mode 100644
index 00000000..bc3c0580
--- /dev/null
+++ b/src/content/dependencies/linkGroupExtra.js
@@ -0,0 +1,34 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'linkGroup',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations(relation, group) {
+    const relations = {};
+
+    relations.info =
+      relation('linkGroup', group);
+
+    if (!empty(group.albums)) {
+      relations.gallery =
+        relation('linkGroupGallery', group);
+    }
+
+    return relations;
+  },
+
+  slots: {
+    extra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(relations, slots) {
+    return relations[slots.extra ?? 'info'] ?? relations.info;
+  },
+};
diff --git a/src/content/dependencies/linkGroupGallery.js b/src/content/dependencies/linkGroupGallery.js
new file mode 100644
index 00000000..86c4a0f3
--- /dev/null
+++ b/src/content/dependencies/linkGroupGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, group) =>
+    ({link: relation('linkThing', 'localized.groupGallery', group)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkListing.js b/src/content/dependencies/linkListing.js
new file mode 100644
index 00000000..ac66919a
--- /dev/null
+++ b/src/content/dependencies/linkListing.js
@@ -0,0 +1,15 @@
+export default {
+  contentDependencies: ['linkThing'],
+  extraDependencies: ['language'],
+
+  relations: (relation, listing) =>
+    ({link: relation('linkThing', 'localized.listing', listing)}),
+
+  data: (listing) =>
+    ({stringsKey: listing.stringsKey}),
+
+  generate: (data, relations, {language}) =>
+    relations.link
+      .slot('content',
+        language.$('listingPage', data.stringsKey, 'title')),
+};
diff --git a/src/content/dependencies/linkListingIndex.js b/src/content/dependencies/linkListingIndex.js
new file mode 100644
index 00000000..1bfaf46e
--- /dev/null
+++ b/src/content/dependencies/linkListingIndex.js
@@ -0,0 +1,12 @@
+export default {
+  contentDependencies: ['linkStationaryIndex'],
+
+  relations: (relation) =>
+    ({link:
+        relation(
+          'linkStationaryIndex',
+          'localized.listingIndex',
+          'listingIndex.title')}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkNewsEntry.js b/src/content/dependencies/linkNewsEntry.js
new file mode 100644
index 00000000..1fb32dd9
--- /dev/null
+++ b/src/content/dependencies/linkNewsEntry.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, newsEntry) =>
+    ({link: relation('linkThing', 'localized.newsEntry', newsEntry)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkNewsIndex.js b/src/content/dependencies/linkNewsIndex.js
new file mode 100644
index 00000000..e911a384
--- /dev/null
+++ b/src/content/dependencies/linkNewsIndex.js
@@ -0,0 +1,12 @@
+export default {
+  contentDependencies: ['linkStationaryIndex'],
+
+  relations: (relation) =>
+    ({link:
+        relation(
+          'linkStationaryIndex',
+          'localized.newsIndex',
+          'newsIndex.title')}),
+
+  generate: (relations) => relations.link,
+};
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
new file mode 100644
index 00000000..d71c69f8
--- /dev/null
+++ b/src/content/dependencies/linkPathFromMedia.js
@@ -0,0 +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, {
+    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/linkPathFromRoot.js b/src/content/dependencies/linkPathFromRoot.js
new file mode 100644
index 00000000..dab3ac1f
--- /dev/null
+++ b/src/content/dependencies/linkPathFromRoot.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+
+  relations: (relation) =>
+    ({link: relation('linkTemplate')}),
+
+  data: (path) =>
+    ({path}),
+
+  generate: (data, relations) =>
+    relations.link
+      .slot('path', ['shared.path', data.path]),
+};
diff --git a/src/content/dependencies/linkPathFromSite.js b/src/content/dependencies/linkPathFromSite.js
new file mode 100644
index 00000000..64676465
--- /dev/null
+++ b/src/content/dependencies/linkPathFromSite.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+
+  relations: (relation) =>
+    ({link: relation('linkTemplate')}),
+
+  data: (path) =>
+    ({path}),
+
+  generate: (data, relations) =>
+    relations.link
+      .slot('path', ['localized.path', data.path]),
+};
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/linkStaticPage.js b/src/content/dependencies/linkStaticPage.js
new file mode 100644
index 00000000..032af6c9
--- /dev/null
+++ b/src/content/dependencies/linkStaticPage.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, staticPage) =>
+    ({link: relation('linkThing', 'localized.staticPage', staticPage)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkStationaryIndex.js b/src/content/dependencies/linkStationaryIndex.js
new file mode 100644
index 00000000..d5506e60
--- /dev/null
+++ b/src/content/dependencies/linkStationaryIndex.js
@@ -0,0 +1,24 @@
+// Not to be confused with "html.Stationery".
+
+export default {
+  contentDependencies: ['linkTemplate'],
+  extraDependencies: ['language'],
+
+  relations(relation) {
+    return {
+      linkTemplate: relation('linkTemplate'),
+    };
+  },
+
+  data(pathKey, stringKey) {
+    return {pathKey, stringKey};
+  },
+
+  generate(data, relations, {language}) {
+    return relations.linkTemplate
+      .slots({
+        path: [data.pathKey],
+        content: language.formatString(data.stringKey),
+      });
+  }
+}
diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js
new file mode 100644
index 00000000..4f853dc4
--- /dev/null
+++ b/src/content/dependencies/linkTemplate.js
@@ -0,0 +1,87 @@
+import {empty} from '#sugar';
+
+import striptags from 'striptags';
+
+export default {
+  extraDependencies: [
+    'appendIndexHTML',
+    'html',
+    'language',
+    'to',
+  ],
+
+  slots: {
+    href: {type: 'string'},
+    path: {validate: v => v.validateArrayItems(v.isString)},
+    hash: {type: 'string'},
+    linkless: {type: 'boolean', default: false},
+    tooltip: {type: 'string'},
+
+    attributes: {
+      type: 'attributes',
+      mutable: true,
+    },
+
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    suffixNormalContent: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate(slots, {
+    appendIndexHTML,
+    html,
+    language,
+    to,
+  }) {
+    const {attributes} = slots;
+
+    if (!slots.linkless) {
+      let href =
+        (slots.href
+          ? encodeURI(slots.href)
+       : !empty(slots.path)
+          ? to(...slots.path)
+          : '');
+
+      if (appendIndexHTML) {
+        if (/^(?!https?:\/\/).+\/$/.test(href) && href.endsWith('/')) {
+          href += 'index.html';
+        }
+      }
+
+      if (slots.hash) {
+        href += (slots.hash.startsWith('#') ? '' : '#') + slots.hash;
+      }
+
+      attributes.add({href});
+    }
+
+    if (slots.tooltip) {
+      attributes.set('title', slots.tooltip);
+    }
+
+    const mainContent =
+      (html.isBlank(slots.content)
+        ? language.$('misc.missingLinkContent')
+        : 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, allContent);
+  },
+}
diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js
new file mode 100644
index 00000000..3902f380
--- /dev/null
+++ b/src/content/dependencies/linkThing.js
@@ -0,0 +1,154 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateTextWithTooltip',
+    'generateTooltip',
+    'linkTemplate',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _pathKey, thing) => ({
+    linkTemplate:
+      relation('linkTemplate'),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', thing.color ?? null),
+
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateTooltip'),
+  }),
+
+  data: (pathKey, thing) => ({
+    name: thing.name,
+    nameShort: thing.nameShort ?? thing.shortName,
+
+    path:
+      (pathKey
+        ? [pathKey, thing.directory]
+        : null),
+  }),
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: true,
+    },
+
+    preferShortName: {
+      type: 'boolean',
+      default: false,
+    },
+
+    tooltipStyle: {
+      validate: v => v.is('none', 'auto', 'browser', 'wiki'),
+      default: 'auto',
+    },
+
+    color: {
+      validate: v => v.anyOf(v.isBoolean, v.isColor),
+      default: true,
+    },
+
+    colorContext: {
+      validate: v => v.is(
+        'image-box',
+        'primary-only'),
+
+      default: 'primary-only',
+    },
+
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
+    },
+
+    anchor: {type: 'boolean', default: false},
+    linkless: {type: 'boolean', default: false},
+    hash: {type: 'string'},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const path =
+      slots.path ?? data.path;
+
+    const linkAttributes = slots.attributes;
+    const wrapperAttributes = html.attributes();
+
+    const showShortName =
+      (slots.preferShortName
+        ? data.nameShort && data.nameShort !== data.name
+        : false);
+
+    const name =
+      (showShortName
+        ? data.nameShort
+        : data.name);
+
+    const showWikiTooltip =
+      (slots.tooltipStyle === 'auto'
+        ? showShortName
+        : slots.tooltipStyle === 'wiki');
+
+    const wikiTooltip =
+      showWikiTooltip &&
+        relations.tooltip.slots({
+          attributes: {class: 'thing-name-tooltip'},
+          content: data.name,
+        });
+
+    if (slots.tooltipStyle === 'browser') {
+      linkAttributes.add('title', data.name);
+    }
+
+    if (showWikiTooltip) {
+      linkAttributes.add('class', 'text-with-tooltip-interaction-cue');
+    }
+
+    const content =
+      (html.isBlank(slots.content)
+        ? language.sanitize(name)
+        : slots.content);
+
+    if (slots.color !== false) {
+      const {colorStyle} = relations;
+
+      colorStyle.setSlot('context', slots.colorContext);
+
+      if (typeof slots.color === 'string') {
+        colorStyle.setSlot('color', slots.color);
+      }
+
+      if (showWikiTooltip) {
+        wrapperAttributes.add(colorStyle);
+      } else {
+        linkAttributes.add(colorStyle);
+      }
+    }
+
+    return relations.textWithTooltip.slots({
+      attributes: wrapperAttributes,
+      customInteractionCue: true,
+
+      text:
+        relations.linkTemplate.slots({
+          path: slots.anchor ? [] : path,
+          href: slots.anchor ? '' : null,
+          attributes: linkAttributes,
+          hash: slots.hash,
+          linkless: slots.linkless,
+          content,
+        }),
+
+      tooltip:
+        wikiTooltip ?? null,
+    });
+  },
+}
diff --git a/src/content/dependencies/linkTrack.js b/src/content/dependencies/linkTrack.js
new file mode 100644
index 00000000..d5d96726
--- /dev/null
+++ b/src/content/dependencies/linkTrack.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, track) =>
+    ({link: relation('linkThing', 'localized.track', track)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkTrackDynamically.js b/src/content/dependencies/linkTrackDynamically.js
new file mode 100644
index 00000000..bbcf1c34
--- /dev/null
+++ b/src/content/dependencies/linkTrackDynamically.js
@@ -0,0 +1,36 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, track) => ({
+    infoLink: relation('linkTrack', track),
+  }),
+
+  data: (track) => ({
+    trackDirectory:
+      track.directory,
+
+    albumDirectory:
+      track.album.directory,
+
+    trackHasCommentary:
+      !empty(track.commentary),
+  }),
+
+  generate(data, relations, {pagePath}) {
+    if (
+      pagePath[0] === 'albumCommentary' &&
+      pagePath[1] === data.albumDirectory &&
+      data.trackHasCommentary
+    ) {
+      relations.infoLink.setSlots({
+        anchor: true,
+        hash: data.trackDirectory,
+      });
+    }
+
+    return relations.infoLink;
+  },
+};
diff --git a/src/content/dependencies/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/linkWikiHomepage.js b/src/content/dependencies/linkWikiHomepage.js
new file mode 100644
index 00000000..d8d3d0a0
--- /dev/null
+++ b/src/content/dependencies/linkWikiHomepage.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+  extraDependencies: ['wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {wikiShortName: wikiInfo.nameShort};
+  },
+
+  relations: (relation) =>
+    ({link: relation('linkTemplate')}),
+
+  data: (sprawl) =>
+    ({wikiShortName: sprawl.wikiShortName}),
+
+  generate: (data, relations) =>
+    relations.link.slots({
+      path: ['localized.home'],
+      content: data.wikiShortName,
+    }),
+};
diff --git a/src/content/dependencies/listAlbumsByDate.js b/src/content/dependencies/listAlbumsByDate.js
new file mode 100644
index 00000000..c83ffc97
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByDate.js
@@ -0,0 +1,52 @@
+import {sortChronologically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    return {
+      spec,
+
+      albums:
+        sortChronologically(albumData.filter(album => album.date)),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      dates:
+        query.albums
+          .map(album => album.date),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.albumLinks,
+          date: data.dates,
+        }).map(({link, date}) => ({
+            album: link,
+            date: language.formatDate(date),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAlbumsByDateAdded.js b/src/content/dependencies/listAlbumsByDateAdded.js
new file mode 100644
index 00000000..d462ad46
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByDateAdded.js
@@ -0,0 +1,60 @@
+import {sortAlphabetically} from '#sort';
+import {chunkByProperties} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    return {
+      spec,
+
+      chunks:
+        chunkByProperties(
+          sortAlphabetically(albumData.filter(a => a.dateAddedToWiki))
+            .sort((a, b) => {
+              if (a.dateAddedToWiki < b.dateAddedToWiki) return -1;
+              if (a.dateAddedToWiki > b.dateAddedToWiki) return 1;
+            }),
+          ['dateAddedToWiki']),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(album => relation('linkAlbum', album))),
+    };
+  },
+
+  data(query) {
+    return {
+      dates:
+        query.chunks.map(({dateAddedToWiki}) => dateAddedToWiki),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        data.dates.map(date => ({
+          date: language.formatDate(date),
+        })),
+
+      chunkRows:
+        relations.albumLinks.map(albumLinks =>
+          albumLinks.map(link => ({
+            album: link,
+          }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAlbumsByDuration.js b/src/content/dependencies/listAlbumsByDuration.js
new file mode 100644
index 00000000..c60685ab
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByDuration.js
@@ -0,0 +1,52 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    const albums = sortAlphabetically(albumData.slice());
+    const durations = albums.map(album => getTotalDuration(album.tracks));
+
+    filterByCount(albums, durations);
+    sortByCount(albums, durations, {greatestFirst: true});
+
+    return {spec, albums, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.albumLinks,
+          duration: data.durations,
+        }).map(({link, duration}) => ({
+            album: link,
+            duration: language.formatDuration(duration),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAlbumsByName.js b/src/content/dependencies/listAlbumsByName.js
new file mode 100644
index 00000000..21419537
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByName.js
@@ -0,0 +1,50 @@
+import {sortAlphabetically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    return {
+      spec,
+      albums: sortAlphabetically(albumData.slice()),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts:
+        query.albums
+          .map(album => album.tracks.length),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.albumLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            album: link,
+            tracks: language.countTracks(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAlbumsByTracks.js b/src/content/dependencies/listAlbumsByTracks.js
new file mode 100644
index 00000000..798e6c2e
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByTracks.js
@@ -0,0 +1,51 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    const albums = sortAlphabetically(albumData.slice());
+    const counts = albums.map(album => album.tracks.length);
+
+    filterByCount(albums, counts);
+    sortByCount(albums, counts, {greatestFirst: true});
+
+    return {spec, albums, counts};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts: query.counts,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.albumLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            album: link,
+            tracks: language.countTracks(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAllAdditionalFiles.js b/src/content/dependencies/listAllAdditionalFiles.js
new file mode 100644
index 00000000..a6e34b9a
--- /dev/null
+++ b/src/content/dependencies/listAllAdditionalFiles.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listAllAdditionalFilesTemplate'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listAllAdditionalFilesTemplate', spec, 'additionalFiles')}),
+
+  generate: (relations) =>
+    relations.page.slot('stringsKey', 'other.allAdditionalFiles'),
+};
diff --git a/src/content/dependencies/listAllAdditionalFilesTemplate.js b/src/content/dependencies/listAllAdditionalFilesTemplate.js
new file mode 100644
index 00000000..e33ad7b5
--- /dev/null
+++ b/src/content/dependencies/listAllAdditionalFilesTemplate.js
@@ -0,0 +1,209 @@
+import {sortChronologically} from '#sort';
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateListingPage',
+    'generateListAllAdditionalFilesChunk',
+    'linkAlbum',
+    'linkTrack',
+    'linkAlbumAdditionalFile',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({albumData}) => ({albumData}),
+
+  query(sprawl, spec, property) {
+    const albums =
+      sortChronologically(sprawl.albumData.slice());
+
+    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,
+    };
+  },
+
+  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,
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    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,
+                  })),
+            ]),
+          ]),
+    }),
+};
diff --git a/src/content/dependencies/listAllMidiProjectFiles.js b/src/content/dependencies/listAllMidiProjectFiles.js
new file mode 100644
index 00000000..31a70ef0
--- /dev/null
+++ b/src/content/dependencies/listAllMidiProjectFiles.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listAllAdditionalFilesTemplate'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listAllAdditionalFilesTemplate', spec, 'midiProjectFiles')}),
+
+  generate: (relations) =>
+    relations.page.slot('stringsKey', 'other.allMidiProjectFiles'),
+};
diff --git a/src/content/dependencies/listAllSheetMusicFiles.js b/src/content/dependencies/listAllSheetMusicFiles.js
new file mode 100644
index 00000000..166b2068
--- /dev/null
+++ b/src/content/dependencies/listAllSheetMusicFiles.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listAllAdditionalFilesTemplate'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listAllAdditionalFilesTemplate', spec, 'sheetMusicFiles')}),
+
+  generate: (relations) =>
+    relations.page.slot('stringsKey', 'other.allSheetMusic'),
+};
diff --git a/src/content/dependencies/listArtTagNetwork.js b/src/content/dependencies/listArtTagNetwork.js
new file mode 100644
index 00000000..93dd4ce8
--- /dev/null
+++ b/src/content/dependencies/listArtTagNetwork.js
@@ -0,0 +1,366 @@
+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/listArtTagsByName.js b/src/content/dependencies/listArtTagsByName.js
new file mode 100644
index 00000000..1df9dfff
--- /dev/null
+++ b/src/content/dependencies/listArtTagsByName.js
@@ -0,0 +1,57 @@
+import {sortAlphabetically} from '#sort';
+import {stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtTagGallery'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artTagData}) {
+    return {artTagData};
+  },
+
+  query({artTagData}, spec) {
+    return {
+      spec,
+
+      artTags:
+        sortAlphabetically(
+          artTagData
+            .filter(artTag => !artTag.isContentWarning)),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      artTagLinks:
+        query.artTags
+          .map(artTag => relation('linkArtTagGallery', artTag)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts:
+        query.artTags.map(artTag =>
+          unique([
+            ...artTag.indirectlyFeaturedInArtworks,
+            ...artTag.directlyFeaturedInArtworks,
+          ]).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/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/listArtistsByCommentaryEntries.js b/src/content/dependencies/listArtistsByCommentaryEntries.js
new file mode 100644
index 00000000..eff2dba3
--- /dev/null
+++ b/src/content/dependencies/listArtistsByCommentaryEntries.js
@@ -0,0 +1,58 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artistData}) {
+    return {artistData};
+  },
+
+  query({artistData}, spec) {
+    const artists =
+      sortAlphabetically(
+        artistData.filter(artist => !artist.isAlias));
+
+    const counts =
+      artists.map(artist =>
+        artist.tracksAsCommentator.length +
+        artist.albumsAsCommentator.length);
+
+    filterByCount(artists, counts);
+    sortByCount(artists, counts, {greatestFirst: true});
+
+    return {artists, counts, spec};
+  },
+
+  relations(relation, query) {
+    return {
+      page:
+        relation('generateListingPage', query.spec),
+
+      artistLinks:
+        query.artists
+          .map(artist => relation('linkArtist', artist)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts: query.counts,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artistLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            artist: link,
+            entries: language.countCommentaryEntries(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js
new file mode 100644
index 00000000..41944959
--- /dev/null
+++ b/src/content/dependencies/listArtistsByContributions.js
@@ -0,0 +1,174 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+
+import {
+  accumulateSum,
+  empty,
+  filterByCount,
+  filterMultipleArrays,
+  stitchArrays,
+  unique,
+} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({artistData, wikiInfo}) {
+    return {
+      artistData,
+      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
+    };
+  },
+
+  query(sprawl, spec) {
+    const query = {
+      spec,
+      enableFlashesAndGames: sprawl.enableFlashesAndGames,
+    };
+
+    const queryContributionInfo = (artistsKey, countsKey, fn) => {
+      const artists =
+        sortAlphabetically(
+          sprawl.artistData.filter(artist => !artist.isAlias));
+
+      const counts =
+        artists.map(artist => fn(artist));
+
+      filterByCount(artists, counts);
+      sortByCount(artists, counts, {greatestFirst: true});
+
+      query[artistsKey] = artists;
+      query[countsKey] = counts;
+    };
+
+    queryContributionInfo(
+      'artistsByTrackContributions',
+      'countsByTrackContributions',
+      artist =>
+        (unique(
+          ([
+            artist.trackArtistContributions,
+            artist.trackContributorContributions,
+          ]).flat()
+            .map(({thing}) => thing)
+        )).length);
+
+    queryContributionInfo(
+      'artistsByArtworkContributions',
+      'countsByArtworkContributions',
+      artist =>
+        accumulateSum(
+          [
+            artist.albumCoverArtistContributions,
+            artist.albumWallpaperArtistContributions,
+            artist.albumBannerArtistContributions,
+            artist.trackCoverArtistContributions,
+          ],
+          contribs => contribs.length));
+
+    if (sprawl.enableFlashesAndGames) {
+      queryContributionInfo(
+        'artistsByFlashContributions',
+        'countsByFlashContributions',
+        artist =>
+          artist.flashContributorContributions.length);
+    }
+
+    return query;
+  },
+
+  relations(relation, query) {
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    relations.artistLinksByTrackContributions =
+      query.artistsByTrackContributions
+        .map(artist => relation('linkArtist', artist));
+
+    relations.artistLinksByArtworkContributions =
+      query.artistsByArtworkContributions
+        .map(artist => relation('linkArtist', artist));
+
+    if (query.enableFlashesAndGames) {
+      relations.artistLinksByFlashContributions =
+        query.artistsByFlashContributions
+          .map(artist => relation('linkArtist', artist));
+    }
+
+    return relations;
+  },
+
+  data(query) {
+    const data = {};
+
+    data.enableFlashesAndGames = query.enableFlashesAndGames;
+
+    data.countsByTrackContributions = query.countsByTrackContributions;
+    data.countsByArtworkContributions = query.countsByArtworkContributions;
+
+    if (query.enableFlashesAndGames) {
+      data.countsByFlashContributions = query.countsByFlashContributions;
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {language}) {
+    const listChunkIDs = ['tracks', 'artworks'];
+    const listTitleStringsKeys = ['trackContributors', 'artContributors'];
+    const listCountFunctions = ['countTracks', 'countArtworks'];
+
+    const listArtistLinks = [
+      relations.artistLinksByTrackContributions,
+      relations.artistLinksByArtworkContributions,
+    ];
+
+    const listArtistCounts = [
+      data.countsByTrackContributions,
+      data.countsByArtworkContributions,
+    ];
+
+    if (data.enableFlashesAndGames) {
+      listChunkIDs.push('flashes');
+      listTitleStringsKeys.push('flashContributors');
+      listCountFunctions.push('countFlashes');
+      listArtistLinks.push(relations.artistLinksByFlashContributions);
+      listArtistCounts.push(data.countsByFlashContributions);
+    }
+
+    filterMultipleArrays(
+      listChunkIDs,
+      listTitleStringsKeys,
+      listCountFunctions,
+      listArtistLinks,
+      listArtistCounts,
+      (_chunkID, _titleStringsKey, _countFunction, artistLinks, _artistCounts) =>
+        !empty(artistLinks));
+
+    return relations.page.slots({
+      type: 'chunks',
+
+      showSkipToSection: true,
+      chunkIDs: listChunkIDs,
+
+      chunkTitles:
+        listTitleStringsKeys.map(stringsKey => ({stringsKey})),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: listArtistLinks,
+          artistCounts: listArtistCounts,
+          countFunction: listCountFunctions,
+        }).map(({artistLinks, artistCounts, countFunction}) =>
+            stitchArrays({
+              artistLink: artistLinks,
+              artistCount: artistCounts,
+            }).map(({artistLink, artistCount}) => ({
+                artist: artistLink,
+                contributions: language[countFunction](artistCount, {unit: true}),
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtistsByDuration.js b/src/content/dependencies/listArtistsByDuration.js
new file mode 100644
index 00000000..6b2a18a0
--- /dev/null
+++ b/src/content/dependencies/listArtistsByDuration.js
@@ -0,0 +1,55 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artistData}) {
+    return {artistData};
+  },
+
+  query({artistData}, spec) {
+    const artists =
+      sortAlphabetically(
+        artistData.filter(artist => !artist.isAlias));
+
+    const durations =
+      artists.map(artist => artist.totalDuration);
+
+    filterByCount(artists, durations);
+    sortByCount(artists, durations, {greatestFirst: true});
+
+    return {spec, artists, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      artistLinks:
+        query.artists
+          .map(artist => relation('linkArtist', artist)),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artistLinks,
+          duration: data.durations,
+        }).map(({link, duration}) => ({
+            artist: link,
+            duration: language.formatDuration(duration),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js
new file mode 100644
index 00000000..17096cfc
--- /dev/null
+++ b/src/content/dependencies/listArtistsByGroup.js
@@ -0,0 +1,157 @@
+import {sortAlphabetically} from '#sort';
+
+import {
+  empty,
+  filterByCount,
+  filterMultipleArrays,
+  stitchArrays,
+  transposeArrays,
+  unique,
+} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artistData, wikiInfo}) {
+    return {artistData, wikiInfo};
+  },
+
+  query(sprawl, spec) {
+    const artists =
+      sortAlphabetically(
+        sprawl.artistData.filter(artist => !artist.isAlias));
+
+    const interestingGroups =
+      sprawl.wikiInfo.divideTrackListsByGroups;
+
+    if (empty(interestingGroups)) {
+      return {spec};
+    }
+
+    // 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(
+            ([
+              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) {
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    if (query.artistsByGroup) {
+      relations.groupLinks =
+        query.groups
+          .map(group => relation('linkGroup', group));
+
+      relations.artistLinksByGroup =
+        query.artistsByGroup
+          .map(artists => artists
+            .map(artist => relation('linkArtist', artist)));
+    }
+
+    return relations;
+  },
+
+  data(query) {
+    const data = {};
+
+    if (query.artistsByGroup) {
+      data.groupDirectories =
+        query.groups
+          .map(group => group.directory);
+
+      data.countsByGroup =
+        query.countsByGroup;
+    }
+
+    return data;
+  },
+
+  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
new file mode 100644
index 00000000..2a8d1b4c
--- /dev/null
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -0,0 +1,323 @@
+import {chunkMultipleArrays, empty, sortMultipleArrays, stitchArrays}
+  from '#sugar';
+import T from '#things';
+
+import {
+  sortAlphabetically,
+  sortAlbumsTracksChronologically,
+  sortFlashesChronologically,
+} from '#sort';
+
+const {Album, Flash} = T;
+
+export default {
+  contentDependencies: [
+    'generateListingPage',
+    'linkAlbum',
+    'linkArtist',
+    'linkFlash',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({albumData, artistData, flashData, trackData, wikiInfo}) =>
+    ({albumData, artistData, flashData, trackData,
+      enableFlashesAndGames: wikiInfo.enableFlashesAndGames}),
+
+  query(sprawl, spec) {
+    //
+    // First main step is to get the latest thing each artist has contributed
+    // to, and the date associated with that contribution! Some notes:
+    //
+    // * Album and track contributions are considered before flashes, so
+    //   they'll take priority if an artist happens to have multiple contribs
+    //   landing on the same date to both an album and a flash.
+    //
+    // * The final (album) contribution list is chunked by album, but also by
+    //   date, because an individual album can cover a variety of dates.
+    //
+    // * If an artist has contributed both artworks and tracks to the album
+    //   containing their latest contribution, then that will be indicated
+    //   in an annotation, but *only if* those contributions were also on
+    //   the same date.
+    //
+    // * If an artist made contributions to multiple albums on the same date,
+    //   then the first of the *albums* sorted chronologically (latest first)
+    //   is the one that will count.
+    //
+    // * Same for artists who've contributed to multiple flashes which were
+    //   released on the same date.
+    //
+    // * The map may exclude artists none of whose contributions were dated.
+    //
+
+    const artistLatestContribMap = new Map();
+
+    const considerDate = (artist, date, thing, contribution) => {
+      if (!date) {
+        return;
+      }
+
+      if (artistLatestContribMap.has(artist)) {
+        const latest = artistLatestContribMap.get(artist);
+        if (latest.date > date) {
+          return;
+        }
+
+        if (latest.date === date) {
+          if (latest.thing === thing) {
+            // May combine differnt contributions to the same thing and date.
+            latest.contribution.add(contribution);
+          }
+
+          // Earlier-processed things of same date take priority.
+          return;
+        }
+      }
+
+      // First entry for artist or more recent contribution than latest date.
+      artistLatestContribMap.set(artist, {
+        date,
+        thing,
+        contribution: new Set([contribution]),
+      });
+    };
+
+    const getArtists = (thing, key) =>
+      thing[key].map(({artist}) => artist);
+
+    const albumsLatestFirst = sortAlbumsTracksChronologically(sprawl.albumData.slice());
+    const tracksLatestFirst = sortAlbumsTracksChronologically(sprawl.trackData.slice());
+    const flashesLatestFirst = sortFlashesChronologically(sprawl.flashData.slice());
+
+    for (const album of albumsLatestFirst) {
+      for (const artist of new Set([
+        ...getArtists(album, 'coverArtistContribs'),
+        ...getArtists(album, 'wallpaperArtistContribs'),
+        ...getArtists(album, 'bannerArtistContribs'),
+      ])) {
+        // Might combine later with 'track' of the same album and date.
+        considerDate(artist, album.coverArtDate ?? album.date, album, 'artwork');
+        // '?? 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.album, 'artwork');
+      }
+
+      for (const artist of new Set([
+        ...getArtists(track, 'artistContribs'),
+        ...getArtists(track, 'contributorContribs'),
+      ])) {
+        // Might be combining with 'artwork' of the same album and date.
+        considerDate(artist, track.date, track.album, 'track');
+      }
+    }
+
+    for (const flash of flashesLatestFirst) {
+      for (const artist of getArtists(flash, 'contributorContribs')) {
+        // Won't take priority above album contributions of the same date.
+        considerDate(artist, flash.date, flash, 'flash');
+      }
+    }
+
+    //
+    // Next up is to sort all the processed artist information!
+    //
+    // Entries with the same album/flash and the same date go together first,
+    // with the following rules for sorting artists therein:
+    //
+    // * If the contributions are different, which can only happen for albums,
+    //   then it's tracks-only first, tracks + artworks next, and artworks-only
+    //   last.
+    //
+    // * If the contributions are the same, then sort alphabetically.
+    //
+    // Entries with different albums/flashes follow another set of rules:
+    //
+    // * Later dates come before earlier dates.
+    //
+    // * On the same date, albums come before flashes.
+    //
+    // * Things of the same type *and* date are sorted alphabetically.
+    //
+
+    const artistsAlphabetically =
+      sortAlphabetically(
+        sprawl.artistData.filter(artist => !artist.isAlias));
+
+    const artists =
+      Array.from(artistLatestContribMap.keys());
+
+    const artistContribEntries =
+      Array.from(artistLatestContribMap.values());
+
+    const artistThings =
+      artistContribEntries.map(({thing}) => thing);
+
+    const artistDates =
+      artistContribEntries.map(({date}) => date);
+
+    const artistContributions =
+      artistContribEntries.map(({contribution}) => contribution);
+
+    sortMultipleArrays(artistThings, artistDates, artistContributions, artists,
+      (thing1, thing2, date1, date2, contrib1, contrib2, artist1, artist2) => {
+        if (date1 === date2 && thing1 === thing2) {
+          // Move artwork-only contribs after contribs with tracks.
+          if (!contrib1.has('track') && contrib2.has('track')) return 1;
+          if (!contrib2.has('track') && contrib1.has('track')) return -1;
+
+          // Move track-only contribs before tracks with tracks and artwork.
+          if (!contrib1.has('artwork') && contrib2.has('artwork')) return -1;
+          if (!contrib2.has('artwork') && contrib1.has('artwork')) return 1;
+
+          // Sort artists of the same type of contribution alphabetically,
+          // referring to a previous sort.
+          const index1 = artistsAlphabetically.indexOf(artist1);
+          const index2 = artistsAlphabetically.indexOf(artist2);
+          return index1 - index2;
+        } else {
+          // Move later dates before earlier ones.
+          if (date1 !== date2) return date2 - date1;
+
+          // Move albums before flashes.
+          if (thing1 instanceof Album && thing2 instanceof Flash) return -1;
+          if (thing1 instanceof Flash && thing2 instanceof Album) return 1;
+
+          // Sort two albums or two flashes alphabetically, referring to a
+          // previous sort (which was chronological but includes the correct
+          // ordering for things released on the same date).
+          const thingsLatestFirst =
+            (thing1 instanceof Album
+              ? albumsLatestFirst
+              : flashesLatestFirst);
+          const index1 = thingsLatestFirst.indexOf(thing1);
+          const index2 = thingsLatestFirst.indexOf(thing2);
+          return index2 - index1;
+        }
+      });
+
+    const chunks =
+      chunkMultipleArrays(artistThings, artistDates, artistContributions, artists,
+        (thing, lastThing, date, lastDate) =>
+          thing !== lastThing ||
+          +date !== +lastDate);
+
+    const chunkThings =
+      chunks.map(([artistThings, , , ]) => artistThings[0]);
+
+    const chunkDates =
+      chunks.map(([, artistDates, , ]) => artistDates[0]);
+
+    const chunkArtistContributions =
+      chunks.map(([, , artistContributions, ]) => artistContributions);
+
+    const chunkArtists =
+      chunks.map(([, , , artists]) => artists);
+
+    // And one bonus step - keep track of all the artists whose contributions
+    // were all without date.
+
+    const datelessArtists =
+      artistsAlphabetically
+        .filter(artist => !artists.includes(artist));
+
+    return {
+      spec,
+      chunkThings,
+      chunkDates,
+      chunkArtistContributions,
+      chunkArtists,
+      datelessArtists,
+    };
+  },
+
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    chunkAlbumLinks:
+      query.chunkThings
+        .map(thing =>
+          (thing instanceof Album
+            ? relation('linkAlbum', thing)
+            : null)),
+
+    chunkFlashLinks:
+      query.chunkThings
+        .map(thing =>
+          (thing instanceof Flash
+            ? relation('linkFlash', thing)
+            : null)),
+
+    chunkArtistLinks:
+      query.chunkArtists
+        .map(artists => artists
+          .map(artist => relation('linkArtist', artist))),
+
+    datelessArtistLinks:
+      query.datelessArtists
+        .map(artist => relation('linkArtist', artist)),
+  }),
+
+  data: (query) => ({
+    chunkDates: query.chunkDates,
+    chunkArtistContributions: query.chunkArtistContributions,
+  }),
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          albumLink: relations.chunkAlbumLinks,
+          flashLink: relations.chunkFlashLinks,
+          date: data.chunkDates,
+        }).map(({albumLink, flashLink, date}) => ({
+            date: language.formatDate(date),
+            ...(albumLink
+              ? {stringsKey: 'album', album: albumLink}
+              : {stringsKey: 'flash', flash: flashLink}),
+          }))
+          .concat(
+            (empty(relations.datelessArtistLinks)
+              ? []
+              : [{stringsKey: 'dateless'}])),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: relations.chunkArtistLinks,
+          contributions: data.chunkArtistContributions,
+        }).map(({artistLinks, contributions}) =>
+            stitchArrays({
+              artistLink: artistLinks,
+              contribution: contributions,
+            }).map(({artistLink, contribution}) => ({
+                artist: artistLink,
+                stringsKey:
+                  (contribution.has('track') && contribution.has('artwork')
+                    ? 'tracksAndArt'
+                 : contribution.has('track')
+                    ? 'tracks'
+                 : contribution.has('artwork')
+                    ? 'art'
+                    : null),
+              })))
+          .concat(
+            (empty(relations.datelessArtistLinks)
+              ? []
+              : [
+                  relations.datelessArtistLinks.map(artistLink => ({
+                    artist: artistLink,
+                  })),
+                ])),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js
new file mode 100644
index 00000000..93218492
--- /dev/null
+++ b/src/content/dependencies/listArtistsByName.js
@@ -0,0 +1,48 @@
+import {sortAlphabetically} from '#sort';
+import {stitchArrays} from '#sugar';
+import {getArtistNumContributions} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl: ({artistData, wikiInfo}) =>
+    ({artistData, wikiInfo}),
+
+  query: (sprawl, spec) => ({
+    spec,
+
+    artists:
+      sortAlphabetically(
+        sprawl.artistData.filter(artist => !artist.isAlias)),
+  }),
+
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    artistLinks:
+      query.artists
+        .map(artist => relation('linkArtist', artist)),
+  }),
+
+  data: (query) => ({
+    counts:
+      query.artists
+        .map(artist => getArtistNumContributions(artist)),
+  }),
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artistLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            artist: link,
+            contributions: language.countContributions(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByAlbums.js b/src/content/dependencies/listGroupsByAlbums.js
new file mode 100644
index 00000000..4adfb6d9
--- /dev/null
+++ b/src/content/dependencies/listGroupsByAlbums.js
@@ -0,0 +1,51 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    const groups = sortAlphabetically(groupData.slice());
+    const counts = groups.map(group => group.albums.length);
+
+    filterByCount(groups, counts);
+    sortByCount(groups, counts, {greatestFirst: true});
+
+    return {spec, groups, counts};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      groupLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts: query.counts,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.groupLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            group: link,
+            albums: language.countAlbums(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByCategory.js b/src/content/dependencies/listGroupsByCategory.js
new file mode 100644
index 00000000..43919bef
--- /dev/null
+++ b/src/content/dependencies/listGroupsByCategory.js
@@ -0,0 +1,76 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupCategoryData}) {
+    return {groupCategoryData};
+  },
+
+  query({groupCategoryData}, spec) {
+    return {
+      spec,
+      groupCategories: groupCategoryData,
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      categoryLinks:
+        query.groupCategories
+          .map(category => relation('linkGroup', category.groups[0])),
+
+      infoLinks:
+        query.groupCategories
+          .map(category =>
+            category.groups
+              .map(group => relation('linkGroup', group))),
+
+      galleryLinks:
+        query.groupCategories
+          .map(category =>
+            category.groups
+              .map(group => relation('linkGroupGallery', group)))
+    };
+  },
+
+  data(query) {
+    return {
+      categoryNames:
+        query.groupCategories
+          .map(category => category.name),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          link: relations.categoryLinks,
+          name: data.categoryNames,
+        }).map(({link, name}) => ({
+            category: link.slot('content', name),
+          })),
+
+      chunkRows:
+        stitchArrays({
+          infoLinks: relations.infoLinks,
+          galleryLinks: relations.galleryLinks,
+        }).map(({infoLinks, galleryLinks}) =>
+            stitchArrays({
+              infoLink: infoLinks,
+              galleryLink: galleryLinks,
+            }).map(({infoLink, galleryLink}) => ({
+                group: infoLink,
+                gallery:
+                  galleryLink
+                    .slot('content', language.$('listingPage.listGroups.byCategory.chunk.item.gallery')),
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByDuration.js b/src/content/dependencies/listGroupsByDuration.js
new file mode 100644
index 00000000..c79e1bc4
--- /dev/null
+++ b/src/content/dependencies/listGroupsByDuration.js
@@ -0,0 +1,56 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    const groups = sortAlphabetically(groupData.slice());
+    const durations =
+      groups.map(group =>
+        getTotalDuration(
+          group.albums.flatMap(album => album.tracks),
+          {mainReleasesOnly: true}));
+
+    filterByCount(groups, durations);
+    sortByCount(groups, durations, {greatestFirst: true});
+
+    return {spec, groups, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      groupLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.groupLinks,
+          duration: data.durations,
+        }).map(({link, duration}) => ({
+            group: link,
+            duration: language.formatDuration(duration),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByLatestAlbum.js b/src/content/dependencies/listGroupsByLatestAlbum.js
new file mode 100644
index 00000000..48319314
--- /dev/null
+++ b/src/content/dependencies/listGroupsByLatestAlbum.js
@@ -0,0 +1,72 @@
+import {compareDates, sortChronologically} from '#sort';
+import {filterMultipleArrays, sortMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateListingPage',
+    'linkAlbum',
+    'linkGroup',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    const groups = sortChronologically(groupData.slice());
+
+    const albums =
+      groups
+        .map(group =>
+          sortChronologically(
+            group.albums.filter(album => album.date),
+            {latestFirst: true}))
+        .map(albums => albums[0]);
+
+    filterMultipleArrays(groups, albums, (group, album) => album);
+
+    const dates = albums.map(album => album.date);
+
+    // Note: After this sort, the groups/dates arrays are misaligned with
+    // albums. That's OK only because we aren't doing anything further with
+    // the albums array.
+    sortMultipleArrays(groups, dates,
+      (groupA, groupB, dateA, dateB) =>
+        compareDates(dateA, dateB, {latestFirst: true}));
+
+    return {spec, groups, dates};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      groupLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return {
+      dates: query.dates,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          groupLink: relations.groupLinks,
+          date: data.dates,
+        }).map(({groupLink, date}) => ({
+            group: groupLink,
+            date: language.formatDate(date),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByName.js b/src/content/dependencies/listGroupsByName.js
new file mode 100644
index 00000000..696a49bd
--- /dev/null
+++ b/src/content/dependencies/listGroupsByName.js
@@ -0,0 +1,49 @@
+import {sortAlphabetically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    return {
+      spec,
+
+      groups: sortAlphabetically(groupData.slice()),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      infoLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+
+      galleryLinks:
+        query.groups
+          .map(group => relation('linkGroupGallery', group)),
+    };
+  },
+
+  generate(relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          infoLink: relations.infoLinks,
+          galleryLink: relations.galleryLinks,
+        }).map(({infoLink, galleryLink}) => ({
+            group: infoLink,
+            gallery:
+              galleryLink
+                .slot('content', language.$('listingPage.listGroups.byName.item.gallery')),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByTracks.js b/src/content/dependencies/listGroupsByTracks.js
new file mode 100644
index 00000000..0b5e4e97
--- /dev/null
+++ b/src/content/dependencies/listGroupsByTracks.js
@@ -0,0 +1,55 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {accumulateSum, filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    const groups = sortAlphabetically(groupData.slice());
+    const counts =
+      groups.map(group =>
+        accumulateSum(
+          group.albums,
+          ({tracks}) => tracks.length));
+
+    filterByCount(groups, counts);
+    sortByCount(groups, counts, {greatestFirst: true});
+
+    return {spec, groups, counts};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      groupLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts: query.counts,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.groupLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            group: link,
+            tracks: language.countTracks(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js
new file mode 100644
index 00000000..79bba441
--- /dev/null
+++ b/src/content/dependencies/listRandomPageLinks.js
@@ -0,0 +1,197 @@
+import {sortChronologically} from '#sort';
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateListingPage',
+    'generateListRandomPageLinksAlbumLink',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({albumData, wikiInfo}) => ({albumData, wikiInfo}),
+
+  query(sprawl, spec) {
+    const query = {spec};
+
+    const groups = sprawl.wikiInfo.divideTrackListsByGroups;
+
+    query.divideByGroups = !empty(groups);
+
+    if (query.divideByGroups) {
+      query.groups = groups;
+
+      query.groupAlbums =
+        groups
+          .map(group =>
+            group.albums.filter(album => album.tracks.length > 1));
+    } else {
+      query.undividedAlbums =
+        sortChronologically(sprawl.albumData.slice())
+          .filter(album => album.tracks.length > 1);
+    }
+
+    return query;
+  },
+
+  relations(relation, query) {
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    if (query.divideByGroups) {
+      relations.groupLinks =
+        query.groups
+          .map(group => relation('linkGroup', group));
+
+      relations.groupAlbumLinks =
+        query.groupAlbums
+          .map(albums => albums
+            .map(album =>
+              relation('generateListRandomPageLinksAlbumLink', album)));
+    } else {
+      relations.undividedAlbumLinks =
+        query.undividedAlbums
+          .map(album =>
+            relation('generateListRandomPageLinksAlbumLink', album));
+    }
+
+    return relations;
+  },
+
+  data(query) {
+    const data = {};
+
+    if (query.divideByGroups) {
+      data.groupDirectories =
+        query.groups
+          .map(group => group.directory);
+    }
+
+    return data;
+  },
+
+  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.$(capsule, 'mainLink')),
+
+        atLeastTwoContributions:
+          html.tag('a',
+            {href: '#', 'data-random': 'artist-more-than-one-contrib'},
+            language.$(capsule, 'atLeastTwoContributions')),
+      })),
+
+      {stringsKey: 'randomAlbumWholeSite'},
+      {stringsKey: 'randomTrackWholeSite'},
+    ];
+
+    const miscellaneousChunkRowAttributes = [
+      null,
+      {href: '#', 'data-random': 'album'},
+      {href: '#','data-random': 'track'},
+    ];
+
+    return relations.page.slots({
+      type: 'chunks',
+
+      content: [
+        html.tag('p',
+          language.encapsulate(capsule, 'chooseLinkLine', capsule =>
+            language.$(capsule, {
+              fromPart:
+                (relations.groupLinks
+                  ? language.$(capsule, 'fromPart.dividedByGroups')
+                  : language.$(capsule, 'fromPart.notDividedByGroups')),
+
+              browserSupportPart:
+                language.$(capsule, 'browserSupportPart'),
+            }))),
+
+        html.tag('p', {id: 'data-loading-line'},
+          language.$(capsule, 'dataLoadingLine')),
+
+        html.tag('p', {id: 'data-loaded-line'},
+          language.$(capsule, 'dataLoadedLine')),
+
+        html.tag('p', {id: 'data-error-line'},
+          language.$(capsule, 'dataErrorLine')),
+      ],
+
+      showSkipToSection: true,
+
+      chunkIDs:
+        (data.groupDirectories
+          ? [null, ...data.groupDirectories]
+          : null),
+
+      chunkTitles: [
+        {stringsKey: 'misc'},
+
+        ...
+          (relations.groupLinks
+            ? relations.groupLinks.map(groupLink => ({
+                stringsKey: 'fromGroup',
+                group: groupLink,
+              }))
+            : [{stringsKey: 'fromAlbum'}]),
+      ],
+
+      chunkTitleAccents: [
+        null,
+
+        ...
+          (relations.groupLinks
+            ? relations.groupLinks.map(() =>
+                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]),
+      ],
+
+      chunkRows: [
+        miscellaneousChunkRows,
+
+        ...
+          (relations.groupAlbumLinks
+            ? relations.groupAlbumLinks.map(albumLinks =>
+                albumLinks.map(albumLink => ({
+                  stringsKey: 'album',
+                  album: albumLink,
+                })))
+            : [
+                relations.undividedAlbumLinks.map(albumLink => ({
+                  stringsKey: 'album',
+                  album: albumLink,
+                })),
+              ]),
+      ],
+
+      chunkRowAttributes: [
+        miscellaneousChunkRowAttributes,
+        ...
+          (relations.groupAlbumLinks
+            ? relations.groupAlbumLinks.map(albumLinks =>
+                albumLinks.map(() => null))
+            : [relations.undividedAlbumLinks.map(() => null)]),
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByAlbum.js b/src/content/dependencies/listTracksByAlbum.js
new file mode 100644
index 00000000..b2405034
--- /dev/null
+++ b/src/content/dependencies/listTracksByAlbum.js
@@ -0,0 +1,48 @@
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    return {
+      spec,
+      albums: albumData,
+      tracks: albumData.map(album => album.tracks),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+    };
+  },
+
+  generate(relations) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        relations.albumLinks
+          .map(albumLink => ({album: albumLink})),
+
+      listStyle: 'ordered',
+
+      chunkRows:
+        relations.trackLinks
+          .map(trackLinks => trackLinks
+            .map(trackLink => ({track: trackLink}))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js
new file mode 100644
index 00000000..dcfaeaf0
--- /dev/null
+++ b/src/content/dependencies/listTracksByDate.js
@@ -0,0 +1,91 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import {chunkByProperties, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl: ({trackData}) => ({trackData}),
+
+  query({trackData}, spec) {
+    const query = {spec};
+
+    query.tracks =
+      sortAlbumsTracksChronologically(
+        trackData.filter(track => track.date));
+
+    query.chunks =
+      chunkByProperties(query.tracks, ['album', 'date']);
+
+    return query;
+  },
+
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    albumLinks:
+      query.chunks
+        .map(({album}) => relation('linkAlbum', album)),
+
+    trackLinks:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(track => relation('linkTrack', track))),
+  }),
+
+  data: (query) => ({
+    dates:
+      query.chunks
+        .map(({date}) => date),
+
+    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({
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          albumLink: relations.albumLinks,
+          date: data.dates,
+        }).map(({albumLink, date}) => ({
+            album: albumLink,
+            date: language.formatDate(date),
+          })),
+
+      chunkRows:
+        stitchArrays({
+          trackLinks: relations.trackLinks,
+          rereleases: data.rereleases,
+        }).map(({trackLinks, rereleases}) =>
+            stitchArrays({
+              trackLink: trackLinks,
+              rerelease: rereleases,
+            }).map(({trackLink, rerelease}) =>
+                (rerelease
+                  ? {stringsKey: 'rerelease', track: trackLink}
+                  : {track: trackLink}))),
+
+      chunkRowAttributes:
+        data.rereleases.map(rereleases =>
+          rereleases.map(rerelease =>
+            (rerelease
+              ? {class: 'rerelease-line'}
+              : null))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByDuration.js b/src/content/dependencies/listTracksByDuration.js
new file mode 100644
index 00000000..64feb4f1
--- /dev/null
+++ b/src/content/dependencies/listTracksByDuration.js
@@ -0,0 +1,51 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({trackData}) {
+    return {trackData};
+  },
+
+  query({trackData}, spec) {
+    const tracks = sortAlphabetically(trackData.slice());
+    const durations = tracks.map(track => track.duration);
+
+    filterByCount(tracks, durations);
+    sortByCount(tracks, durations, {greatestFirst: true});
+
+    return {spec, tracks, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      trackLinks:
+        query.tracks
+          .map(track => relation('linkTrack', track)),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.trackLinks,
+          duration: data.durations,
+        }).map(({link, duration}) => ({
+            track: link,
+            duration: language.formatDuration(duration),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByDurationInAlbum.js b/src/content/dependencies/listTracksByDurationInAlbum.js
new file mode 100644
index 00000000..c1ea32a1
--- /dev/null
+++ b/src/content/dependencies/listTracksByDurationInAlbum.js
@@ -0,0 +1,87 @@
+import {sortByCount, sortChronologically} from '#sort';
+import {filterByCount, filterMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    const albums = sortChronologically(albumData.slice());
+
+    const tracks =
+      albums.map(album =>
+        album.tracks.slice());
+
+    const durations =
+      tracks.map(tracks =>
+        tracks.map(track =>
+          track.duration));
+
+    // Filter out tracks without any duration.
+    // Sort at the same time, to avoid redundantly stitching again later.
+    const stitched = stitchArrays({tracks, durations});
+    for (const {tracks, durations} of stitched) {
+      filterByCount(tracks, durations);
+      sortByCount(tracks, durations, {greatestFirst: true});
+    }
+
+    // Filter out albums which don't have at least two (remaining) tracks.
+    // If the album only has one track in the first place, or if only one
+    // has any duration, then there aren't any comparisons to be made and
+    // it just takes up space on the listing page.
+    const numTracks = tracks.map(tracks => tracks.length);
+    filterMultipleArrays(albums, tracks, durations, numTracks,
+      (album, tracks, durations, numTracks) =>
+        numTracks >= 2);
+
+    return {spec, albums, tracks, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        relations.albumLinks
+          .map(albumLink => ({album: albumLink})),
+
+      chunkRows:
+        stitchArrays({
+          trackLinks: relations.trackLinks,
+          durations: data.durations,
+        }).map(({trackLinks, durations}) =>
+            stitchArrays({
+              trackLink: trackLinks,
+              duration: durations,
+            }).map(({trackLink, duration}) => ({
+                track: trackLink,
+                duration: language.formatDuration(duration),
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByName.js b/src/content/dependencies/listTracksByName.js
new file mode 100644
index 00000000..773b0473
--- /dev/null
+++ b/src/content/dependencies/listTracksByName.js
@@ -0,0 +1,36 @@
+import {sortAlphabetically} from '#sort';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkTrack'],
+  extraDependencies: ['wikiData'],
+
+  sprawl({trackData}) {
+    return {trackData};
+  },
+
+  query({trackData}, spec) {
+    return {
+      spec,
+      tracks: sortAlphabetically(trackData.slice()),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      trackLinks:
+        query.tracks
+          .map(track => relation('linkTrack', track)),
+    };
+  },
+
+  generate(relations) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        relations.trackLinks
+          .map(link => ({track: link})),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByTimesReferenced.js b/src/content/dependencies/listTracksByTimesReferenced.js
new file mode 100644
index 00000000..5838ded0
--- /dev/null
+++ b/src/content/dependencies/listTracksByTimesReferenced.js
@@ -0,0 +1,52 @@
+import {sortAlbumsTracksChronologically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({trackData}) {
+    return {trackData};
+  },
+
+  query({trackData}, spec) {
+    const tracks = sortAlbumsTracksChronologically(trackData.slice());
+    const timesReferenced = tracks.map(track => track.referencedByTracks.length);
+
+    filterByCount(tracks, timesReferenced);
+    sortByCount(tracks, timesReferenced, {greatestFirst: true});
+
+    return {spec, tracks, timesReferenced};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      trackLinks:
+        query.tracks
+          .map(track => relation('linkTrack', track)),
+    };
+  },
+
+  data(query) {
+    return {
+      timesReferenced: query.timesReferenced,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.trackLinks,
+          timesReferenced: data.timesReferenced,
+        }).map(({link, timesReferenced}) => ({
+            track: link,
+            timesReferenced:
+              language.countTimesReferenced(timesReferenced, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksInFlashesByAlbum.js b/src/content/dependencies/listTracksInFlashesByAlbum.js
new file mode 100644
index 00000000..8ca0d993
--- /dev/null
+++ b/src/content/dependencies/listTracksInFlashesByAlbum.js
@@ -0,0 +1,82 @@
+import {sortChronologically} from '#sort';
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkFlash', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    const albums = sortChronologically(albumData.slice());
+
+    const tracks =
+      albums.map(album =>
+        album.tracks.slice());
+
+    const flashes =
+      tracks.map(tracks =>
+        tracks.map(track =>
+          track.featuredInFlashes));
+
+    // Filter out tracks that aren't featured in any flashes.
+    // This listing doesn't perform any sorting within albums.
+    const stitched = stitchArrays({tracks, flashes});
+    for (const {tracks, flashes} of stitched) {
+      filterMultipleArrays(tracks, flashes,
+        (tracks, flashes) => !empty(flashes));
+    }
+
+    // Filter out albums which don't have at least one remaining track.
+    filterMultipleArrays(albums, tracks, flashes,
+      (album, tracks, _flashes) => !empty(tracks));
+
+    return {spec, albums, tracks, flashes};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+
+      flashLinks:
+        query.flashes
+          .map(flashesByAlbum => flashesByAlbum
+            .map(flashesByTrack => flashesByTrack
+              .map(flash => relation('linkFlash', flash)))),
+    };
+  },
+
+  generate(relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        relations.albumLinks
+          .map(albumLink => ({album: albumLink})),
+
+      chunkRows:
+        stitchArrays({
+          trackLinks: relations.trackLinks,
+          flashLinks: relations.flashLinks,
+        }).map(({trackLinks, flashLinks}) =>
+            stitchArrays({
+              trackLink: trackLinks,
+              flashLinks: flashLinks,
+            }).map(({trackLink, flashLinks}) => ({
+                track: trackLink,
+                flashes: language.formatConjunctionList(flashLinks),
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksInFlashesByFlash.js b/src/content/dependencies/listTracksInFlashesByFlash.js
new file mode 100644
index 00000000..6ab954ed
--- /dev/null
+++ b/src/content/dependencies/listTracksInFlashesByFlash.js
@@ -0,0 +1,69 @@
+import {sortFlashesChronologically} from '#sort';
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkFlash', 'linkTrack'],
+  extraDependencies: ['wikiData'],
+
+  sprawl({flashData}) {
+    return {flashData};
+  },
+
+  query({flashData}, spec) {
+    const flashes = sortFlashesChronologically(
+      flashData
+        .filter(flash => !empty(flash.featuredTracks)));
+
+    const tracks =
+      flashes.map(album => album.featuredTracks);
+
+    const albums =
+      tracks.map(tracks =>
+        tracks.map(track => track.album));
+
+    return {spec, flashes, tracks, albums};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      flashLinks:
+        query.flashes
+          .map(flash => relation('linkFlash', flash)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+
+      albumLinks:
+        query.albums
+          .map(albums => albums
+            .map(album => relation('linkAlbum', album))),
+    };
+  },
+
+  generate(relations) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        relations.flashLinks
+          .map(flashLink => ({flash: flashLink})),
+
+      chunkRows:
+        stitchArrays({
+          trackLinks: relations.trackLinks,
+          albumLinks: relations.albumLinks,
+        }).map(({trackLinks, albumLinks}) =>
+            stitchArrays({
+              trackLink: trackLinks,
+              albumLink: albumLinks,
+            }).map(({trackLink, albumLink}) => ({
+                track: trackLink,
+                album: albumLink,
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksWithExtra.js b/src/content/dependencies/listTracksWithExtra.js
new file mode 100644
index 00000000..c7f42f9d
--- /dev/null
+++ b/src/content/dependencies/listTracksWithExtra.js
@@ -0,0 +1,85 @@
+import {sortChronologically} from '#sort';
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query(sprawl, spec, property, valueMode) {
+    const albums =
+      sortChronologically(sprawl.albumData.slice());
+
+    const tracks =
+      albums
+        .map(album =>
+          album.tracks
+            .filter(track => {
+              switch (valueMode) {
+                case 'truthy': return !!track[property];
+                case 'array': return !empty(track[property]);
+                default: return false;
+              }
+            }));
+
+    filterMultipleArrays(albums, tracks,
+      (album, tracks) => !empty(tracks));
+
+    return {spec, albums, tracks};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+    };
+  },
+
+  data(query) {
+    return {
+      dates:
+        query.albums.map(album => album.date),
+    };
+  },
+
+  slots: {
+    hash: {type: 'string'},
+  },
+
+  generate(data, relations, slots, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          albumLink: relations.albumLinks,
+          date: data.dates,
+        }).map(({albumLink, date}) =>
+            (date
+              ? {
+                  stringsKey: 'withDate',
+                  album: albumLink,
+                  date: language.formatDate(date),
+                }
+              : {album: albumLink})),
+
+      chunkRows:
+        relations.trackLinks
+          .map(trackLinks => trackLinks
+            .map(trackLink => ({
+              track: trackLink.slot('hash', slots.hash),
+            }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksWithLyrics.js b/src/content/dependencies/listTracksWithLyrics.js
new file mode 100644
index 00000000..a13a76f0
--- /dev/null
+++ b/src/content/dependencies/listTracksWithLyrics.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listTracksWithExtra'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listTracksWithExtra', spec, 'lyrics', 'truthy')}),
+
+  generate: (relations) =>
+    relations.page,
+};
diff --git a/src/content/dependencies/listTracksWithMidiProjectFiles.js b/src/content/dependencies/listTracksWithMidiProjectFiles.js
new file mode 100644
index 00000000..418af4c2
--- /dev/null
+++ b/src/content/dependencies/listTracksWithMidiProjectFiles.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listTracksWithExtra'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listTracksWithExtra', spec, 'midiProjectFiles', 'array')}),
+
+  generate: (relations) =>
+    relations.page.slot('hash', 'midi-project-files'),
+};
diff --git a/src/content/dependencies/listTracksWithSheetMusicFiles.js b/src/content/dependencies/listTracksWithSheetMusicFiles.js
new file mode 100644
index 00000000..0c6761eb
--- /dev/null
+++ b/src/content/dependencies/listTracksWithSheetMusicFiles.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listTracksWithExtra'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listTracksWithExtra', spec, 'sheetMusicFiles', 'array')}),
+
+  generate: (relations) =>
+    relations.page.slot('hash', 'sheet-music-files'),
+};
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
new file mode 100644
index 00000000..1bbd45e2
--- /dev/null
+++ b/src/content/dependencies/transformContent.js
@@ -0,0 +1,756 @@
+import {bindFind} from '#find';
+import {replacerSpec, parseInput} 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({
+  ...commonMarkedOptions,
+});
+
+const inlineMarked = new Marked({
+  ...commonMarkedOptions,
+
+  renderer: {
+    paragraph(text) {
+      return text;
+    },
+  },
+});
+
+const lyricsMarked = new Marked({
+  ...commonMarkedOptions,
+});
+
+function getPlaceholder(node, content) {
+  return {type: 'text', data: content.slice(node.i, node.iEnd)};
+}
+
+export default {
+  contentDependencies: [
+    ...(
+      Object.values(replacerSpec)
+        .map(description => description.link)
+        .filter(Boolean)),
+    'image',
+    'linkExternal',
+  ],
+
+  extraDependencies: ['html', 'language', 'to', 'wikiData'],
+
+  sprawl(wikiData, content) {
+    const find = bindFind(wikiData);
+
+    const parsedNodes = parseInput(content ?? '');
+
+    return {
+      nodes: parsedNodes
+        .map(node => {
+          if (node.type !== 'tag') {
+            return node;
+          }
+
+          const placeholder = getPlaceholder(node, content);
+
+          const replacerKeyImplied = !node.data.replacerKey;
+          const replacerKey = replacerKeyImplied ? 'track' : node.data.replacerKey.data;
+
+          // TODO: We don't support recursive nodes like before, at the moment. Sorry!
+          // const replacerValue = transformNodes(node.data.replacerValue, opts);
+          const replacerValue = node.data.replacerValue[0].data;
+
+          const spec = replacerSpec[replacerKey];
+
+          if (!spec) {
+            return placeholder;
+          }
+
+          if (spec.link) {
+            let data = {link: spec.link};
+
+            determineData: {
+              // No value at all: this is an index link.
+              if (!replacerValue || replacerValue === '-') {
+                break determineData;
+              }
+
+              // Nothing to find: the link operates on a path or string, not a data object.
+              if (!spec.find) {
+                data.value = replacerValue;
+                break determineData;
+              }
+
+              const thing =
+                find[spec.find](
+                  (replacerKeyImplied
+                    ? replacerValue
+                    : replacerKey + `:` + replacerValue),
+                  wikiData);
+
+              // Nothing was found: this is unexpected, so return placeholder.
+              if (!thing) {
+                return placeholder;
+              }
+
+              // Something was found: the link operates on that thing.
+              data.thing = thing;
+            }
+
+            const {transformName} = spec;
+
+            // TODO: Again, no recursive nodes. Sorry!
+            // const enteredLabel = node.data.label && transformNode(node.data.label, opts);
+            const enteredLabel = node.data.label?.data;
+            const enteredHash = node.data.hash?.data;
+
+            data.label =
+              enteredLabel ??
+                (transformName && data.thing.name
+                  ? transformName(data.thing.name, node, content)
+                  : null);
+
+            data.hash = enteredHash ?? null;
+
+            return {i: node.i, iEnd: node.iEnd, type: 'internal-link', data};
+          }
+
+          // This will be another {type: 'tag'} node which gets processed in
+          // generate. Extract replacerKey and replacerValue now, since it'd
+          // be a pain to deal with later.
+          return {
+            ...node,
+            data: {
+              ...node.data,
+              replacerKey: node.data.replacerKey.data,
+              replacerValue: node.data.replacerValue[0].data,
+            },
+          };
+        }),
+    };
+  },
+
+  data(sprawl, content) {
+    return {
+      content,
+
+      nodes:
+        sprawl.nodes
+          .map(node => {
+            switch (node.type) {
+              // 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:
+                return node;
+            }
+          }),
+    };
+  },
+
+  relations(relation, sprawl, content) {
+    const {nodes} = sprawl;
+
+    const relationOrPlaceholder =
+      (node, name, arg) =>
+        (name
+          ? {
+              link: relation(name, arg),
+              label: node.data.label,
+              hash: node.data.hash,
+              name: arg?.name,
+              shortName: arg?.shortName ?? arg?.nameShort,
+            }
+          : getPlaceholder(node, content));
+
+    return {
+      internalLinks:
+        nodes
+          .filter(({type}) => type === 'internal-link')
+          .map(node => {
+            const {link, thing, value} = node.data;
+
+            if (thing) {
+              return relationOrPlaceholder(node, link, thing);
+            } else if (value && value !== '-') {
+              return relationOrPlaceholder(node, link, value);
+            } else {
+              return relationOrPlaceholder(node, link);
+            }
+          }),
+
+      externalLinks:
+        nodes
+          .filter(({type}) => type === 'external-link')
+          .map(node => {
+            const {href} = node.data;
+
+            return relation('linkExternal', href);
+          }),
+
+      images:
+        nodes
+          .filter(({type}) => type === 'image')
+          .filter(({inline}) => !inline)
+          .map(() => relation('image')),
+    };
+  },
+
+  slots: {
+    mode: {
+      validate: v => v.is('inline', 'multiline', 'lyrics', 'single-link'),
+      default: 'multiline',
+    },
+
+    preferShortLinkNames: {
+      type: 'boolean',
+      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 imageIndex = 0;
+    let internalLinkIndex = 0;
+    let externalLinkIndex = 0;
+
+    let offsetTextNode = 0;
+
+    const contentFromNodes =
+      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': {
+            const text = node.data.slice(offsetTextNode);
+
+            offsetTextNode = 0;
+
+            return {type: 'text', data: text};
+          }
+
+          case 'image': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {
+              link,
+              style,
+              warnings,
+              width,
+              height,
+              align,
+              pixelate,
+            } = node;
+
+            if (node.inline) {
+              let content =
+                html.tag('img',
+                  src && {src},
+                  width && {width},
+                  height && {height},
+                  style && {style},
+
+                  align === 'center' &&
+                  !link &&
+                    {class: 'align-center'},
+
+                  pixelate &&
+                    {class: 'pixelate'});
+
+              if (link) {
+                content =
+                  html.tag('a',
+                    {href: link},
+                    {target: '_blank'},
+
+                    align === 'center' &&
+                      {class: 'align-center'},
+
+                    {title:
+                      language.encapsulate('misc.external.opensInNewTab', capsule =>
+                        language.$(capsule, {
+                          link:
+                            language.formatExternalLink(link, {
+                              style: 'platform',
+                            }),
+
+                          annotation:
+                            language.$(capsule, 'annotation'),
+                        }).toString())},
+
+                    content);
+              }
+
+              return {
+                type: 'processed-image',
+                inline: true,
+                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: 'processed-image',
+              inline: false,
+              data:
+                html.tag('div', {class: 'content-image-container'},
+                  align === 'center' &&
+                    {class: 'align-center'},
+
+                  image),
+            };
+          }
+
+          case 'video': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {width, height, align, pixelate} = node;
+
+            const content =
+              html.tag('div', {class: 'content-video-container'},
+                align === 'center' &&
+                  {class: 'align-center'},
+
+                html.tag('video',
+                  src && {src},
+                  width && {width},
+                  height && {height},
+
+                  {controls: true},
+
+                  pixelate &&
+                    {class: 'pixelate'}));
+
+            return {
+              type: 'processed-video',
+              data: content,
+            };
+          }
+
+          case 'audio': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {align, inline} = node;
+
+            const audio =
+              html.tag('audio',
+                src && {src},
+
+                align === 'center' &&
+                inline &&
+                  {class: 'align-center'},
+
+                {controls: true});
+
+            const content =
+              (inline
+                ? audio
+                : html.tag('div', {class: 'content-audio-container'},
+                    align === 'center' &&
+                      {class: 'align-center'},
+
+                    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};
+            }
+
+            // 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
+            // by something that's wrapping the linkTemplate or linkThing
+            // template.
+            if (label) link.setSlot('content', label);
+            if (hash) link.setSlot('hash', hash);
+
+            // TODO: This is obviously hacky.
+            let hasPreferShortNameSlot;
+            try {
+              link.getSlotDescription('preferShortName');
+              hasPreferShortNameSlot = true;
+            } catch (error) {
+              hasPreferShortNameSlot = false;
+            }
+
+            if (hasPreferShortNameSlot) {
+              link.setSlot('preferShortName', slots.preferShortLinkNames);
+            }
+
+            // TODO: The same, the same.
+            let hasTooltipStyleSlot;
+            try {
+              link.getSlotDescription('tooltipStyle');
+              hasTooltipStyleSlot = true;
+            } catch (error) {
+              hasTooltipStyleSlot = false;
+            }
+
+            if (hasTooltipStyleSlot) {
+              link.setSlot('tooltipStyle', 'none');
+            }
+
+            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 'tag': {
+            const {replacerKey, replacerValue} = node.data;
+
+            const spec = replacerSpec[replacerKey];
+
+            if (!spec) {
+              return getPlaceholder(node, data.content);
+            }
+
+            const {value: valueFn, html: htmlFn} = spec;
+
+            const value =
+              (valueFn
+                ? valueFn(replacerValue)
+                : replacerValue);
+
+            const content =
+              (htmlFn
+                ? htmlFn(value, {html, language})
+                : value);
+
+            const contentText =
+              html.resolve(content, {normalize: 'string'});
+
+            if (slots.textOnly) {
+              return {type: 'text', data: striptags(contentText)};
+            } else {
+              return {type: 'text', data: contentText};
+            }
+          }
+
+          default:
+            return getPlaceholder(node, data.content);
+        }
+      });
+
+    // In single-link mode, return the link node exactly as is - exposing
+    // access to its slots.
+
+    if (slots.mode === 'single-link') {
+      const link =
+        contentFromNodes.find(node =>
+          node.type === 'processed-internal-link' ||
+          node.type === 'processed-external-link');
+
+      if (!link) {
+        return html.blank();
+      }
+
+      return link.data;
+    }
+
+    // Content always goes through marked (i.e. parsing as Markdown).
+    // This does require some attention to detail, mostly to do with line
+    // breaks (in multiline mode) and extracting/re-inserting non-text nodes.
+
+    // The content of non-text nodes can end up getting mangled by marked.
+    // To avoid this, we replace them with mundane placeholders, then
+    // reinsert the content in the correct positions. This also avoids
+    // having to stringify tag content within this generate() function.
+
+    const extractNonTextNodes = ({
+      getTextNodeContents = node => node.data,
+    } = {}) =>
+      contentFromNodes
+        .map((node, index) => {
+          if (node.type === 'text') {
+            return getTextNodeContents(node, index);
+          }
+
+          let attributes = `class="INSERT-NON-TEXT" data-type="${node.type}"`;
+
+          if (node.type === 'processed-image' && node.inline) {
+            attributes += ` data-inline`;
+          }
+
+          return `<span ${attributes}>${index}</span>`;
+        })
+        .join('');
+
+    const reinsertNonTextNodes = (markedOutput) => {
+      markedOutput = markedOutput.trim();
+
+      const tags = [];
+      const regexp = /<span class="INSERT-NON-TEXT" (.*?)>([0-9]+?)<\/span>/g;
+
+      let deleteParagraph = false;
+
+      const addText = (text) => {
+        if (deleteParagraph) {
+          text = text.replace(/^<\/p>/, '');
+          deleteParagraph = false;
+        }
+
+        tags.push(text);
+      };
+
+      let match = null, parseFrom = 0;
+      while (match = regexp.exec(markedOutput)) {
+        addText(markedOutput.slice(parseFrom, match.index));
+        parseFrom = match.index + match[0].length;
+
+        const attributes = html.parseAttributes(match[1]);
+
+        // 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];
+        tags.push(contentFromNodes[nonTextNodeIndex].data);
+      }
+
+      if (parseFrom !== markedOutput.length) {
+        addText(markedOutput.slice(parseFrom));
+      }
+
+      return (
+        html.tags(tags, {
+          [html.joinChildren]: '',
+          [html.onlyIfContent]: true,
+        }));
+    };
+
+    if (slots.mode === 'inline') {
+      const markedInput =
+        extractNonTextNodes();
+
+      const markedOutput =
+        inlineMarked.parse(markedInput);
+
+      return reinsertNonTextNodes(markedOutput);
+    }
+
+    // This is separated into its own function just since we're gonna reuse
+    // it in a minute if everything goes to heck in lyrics mode.
+    const transformMultiline = () => {
+      const markedInput =
+        extractNonTextNodes()
+          // Compress multiple line breaks into single line breaks,
+          // except when they're preceding or following indented
+          // text (by at least two spaces).
+          .replace(/(?<!  .*)\n{2,}(?!^  )/gm, '\n') /* eslint-disable-line no-regex-spaces */
+          // Expand line breaks which don't follow a list, quote,
+          // or <br> / "  ", and which don't precede or follow
+          // indented text (by at least two 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(/(?<=^ *(?:-|\d+\.).*)\n+(?!^ *(?:-|\d+\.))/gm, '\n\n')
+          // Expand line breaks which are at the end of a quote.
+          .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n');
+
+      const markedOutput =
+        multilineMarked.parse(markedInput);
+
+      return reinsertNonTextNodes(markedOutput);
+    }
+
+    if (slots.mode === 'multiline') {
+      return transformMultiline();
+    }
+
+    // Lyrics mode goes through marked too, but line breaks are processed
+    // differently. Instead of having each line get its own paragraph,
+    // "adjacent" lines are joined together (with blank lines separating
+    // each verse/paragraph).
+
+    if (slots.mode === 'lyrics') {
+      // If it looks like old data, using <br> instead of bunched together
+      // lines... then oh god... just use transformMultiline. Perishes.
+      if (
+        contentFromNodes.some(node =>
+          node.type === 'text' &&
+          node.data.includes('<br'))
+      ) {
+        return transformMultiline();
+      }
+
+      const markedInput =
+        extractNonTextNodes({
+          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');
+          },
+        });
+
+      const markedOutput =
+        lyricsMarked.parse(markedInput);
+
+      return reinsertNonTextNodes(markedOutput);
+    }
+  },
+}