« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js2
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunk.js60
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js122
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js11
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js286
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js11
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js83
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js66
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js25
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbed.js60
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js35
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js97
-rw-r--r--src/content/dependencies/generateAlbumTrackListMissingDuration.js34
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js15
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js31
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js188
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js439
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunk.js34
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js62
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js271
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunk.js45
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkItem.js70
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkedList.js11
-rw-r--r--src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js98
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunk.js34
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js34
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js177
-rw-r--r--src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js36
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunk.js67
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkItem.js115
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkedList.js330
-rw-r--r--src/content/dependencies/generateArtistNavLinks.js4
-rw-r--r--src/content/dependencies/generateChronologyLinks.js82
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js2
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js119
-rw-r--r--src/content/dependencies/generateCommentaryIndexPage.js86
-rw-r--r--src/content/dependencies/generateCommentarySection.js25
-rw-r--r--src/content/dependencies/generateContentHeading.js24
-rw-r--r--src/content/dependencies/generateContributionList.js30
-rw-r--r--src/content/dependencies/generateContributionTooltip.js48
-rw-r--r--src/content/dependencies/generateContributionTooltipChronologySection.js117
-rw-r--r--src/content/dependencies/generateContributionTooltipExternalLinkSection.js70
-rw-r--r--src/content/dependencies/generateCoverArtwork.js19
-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.js78
-rw-r--r--src/content/dependencies/generateFlashCoverArtwork.js24
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js141
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js239
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js19
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js231
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsSection.js136
-rw-r--r--src/content/dependencies/generateGroupSidebarCategoryDetails.js66
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesChunk.js79
-rw-r--r--src/content/dependencies/generateListingPage.js72
-rw-r--r--src/content/dependencies/generateNewsEntryPage.js74
-rw-r--r--src/content/dependencies/generateNewsIndexPage.js67
-rw-r--r--src/content/dependencies/generatePageLayout.js183
-rw-r--r--src/content/dependencies/generatePageSidebar.js19
-rw-r--r--src/content/dependencies/generatePageSidebarBox.js2
-rw-r--r--src/content/dependencies/generateQuickDescription.js134
-rw-r--r--src/content/dependencies/generateReleaseInfoContributionsLine.js13
-rw-r--r--src/content/dependencies/generateSearchSidebarBox.js62
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js15
-rw-r--r--src/content/dependencies/generateTooltip.js3
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js723
-rw-r--r--src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js62
-rw-r--r--src/content/dependencies/generateTrackInfoPageOtherReleasesList.js80
-rw-r--r--src/content/dependencies/generateTrackList.js89
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js161
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js67
-rw-r--r--src/content/dependencies/generateTrackSocialEmbed.js52
-rw-r--r--src/content/dependencies/generateWikiHomeAlbumsRow.js8
-rw-r--r--src/content/dependencies/generateWikiHomeNewsBox.js81
-rw-r--r--src/content/dependencies/image.js15
-rw-r--r--src/content/dependencies/linkAnythingMan.js25
-rw-r--r--src/content/dependencies/linkContribution.js187
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js51
-rw-r--r--src/content/dependencies/listArtistsByContributions.js36
-rw-r--r--src/content/dependencies/listArtistsByDuration.js7
-rw-r--r--src/content/dependencies/listArtistsByGroup.js171
-rw-r--r--src/content/dependencies/listRandomPageLinks.js56
-rw-r--r--src/content/dependencies/listTracksByDate.js3
-rw-r--r--src/content/dependencies/transformContent.js36
-rw-r--r--src/content/util/getChronologyRelations.js55
-rw-r--r--src/content/util/groupTracksByGroup.js23
-rw-r--r--src/data/cacheable-object.js18
-rw-r--r--src/data/checks.js3
-rw-r--r--src/data/composite.js175
-rw-r--r--src/data/composite/control-flow/exposeWhetherDependencyAvailable.js42
-rw-r--r--src/data/composite/control-flow/index.js1
-rw-r--r--src/data/composite/control-flow/withResultOfAvailabilityCheck.js1
-rw-r--r--src/data/composite/data/excludeFromList.js5
-rw-r--r--src/data/composite/data/fillMissingListItems.js5
-rw-r--r--src/data/composite/data/index.js29
-rw-r--r--src/data/composite/data/withFilteredList.js6
-rw-r--r--src/data/composite/data/withFlattenedList.js6
-rw-r--r--src/data/composite/data/withIndexInList.js38
-rw-r--r--src/data/composite/data/withMappedList.js6
-rw-r--r--src/data/composite/data/withNearbyItemFromList.js73
-rw-r--r--src/data/composite/data/withPropertiesFromList.js6
-rw-r--r--src/data/composite/data/withPropertyFromList.js6
-rw-r--r--src/data/composite/data/withPropertyFromObject.js28
-rw-r--r--src/data/composite/data/withSortedList.js6
-rw-r--r--src/data/composite/data/withSum.js33
-rw-r--r--src/data/composite/data/withUnflattenedList.js6
-rw-r--r--src/data/composite/things/album/index.js1
-rw-r--r--src/data/composite/things/album/withTrackSections.js21
-rw-r--r--src/data/composite/things/album/withTracks.js13
-rw-r--r--src/data/composite/things/artist/artistTotalDuration.js70
-rw-r--r--src/data/composite/things/artist/index.js1
-rw-r--r--src/data/composite/things/contribution/index.js7
-rw-r--r--src/data/composite/things/contribution/inheritFromContributionPresets.js61
-rw-r--r--src/data/composite/things/contribution/thingPropertyMatches.js33
-rw-r--r--src/data/composite/things/contribution/thingReferenceTypeMatches.js39
-rw-r--r--src/data/composite/things/contribution/withContainingReverseContributionList.js40
-rw-r--r--src/data/composite/things/contribution/withContributionArtist.js34
-rw-r--r--src/data/composite/things/contribution/withContributionContext.js45
-rw-r--r--src/data/composite/things/contribution/withMatchingContributionPresets.js70
-rw-r--r--src/data/composite/things/track/index.js4
-rw-r--r--src/data/composite/things/track/inheritContributionListFromOriginalRelease.js44
-rw-r--r--src/data/composite/things/track/withContainingTrackSection.js1
-rw-r--r--src/data/composite/things/track/withDate.js34
-rw-r--r--src/data/composite/things/track/withHasUniqueCoverArt.js5
-rw-r--r--src/data/composite/things/track/withTrackArtDate.js80
-rw-r--r--src/data/composite/wiki-data/exitWithoutContribs.js1
-rw-r--r--src/data/composite/wiki-data/index.js5
-rw-r--r--src/data/composite/wiki-data/withClonedThings.js68
-rw-r--r--src/data/composite/wiki-data/withContributionListSums.js95
-rw-r--r--src/data/composite/wiki-data/withCoverArtDate.js70
-rw-r--r--src/data/composite/wiki-data/withRecontextualizedContributionList.js101
-rw-r--r--src/data/composite/wiki-data/withRedatedContributionList.js127
-rw-r--r--src/data/composite/wiki-data/withResolvedContribs.js115
-rw-r--r--src/data/composite/wiki-data/withReverseContributionList.js120
-rw-r--r--src/data/composite/wiki-data/withReverseReferenceList.js111
-rw-r--r--src/data/composite/wiki-properties/contributionList.js31
-rw-r--r--src/data/composite/wiki-properties/index.js1
-rw-r--r--src/data/composite/wiki-properties/thing.js31
-rw-r--r--src/data/things/album.js78
-rw-r--r--src/data/things/artist.js125
-rw-r--r--src/data/things/contribution.js265
-rw-r--r--src/data/things/flash.js27
-rw-r--r--src/data/things/index.js2
-rw-r--r--src/data/things/language.js237
-rw-r--r--src/data/things/track.js123
-rw-r--r--src/data/things/wiki-info.js42
-rw-r--r--src/data/validators.js55
-rw-r--r--src/data/yaml.js133
-rw-r--r--src/gen-thumbs.js88
-rw-r--r--src/listing-spec.js2
-rw-r--r--src/page/artist.js4
-rw-r--r--src/search.js119
-rw-r--r--src/static/css/site-basic.css (renamed from src/static/site-basic.css)0
-rw-r--r--src/static/css/site.css (renamed from src/static/site7.css)646
-rw-r--r--src/static/js/client.js (renamed from src/static/client4.js)1557
-rw-r--r--src/static/js/lazy-loading.js (renamed from src/static/lazy-loading.js)0
-rw-r--r--src/static/js/module-import-shims.js27
-rw-r--r--src/static/js/search-worker.js621
-rw-r--r--src/static/js/xhr-util.js (renamed from src/static/xhr-util.js)0
-rw-r--r--src/static/misc/icons.svg (renamed from src/static/icons.svg)0
-rw-r--r--src/static/misc/warning.svg (renamed from src/static/warning.svg)0
-rw-r--r--src/static/shared-util/README.md11
-rw-r--r--src/strings-default.yaml140
-rwxr-xr-xsrc/upd8.js660
-rw-r--r--src/url-spec.js47
-rw-r--r--src/util/aggregate.js3
-rw-r--r--src/util/cli.js73
-rw-r--r--src/util/colors.js2
-rw-r--r--src/util/html.js91
-rw-r--r--src/util/search-spec.js259
-rw-r--r--src/util/sort.js35
-rw-r--r--src/util/sugar.js58
-rw-r--r--src/util/wiki-data.js21
-rw-r--r--src/web-routes.js98
-rw-r--r--src/write/bind-utilities.js2
-rw-r--r--src/write/build-modes/live-dev-server.js6
-rw-r--r--src/write/build-modes/repl.js6
-rw-r--r--src/write/build-modes/static-build.js126
179 files changed, 10753 insertions, 4294 deletions
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js
index f504cf80..68120b23 100644
--- a/src/content/dependencies/generateAdditionalFilesList.js
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -15,6 +15,8 @@ export default {
 
   generate: (slots, {html}) =>
     html.tag('ul', {class: 'additional-files-list'},
+      {[html.onlyIfContent]: true},
+
       stitchArrays({
         chunk: slots.chunks,
         items: slots.chunkItems,
diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js
index 5804115a..e66560fc 100644
--- a/src/content/dependencies/generateAdditionalFilesListChunk.js
+++ b/src/content/dependencies/generateAdditionalFilesListChunk.js
@@ -17,37 +17,31 @@ export default {
     },
   },
 
-  generate(slots, {html, language}) {
-    const summary =
-      html.tag('summary',
-        html.tag('span',
-          language.$('releaseInfo.additionalFiles.entry', {
-            title:
-              html.tag('span', {class: 'group-name'},
-                slots.title),
-          })));
-
-    const description =
-      html.tag('li', {class: 'entry-description'},
-        {[html.onlyIfContent]: true},
-        slots.description);
-
-    const items =
-      (html.isBlank(slots.items)
-        ? html.tag('li',
-            language.$('releaseInfo.additionalFiles.entry.noFilesAvailable'))
-        : slots.items);
-
-    const content =
-      html.tag('ul', [description, items]);
-
-    const details =
-      html.tag('details',
-        html.isBlank(slots.items) &&
-          {open: true},
-
-        [summary, content]);
-
-    return html.tag('li', details);
-  },
+  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('span', {class: 'group-name'},
+                      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/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 7879269f..c14640af 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -130,11 +130,11 @@ export default {
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumCommentaryPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('albumCommentaryPage.title', {
+          language.$(pageCapsule, 'title', {
             album: data.name,
           }),
 
@@ -146,7 +146,7 @@ export default {
         mainClasses: ['long-content'],
         mainContent: [
           html.tag('p',
-            language.$('albumCommentaryPage.infoLine', {
+            language.$(pageCapsule, 'infoLine', {
               words:
                 html.tag('b',
                   language.formatWordCount(data.wordCount, {unit: true})),
@@ -156,34 +156,41 @@ export default {
                   language.countCommentaryEntries(data.entryCount, {unit: true})),
             })),
 
-          relations.albumCommentaryEntries && [
-            relations.albumCommentaryHeading.slots({
-              tag: 'h3',
-              color: data.color,
-
-              title:
-                language.$('albumCommentaryPage.entry.title.albumCommentary', {
-                  album: relations.albumCommentaryLink,
-                }),
-
-              accent:
-                !empty(relations.albumCommentaryListeningLinks) &&
-                  language.$('albumCommentaryPage.entry.title.albumCommentary.accent', {
-                    listeningLinks:
-                      language.formatUnitList(
-                        relations.albumCommentaryListeningLinks
-                          .map(link => link.slots({
-                            context: 'album',
-                            tab: 'separate',
-                          }))),
-                  }),
-            }),
-
-            relations.albumCommentaryCover
-              ?.slots({mode: 'commentary'}),
-
-            relations.albumCommentaryEntries,
-          ],
+          relations.albumCommentaryEntries &&
+            language.encapsulate(pageCapsule, 'entry', entryCapsule => [
+              language.encapsulate(entryCapsule, 'title.albumCommentary', titleCapsule =>
+                relations.albumCommentaryHeading.slots({
+                  tag: 'h3',
+                  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,
@@ -201,31 +208,33 @@ export default {
               cover,
               entries,
               color,
-            }) => [
-              heading.slots({
-                tag: 'h3',
-                id: directory,
-                color,
-
-                title:
-                  language.$('albumCommentaryPage.entry.title.trackCommentary', {
-                    track: link,
-                  }),
-
-                accent:
-                  !empty(listeningLinks) &&
-                    language.$('albumCommentaryPage.entry.title.trackCommentary.accent', {
-                      listeningLinks:
-                        language.formatUnitList(
-                          listeningLinks.map(link =>
-                            link.slot('tab', 'separate'))),
-                    }),
-              }),
+            }) =>
+              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'}),
 
               entries.map(entry => entry.slot('color', color)),
-            ]),
+            ])),
         ],
 
         navLinkStyle: 'hierarchical',
@@ -246,6 +255,5 @@ export default {
         ],
 
         leftSidebar: relations.sidebar,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
index aa025688..44d49c54 100644
--- a/src/content/dependencies/generateAlbumGalleryPage.js
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -160,11 +160,11 @@ export default {
     return data;
   },
 
-  generate(data, relations, {language}) {
-    return relations.layout
-      .slots({
+  generate: (data, relations, {language}) =>
+    language.encapsulate('albumGalleryPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('albumGalleryPage.title', {
+          language.$(pageCapsule, 'title', {
             album: data.name,
           }),
 
@@ -223,6 +223,5 @@ export default {
         ],
 
         secondaryNav: relations.secondaryNav,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index 739a6669..1bffe2d0 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -1,8 +1,3 @@
-import {sortAlbumsTracksChronologically} from '#sort';
-import {empty} from '#sugar';
-
-import getChronologyRelations from '../util/getChronologyRelations.js';
-
 export default {
   contentDependencies: [
     'generateAlbumAdditionalFilesList',
@@ -15,148 +10,93 @@ export default {
     'generateAlbumSocialEmbed',
     'generateAlbumStyleRules',
     'generateAlbumTrackList',
-    'generateChronologyLinks',
     'generateCommentarySection',
     'generateContentHeading',
     'generatePageLayout',
-    'linkAlbum',
     'linkAlbumCommentary',
     'linkAlbumGallery',
-    'linkArtist',
-    'linkTrack',
-    'transformContent',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  relations(relation, album) {
-    const relations = {};
-    const sections = relations.sections = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.albumStyleRules =
-      relation('generateAlbumStyleRules', album, null);
-
-    relations.socialEmbed =
-      relation('generateAlbumSocialEmbed', album);
-
-    relations.coverArtistChronologyContributions =
-      getChronologyRelations(album, {
-        contributions: album.coverArtistContribs ?? [],
-
-        linkArtist: artist => relation('linkArtist', artist),
-
-        linkThing: trackOrAlbum =>
-          (trackOrAlbum.album
-            ? relation('linkTrack', trackOrAlbum)
-            : relation('linkAlbum', trackOrAlbum)),
-
-        getThings(artist) {
-          const getDate = thing => thing.coverArtDate ?? thing.date;
-
-          const things = [
-            ...artist.albumsAsCoverArtist,
-            ...artist.tracksAsCoverArtist,
-          ].filter(getDate);
-
-          return sortAlbumsTracksChronologically(things, {getDate});
-        },
-      });
-
-    relations.albumNavAccent =
-      relation('generateAlbumNavAccent', album, null);
-
-    relations.chronologyLinks =
-      relation('generateChronologyLinks');
-
-    relations.secondaryNav =
-      relation('generateAlbumSecondaryNav', album);
-
-    relations.sidebar =
-      relation('generateAlbumSidebar', album, null);
-
-    if (album.hasCoverArt) {
-      relations.cover =
-        relation('generateAlbumCoverArtwork', album);
-    }
-
-    if (album.hasBannerArt) {
-      relations.banner =
-        relation('generateAlbumBanner', album);
-    }
+  relations: (relation, album) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    // Section: Release info
+    albumStyleRules:
+      relation('generateAlbumStyleRules', album, null),
 
-    relations.releaseInfo =
-      relation('generateAlbumReleaseInfo', album);
+    socialEmbed:
+      relation('generateAlbumSocialEmbed', album),
 
-    // Section: Extra links
+    albumNavAccent:
+      relation('generateAlbumNavAccent', album, null),
 
-    const extra = sections.extra = {};
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', album),
 
-    if (album.tracks.some(t => t.hasUniqueCoverArt)) {
-      extra.galleryLink =
-        relation('linkAlbumGallery', album);
-    }
+    sidebar:
+      relation('generateAlbumSidebar', album, null),
 
-    if (album.commentary || album.tracks.some(t => t.commentary)) {
-      extra.commentaryLink =
-        relation('linkAlbumCommentary', album);
-    }
+    cover:
+      (album.hasCoverArt
+        ? relation('generateAlbumCoverArtwork', album)
+        : null),
 
-    // Section: Track list
+    banner:
+      (album.hasBannerArt
+        ? relation('generateAlbumBanner', album)
+        : null),
 
-    relations.trackList =
-      relation('generateAlbumTrackList', album);
+    contentHeading:
+      relation('generateContentHeading'),
 
-    // Section: Additional files
+    releaseInfo:
+      relation('generateAlbumReleaseInfo', album),
 
-    if (!empty(album.additionalFiles)) {
-      const additionalFiles = sections.additionalFiles = {};
+    galleryLink:
+      (album.tracks.some(t => t.hasUniqueCoverArt)
+        ? relation('linkAlbumGallery', album)
+        : null),
 
-      additionalFiles.heading =
-        relation('generateContentHeading');
+    commentaryLink:
+      (album.commentary || album.tracks.some(t => t.commentary)
+        ? relation('linkAlbumCommentary', album)
+        : null),
 
-      additionalFiles.additionalFilesList =
-        relation('generateAlbumAdditionalFilesList', album, album.additionalFiles);
-    }
+    trackList:
+      relation('generateAlbumTrackList', album),
 
-    // Section: Artist commentary
+    additionalFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        album,
+        album.additionalFiles),
 
-    if (album.commentary) {
-      sections.artistCommentary =
-        relation('generateCommentarySection', album.commentary);
-    }
+    artistCommentarySection:
+      relation('generateCommentarySection', album.commentary),
+  }),
 
-    return relations;
-  },
+  data: (album) => ({
+    name:
+      album.name,
 
-  data(album) {
-    const data = {};
+    color:
+      album.color,
 
-    data.name = album.name;
-    data.color = album.color;
+    dateAddedToWiki:
+      album.dateAddedToWiki,
+  }),
 
-    if (!empty(album.additionalFiles)) {
-      data.numAdditionalFiles = album.additionalFiles.length;
-    }
-
-    data.dateAddedToWiki = album.dateAddedToWiki;
-
-    return data;
-  },
-
-  generate(data, relations, {html, language}) {
-    const {sections: sec} = relations;
-
-    return relations.layout
-      .slots({
-        title: language.$('albumPage.title', {album: data.name}),
-        headingMode: 'sticky',
+  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],
 
         cover:
@@ -173,38 +113,44 @@ export default {
             {[html.onlyIfContent]: true},
             {[html.joinChildren]: html.tag('br')},
 
-            [
-              sec.additionalFiles &&
-                language.$('releaseInfo.additionalFiles.shortcut', {
+            language.encapsulate('releaseInfo', capsule => [
+              !html.isBlank(relations.additionalFilesList) &&
+                language.$(capsule, 'additionalFiles.shortcut', {
                   link: html.tag('a',
                     {href: '#additional-files'},
-                    language.$('releaseInfo.additionalFiles.shortcut.link')),
-                }),
-
-              sec.extra.galleryLink && sec.extra.commentaryLink &&
-                language.$('releaseInfo.viewGalleryOrCommentary', {
-                  gallery:
-                    sec.extra.galleryLink
-                      .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.gallery')),
-                  commentary:
-                    sec.extra.commentaryLink
-                      .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.commentary')),
-                }),
-
-              sec.extra.galleryLink && !sec.extra.commentaryLink &&
-                language.$('releaseInfo.viewGallery', {
-                  link:
-                    sec.extra.galleryLink
-                      .slot('content', language.$('releaseInfo.viewGallery.link')),
+                    language.$(capsule, 'additionalFiles.shortcut.link')),
                 }),
 
-              !sec.extra.galleryLink && sec.extra.commentaryLink &&
-                language.$('releaseInfo.viewCommentary', {
-                  link:
-                    sec.extra.commentaryLink
-                      .slot('content', language.$('releaseInfo.viewCommentary.link')),
-                }),
-            ]),
+              (relations.galleryLink && relations.commentaryLink
+                ? language.encapsulate(capsule, 'viewGalleryOrCommentary', capsule =>
+                    language.$(capsule, {
+                      gallery:
+                        relations.galleryLink
+                          .slot('content', language.$(capsule, 'gallery')),
+
+                      commentary:
+                        relations.commentaryLink
+                          .slot('content', language.$(capsule, 'commentary')),
+                    }))
+
+             : relations.galleryLink
+                ? language.encapsulate(capsule, 'viewGallery', capsule =>
+                    language.$(capsule, {
+                      link:
+                        relations.galleryLink
+                          .slot('content', language.$(capsule, 'link')),
+                    }))
+
+             : relations.commentaryLink
+                ? language.encapsulate(capsule, 'viewCommentary', capsule =>
+                    language.$(capsule, {
+                      link:
+                        relations.commentaryLink
+                          .slot('content', language.$(capsule, 'link')),
+                    }))
+
+                : html.blank()),
+            ])),
 
           relations.trackList,
 
@@ -212,28 +158,25 @@ export default {
             {[html.onlyIfContent]: true},
             {[html.joinChildren]: html.tag('br')},
 
-            [
-              data.dateAddedToWiki &&
-                language.$('releaseInfo.addedToWiki', {
-                  date: language.formatDate(data.dateAddedToWiki),
-                }),
-            ]),
-
-          sec.additionalFiles && [
-            sec.additionalFiles.heading
-              .slots({
-                id: 'additional-files',
-                title:
-                  language.$('releaseInfo.additionalFiles.heading', {
-                    additionalFiles:
-                      language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
-                  }),
+            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'),
+                }),
 
-            sec.additionalFiles.additionalFilesList,
-          ],
+              relations.additionalFilesList,
+            ])),
 
-          sec.artistCommentary,
+          relations.artistCommentarySection,
         ],
 
         navLinkStyle: 'hierarchical',
@@ -249,16 +192,6 @@ export default {
           },
         ],
 
-        navContent:
-          relations.chronologyLinks.slots({
-            chronologyInfoSets: [
-              {
-                headingString: 'misc.chronology.heading.coverArt',
-                contributions: relations.coverArtistChronologyContributions,
-              },
-            ],
-          }),
-
         banner: relations.banner ?? null,
         bannerPosition: 'top',
 
@@ -267,6 +200,5 @@ export default {
         leftSidebar: relations.sidebar,
 
         socialEmbed: relations.socialEmbed,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js
index 121af439..4b6fb062 100644
--- a/src/content/dependencies/generateAlbumNavAccent.js
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -62,18 +62,21 @@ export default {
   },
 
   generate(data, relations, slots, {html, language}) {
+    const albumNavCapsule = language.encapsulate('albumPage.nav');
+    const trackNavCapsule = language.encapsulate('trackPage.nav');
+
     const {content: extraLinks = []} =
       slots.showExtraLinks &&
         {content: [
           (!data.galleryIsStub || slots.currentExtra === 'gallery') &&
             relations.albumGalleryLink?.slots({
               attributes: {class: slots.currentExtra === 'gallery' && 'current'},
-              content: language.$('albumPage.nav.gallery'),
+              content: language.$(albumNavCapsule, 'gallery'),
             }),
 
           relations.albumCommentaryLink?.slots({
             attributes: {class: slots.currentExtra === 'commentary' && 'current'},
-            content: language.$('albumPage.nav.commentary'),
+            content: language.$(albumNavCapsule, 'commentary'),
           }),
         ]};
 
@@ -94,8 +97,8 @@ export default {
           {href: '#', 'data-random': 'track-in-sidebar'},
 
           (data.isTrackPage
-            ? language.$('trackPage.nav.random')
-            : language.$('albumPage.nav.randomTrack')));
+            ? language.$(trackNavCapsule, 'random')
+            : language.$(albumNavCapsule, 'randomTrack')));
 
     const allLinks = [
       ...previousNextLinks,
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index 6fc1375b..28227f45 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -23,11 +23,9 @@ export default {
     relations.bannerArtistContributionsLine =
       relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
 
-    if (!empty(album.urls)) {
-      relations.externalLinks =
-        album.urls.map(url =>
-          relation('linkExternal', url));
-    }
+    relations.externalLinks =
+      album.urls.map(url =>
+        relation('linkExternal', url));
 
     return relations;
   },
@@ -43,55 +41,77 @@ export default {
       data.coverArtDate = album.coverArtDate;
     }
 
-    data.duration = accumulateSum(album.tracks, track => track.duration);
-    data.durationApproximate = album.tracks.length > 1;
+    const durationTerms =
+      album.tracks
+        .map(track => track.duration)
+        .filter(value => value > 0);
+
+    if (empty(durationTerms)) {
+      data.duration = null;
+      data.durationApproximate = null;
+    } else {
+      data.duration = accumulateSum(durationTerms);
+      data.durationApproximate = album.tracks.length > 1;
+    }
 
     data.numTracks = album.tracks.length;
 
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return html.tags([
-      html.tag('p',
-        {[html.onlyIfContent]: true},
-        {[html.joinChildren]: html.tag('br')},
+  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: 'releaseInfo.by'}),
+          [
+            relations.artistContributionsLine.slots({
+              stringKey: capsule + '.by',
+              chronologyKind: 'album',
+            }),
 
-          relations.coverArtistContributionsLine
-            .slots({stringKey: 'releaseInfo.coverArtBy'}),
+            relations.coverArtistContributionsLine.slots({
+              stringKey: capsule + '.coverArtBy',
+              chronologyKind: 'coverArt',
+            }),
 
-          relations.wallpaperArtistContributionsLine
-            .slots({stringKey: 'releaseInfo.wallpaperArtBy'}),
+            relations.wallpaperArtistContributionsLine.slots({
+              stringKey: capsule + '.wallpaperArtBy',
+              chronologyKind: 'wallpaperArt',
+            }),
 
-          relations.bannerArtistContributionsLine
-            .slots({stringKey: 'releaseInfo.bannerArtBy'}),
+            relations.bannerArtistContributionsLine.slots({
+              stringKey: capsule + '.bannerArtBy',
+              chronologyKind: 'bannerArt',
+            }),
 
-          data.date &&
-            language.$('releaseInfo.released', {
+            language.$(capsule, 'released', {
+              [language.onlyIfOptions]: ['date'],
               date: language.formatDate(data.date),
             }),
 
-          data.coverArtDate &&
-            language.$('releaseInfo.artReleased', {
+            language.$(capsule, 'artReleased', {
+              [language.onlyIfOptions]: ['date'],
               date: language.formatDate(data.coverArtDate),
             }),
 
-          data.duration &&
-            language.$('releaseInfo.duration', {
+            language.$(capsule, 'duration', {
+              [language.onlyIfOptions]: ['duration'],
               duration:
                 language.formatDuration(data.duration, {
                   approximate: data.durationApproximate,
                 }),
             }),
-        ]),
+          ]),
 
-      relations.externalLinks &&
         html.tag('p',
-          language.$('releaseInfo.listenOn', {
+          {[html.onlyIfContent]: true},
+
+          language.$(capsule, 'listenOn', {
+            [language.onlyIfOptions]: ['links'],
+
             links:
               language.formatDisjunctionList(
                 relations.externalLinks
@@ -105,6 +125,5 @@ export default {
                         : 'albumMultipleTracks'),
                     ]))),
           })),
-    ]);
-  },
+      ])),
 };
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
index 00a96c31..f3be74f7 100644
--- a/src/content/dependencies/generateAlbumSidebarGroupBox.js
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -1,5 +1,5 @@
 import {sortChronologically} from '#sort';
-import {atOffset, empty} from '#sugar';
+import {atOffset} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -77,40 +77,50 @@ export default {
   },
 
   generate: (relations, slots, {html, language}) =>
-    relations.box.slots({
-      attributes: {class: 'individual-group-sidebar-box'},
-      content: [
-        html.tag('h1',
-          language.$('albumSidebar.groupBox.title', {
-            group: relations.groupLink,
-          })),
-
-        slots.mode === 'album' &&
-          relations.description
-            ?.slot('mode', 'multiline'),
-
-        !empty(relations.externalLinks) &&
+    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' &&
-        relations.nextAlbumLink &&
-          html.tag('p', {class: 'group-chronology-link'},
-            language.$('albumSidebar.groupBox.next', {
-              album: relations.nextAlbumLink,
-            })),
+          slots.mode === 'album' &&
+            html.tag('p', {class: 'group-chronology-link'},
+              {[html.onlyIfContent]: true},
 
-        slots.mode === 'album' &&
-        relations.previousAlbumLink &&
-          html.tag('p', {class: 'group-chronology-link'},
-            language.$('albumSidebar.groupBox.previous', {
-              album: relations.previousAlbumLink,
-            })),
-      ],
-    }),
+              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/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
index aa5c723d..d0c46060 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackSection.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -55,10 +55,12 @@ export default {
   },
 
   generate(data, relations, slots, {getColors, html, language}) {
+    const capsule = language.encapsulate('albumSidebar.trackList');
+
     const sectionName =
       html.tag('span', {class: 'group-name'},
         (data.isDefaultTrackSection
-          ? language.$('albumSidebar.trackList.fallbackSectionName')
+          ? language.$(capsule, 'fallbackSectionName')
           : data.name));
 
     let colorStyle;
@@ -78,7 +80,7 @@ export default {
           data.tracksAreMissingCommentary[index] &&
             {class: 'no-commentary'},
 
-          language.$('albumSidebar.trackList.item', {
+          language.$(capsule, 'item', {
             track:
               (slots.mode === 'commentary' && data.tracksAreMissingCommentary[index]
                 ? trackLink.slots({
@@ -117,14 +119,17 @@ export default {
           colorStyle,
 
           html.tag('span',
-            (data.hasTrackNumbers
-              ? language.$('albumSidebar.trackList.group.withRange', {
-                  group: sectionName,
-                  range: `${data.firstTrackNumber}–${data.lastTrackNumber}`
-                })
-              : language.$('albumSidebar.trackList.group', {
-                  group: sectionName,
-                })))),
+            language.encapsulate(capsule, 'group', workingCapsule => {
+              const workingOptions = {group: sectionName};
+
+              if (data.hasTrackNumbers) {
+                workingCapsule += '.withRange';
+                workingOptions.range =
+                  `${data.firstTrackNumber}–${data.lastTrackNumber}`;
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            }))),
 
         (data.hasTrackNumbers
           ? html.tag('ol',
diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js
index c8b123fe..7500109e 100644
--- a/src/content/dependencies/generateAlbumSocialEmbed.js
+++ b/src/content/dependencies/generateAlbumSocialEmbed.js
@@ -41,34 +41,34 @@ export default {
     return data;
   },
 
-  generate(data, relations, {absoluteTo, language, urls}) {
-    return relations.socialEmbed.slots({
-      title:
-        language.$('albumPage.socialEmbed.title', {
-          album: data.albumName,
-        }),
-
-      description: relations.description,
-
-      headingContent:
-        (data.hasHeading
-          ? language.$('albumPage.socialEmbed.heading', {
-              group: data.headingGroupName,
-            })
-          : null),
-
-      headingLink:
-        (data.hasHeading
-          ? absoluteTo('localized.groupGallery', data.headingGroupDirectory)
-          : null),
-
-      imagePath:
-        (data.hasImage
-          ? '/' +
-            urls
-              .from('shared.root')
-              .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension)
-          : null),
-    });
-  },
+  generate: (data, relations, {absoluteTo, language, urls}) =>
+    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
+            ? '/' +
+              urls
+                .from('shared.root')
+                .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension)
+            : null),
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
index ee06b9e6..a3435bea 100644
--- a/src/content/dependencies/generateAlbumTrackList.js
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -147,21 +147,30 @@ export default {
               durationApproximate,
               startIndex,
             }) => [
-              heading.slots({
-                tag: 'dt',
-                title:
-                  (duration === 0
-                    ? language.$('trackList.section', {
-                        section: name,
-                      })
-                    : language.$('trackList.section.withDuration', {
-                        section: name,
-                        duration:
+              language.encapsulate('trackList.section', capsule =>
+                heading.slots({
+                  tag: 'dt',
+
+                  title:
+                    language.encapsulate(capsule, capsule => {
+                      const options = {section: name};
+
+                      if (duration !== 0) {
+                        capsule += '.withDuration';
+                        options.duration =
                           language.formatDuration(duration, {
                             approximate: durationApproximate,
-                          }),
-                      })),
-              }),
+                          });
+                      }
+
+                      return language.$(capsule, options);
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky', {
+                      section: name,
+                    }),
+                })),
 
               html.tag('dd',
                 html.tag(listTag,
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
index 7190fb4c..7d5d2c6e 100644
--- a/src/content/dependencies/generateAlbumTrackListItem.js
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -80,54 +80,51 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {getColors, html, language}) {
-    let colorStyle;
-    if (data.color) {
-      const {primary} = getColors(data.color);
-      colorStyle = {style: `--primary-color: ${primary}`};
-    }
-
-    const parts = ['trackList.item'];
-    const options = {};
-
-    options.track =
-      relations.trackLink
-        .slot('color', false);
-
-    const collapseDuration =
-      (slots.collapseDurationScope === 'track'
-        ? !data.trackHasDuration
-     : slots.collapseDurationScope === 'section'
-        ? !data.sectionHasDuration
-     : slots.collapseDurationScope === 'album'
-        ? !data.albumHasDuration
-        : false);
-
-    if (!collapseDuration) {
-      parts.push('withDuration');
-
-      options.duration =
-        (data.trackHasDuration
-          ? language.$('trackList.item.withDuration.duration', {
-              duration:
-                language.formatDuration(data.duration),
-            })
-          : relations.missingDuration);
-    }
-
-    if (data.showArtists) {
-      parts.push('withArtists');
-      options.by =
-        html.tag('span', {class: 'by'},
-          html.metatag('chunkwrap', {split: ','},
-            html.resolve(
-              language.$('trackList.item.withArtists.by', {
-                artists: language.formatConjunctionList(relations.contributionLinks),
-              }))));
-    }
-
-    return html.tag('li',
-      colorStyle,
-      language.formatString(...parts, options));
-  },
+  generate: (data, relations, slots, {getColors, html, language}) =>
+    language.encapsulate('trackList.item', itemCapsule =>
+      html.tag('li',
+        data.color &&
+          {style: `--primary-color: ${getColors(data.color).primary}`},
+
+        language.encapsulate(itemCapsule, workingCapsule => {
+          const workingOptions = {};
+
+          workingOptions.track =
+            relations.trackLink
+              .slot('color', false);
+
+          const collapseDuration =
+            (slots.collapseDurationScope === 'track'
+              ? !data.trackHasDuration
+           : slots.collapseDurationScope === 'section'
+              ? !data.sectionHasDuration
+           : slots.collapseDurationScope === 'album'
+              ? !data.albumHasDuration
+              : false);
+
+          if (!collapseDuration) {
+            workingCapsule += '.withDuration';
+            workingOptions.duration =
+              (data.trackHasDuration
+                ? language.$(itemCapsule, 'withDuration.duration', {
+                    duration:
+                      language.formatDuration(data.duration),
+                  })
+                : relations.missingDuration);
+          }
+
+          if (data.showArtists) {
+            workingCapsule += '.withArtists';
+            workingOptions.by =
+              html.tag('span', {class: 'by'},
+                html.metatag('chunkwrap', {split: ','},
+                  html.resolve(
+                    language.$(itemCapsule, 'withArtists.by', {
+                      artists:
+                        language.formatConjunctionList(relations.contributionLinks),
+                    }))));
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }))),
 };
diff --git a/src/content/dependencies/generateAlbumTrackListMissingDuration.js b/src/content/dependencies/generateAlbumTrackListMissingDuration.js
index 6d4a6ec8..b5917982 100644
--- a/src/content/dependencies/generateAlbumTrackListMissingDuration.js
+++ b/src/content/dependencies/generateAlbumTrackListMissingDuration.js
@@ -11,23 +11,25 @@ export default {
   }),
 
   generate: (relations, {html, language}) =>
-    relations.textWithTooltip.slots({
-      attributes: {class: 'missing-duration'},
-      customInteractionCue: true,
+    language.encapsulate('trackList.item.withDuration', itemCapsule =>
+      language.encapsulate(itemCapsule, 'duration', durationCapsule =>
+        relations.textWithTooltip.slots({
+          attributes: {class: 'missing-duration'},
+          customInteractionCue: true,
 
-      text:
-        language.$('trackList.item.withDuration.duration', {
-          duration:
-            html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
-              language.$('trackList.item.withDuration.duration.missing')),
-        }),
+          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'},
+          tooltip:
+            relations.tooltip.slots({
+              attributes: {class: 'missing-duration-tooltip'},
 
-          content:
-            language.$('trackList.item.withDuration.duration.missing.info'),
-        }),
-    }),
+              content:
+                language.$(durationCapsule, 'missing.info'),
+            }),
+        }))),
 };
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
index eae48f05..c51faeba 100644
--- a/src/content/dependencies/generateArtTagGalleryPage.js
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -85,11 +85,11 @@ export default {
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('tagPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('tagPage.title', {
+          language.$(pageCapsule, 'title', {
             tag: data.name,
           }),
 
@@ -100,7 +100,7 @@ export default {
         mainClasses: ['top-index'],
         mainContent: [
           html.tag('p', {class: 'quick-info'},
-            language.$('tagPage.infoLine', {
+            language.$(pageCapsule, 'infoLine', {
               coverArts: language.countCoverArts(data.numArtworks, {
                 unit: true,
               }),
@@ -143,11 +143,10 @@ export default {
 
           {
             html:
-              language.$('tagPage.nav.tag', {
+              language.$(pageCapsule, 'nav.tag', {
                 tag: relations.artTagMainLink,
               }),
           },
         ],
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
index db8f123f..28f06a21 100644
--- a/src/content/dependencies/generateArtistGalleryPage.js
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -14,10 +14,12 @@ export default {
   extraDependencies: ['html', 'language'],
 
   query(artist) {
-    const things = [
-      ...artist.albumsAsCoverArtist,
-      ...artist.tracksAsCoverArtist,
-    ];
+    const things =
+      ([
+        artist.albumCoverArtistContributions,
+        artist.trackCoverArtistContributions,
+      ]).flat()
+        .map(({thing}) => thing);
 
     sortAlbumsTracksChronologically(things, {
       latestFirst: true,
@@ -82,11 +84,11 @@ export default {
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistGalleryPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('artistGalleryPage.title', {
+          language.$(pageCapsule, 'title', {
             artist: data.name,
           }),
 
@@ -95,10 +97,11 @@ export default {
         mainClasses: ['top-index'],
         mainContent: [
           html.tag('p', {class: 'quick-info'},
-            language.$('artistGalleryPage.infoLine', {
-              coverArts: language.countCoverArts(data.numArtworks, {
-                unit: true,
-              }),
+            language.$(pageCapsule, 'infoLine', {
+              coverArts:
+                language.countCoverArts(data.numArtworks, {
+                  unit: true,
+                }),
             })),
 
           relations.coverGrid
@@ -117,6 +120,7 @@ export default {
                       dimensions,
                     })),
 
+              // TODO: Can this be [language.onlyIfOptions]?
               info:
                 data.otherCoverArtists.map(names =>
                   (names === null
@@ -135,6 +139,5 @@ export default {
               currentExtra: 'gallery',
             })
             .content,
-      })
-  },
+      })),
 }
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index 1725d4b9..f84d00de 100644
--- a/src/content/dependencies/generateArtistGroupContributionsInfo.js
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -131,94 +131,104 @@ export default {
     countUnit: {validate: v => v.is('tracks', 'artworks')},
   },
 
-  generate(data, relations, slots, {html, language}) {
-    if (slots.sort === 'count' && empty(relations.groupLinksSortedByCount)) {
-      return html.blank();
-    } else if (slots.sort === 'duration' && empty(relations.groupLinksSortedByDuration)) {
-      return html.blank();
-    }
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('artistPage.groupContributions', capsule => {
+      if (slots.sort === 'count' && empty(relations.groupLinksSortedByCount)) {
+        return html.blank();
+      } else if (slots.sort === 'duration' && empty(relations.groupLinksSortedByDuration)) {
+        return html.blank();
+      }
 
-    const getCounts = counts =>
-      counts.map(count => {
-        switch (slots.countUnit) {
-          case 'tracks': return language.countTracks(count, {unit: true});
-          case 'artworks': return language.countArtworks(count, {unit: true});
-        }
-      });
-
-    // We aren't displaying the "~" approximate symbol here for now.
-    // The general notion that these sums aren't going to be 100% accurate
-    // is made clear by the "XYZ has contributed ~1:23:45 hours of music..."
-    // line that's always displayed above this table.
-    const getDurations = (durations, approximate) =>
-      stitchArrays({
-        duration: durations,
-        approximate: approximate,
-      }).map(({duration}) => language.formatDuration(duration));
-
-    const topLevelClasses = [
-      'group-contributions-sorted-by-' + slots.sort,
-      slots.visible && 'visible',
-    ];
-
-    return html.tags([
-      html.tag('dt', {class: topLevelClasses},
-        (slots.showSortButton
-          ? language.$('artistPage.groupContributions.title.withSortButton', {
-              title: slots.title,
-              sort:
-                html.tag('a', {class: 'group-contributions-sort-button'},
-                  {href: '#'},
-
-                  (slots.sort === 'count'
-                    ? language.$('artistPage.groupContributions.title.sorting.count')
-                    : language.$('artistPage.groupContributions.title.sorting.duration'))),
-            })
-          : slots.title)),
-
-      html.tag('dd', {class: topLevelClasses},
-        html.tag('ul', {class: 'group-contributions-table'},
-          {role: 'list'},
-
-          (slots.sort === 'count'
-            ? stitchArrays({
-                group: relations.groupLinksSortedByCount,
-                count: getCounts(data.groupCountsSortedByCount),
-                duration:
-                  getDurations(
-                    data.groupDurationsSortedByCount,
-                    data.groupDurationsApproximateSortedByCount),
-              }).map(({group, count, duration}) =>
-                  html.tag('li',
-                    html.tag('div', {class: 'group-contributions-row'}, [
-                      group,
-                      html.tag('span', {class: 'group-contributions-metrics'},
-                        // When sorting by count, duration details aren't necessarily
-                        // available for all items.
-                        (slots.showBothColumns && duration
-                          ? language.$('artistPage.groupContributions.item.countDurationAccent', {count, duration})
-                          : language.$('artistPage.groupContributions.item.countAccent', {count}))),
-                    ])))
-
-            : stitchArrays({
-                group: relations.groupLinksSortedByDuration,
-                count: getCounts(data.groupCountsSortedByDuration),
-                duration:
-                  getDurations(
-                    data.groupDurationsSortedByDuration,
-                    data.groupDurationsApproximateSortedByDuration),
-              }).map(({group, count, duration}) =>
-                  html.tag('li',
-                    html.tag('div', {class: 'group-contributions-row'}, [
-                      group,
-                      html.tag('span', {class: 'group-contributions-metrics'},
-                        // Count details are always available, since they're just the
-                        // number of contributions directly. And duration details are
-                        // guaranteed for every item when sorting by duration.
-                        (slots.showBothColumns
-                          ? language.$('artistPage.groupContributions.item.durationCountAccent', {duration, count})
-                          : language.$('artistPage.groupContributions.item.durationAccent', {duration}))),
-                    ])))))),
-    ]);
-  },
+      const getCounts = counts =>
+        counts.map(count => {
+          switch (slots.countUnit) {
+            case 'tracks': return language.countTracks(count, {unit: true});
+            case 'artworks': return language.countArtworks(count, {unit: true});
+          }
+        });
+
+      // We aren't displaying the "~" approximate symbol here for now.
+      // The general notion that these sums aren't going to be 100% accurate
+      // is made clear by the "XYZ has contributed ~1:23:45 hours of music..."
+      // line that's always displayed above this table.
+      const getDurations = (durations, approximate) =>
+        stitchArrays({
+          duration: durations,
+          approximate: approximate,
+        }).map(({duration}) => language.formatDuration(duration));
+
+      const topLevelClasses = [
+        'group-contributions-sorted-by-' + slots.sort,
+        slots.visible && 'visible',
+      ];
+
+      // TODO: It feels pretty awkward that this component is the only one that
+      // has enough knowledge to decide if the sort button is even applicable...
+      const switchingSortPossible =
+        !empty(relations.groupLinksSortedByCount) &&
+        !empty(relations.groupLinksSortedByDuration);
+
+      return html.tags([
+        html.tag('dt', {class: topLevelClasses},
+          language.encapsulate(capsule, 'title', capsule =>
+            (switchingSortPossible && slots.showSortButton
+              ? language.$(capsule, 'withSortButton', {
+                  title: slots.title,
+                  sort:
+                    html.tag('a', {class: 'group-contributions-sort-button'},
+                      {href: '#'},
+
+                      (slots.sort === 'count'
+                        ? language.$(capsule, 'sorting.count')
+                        : language.$(capsule, 'sorting.duration'))),
+                })
+              : slots.title))),
+
+        html.tag('dd', {class: topLevelClasses},
+          html.tag('ul', {class: 'group-contributions-table'},
+            {role: 'list'},
+
+            (slots.sort === 'count'
+              ? stitchArrays({
+                  group: relations.groupLinksSortedByCount,
+                  count: getCounts(data.groupCountsSortedByCount),
+                  duration:
+                    getDurations(
+                      data.groupDurationsSortedByCount,
+                      data.groupDurationsApproximateSortedByCount),
+                }).map(({group, count, duration}) =>
+                    language.encapsulate(capsule, 'item', capsule =>
+                      html.tag('li',
+                        html.tag('div', {class: 'group-contributions-row'}, [
+                          group,
+                          html.tag('span', {class: 'group-contributions-metrics'},
+                            // When sorting by count, duration details aren't necessarily
+                            // available for all items.
+                            (slots.showBothColumns && duration
+                              ? language.$(capsule, 'countDurationAccent', {count, duration})
+                              : language.$(capsule, 'countAccent', {count}))),
+                        ]))))
+
+              : stitchArrays({
+                  group: relations.groupLinksSortedByDuration,
+                  count: getCounts(data.groupCountsSortedByDuration),
+                  duration:
+                    getDurations(
+                      data.groupDurationsSortedByDuration,
+                      data.groupDurationsApproximateSortedByDuration),
+                }).map(({group, count, duration}) =>
+                    language.encapsulate(capsule, 'item', capsule =>
+                      html.tag('li',
+                        html.tag('div', {class: 'group-contributions-row'}, [
+                          group,
+                          html.tag('span', {class: 'group-contributions-metrics'},
+                            // Count details are always available, since they're just the
+                            // number of contributions directly. And duration details are
+                            // guaranteed for every item when sorting by duration.
+                            (slots.showBothColumns
+                              ? language.$(capsule, 'durationCountAccent', {duration, count})
+                              : language.$(capsule, 'durationAccent', {duration}))),
+                        ]))))))),
+      ]);
+    }),
 };
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index ac9209a7..f9ce7e3b 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -1,5 +1,4 @@
 import {empty, unique} from '#sugar';
-import {getTotalDuration} from '#wiki-data';
 
 export default {
   contentDependencies: [
@@ -12,131 +11,112 @@ export default {
     'generateContentHeading',
     'generateCoverArtwork',
     'generatePageLayout',
-    'linkAlbum',
     'linkArtistGallery',
     'linkExternal',
-    'linkGroup',
-    'linkTrack',
     'transformContent',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
-
-  sprawl({wikiInfo}) {
-    return {
-      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
-    };
-  },
-
-  query(sprawl, artist) {
-    return {
-      // Even if an artist has served as both "artist" (compositional) and
-      // "contributor" (instruments, production, etc) on the same track, that
-      // track only counts as one unique contribution.
-      allTracks:
-        unique([...artist.tracksAsArtist, ...artist.tracksAsContributor]),
-
-      // Artworks are different, though. We intentionally duplicate album data
-      // objects when the artist has contributed some combination of cover art,
-      // wallpaper, and banner - these each count as a unique contribution.
-      allArtworks: [
-        ...artist.albumsAsCoverArtist,
-        ...artist.albumsAsWallpaperArtist,
-        ...artist.albumsAsBannerArtist,
-        ...artist.tracksAsCoverArtist,
-      ],
-
-      // Banners and wallpapers don't show up in the artist gallery page, only
-      // cover art.
-      hasGallery:
-        !empty(artist.albumsAsCoverArtist) ||
-        !empty(artist.tracksAsCoverArtist),
-    };
-  },
-
-  relations(relation, query, sprawl, artist) {
-    const relations = {};
-    const sections = relations.sections = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.artistNavLinks =
-      relation('generateArtistNavLinks', artist);
-
-    if (artist.hasAvatar) {
-      relations.cover =
-        relation('generateCoverArtwork', []);
-    }
-
-    if (artist.contextNotes) {
-      const contextNotes = sections.contextNotes = {};
-      contextNotes.content = relation('transformContent', artist.contextNotes);
-    }
-
-    if (!empty(artist.urls)) {
-      const visit = sections.visit = {};
-      visit.externalLinks =
-        artist.urls.map(url =>
-          relation('linkExternal', url));
-    }
-
-    if (!empty(query.allTracks)) {
-      const tracks = sections.tracks = {};
-      tracks.heading = relation('generateContentHeading');
-      tracks.list = relation('generateArtistInfoPageTracksChunkedList', artist);
-      tracks.groupInfo = relation('generateArtistGroupContributionsInfo', query.allTracks);
-    }
-
-    if (!empty(query.allArtworks)) {
-      const artworks = sections.artworks = {};
-      artworks.heading = relation('generateContentHeading');
-      artworks.list = relation('generateArtistInfoPageArtworksChunkedList', artist);
-      artworks.groupInfo =
-        relation('generateArtistGroupContributionsInfo', query.allArtworks);
-
-      if (query.hasGallery) {
-        artworks.artistGalleryLink =
-          relation('linkArtistGallery', artist);
-      }
-    }
-
-    if (sprawl.enableFlashesAndGames && !empty(artist.flashesAsContributor)) {
-      const flashes = sections.flashes = {};
-      flashes.heading = relation('generateContentHeading');
-      flashes.list = relation('generateArtistInfoPageFlashesChunkedList', artist);
-    }
-
-    if (!empty(artist.albumsAsCommentator) || !empty(artist.tracksAsCommentator)) {
-      const commentary = sections.commentary = {};
-      commentary.heading = relation('generateContentHeading');
-      commentary.list = relation('generateArtistInfoPageCommentaryChunkedList', artist);
-    }
-
-    return relations;
-  },
-
-  data(query, sprawl, artist) {
-    const data = {};
-
-    data.name = artist.name;
-    data.directory = artist.directory;
-
-    if (artist.hasAvatar) {
-      data.avatarFileExtension = artist.avatarFileExtension;
-    }
-
-    data.totalTrackCount = query.allTracks.length;
-    data.totalDuration = getTotalDuration(query.allTracks, {originalReleasesOnly: true});
-
-    return data;
-  },
-
-  generate(data, relations, {html, language}) {
-    const {sections: sec} = relations;
-
-    return relations.layout
-      .slots({
+  extraDependencies: ['html', 'language'],
+
+  query: (artist) => ({
+    // 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.
+    allArtworks:
+      ([
+        artist.albumCoverArtistContributions,
+        artist.albumWallpaperArtistContributions,
+        artist.albumBannerArtistContributions,
+        artist.trackCoverArtistContributions,
+      ]).flat()
+        .map(({thing}) => thing),
+
+    // Banners and wallpapers don't show up in the artist gallery page, only
+    // cover art.
+    hasGallery:
+      !empty(artist.albumCoverArtistContributions) ||
+      !empty(artist.trackCoverArtistContributions),
+  }),
+
+  relations: (relation, query, artist) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    artistNavLinks:
+      relation('generateArtistNavLinks', artist),
+
+    cover:
+      (artist.hasAvatar
+        ? relation('generateCoverArtwork', [])
+        : null),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    contextNotes:
+      relation('transformContent', artist.contextNotes),
+
+    visitLinks:
+      artist.urls
+        .map(url => relation('linkExternal', url)),
+
+    tracksChunkedList:
+      relation('generateArtistInfoPageTracksChunkedList', artist),
+
+    tracksGroupInfo:
+      relation('generateArtistGroupContributionsInfo', query.allTracks),
+
+    artworksChunkedList:
+      relation('generateArtistInfoPageArtworksChunkedList', artist),
+
+    artworksGroupInfo:
+      relation('generateArtistGroupContributionsInfo', query.allArtworks),
+
+    artistGalleryLink:
+      (query.hasGallery
+        ? relation('linkArtistGallery', artist)
+        : null),
+
+    flashesChunkedList:
+      relation('generateArtistInfoPageFlashesChunkedList', artist),
+
+    commentaryChunkedList:
+      relation('generateArtistInfoPageCommentaryChunkedList', artist),
+  }),
+
+  data: (query, artist) => ({
+    name:
+      artist.name,
+
+    directory:
+      artist.directory,
+
+    avatarFileExtension:
+      (artist.hasAvatar
+        ? artist.avatarFileExtension
+        : null),
+
+    totalTrackCount:
+      query.allTracks.length,
+
+    totalDuration:
+      artist.totalDuration,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistPage', pageCapsule =>
+      relations.layout.slots({
         title: data.name,
         headingMode: 'sticky',
 
@@ -152,67 +132,85 @@ export default {
             : null),
 
         mainContent: [
-          sec.contextNotes && [
-            html.tag('p', language.$('releaseInfo.note')),
+          html.tags([
+            html.tag('p',
+              {[html.onlyIfSiblings]: true},
+              language.$('releaseInfo.note')),
+
             html.tag('blockquote',
-              sec.contextNotes.content),
-          ],
+              {[html.onlyIfContent]: true},
+              relations.contextNotes),
+          ]),
 
-          sec.visit &&
-            html.tag('p',
-              language.$('releaseInfo.visitOn', {
-                links:
-                  language.formatDisjunctionList(
-                    sec.visit.externalLinks
-                      .map(link => link.slot('context', 'artist'))),
-              })),
-
-          sec.artworks?.artistGalleryLink &&
-            html.tag('p',
-              language.$('artistPage.viewArtGallery', {
-                link: sec.artworks.artistGalleryLink.slots({
-                  content: language.$('artistPage.viewArtGallery.link'),
-                }),
-              })),
+          html.tag('p',
+            {[html.onlyIfContent]: true},
 
-          (sec.tracks || sec.artworsk || sec.flashes || sec.commentary) &&
-            html.tag('p',
-              language.$('misc.jumpTo.withLinks', {
-                links: language.formatUnitList(
-                  [
-                    sec.tracks &&
-                      html.tag('a',
-                        {href: '#tracks'},
-                        language.$('artistPage.trackList.title')),
-
-                    sec.artworks &&
-                      html.tag('a',
-                        {href: '#art'},
-                        language.$('artistPage.artList.title')),
-
-                    sec.flashes &&
-                      html.tag('a',
-                        {href: '#flashes'},
-                        language.$('artistPage.flashList.title')),
-
-                    sec.commentary &&
-                      html.tag('a',
-                        {href: '#commentary'},
-                        language.$('artistPage.commentaryList.title')),
-                  ].filter(Boolean)),
-              })),
-
-          sec.tracks && [
-            sec.tracks.heading
+            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.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.tag('a',
+                      {href: '#commentary'},
+                      language.$(pageCapsule, 'commentaryList.title')),
+                ].filter(Boolean)),
+            })),
+
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
-                id: 'tracks',
-                title: language.$('artistPage.trackList.title'),
+                attributes: {id: 'tracks'},
+                title: language.$(pageCapsule, 'trackList.title'),
               }),
 
             data.totalDuration > 0 &&
               html.tag('p',
-                language.$('artistPage.contributedDurationLine', {
+                {[html.onlyIfSiblings]: true},
+
+                language.$(pageCapsule, 'contributedDurationLine', {
                   artist: data.name,
                   duration:
                     language.formatDuration(data.totalDuration, {
@@ -221,82 +219,86 @@ export default {
                     }),
                 })),
 
-            sec.tracks.list
-              .slots({
-                groupInfo: [
-                  sec.tracks.groupInfo
-                    .clone()
+            relations.tracksChunkedList.slots({
+              groupInfo:
+                language.encapsulate(pageCapsule, 'groupContributions', capsule => [
+                  relations.tracksGroupInfo.clone()
                     .slots({
-                      title: language.$('artistPage.groupContributions.title.music'),
+                      title: language.$(capsule, 'title.music'),
                       showSortButton: true,
                       sort: 'count',
                       countUnit: 'tracks',
                       visible: true,
                     }),
 
-                  sec.tracks.groupInfo
-                    .clone()
+                  relations.tracksGroupInfo.clone()
                     .slots({
-                      title: language.$('artistPage.groupContributions.title.music'),
+                      title: language.$(capsule, 'title.music'),
                       showSortButton: true,
                       sort: 'duration',
                       countUnit: 'tracks',
                       visible: false,
                     }),
-                ],
-              }),
-          ],
+                ]),
+            }),
+          ]),
 
-          sec.artworks && [
-            sec.artworks.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
-                id: 'art',
-                title: language.$('artistPage.artList.title'),
+                attributes: {id: 'art'},
+                title: language.$(pageCapsule, 'artList.title'),
               }),
 
-            sec.artworks.artistGalleryLink &&
-              html.tag('p',
-                language.$('artistPage.viewArtGallery.orBrowseList', {
-                  link: sec.artworks.artistGalleryLink.slots({
-                    content: language.$('artistPage.viewArtGallery.link'),
-                  }),
-                })),
+            html.tag('p',
+              {[html.onlyIfContent]: true},
 
-            sec.artworks.list
+              language.encapsulate(pageCapsule, 'viewArtGallery', capsule =>
+                language.$(capsule, 'orBrowseList', {
+                  [language.onlyIfOptions]: ['link'],
+
+                  link:
+                    relations.artistGalleryLink?.slots({
+                      content: language.$(capsule, 'link'),
+                    }),
+                }))),
+
+            relations.artworksChunkedList
               .slots({
                 groupInfo:
-                  sec.artworks.groupInfo
-                    .slots({
-                      title: language.$('artistPage.groupContributions.title.artworks'),
-                      showBothColumns: false,
-                      sort: 'count',
-                      countUnit: 'artworks',
-                    }),
+                  language.encapsulate(pageCapsule, 'groupContributions', capsule =>
+                    relations.artworksGroupInfo
+                      .slots({
+                        title: language.$(capsule, 'title.artworks'),
+                        showBothColumns: false,
+                        sort: 'count',
+                        countUnit: 'artworks',
+                      })),
               }),
-          ],
+          ]),
 
-          sec.flashes && [
-            sec.flashes.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
-                id: 'flashes',
-                title: language.$('artistPage.flashList.title'),
+                attributes: {id: 'flashes'},
+                title: language.$(pageCapsule, 'flashList.title'),
               }),
 
-            sec.flashes.list,
-          ],
+            relations.flashesChunkedList,
+          ]),
 
-          sec.commentary && [
-            sec.commentary.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
-                id: 'commentary',
-                title: language.$('artistPage.commentaryList.title'),
+                attributes: {id: 'commentary'},
+                title: language.$(pageCapsule, 'commentaryList.title'),
               }),
 
-            sec.commentary.list,
-          ],
+            relations.commentaryChunkedList,
+          ]),
         ],
 
         navLinkStyle: 'hierarchical',
@@ -306,6 +308,5 @@ export default {
               showExtraLinks: true,
             })
             .content,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunk.js b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js
new file mode 100644
index 00000000..2b10df3e
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js
@@ -0,0 +1,34 @@
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageArtworksChunkItem',
+    'linkAlbum',
+  ],
+
+  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),
+  }),
+
+  generate: (data, relations) =>
+    relations.template.slots({
+      mode: 'album',
+      albumLink: relations.albumLink,
+      dates: data.dates,
+      items: relations.items,
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
new file mode 100644
index 00000000..e8d887b1
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
@@ -0,0 +1,62 @@
+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)
+        : null),
+
+    otherArtistLinks:
+      relation('generateArtistInfoPageOtherArtistLinks', [contrib]),
+  }),
+
+  data: (query, contrib) => ({
+    kind:
+      query.kind,
+
+    annotation:
+      contrib.annotation,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.template.slots({
+      otherArtistLinks: relations.otherArtistLinks,
+
+      annotation: 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
index 44fb42f2..caefb7a3 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -1,241 +1,58 @@
-import {sortAlbumsTracksChronologically, sortEntryThingPairs} from '#sort';
-import {chunkByProperties, stitchArrays} from '#sugar';
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {chunkByConditions, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateArtistInfoPageChunk',
     'generateArtistInfoPageChunkedList',
-    'generateArtistInfoPageChunkItem',
-    'generateArtistInfoPageOtherArtistLinks',
-    'linkAlbum',
-    'linkTrack',
+    'generateArtistInfoPageArtworksChunk',
   ],
 
-  extraDependencies: ['html', 'language'],
-
   query(artist) {
-    // TODO: Add and integrate wallpaper and banner date fields (#90)
-    // This will probably only happen once all artworks follow a standard
-    // shape (#70) and get their own sorting function. Read for more info:
-    // https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607422961
-
-    const processEntry = ({thing, type, track, album, contribs}) => ({
-      thing: thing,
-      entry: {
-        type: type,
-        track: track,
-        album: album,
-        contribs: contribs,
-        date: thing.coverArtDate ?? thing.date,
-      },
-    });
-
-    const processAlbumEntry = ({type, album, contribs}) =>
-      processEntry({
-        thing: album,
-        type: type,
-        track: null,
-        album: album,
-        contribs: contribs,
-      });
-
-    const processTrackEntry = ({type, track, contribs}) =>
-      processEntry({
-        thing: track,
-        type: type,
-        track: track,
-        album: track.album,
-        contribs: contribs,
-      });
-
-    const processAlbumEntries = ({type, albums, contribs}) =>
-      stitchArrays({
-        album: albums,
-        contribs: contribs,
-      }).map(entry =>
-          processAlbumEntry({type, ...entry}));
-
-    const processTrackEntries = ({type, tracks, contribs}) =>
-      stitchArrays({
-        track: tracks,
-        contribs: contribs,
-      }).map(entry =>
-          processTrackEntry({type, ...entry}));
-
-    const {
-      albumsAsCoverArtist,
-      albumsAsWallpaperArtist,
-      albumsAsBannerArtist,
-      tracksAsCoverArtist,
-    } = artist;
+    const query = {};
 
-    const albumsAsCoverArtistContribs =
-      albumsAsCoverArtist
-        .map(album => album.coverArtistContribs);
-
-    const albumsAsWallpaperArtistContribs =
-      albumsAsWallpaperArtist
-        .map(album => album.wallpaperArtistContribs);
-
-    const albumsAsBannerArtistContribs =
-      albumsAsBannerArtist
-        .map(album => album.bannerArtistContribs);
-
-    const tracksAsCoverArtistContribs =
-      tracksAsCoverArtist
-        .map(track => track.coverArtistContribs);
-
-    const albumsAsCoverArtistEntries =
-      processAlbumEntries({
-        type: 'albumCover',
-        albums: albumsAsCoverArtist,
-        contribs: albumsAsCoverArtistContribs,
-      });
-
-    const albumsAsWallpaperArtistEntries =
-      processAlbumEntries({
-        type: 'albumWallpaper',
-        albums: albumsAsWallpaperArtist,
-        contribs: albumsAsWallpaperArtistContribs,
-      });
-
-    const albumsAsBannerArtistEntries =
-      processAlbumEntries({
-        type: 'albumBanner',
-        albums: albumsAsBannerArtist,
-        contribs: albumsAsBannerArtistContribs,
-      });
-
-    const tracksAsCoverArtistEntries =
-      processTrackEntries({
-        type: 'trackCover',
-        tracks: tracksAsCoverArtist,
-        contribs: tracksAsCoverArtistContribs,
-      });
-
-    const entries = [
-      ...albumsAsCoverArtistEntries,
-      ...albumsAsWallpaperArtistEntries,
-      ...albumsAsBannerArtistEntries,
-      ...tracksAsCoverArtistEntries,
+    const allContributions = [
+      ...artist.albumCoverArtistContributions,
+      ...artist.albumWallpaperArtistContributions,
+      ...artist.albumBannerArtistContributions,
+      ...artist.trackCoverArtistContributions,
     ];
 
-    sortEntryThingPairs(entries,
-      things => sortAlbumsTracksChronologically(things, {
-        getDate: thing => thing.coverArtDate ?? thing.date,
-      }));
-
-    const chunks =
-      chunkByProperties(
-        entries.map(({entry}) => entry),
-        ['album', 'date']);
-
-    return {chunks};
+    sortContributionsChronologically(
+      allContributions,
+      sortAlbumsTracksChronologically);
+
+    query.contribs =
+      chunkByConditions(allContributions, [
+        ({date: date1}, {date: date2}) =>
+          +date1 !== +date2,
+        ({thing: thing1}, {thing: thing2}) =>
+          (thing1.album ?? thing1) !==
+          (thing2.album ?? thing2),
+      ]);
+
+    query.albums =
+      query.contribs
+        .map(contribs => contribs[0].thing)
+        .map(thing => thing.album ?? thing);
+
+    return query;
   },
 
-  relations(relation, query, artist) {
-    return {
-      chunkedList:
-        relation('generateArtistInfoPageChunkedList'),
-
-      chunks:
-        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
-
-      albumLinks:
-        query.chunks.map(({album}) => relation('linkAlbum', album)),
-
-      items:
-        query.chunks.map(({chunk}) =>
-          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
-
-      itemTrackLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) => track ? relation('linkTrack', track) : null)),
-
-      itemOtherArtistLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))),
-    };
-  },
-
-  data(query, artist) {
-    return {
-      chunkDates:
-        query.chunks.map(({date}) => date),
-
-      itemTypes:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({type}) => type)),
+  relations: (relation, query, _artist) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
 
-      itemContributions:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({contribs}) =>
-            contribs
-              .find(contrib => contrib.artist === artist)
-              .annotation)),
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    return relations.chunkedList.slots({
-      chunks:
-        stitchArrays({
-          chunk: relations.chunks,
-          albumLink: relations.albumLinks,
-          date: data.chunkDates,
-
-          items: relations.items,
-          itemTrackLinks: relations.itemTrackLinks,
-          itemOtherArtistLinks: relations.itemOtherArtistLinks,
-          itemTypes: data.itemTypes,
-          itemContributions: data.itemContributions,
-        }).map(({
-            chunk,
-            albumLink,
-            date,
-
-            items,
-            itemTrackLinks,
-            itemOtherArtistLinks,
-            itemTypes,
-            itemContributions,
-          }) =>
-            chunk.slots({
-              mode: 'album',
-              albumLink,
-              date,
-
-              items:
-                stitchArrays({
-                  item: items,
-                  trackLink: itemTrackLinks,
-                  otherArtistLinks: itemOtherArtistLinks,
-                  type: itemTypes,
-                  contribution: itemContributions,
-                }).map(({
-                    item,
-                    trackLink,
-                    otherArtistLinks,
-                    type,
-                    contribution,
-                  }) =>
-                    item.slots({
-                      otherArtistLinks,
-                      annotation: contribution,
-
-                      content:
-                        (type === 'trackCover'
-                          ? language.$('artistPage.creditList.entry.track', {
-                              track: trackLink,
-                            })
-                          : html.tag('i',
-                              language.$('artistPage.creditList.entry.album.' + {
-                                albumWallpaper: 'wallpaperArt',
-                                albumBanner: 'bannerArt',
-                                albumCover: 'coverArt',
-                              }[type]))),
-                    })),
-            })),
-    });
-  },
+    chunks:
+      stitchArrays({
+        album: query.albums,
+        contribs: query.contribs,
+      }).map(({album, contribs}) =>
+          relation('generateArtistInfoPageArtworksChunk', album, contribs)),
+  }),
+
+  generate: (relations) =>
+    relations.chunkedList.slots({
+      chunks: relations.chunks,
+    }),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js
index 40943914..c16d50f3 100644
--- a/src/content/dependencies/generateArtistInfoPageChunk.js
+++ b/src/content/dependencies/generateArtistInfoPageChunk.js
@@ -1,3 +1,5 @@
+import {empty} from '#sugar';
+
 export default {
   extraDependencies: ['html', 'language'],
 
@@ -21,15 +23,33 @@ export default {
       mutable: false,
     },
 
-    date: {validate: v => v.isDate},
-    dateRangeStart: {validate: v => v.isDate},
-    dateRangeEnd: {validate: v => v.isDate},
+    dates: {
+      validate: v => v.sparseArrayOf(v.isDate),
+    },
 
     duration: {validate: v => v.isDuration},
     durationApproximate: {type: 'boolean'},
   },
 
   generate(slots, {html, language}) {
+    let earliestDate = null;
+    let latestDate = null;
+    let onlyDate = null;
+
+    if (!empty(slots.dates)) {
+      earliestDate =
+        slots.dates
+          .reduce((a, b) => a <= b ? a : b);
+
+      latestDate =
+        slots.dates
+          .reduce((a, b) => a <= b ? b : a);
+
+      if (+earliestDate === +latestDate) {
+        onlyDate = earliestDate;
+      }
+    }
+
     let accentedLink;
 
     accent: {
@@ -40,9 +60,9 @@ export default {
           const options = {album: accentedLink};
           const parts = ['artistPage.creditList.album'];
 
-          if (slots.date) {
+          if (onlyDate) {
             parts.push('withDate');
-            options.date = language.formatDate(slots.date);
+            options.date = language.formatDate(onlyDate);
           }
 
           if (slots.duration) {
@@ -63,16 +83,13 @@ export default {
           const options = {act: accentedLink};
           const parts = ['artistPage.creditList.flashAct'];
 
-          if (
-            slots.dateRangeStart &&
-            slots.dateRangeEnd &&
-            slots.dateRangeStart !== slots.dateRangeEnd
-          ) {
-            parts.push('withDateRange');
-            options.dateRange = language.formatDateRange(slots.dateRangeStart, slots.dateRangeEnd);
-          } else if (slots.dateRangeStart || slots.date) {
+          if (onlyDate) {
             parts.push('withDate');
-            options.date = language.formatDate(slots.dateRangeStart ?? slots.date);
+            options.date = language.formatDate(onlyDate);
+          } else if (earliestDate && latestDate) {
+            parts.push('withDateRange');
+            options.dateRange =
+              language.formatDateRange(earliestDate, latestDate);
           }
 
           accentedLink = language.formatString(...parts, options);
diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js
index b6f40727..9d406c67 100644
--- a/src/content/dependencies/generateArtistInfoPageChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js
@@ -1,3 +1,5 @@
+import {empty} from '#sugar';
+
 export default {
   extraDependencies: ['html', 'language'],
 
@@ -19,42 +21,38 @@ export default {
     rerelease: {type: 'boolean'},
   },
 
-  generate(slots, {html, language}) {
-    let accentedContent = slots.content;
-
-    accent: {
-      if (slots.rerelease) {
-        accentedContent =
-          language.$('artistPage.creditList.entry.rerelease', {
-            entry: accentedContent,
-          });
-
-        break accent;
-      }
-
-      const parts = ['artistPage.creditList.entry'];
-      const options = {entry: accentedContent};
-
-      if (slots.otherArtistLinks) {
-        parts.push('withArtists');
-        options.artists = language.formatConjunctionList(slots.otherArtistLinks);
-      }
-
-      if (!html.isBlank(slots.annotation)) {
-        parts.push('withAnnotation');
-        options.annotation = slots.annotation;
-      }
-
-      if (parts.length === 1) {
-        break accent;
-      }
-
-      accentedContent = language.formatString(...parts, options);
-    }
-
-    return (
+  generate: (slots, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry', entryCapsule =>
       html.tag('li',
         slots.rerelease && {class: 'rerelease'},
-        accentedContent));
-  },
+
+        language.encapsulate(entryCapsule, workingCapsule => {
+          const workingOptions = {entry: slots.content};
+
+          if (slots.rerelease) {
+            workingCapsule += '.rerelease';
+            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
index 8503d014..e7915ab7 100644
--- a/src/content/dependencies/generateArtistInfoPageChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageChunkedList.js
@@ -13,11 +13,8 @@ export default {
     },
   },
 
-  generate(slots, {html}) {
-    return (
-      html.tag('dl', [
-        slots.groupInfo,
-        slots.chunks,
-      ]));
-  },
+  generate: (slots, {html}) =>
+    html.tag('dl',
+      {[html.onlyIfContent]: true},
+      [slots.groupInfo, slots.chunks]),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
index 133095ea..72bbf1b6 100644
--- a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
@@ -10,7 +10,6 @@ export default {
   contentDependencies: [
     'generateArtistInfoPageChunk',
     'generateArtistInfoPageChunkItem',
-    'generateArtistInfoPageOtherArtistLinks',
     'linkAlbum',
     'linkFlash',
     'linkFlashAct',
@@ -217,53 +216,52 @@ export default {
           itemAnnotations,
           itemTypes,
         }) =>
-          (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
-                          ? annotation.slot('mode', 'inline')
-                          : null),
-
-                      content:
-                        (type === 'album'
-                          ? html.tag('i',
-                              language.$('artistPage.creditList.entry.album.commentary'))
-                          : language.$('artistPage.creditList.entry.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.slot('mode', 'inline')
-                          : null),
-
-                      content:
-                        language.$('artistPage.creditList.entry.flash', {
-                          flash: link,
-                        }),
-                    })),
-              })
-            : null))),
+          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
+                            ? annotation.slot('mode', 'inline')
+                            : null),
+
+                        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.slot('mode', 'inline')
+                            : null),
+
+                        content:
+                          language.$(capsule, 'flash', {
+                            flash: link,
+                          }),
+                      })),
+                })
+              : null)))),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunk.js b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js
new file mode 100644
index 00000000..8aa7223a
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js
@@ -0,0 +1,34 @@
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageFlashesChunkItem',
+    'linkFlashAct',
+  ],
+
+  relations: (relation, flashAct, contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunk'),
+
+    flashActLink:
+      relation('linkFlashAct', flashAct),
+
+    items:
+      contribs
+        .map(contrib =>
+          relation('generateArtistInfoPageFlashesChunkItem', contrib)),
+  }),
+
+  data: (_flashAct, contribs) => ({
+    dates:
+      contribs
+        .map(contrib => contrib.date),
+  }),
+
+  generate: (data, relations) =>
+    relations.template.slots({
+      mode: 'flash',
+      flashActLink: relations.flashActLink,
+      dates: data.dates,
+      items: relations.items,
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js
new file mode 100644
index 00000000..e4908bf9
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js
@@ -0,0 +1,34 @@
+export default {
+  contentDependencies: ['generateArtistInfoPageChunkItem', 'linkFlash'],
+
+  extraDependencies: ['language'],
+
+  relations: (relation, contrib) => ({
+    // Flashes and games can list multiple contributors as collaborative
+    // credits, but we don't display these on the artist page, since they
+    // usually involve many artists crediting a larger team where collaboration
+    // isn't as relevant (without more particular details that aren't tracked
+    // on the wiki).
+
+    template:
+      relation('generateArtistInfoPageChunkItem'),
+
+    flashLink:
+      relation('linkFlash', contrib.thing),
+  }),
+
+  data: (contrib) => ({
+    annotation:
+      contrib.annotation,
+  }),
+
+  generate: (data, relations, {language}) =>
+    relations.template.slots({
+      annotation: data.annotation,
+
+      content:
+        language.$('artistPage.creditList.entry.flash', {
+          flash: relations.flashLink,
+        }),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
index 447e697e..b347faf5 100644
--- a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
@@ -1,149 +1,62 @@
-import {sortEntryThingPairs, sortFlashesChronologically} from '#sort';
-import {chunkByProperties, stitchArrays} from '#sugar';
+import {sortContributionsChronologically, sortFlashesChronologically}
+  from '#sort';
+import {chunkByConditions, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateArtistInfoPageChunk',
-    'generateArtistInfoPageChunkItem',
-    'linkFlash',
+    'generateArtistInfoPageChunkedList',
+    'generateArtistInfoPageFlashesChunk',
   ],
 
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['wikiData'],
 
-  query(artist) {
-    const processFlashEntry = ({flash, contribs}) => ({
-      thing: flash,
-      entry: {
-        flash: flash,
-        act: flash.act,
-        contribs: contribs,
-      },
-    });
+  sprawl: ({wikiInfo}) => ({
+    enableFlashesAndGames:
+      wikiInfo.enableFlashesAndGames,
+  }),
 
-    const processFlashEntries = ({flashes, contribs}) =>
-      stitchArrays({
-        flash: flashes,
-        contribs: contribs,
-      }).map(processFlashEntry);
-
-    const {flashesAsContributor} = artist;
-
-    const flashesAsContributorContribs =
-      flashesAsContributor
-        .map(flash => flash.contributorContribs);
-
-    const flashesAsContributorEntries =
-      processFlashEntries({
-        flashes: flashesAsContributor,
-        contribs: flashesAsContributorContribs,
-      });
-
-    const entries = [
-      ...flashesAsContributorEntries,
-    ];
-
-    sortEntryThingPairs(entries, sortFlashesChronologically);
-
-    const chunks =
-      chunkByProperties(
-        entries.map(({entry}) => entry),
-        ['act']);
-
-    return {chunks};
-  },
+  query(sprawl, artist) {
+    const query = {};
 
-  relations(relation, query) {
-    // Flashes and games can list multiple contributors as collaborative
-    // credits, but we don't display these on the artist page, since they
-    // usually involve many artists crediting a larger team where collaboration
-    // isn't as relevant (without more particular details that aren't tracked
-    // on the wiki).
+    const allContributions =
+      (sprawl.enableFlashesAndGames
+        ? [
+            ...artist.flashContributorContributions,
+          ]
+      : []);
 
-    return {
-      chunks:
-        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
+    sortContributionsChronologically(
+      allContributions,
+      sortFlashesChronologically);
 
-      actLinks:
-        query.chunks.map(({chunk}) =>
-          relation('linkFlash', chunk[0].flash)),
+    query.contribs =
+      chunkByConditions(allContributions, [
+        ({thing: flash1}, {thing: flash2}) =>
+          flash1.act !== flash2.act,
+      ]);
 
-      items:
-        query.chunks.map(({chunk}) =>
-          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+    query.flashActs =
+      query.contribs
+        .map(contribs => contribs[0].thing)
+        .map(thing => thing.act);
 
-      itemFlashLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({flash}) => relation('linkFlash', flash))),
-    };
+    return query;
   },
 
-  data(query, artist) {
-    return {
-      actNames:
-        query.chunks.map(({act}) => act.name),
+  relations: (relation, query, _sprawl, _artist) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
 
-      firstDates:
-        query.chunks.map(({chunk}) => chunk[0].flash.date ?? null),
-
-      lastDates:
-        query.chunks.map(({chunk}) => chunk.at(-1).flash.date ?? null),
-
-      itemContributions:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({contribs}) =>
-            contribs
-              .find(contrib => contrib.artist === artist)
-              .annotation)),
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    return html.tag('dl',
+    chunks:
       stitchArrays({
-        chunk: relations.chunks,
-        actLink: relations.actLinks,
-        actName: data.actNames,
-        firstDate: data.firstDates,
-        lastDate: data.lastDates,
-
-        items: relations.items,
-        itemFlashLinks: relations.itemFlashLinks,
-        itemContributions: data.itemContributions,
-      }).map(({
-          chunk,
-          actLink,
-          actName,
-          firstDate,
-          lastDate,
-
-          items,
-          itemFlashLinks,
-          itemContributions,
-        }) =>
-          chunk.slots({
-            mode: 'flash',
-            flashActLink: actLink.slot('content', actName),
-            dateRangeStart: firstDate,
-            dateRangeEnd: lastDate,
-
-            items:
-              stitchArrays({
-                item: items,
-                flashLink: itemFlashLinks,
-                contribution: itemContributions,
-              }).map(({
-                  item,
-                  flashLink,
-                  contribution,
-                }) =>
-                  item.slots({
-                    annotation: contribution,
-
-                    content:
-                      language.$('artistPage.creditList.entry.flash', {
-                        flash: flashLink,
-                      }),
-                  })),
-          })));
-  },
+        flashAct: query.flashActs,
+        contribs: query.contribs,
+      }).map(({flashAct, contribs}) =>
+          relation('generateArtistInfoPageFlashesChunk', flashAct, contribs)),
+  }),
+
+  generate: (relations) =>
+    relations.chunkedList.slots({
+      chunks: relations.chunks,
+    }),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
index 471ee26c..dcee9c00 100644
--- a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
+++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
@@ -1,24 +1,30 @@
-import {empty} from '#sugar';
+import {unique} from '#sugar';
 
 export default {
   contentDependencies: ['linkArtist'],
 
-  relations(relation, contribs, artist) {
-    const otherArtistContribs =
-      contribs.filter(contrib => contrib.artist !== artist);
+  query(contribs) {
+    const associatedContributionsByOtherArtists =
+      contribs
+        .flatMap(ownContrib =>
+          ownContrib.associatedContributions
+            .filter(associatedContrib =>
+              associatedContrib.artist !== ownContrib.artist));
 
-    if (empty(otherArtistContribs)) {
-      return {};
-    }
+    const otherArtists =
+      unique(
+        associatedContributionsByOtherArtists
+          .map(contrib => contrib.artist));
 
-    const otherArtistLinks =
-      otherArtistContribs
-        .map(contrib => relation('linkArtist', contrib.artist));
-
-    return {otherArtistLinks};
+    return {otherArtists};
   },
 
-  generate(relations) {
-    return relations.otherArtistLinks ?? null;
-  },
+  relations: (relation, query) => ({
+    artistLinks:
+      query.otherArtists
+        .map(artist => relation('linkArtist', artist)),
+  }),
+
+  generate: (relations) =>
+    relations.artistLinks,
 };
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunk.js b/src/content/dependencies/generateArtistInfoPageTracksChunk.js
new file mode 100644
index 00000000..b42e4165
--- /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.isOriginalRelease)
+          .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..96976826
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
@@ -0,0 +1,115 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageOtherArtistLinks',
+    '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,
+      ];
+    }
+
+    return query;
+  },
+
+  relations: (relation, query, artist, contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunkItem'),
+
+    trackLink:
+      relation('linkTrack', query.track),
+
+    otherArtistLinks:
+      relation('generateArtistInfoPageOtherArtistLinks', contribs),
+  }),
+
+  data: (query) => ({
+    duration:
+      query.track.duration,
+
+    rerelease:
+      query.track.isRerelease,
+
+    contribAnnotations:
+      (query.displayedContributions
+        ? query.displayedContributions
+            .map(contrib => contrib.annotation)
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.template.slots({
+      otherArtistLinks: relations.otherArtistLinks,
+      rerelease: data.rerelease,
+
+      annotation:
+        (data.contribAnnotations
+          ? language.formatUnitList(data.contribAnnotations)
+          : html.blank()),
+
+      content:
+        language.encapsulate('artistPage.creditList.entry.track', workingCapsule => {
+          const workingOptions = {track: relations.trackLink};
+
+          if (data.duration) {
+            workingCapsule += '.withDuration';
+            workingOptions.duration =
+              language.formatDuration(data.duration);
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
index bce6cedf..7c01accb 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -1,293 +1,65 @@
-import {sortAlbumsTracksChronologically, sortEntryThingPairs} from '#sort';
-import {accumulateSum, chunkByProperties, empty, stitchArrays} from '#sugar';
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {chunkByConditions, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateArtistInfoPageChunk',
     'generateArtistInfoPageChunkedList',
-    'generateArtistInfoPageChunkItem',
-    'generateArtistInfoPageOtherArtistLinks',
-    'linkAlbum',
-    'linkTrack',
+    'generateArtistInfoPageTracksChunk',
   ],
 
-  extraDependencies: ['html', 'language'],
-
   query(artist) {
-    const processTrackEntry = ({track, contribs}) => ({
-      thing: track,
-      entry: {
-        track: track,
-        album: track.album,
-        date: track.date,
-        contribs: contribs,
-      },
-    });
-
-    const processTrackEntries = ({tracks, contribs}) =>
-      stitchArrays({
-        track: tracks,
-        contribs: contribs,
-      }).map(processTrackEntry);
-
-    const {tracksAsArtist, tracksAsContributor} = artist;
-
-    const tracksAsArtistAndContributor =
-      tracksAsArtist
-        .filter(track => tracksAsContributor.includes(track));
-
-    const tracksAsArtistOnly =
-      tracksAsArtist
-        .filter(track => !tracksAsContributor.includes(track));
-
-    const tracksAsContributorOnly =
-      tracksAsContributor
-        .filter(track => !tracksAsArtist.includes(track));
-
-    const tracksAsArtistAndContributorContribs =
-      tracksAsArtistAndContributor
-        .map(track => [
-          ...
-            track.artistContribs
-              .map(contrib => ({...contrib, kind: 'artist'})),
-          ...
-            track.contributorContribs
-              .map(contrib => ({...contrib, kind: 'contributor'})),
-        ]);
-
-    const tracksAsArtistOnlyContribs =
-      tracksAsArtistOnly
-        .map(track => track.artistContribs
-          .map(contrib => ({...contrib, kind: 'artist'})));
-
-    const tracksAsContributorOnlyContribs =
-      tracksAsContributorOnly
-        .map(track => track.contributorContribs
-          .map(contrib => ({...contrib, kind: 'contributor'})));
+    const query = {};
 
-    const tracksAsArtistAndContributorEntries =
-      processTrackEntries({
-        tracks: tracksAsArtistAndContributor,
-        contribs: tracksAsArtistAndContributorContribs,
-      });
-
-    const tracksAsArtistOnlyEntries =
-      processTrackEntries({
-        tracks: tracksAsArtistOnly,
-        contribs: tracksAsArtistOnlyContribs,
-      });
-
-    const tracksAsContributorOnlyEntries =
-      processTrackEntries({
-        tracks: tracksAsContributorOnly,
-        contribs: tracksAsContributorOnlyContribs,
-      });
-
-    const entries = [
-      ...tracksAsArtistAndContributorEntries,
-      ...tracksAsArtistOnlyEntries,
-      ...tracksAsContributorOnlyEntries,
+    const allContributions = [
+      ...artist.trackArtistContributions,
+      ...artist.trackContributorContributions,
     ];
 
-    sortEntryThingPairs(entries, sortAlbumsTracksChronologically);
-
-    const chunks =
-      chunkByProperties(
-        entries.map(({entry}) => entry),
-        ['album', 'date']);
-
-    return {chunks};
-  },
-
-  relations(relation, query, artist) {
-    return {
-      chunkedList:
-        relation('generateArtistInfoPageChunkedList'),
-
-      chunks:
-        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
-
-      albumLinks:
-        query.chunks.map(({album}) => relation('linkAlbum', album)),
-
-      items:
-        query.chunks.map(({chunk}) =>
-          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
-
-      trackLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) => relation('linkTrack', track))),
-
-      trackOtherArtistLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))),
-    };
-  },
-
-  data(query, artist) {
-    return {
-      chunkDates:
-        query.chunks.map(({date}) => date),
-
-      chunkDurations:
-        query.chunks.map(({chunk}) =>
-          accumulateSum(
-            chunk
-              .filter(({track}) => track.duration && track.originalReleaseTrack === null)
-              .map(({track}) => track.duration))),
-
-      chunkDurationsApproximate:
-        query.chunks.map(({chunk}) =>
-          chunk
-            .filter(({track}) => track.duration && track.originalReleaseTrack === null)
-            .length > 1),
-
-      trackDurations:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) => track.duration)),
-
-      trackContributions:
-        query.chunks.map(({chunk}) =>
-          chunk
-            .map(({contribs}) =>
-              contribs.filter(contrib => contrib.artist === artist))
-            .map(ownContribs => ({
-              creditedAsArtist:
-                ownContribs
-                  .some(({kind}) => kind === 'artist'),
-
-              creditedAsContributor:
-                ownContribs
-                  .some(({kind}) => kind === 'contributor'),
-
-              annotatedContribs:
-                ownContribs
-                  .filter(({annotation}) => annotation),
-            }))
-            .map(({annotatedContribs, ...rest}) => ({
-              ...rest,
-
-              annotatedArtistContribs:
-                annotatedContribs
-                  .filter(({kind}) => kind === 'artist'),
-
-              annotatedContributorContribs:
-                annotatedContribs
-                  .filter(({kind}) => kind === 'contributor'),
-            }))
-            .map(({
-              creditedAsArtist,
-              creditedAsContributor,
-              annotatedArtistContribs,
-              annotatedContributorContribs,
-            }) => {
-              // Don't display annotations associated with crediting in the
-              // Contributors field if the artist is also credited as an Artist
-              // *and* the Artist-field contribution is non-annotated. This is
-              // so that we don't misrepresent the artist - the contributor
-              // annotation tends to be for "secondary" and performance roles.
-              // For example, this avoids crediting Marcy Nabors on Renewed
-              // Return seemingly only for "bass clarinet" when they're also
-              // the one who composed and arranged Renewed Return!
-              if (
-                creditedAsArtist &&
-                creditedAsContributor &&
-                empty(annotatedArtistContribs)
-              ) {
-                return [];
-              }
-
-              return [
-                ...annotatedArtistContribs,
-                ...annotatedContributorContribs,
-              ];
-            })
-            .map(contribs =>
-              contribs.map(({annotation}) => annotation))
-            .map(contributions =>
-              (empty(contributions)
-                ? null
-                : contributions))),
-
-      trackRereleases:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) => track.originalReleaseTrack !== null)),
-    };
+    sortContributionsChronologically(
+      allContributions,
+      sortAlbumsTracksChronologically);
+
+    query.contribs =
+      // First chunk by (contribution) date and album.
+      chunkByConditions(allContributions, [
+        ({date: date1}, {date: date2}) =>
+          +date1 !== +date2,
+        ({thing: track1}, {thing: track2}) =>
+          track1.album !== track2.album,
+      ]).map(contribs =>
+          // Then, *within* the boundaries of the existing chunks,
+          // chunk contributions to the same thing together.
+          chunkByConditions(contribs, [
+            ({thing: thing1}, {thing: thing2}) =>
+              thing1 !== thing2,
+          ]));
+
+    query.albums =
+      query.contribs
+        .map(contribs =>
+          contribs[0][0].thing.album);
+
+    return query;
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.chunkedList.slots({
-      chunks:
-        stitchArrays({
-          chunk: relations.chunks,
-          albumLink: relations.albumLinks,
-          date: data.chunkDates,
-          duration: data.chunkDurations,
-          durationApproximate: data.chunkDurationsApproximate,
-
-          items: relations.items,
-          trackLinks: relations.trackLinks,
-          trackOtherArtistLinks: relations.trackOtherArtistLinks,
-          trackDurations: data.trackDurations,
-          trackContributions: data.trackContributions,
-          trackRereleases: data.trackRereleases,
-        }).map(({
-            chunk,
-            albumLink,
-            date,
-            duration,
-            durationApproximate,
+  relations: (relation, query, artist) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
 
-            items,
-            trackLinks,
-            trackOtherArtistLinks,
-            trackDurations,
-            trackContributions,
-            trackRereleases,
-          }) =>
-            chunk.slots({
-              mode: 'album',
-              albumLink,
-              date,
-              duration,
-              durationApproximate,
-
-              items:
-                stitchArrays({
-                  item: items,
-                  trackLink: trackLinks,
-                  otherArtistLinks: trackOtherArtistLinks,
-                  duration: trackDurations,
-                  contribution: trackContributions,
-                  rerelease: trackRereleases,
-                }).map(({
-                    item,
-                    trackLink,
-                    otherArtistLinks,
-                    duration,
-                    contribution,
-                    rerelease,
-                  }) =>
-                    item.slots({
-                      otherArtistLinks,
-                      rerelease,
-
-                      annotation:
-                        (contribution
-                          ? language.formatUnitList(contribution)
-                          : html.blank()),
-
-                      content:
-                        (duration
-                          ? language.$('artistPage.creditList.entry.track.withDuration', {
-                              track: trackLink,
-                              duration: language.formatDuration(duration),
-                            })
-                          : language.$('artistPage.creditList.entry.track', {
-                              track: trackLink,
-                            })),
-                    })),
-            })),
-    });
-  },
+    chunks:
+      stitchArrays({
+        album: query.albums,
+        contribs: query.contribs,
+      }).map(({album, contribs}) =>
+          relation('generateArtistInfoPageTracksChunk',
+            artist,
+            album,
+            contribs)),
+  }),
+
+  generate: (relations) =>
+    relations.chunkedList.slots({
+      chunks: relations.chunks,
+    }),
 };
diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js
index aa95dba2..527e4741 100644
--- a/src/content/dependencies/generateArtistNavLinks.js
+++ b/src/content/dependencies/generateArtistNavLinks.js
@@ -24,8 +24,8 @@ export default {
       relation('linkArtist', artist);
 
     if (
-      !empty(artist.albumsAsCoverArtist) ||
-      !empty(artist.tracksAsCoverArtist)
+      !empty(artist.albumCoverArtistContributions) ||
+      !empty(artist.trackCoverArtistContributions)
     ) {
       relations.artistGalleryLink =
         relation('linkArtistGallery', artist);
diff --git a/src/content/dependencies/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js
deleted file mode 100644
index 8ec6ee0a..00000000
--- a/src/content/dependencies/generateChronologyLinks.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import {accumulateSum, empty} from '#sugar';
-
-export default {
-  extraDependencies: ['html', 'language'],
-
-  slots: {
-    chronologyInfoSets: {
-      validate: v =>
-        v.strictArrayOf(
-          v.validateProperties({
-            headingString: v.isString,
-            contributions: v.strictArrayOf(v.validateProperties({
-              index: v.isCountingNumber,
-              artistLink: v.isHTML,
-              previousLink: v.isHTML,
-              nextLink: v.isHTML,
-            })),
-          })),
-    }
-  },
-
-  generate(slots, {html, language}) {
-    if (empty(slots.chronologyInfoSets)) {
-      return html.blank();
-    }
-
-    const totalContributionCount =
-      accumulateSum(
-        slots.chronologyInfoSets,
-        ({contributions}) => contributions.length);
-
-    if (totalContributionCount === 0) {
-      return html.blank();
-    }
-
-    if (totalContributionCount > 8) {
-      return html.tag('div', {class: 'chronology'},
-        language.$('misc.chronology.seeArtistPages'));
-    }
-
-    return html.tags(
-      slots.chronologyInfoSets.map(({
-        headingString,
-        contributions,
-      }) =>
-        contributions.map(({
-          index,
-          artistLink,
-          previousLink,
-          nextLink,
-        }) => {
-          const heading =
-            html.tag('span', {class: 'heading'},
-              language.$(headingString, {
-                index: language.formatIndex(index),
-                artist: artistLink,
-              }));
-
-          const navigation =
-            (previousLink || nextLink) &&
-              html.tag('span', {class: 'buttons'},
-                language.formatUnitList([
-                  previousLink?.slots({
-                    tooltipStyle: 'browser',
-                    color: false,
-                    content: language.$('misc.nav.previous'),
-                  }),
-
-                  nextLink?.slots({
-                    tooltipStyle: 'browser',
-                    color: false,
-                    content: language.$('misc.nav.next'),
-                  }),
-                ].filter(Boolean)));
-
-          return html.tag('div', {class: 'chronology'},
-            (navigation
-              ? language.$('misc.chronology.withNavigation', {heading, navigation})
-              : heading));
-        })));
-  },
-};
diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js
index 069d85dd..5270dbe4 100644
--- a/src/content/dependencies/generateColorStyleVariables.js
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -32,6 +32,7 @@ export default {
       dim,
       deep,
       deepGhost,
+      lightGhost,
       bg,
       bgBlack,
       shadow,
@@ -43,6 +44,7 @@ export default {
       `--dim-color: ${dim}`,
       `--deep-color: ${deep}`,
       `--deep-ghost-color: ${deepGhost}`,
+      `--light-ghost-color: ${lightGhost}`,
       `--bg-color: ${bg}`,
       `--bg-black-color: ${bgBlack}`,
       `--shadow-color: ${shadow}`,
diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
index 522a0284..7c4aed80 100644
--- a/src/content/dependencies/generateCommentaryEntry.js
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -43,56 +43,71 @@ export default {
     color: {validate: v => v.isColor},
   },
 
-  generate(data, relations, slots, {html, language}) {
-    const artistsSpan =
-      html.tag('span', {class: 'commentary-entry-artists'},
-        (relations.artistsContent
-          ? relations.artistsContent.slot('mode', 'inline')
-       : relations.artistLinks
-          ? language.formatConjunctionList(relations.artistLinks)
-          : language.$('misc.artistCommentary.entry.title.noArtists')));
-
-    const accentParts = ['misc.artistCommentary.entry.title.accent'];
-    const accentOptions = {};
-
-    if (relations.annotationContent) {
-      accentParts.push('withAnnotation');
-      accentOptions.annotation =
-        relations.annotationContent.slot('mode', 'inline');
-    }
-
-    if (data.date) {
-      accentParts.push('withDate');
-      accentOptions.date =
-        language.formatDate(data.date);
-    }
-
-    const accent =
-      (accentParts.length > 1
-        ? html.tag('span', {class: 'commentary-entry-accent'},
-            language.$(...accentParts, accentOptions))
-        : null);
-
-    const titleParts = ['misc.artistCommentary.entry.title'];
-    const titleOptions = {artists: artistsSpan};
-
-    if (accent) {
-      titleParts.push('withAccent');
-      titleOptions.accent = accent;
-    }
-
-    const style =
-      slots.color &&
-        relations.colorStyle.slot('color', slots.color);
-
-    return html.tags([
-      html.tag('p', {class: 'commentary-entry-heading'},
-        style,
-        language.$(...titleParts, titleOptions)),
-
-      html.tag('blockquote', {class: 'commentary-entry-body'},
-        style,
-        relations.bodyContent.slot('mode', 'multiline')),
-    ]);
-  },
+  generate: (data, 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),
+
+          language.encapsulate(entryCapsule, 'title', titleCapsule => [
+            html.tag('time',
+              {[html.onlyIfContent]: true},
+
+              language.$(titleCapsule, 'date', {
+                [language.onlyIfOptions]: ['date'],
+
+                date:
+                  language.formatDate(data.date),
+              })),
+
+            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.slot('mode', 'inline');
+                      }
+
+                      if (workingCapsule === accentCapsule) {
+                        return html.blank();
+                      } else {
+                        return language.$(workingCapsule, workingOptions);
+                      }
+                    })));
+
+              if (!html.isBlank(accent)) {
+                workingCapsule += '.withAccent';
+                workingOptions.accent = accent;
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            }),
+          ])),
+
+        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/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js
index 3c3504d2..d68ba42e 100644
--- a/src/content/dependencies/generateCommentaryIndexPage.js
+++ b/src/content/dependencies/generateCommentaryIndexPage.js
@@ -57,46 +57,48 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title: language.$('commentaryIndex.title'),
-
-      headingMode: 'static',
-
-      mainClasses: ['long-content'],
-      mainContent: [
-        html.tag('p', language.$('commentaryIndex.infoLine', {
-          words:
-            html.tag('b',
-              language.formatWordCount(data.totalWordCount, {unit: true})),
-
-          entries:
-            html.tag('b',
-                language.countCommentaryEntries(data.totalEntryCount, {unit: true})),
-        })),
-
-        html.tag('p',
-          language.$('commentaryIndex.albumList.title')),
-
-        html.tag('ul',
-          stitchArrays({
-            albumLink: relations.albumLinks,
-            wordCount: data.wordCounts,
-            entryCount: data.entryCounts,
-          }).map(({albumLink, wordCount, entryCount}) =>
-            html.tag('li',
-              language.$('commentaryIndex.albumList.item', {
-                album: albumLink,
-                words: language.formatWordCount(wordCount, {unit: true}),
-                entries: language.countCommentaryEntries(entryCount, {unit: true}),
-              })))),
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {auto: 'current'},
-      ],
-    });
-  },
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('commentaryIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+
+        headingMode: 'static',
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          html.tag('p', language.$(pageCapsule, 'infoLine', {
+            words:
+              html.tag('b',
+                language.formatWordCount(data.totalWordCount, {unit: true})),
+
+            entries:
+              html.tag('b',
+                  language.countCommentaryEntries(data.totalEntryCount, {unit: true})),
+          })),
+
+          language.encapsulate(pageCapsule, 'albumList', listCapsule => [
+            html.tag('p',
+              language.$(listCapsule, 'title')),
+
+            html.tag('ul',
+              stitchArrays({
+                albumLink: relations.albumLinks,
+                wordCount: data.wordCounts,
+                entryCount: data.entryCounts,
+              }).map(({albumLink, wordCount, entryCount}) =>
+                html.tag('li',
+                  language.$(listCapsule, 'item', {
+                    album: albumLink,
+                    words: language.formatWordCount(wordCount, {unit: true}),
+                    entries: language.countCommentaryEntries(entryCount, {unit: true}),
+                  })))),
+          ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js
index 8ae1b2d0..c5090660 100644
--- a/src/content/dependencies/generateCommentarySection.js
+++ b/src/content/dependencies/generateCommentarySection.js
@@ -1,3 +1,5 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: [
     'transformContent',
@@ -12,16 +14,29 @@ export default {
       relation('generateContentHeading'),
 
     entries:
-      entries.map(entry =>
-        relation('generateCommentaryEntry', entry)),
+      (entries
+        ? entries.map(entry =>
+            relation('generateCommentaryEntry', entry))
+        : []),
+  }),
+
+  data: (entries) => ({
+    firstEntryIsDated:
+      (empty(entries)
+        ? null
+        : !!entries[0].date),
   }),
 
-  generate: (relations, {html, language}) =>
+  generate: (data, relations, {html, language}) =>
     html.tags([
       relations.heading
         .slots({
-          id: 'artist-commentary',
-          title: language.$('misc.artistCommentary')
+          title: language.$('misc.artistCommentary'),
+          attributes: [
+            {id: 'artist-commentary'},
+            data.firstEntryIsDated &&
+              {class: 'first-entry-is-dated'},
+          ],
         }),
 
       relations.entries,
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
index 469db876..f52bc043 100644
--- a/src/content/dependencies/generateContentHeading.js
+++ b/src/content/dependencies/generateContentHeading.js
@@ -12,23 +12,35 @@ export default {
       mutable: false,
     },
 
+    stickyTitle: {
+      type: 'html',
+      mutable: false,
+    },
+
     accent: {
       type: 'html',
       mutable: false,
     },
 
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
     color: {validate: v => v.isColor},
 
-    id: {type: 'string'},
-    tag: {type: 'string', default: 'p'},
+    tag: {
+      type: 'string',
+      default: 'p',
+    },
   },
 
   generate: (relations, slots, {html}) =>
     html.tag(slots.tag, {class: 'content-heading'},
       {tabindex: '0'},
+      {[html.onlyIfSiblings]: true},
 
-      slots.id &&
-        {id: slots.id},
+      slots.attributes,
 
       slots.color &&
         relations.colorStyle.slot('color', slots.color),
@@ -38,6 +50,10 @@ export default {
           {[html.onlyIfContent]: true},
           slots.title),
 
+        html.tag('template', {class: 'content-heading-sticky-title'},
+          {[html.onlyIfContent]: true},
+          slots.stickyTitle),
+
         html.tag('span', {class: 'content-heading-accent'},
           {[html.onlyIfContent]: true},
           slots.accent),
diff --git a/src/content/dependencies/generateContributionList.js b/src/content/dependencies/generateContributionList.js
index 6401e65e..8e8c5020 100644
--- a/src/content/dependencies/generateContributionList.js
+++ b/src/content/dependencies/generateContributionList.js
@@ -2,20 +2,28 @@ export default {
   contentDependencies: ['linkContribution'],
   extraDependencies: ['html'],
 
-  relations: (relation, contributions) =>
-    ({contributionLinks:
-        contributions
-          .map(contrib => relation('linkContribution', contrib))}),
+  relations: (relation, contributions) => ({
+    contributionLinks:
+      contributions
+        .map(contrib => relation('linkContribution', contrib)),
+  }),
 
-  generate: (relations, {html}) =>
+  slots: {
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
     html.tag('ul',
-      relations.contributionLinks.map(contributionLink =>
-        html.tag('li',
-          contributionLink
-            .slots({
-              showIcons: true,
+      {[html.onlyIfContent]: true},
+
+      relations.contributionLinks
+        .map(contributionLink =>
+          html.tag('li',
+            contributionLink.slots({
+              showExternalLinks: true,
               showContribution: true,
+              showChronology: true,
               preventWrapping: false,
-              iconMode: 'tooltip',
+              chronologyKind: slots.chronologyKind,
             })))),
 };
diff --git a/src/content/dependencies/generateContributionTooltip.js b/src/content/dependencies/generateContributionTooltip.js
new file mode 100644
index 00000000..3a31014d
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltip.js
@@ -0,0 +1,48 @@
+export default {
+  contentDependencies: [
+    'generateContributionTooltipChronologySection',
+    'generateContributionTooltipExternalLinkSection',
+    'generateTooltip',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, contribution) => ({
+    tooltip:
+      relation('generateTooltip'),
+
+    externalLinkSection:
+      relation('generateContributionTooltipExternalLinkSection', contribution),
+
+    chronologySection:
+      relation('generateContributionTooltipChronologySection', contribution),
+  }),
+
+  slots: {
+    showExternalLinks: {type: 'boolean'},
+    showChronology: {type: 'boolean'},
+
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    relations.tooltip.slots({
+      attributes:
+        {class: 'contribution-tooltip'},
+
+      contentAttributes: {
+        [html.joinChildren]:
+          html.tag('span', {class: 'tooltip-divider'}),
+      },
+
+      content: [
+        slots.showExternalLinks &&
+          relations.externalLinkSection,
+
+        slots.showChronology &&
+          relations.chronologySection.slots({
+            kind: slots.chronologyKind,
+          }),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateContributionTooltipChronologySection.js b/src/content/dependencies/generateContributionTooltipChronologySection.js
new file mode 100644
index 00000000..78c9051c
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltipChronologySection.js
@@ -0,0 +1,117 @@
+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:
+      (query.previous
+        ? query.previous.thing.name
+        : null),
+
+    nextName:
+      (query.next
+        ? query.next.thing.name
+        : null),
+  }),
+
+  slots: {
+    kind: {
+      validate: v =>
+        v.is(
+          'album',
+          'bannerArt',
+          'coverArt',
+          'flash',
+          'track',
+          'trackArt',
+          'trackContribution',
+          'wallpaperArt'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('misc.artistLink.chronology', capsule =>
+      html.tags([
+        html.tags([
+          relations.previousLink?.slots({
+            attributes: {class: 'chronology-link'},
+            content: [
+              html.tag('span', {class: 'chronology-symbol'},
+                language.$(capsule, 'previous.symbol')),
+
+              html.tag('span', {class: 'chronology-text'},
+                language.sanitize(data.previousName)),
+            ],
+          }),
+
+          html.tag('span', {class: 'chronology-info'},
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(capsule, 'previous.info', workingCapsule => {
+              const workingOptions = {};
+
+              if (slots.kind) {
+                workingCapsule += '.withKind';
+                workingOptions.kind =
+                  language.$(capsule, 'kind', slots.kind);
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            })),
+        ]),
+
+        html.tags([
+          relations.nextLink?.slots({
+            attributes: {class: 'chronology-link'},
+            content: [
+              html.tag('span', {class: 'chronology-symbol'},
+                language.$(capsule, 'next.symbol')),
+
+              html.tag('span', {class: 'chronology-text'},
+                language.sanitize(data.nextName)),
+            ],
+          }),
+
+          html.tag('span', {class: 'chronology-info'},
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(capsule, 'next.info', workingCapsule => {
+              const workingOptions = {};
+
+              if (slots.kind) {
+                workingCapsule += '.withKind';
+                workingOptions.kind =
+                  language.$(capsule, 'kind', slots.kind);
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            }))
+        ]),
+      ])),
+};
diff --git a/src/content/dependencies/generateContributionTooltipExternalLinkSection.js b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js
new file mode 100644
index 00000000..4f9a23ed
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js
@@ -0,0 +1,70 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateExternalHandle',
+    'generateExternalIcon',
+    'generateExternalPlatform',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, contribution) => ({
+    icons:
+      contribution.artist.urls
+        .map(url => relation('generateExternalIcon', url)),
+
+    handles:
+      contribution.artist.urls
+        .map(url => relation('generateExternalHandle', url)),
+
+    platforms:
+      contribution.artist.urls
+        .map(url => relation('generateExternalPlatform', url)),
+  }),
+
+  data: (contribution) => ({
+    urls: contribution.artist.urls,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('misc.artistLink', capsule =>
+      html.tags(
+        stitchArrays({
+          icon: relations.icons,
+          handle: relations.handles,
+          platform: relations.platforms,
+          url: data.urls,
+        }).map(({icon, handle, platform, url}) => {
+            for (const template of [icon, handle, platform]) {
+              template.setSlot('context', 'artist');
+            }
+
+            return [
+              html.tag('a', {class: 'external-link'},
+                {href: url},
+
+                [
+                  icon,
+
+                  html.tag('span', {class: 'external-handle'},
+                    (html.isBlank(handle)
+                      ? platform
+                      : handle)),
+                ]),
+
+              html.tag('span', {class: 'external-platform'},
+                // This is a pretty ridiculous hack, but we currently
+                // don't have a way of telling formatExternalLink to *not*
+                // use the fallback string, which just formats the URL as
+                // its host/domain... so is technically detectable.
+                (((new URL(url))
+                    .host
+                    .endsWith(
+                      html.resolve(platform, {normalize: 'string'})))
+
+                  ? language.$(capsule, 'noExternalLinkPlatformName')
+                  : platform)),
+            ];
+          }))),
+};
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index 90c9db98..3d5a614f 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: ['image', 'linkArtTag'],
@@ -89,14 +89,15 @@ export default {
             ...sizeSlots,
           }),
 
-          !empty(relations.tagLinks) &&
-            html.tag('ul', {class: 'image-details'},
-              stitchArrays({
-                tagLink: relations.tagLinks,
-                preferShortName: data.preferShortName,
-              }).map(({tagLink, preferShortName}) =>
-                  html.tag('li',
-                    tagLink.slot('preferShortName', preferShortName)))),
+          html.tag('ul', {class: 'image-details'},
+            {[html.onlyIfContent]: true},
+
+            stitchArrays({
+              tagLink: relations.tagLinks,
+              preferShortName: data.preferShortName,
+            }).map(({tagLink, preferShortName}) =>
+                html.tag('li',
+                  tagLink.slot('preferShortName', preferShortName)))),
         ]);
 
       case 'thumbnail':
diff --git a/src/content/dependencies/generateExternalHandle.js b/src/content/dependencies/generateExternalHandle.js
new file mode 100644
index 00000000..8c0368a4
--- /dev/null
+++ b/src/content/dependencies/generateExternalHandle.js
@@ -0,0 +1,20 @@
+import {isExternalLinkContext} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, slots, {language}) =>
+    language.formatExternalLink(data.url, {
+      style: 'handle',
+      context: slots.context,
+    }),
+};
diff --git a/src/content/dependencies/generateExternalIcon.js b/src/content/dependencies/generateExternalIcon.js
new file mode 100644
index 00000000..637af658
--- /dev/null
+++ b/src/content/dependencies/generateExternalIcon.js
@@ -0,0 +1,26 @@
+import {isExternalLinkContext} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language', 'to'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, slots, {html, language, to}) =>
+    html.tag('span', {class: 'external-icon'},
+      html.tag('svg',
+        html.tag('use', {
+          href:
+            to('staticMisc.icon',
+              language.formatExternalLink(data.url, {
+                style: 'icon-id',
+                context: slots.context,
+              })),
+        }))),
+};
diff --git a/src/content/dependencies/generateExternalPlatform.js b/src/content/dependencies/generateExternalPlatform.js
new file mode 100644
index 00000000..c4f63ecf
--- /dev/null
+++ b/src/content/dependencies/generateExternalPlatform.js
@@ -0,0 +1,20 @@
+import {isExternalLinkContext} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, slots, {language}) =>
+    language.formatExternalLink(data.url, {
+      style: 'platform',
+      context: slots.context,
+    }),
+};
diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js
index 17078124..1fa6de51 100644
--- a/src/content/dependencies/generateFlashActGalleryPage.js
+++ b/src/content/dependencies/generateFlashActGalleryPage.js
@@ -11,7 +11,7 @@ export default {
     'linkFlashIndex',
   ],
 
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['language'],
 
   relations: (relation, act) => ({
     layout:
@@ -50,42 +50,42 @@ export default {
         ['media.flashArt', flash.directory, flash.coverArtFileExtension])
   }),
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title:
-        language.$('flashPage.title', {
-          flash: new html.Tag(null, null, data.name),
-        }),
-
-      color: data.color,
-      headingMode: 'static',
-
-      mainClasses: ['flash-index'],
-      mainContent: [
-        relations.coverGrid.slots({
-          links: relations.flashLinks,
-          names: data.flashNames,
-          lazy: 6,
-
-          images:
-            stitchArrays({
-              image: relations.coverGridImages,
-              path: data.flashCoverPaths,
-            }).map(({image, path}) =>
-                image.slot('path', path)),
-        }),
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {html: relations.flashIndexLink},
-        {auto: 'current'},
-      ],
-
-      navBottomRowContent: relations.flashActNavAccent,
-
-      leftSidebar: relations.sidebar,
-    });
-  },
+  generate: (data, relations, {language}) =>
+    language.encapsulate('flashPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            flash: data.name,
+          }),
+
+        color: data.color,
+        headingMode: 'static',
+
+        mainClasses: ['flash-index'],
+        mainContent: [
+          relations.coverGrid.slots({
+            links: relations.flashLinks,
+            names: data.flashNames,
+            lazy: 6,
+
+            images:
+              stitchArrays({
+                image: relations.coverGridImages,
+                path: data.flashCoverPaths,
+              }).map(({image, path}) =>
+                  image.slot('path', path)),
+          }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.flashIndexLink},
+          {auto: 'current'},
+        ],
+
+        navBottomRowContent: relations.flashActNavAccent,
+
+        leftSidebar: relations.sidebar,
+      })),
 };
diff --git a/src/content/dependencies/generateFlashCoverArtwork.js b/src/content/dependencies/generateFlashCoverArtwork.js
index 374fa3f8..af03ae6b 100644
--- a/src/content/dependencies/generateFlashCoverArtwork.js
+++ b/src/content/dependencies/generateFlashCoverArtwork.js
@@ -1,12 +1,26 @@
 export default {
   contentDependencies: ['generateCoverArtwork'],
 
-  relations: (relation) =>
-    ({coverArtwork: relation('generateCoverArtwork')}),
+  relations: (relation) => ({
+    coverArtwork:
+      relation('generateCoverArtwork'),
+  }),
 
-  data: (flash) =>
-    ({path: ['media.flashArt', flash.directory, flash.coverArtFileExtension]}),
+  data: (flash) => ({
+    path:
+      ['media.flashArt', flash.directory, flash.coverArtFileExtension],
+
+    color:
+      flash.color,
+
+    dimensions:
+      flash.coverArtDimensions,
+  }),
 
   generate: (data, relations) =>
-    relations.coverArtwork.slot('path', data.path),
+    relations.coverArtwork.slots({
+      path: data.path,
+      color: data.color,
+      dimensions: data.dimensions,
+    }),
 };
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index 36bfabae..a21bb49e 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -81,74 +81,77 @@ export default {
   }),
 
   generate: (data, relations, {html, language}) =>
-    relations.layout.slots({
-      title: language.$('flashIndex.title'),
-      headingMode: 'static',
-
-      mainClasses: ['flash-index'],
-      mainContent: [
-        !empty(data.jumpLinkLabels) && [
-          html.tag('p', {class: 'quick-info'},
-            language.$('misc.jumpTo')),
-
-          html.tag('ul', {class: 'quick-info'},
-            stitchArrays({
-              colorStyle: relations.jumpLinkColorStyles,
-              anchor: data.jumpLinkAnchors,
-              label: data.jumpLinkLabels,
-            }).map(({colorStyle, anchor, label}) =>
-                html.tag('li',
-                  html.tag('a',
-                    {href: '#' + anchor},
-                    colorStyle,
-                    label)))),
-        ],
+    language.encapsulate('flashIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+        headingMode: 'static',
+
+        mainClasses: ['flash-index'],
+        mainContent: [
+          html.tags([
+            html.tag('p', {class: 'quick-info'},
+              {[html.onlyIfSiblings]: true},
+              language.$('misc.jumpTo')),
+
+            html.tag('ul', {class: 'quick-info'},
+              {[html.onlyIfContent]: true},
+              stitchArrays({
+                colorStyle: relations.jumpLinkColorStyles,
+                anchor: data.jumpLinkAnchors,
+                label: data.jumpLinkLabels,
+              }).map(({colorStyle, anchor, label}) =>
+                  html.tag('li',
+                    html.tag('a',
+                      {href: '#' + anchor},
+                      colorStyle,
+                      label)))),
+          ]),
 
-        stitchArrays({
-          colorStyle: relations.actColorStyles,
-          actLink: relations.actLinks,
-          anchor: data.actAnchors,
-
-          coverGrid: relations.actCoverGrids,
-          coverGridImages: relations.actCoverGridImages,
-          coverGridLinks: relations.actCoverGridLinks,
-          coverGridNames: data.actCoverGridNames,
-          coverGridPaths: data.actCoverGridPaths,
-        }).map(({
-            colorStyle,
-            actLink,
-            anchor,
-
-            coverGrid,
-            coverGridImages,
-            coverGridLinks,
-            coverGridNames,
-            coverGridPaths,
-          }, index) => [
-            html.tag('h2',
-              {id: anchor},
+          stitchArrays({
+            colorStyle: relations.actColorStyles,
+            actLink: relations.actLinks,
+            anchor: data.actAnchors,
+
+            coverGrid: relations.actCoverGrids,
+            coverGridImages: relations.actCoverGridImages,
+            coverGridLinks: relations.actCoverGridLinks,
+            coverGridNames: data.actCoverGridNames,
+            coverGridPaths: data.actCoverGridPaths,
+          }).map(({
               colorStyle,
-              actLink),
-
-            coverGrid.slots({
-              links: coverGridLinks,
-              names: coverGridNames,
-              lazy: index === 0 ? 4 : true,
-
-              images:
-                stitchArrays({
-                  image: coverGridImages,
-                  path: coverGridPaths,
-                }).map(({image, path}) =>
-                    image.slot('path', path)),
-            }),
-          ]),
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {auto: 'current'},
-      ],
-    }),
+              actLink,
+              anchor,
+
+              coverGrid,
+              coverGridImages,
+              coverGridLinks,
+              coverGridNames,
+              coverGridPaths,
+            }, index) => [
+              html.tag('h2',
+                {id: anchor},
+                colorStyle,
+                actLink),
+
+              coverGrid.slots({
+                links: coverGridLinks,
+                names: coverGridNames,
+                lazy: index === 0 ? 4 : true,
+
+                images:
+                  stitchArrays({
+                    image: coverGridImages,
+                    path: coverGridPaths,
+                  }).map(({image, path}) =>
+                      image.slot('path', path)),
+              }),
+            ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index 05964936..d06f0c01 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -19,180 +19,151 @@ export default {
   query(flash) {
     const query = {};
 
-    if (flash.page || !empty(flash.urls)) {
-      query.urls = [];
+    query.urls = [];
 
-      if (flash.page) {
-        query.urls.push(`https://homestuck.com/story/${flash.page}`);
-      }
+    if (flash.page) {
+      query.urls.push(`https://homestuck.com/story/${flash.page}`);
+    }
 
-      if (!empty(flash.urls)) {
-        query.urls.push(...flash.urls);
-      }
+    if (!empty(flash.urls)) {
+      query.urls.push(...flash.urls);
     }
 
     return query;
   },
 
-  relations(relation, query, flash) {
-    const relations = {};
-    const sections = relations.sections = {};
-
-    relations.layout =
-      relation('generatePageLayout');
+  relations: (relation, query, flash) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    relations.sidebar =
-      relation('generateFlashActSidebar', flash.act, flash);
+    sidebar:
+      relation('generateFlashActSidebar', flash.act, flash),
 
-    if (query.urls) {
-      relations.externalLinks =
-        query.urls.map(url => relation('linkExternal', url));
-    }
-
-    // TODO: Flashes always have cover art (#175)
-    /* eslint-disable-next-line no-constant-condition */
-    if (true) {
-      relations.cover =
-        relation('generateFlashCoverArtwork', flash);
-    }
+    externalLinks:
+      query.urls
+        .map(url => relation('linkExternal', url)),
 
-    // Section: navigation bar
+    cover:
+      relation('generateFlashCoverArtwork', flash),
 
-    const nav = sections.nav = {};
+    contentHeading:
+      relation('generateContentHeading'),
 
-    nav.flashActLink =
-      relation('linkFlashAct', flash.act);
+    flashActLink:
+      relation('linkFlashAct', flash.act),
 
-    nav.flashNavAccent =
-      relation('generateFlashNavAccent', flash);
+    flashNavAccent:
+      relation('generateFlashNavAccent', flash),
 
-    // Section: Featured tracks
-
-    if (!empty(flash.featuredTracks)) {
-      const featuredTracks = sections.featuredTracks = {};
-
-      featuredTracks.heading =
-        relation('generateContentHeading');
-
-      featuredTracks.list =
-        relation('generateTrackList', flash.featuredTracks);
-    }
-
-    // Section: Contributors
-
-    if (!empty(flash.contributorContribs)) {
-      const contributors = sections.contributors = {};
-
-      contributors.heading =
-        relation('generateContentHeading');
-
-      contributors.list =
-        relation('generateContributionList', flash.contributorContribs);
-    }
-
-    // Section: Artist commentary
-
-    if (flash.commentary) {
-      sections.artistCommentary =
-        relation('generateCommentarySection', flash.commentary);
-    }
+    featuredTracksList:
+      relation('generateTrackList', flash.featuredTracks),
 
-    return relations;
-  },
+    contributorContributionList:
+      relation('generateContributionList', flash.contributorContribs),
 
-  data(query, flash) {
-    const data = {};
+    artistCommentarySection:
+      relation('generateCommentarySection', flash.commentary),
+  }),
 
-    data.name = flash.name;
-    data.color = flash.color;
-    data.date = flash.date;
+  data: (_query, flash) => ({
+    name:
+      flash.name,
 
-    return data;
-  },
+    color:
+      flash.color,
 
-  generate(data, relations, {html, language}) {
-    const {sections: sec} = relations;
+    date:
+      flash.date,
+  }),
 
-    return relations.layout.slots({
-      title:
-        language.$('flashPage.title', {
-          flash: data.name,
-        }),
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('flashPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            flash: data.name,
+          }),
 
-      color: data.color,
-      headingMode: 'sticky',
+        color: data.color,
+        headingMode: 'sticky',
 
-      cover:
-        (relations.cover
-          ? relations.cover.slots({
-              alt: language.$('misc.alt.flashArt'),
-            })
-          : null),
+        cover:
+          (relations.cover
+            ? relations.cover.slots({
+                alt: language.$('misc.alt.flashArt'),
+              })
+            : null),
 
-      mainContent: [
-        html.tag('p',
-          language.$('releaseInfo.released', {
-            date: language.formatDate(data.date),
-          })),
+        mainContent: [
+          html.tag('p',
+            language.$('releaseInfo.released', {
+              date: language.formatDate(data.date),
+            })),
 
-        relations.externalLinks &&
           html.tag('p',
+            {[html.onlyIfContent]: true},
+
             language.$('releaseInfo.playOn', {
+              [language.onlyIfOptions]: ['links'],
+
               links:
                 language.formatDisjunctionList(
                   relations.externalLinks
                     .map(link => link.slot('context', 'flash'))),
             })),
 
-        html.tag('p',
-          {[html.onlyIfContent]: true},
-          {[html.joinChildren]: html.tag('br')},
-
-          [
-            sec.artistCommentary &&
-              language.$('releaseInfo.readCommentary', {
-                link: html.tag('a',
-                  {href: '#artist-commentary'},
-                  language.$('releaseInfo.readCommentary.link')),
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate('releaseInfo', capsule => [
+              !html.isBlank(relations.artistCommentarySection) &&
+                language.encapsulate(capsule, 'readCommentary', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#artist-commentary'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'features'},
+                title:
+                  language.$('releaseInfo.tracksFeatured', {
+                    flash: html.tag('i', data.name),
+                  }),
               }),
-          ]),
 
-        sec.featuredTracks && [
-          sec.featuredTracks.heading
-            .slots({
-              id: 'features',
-              title:
-                language.$('releaseInfo.tracksFeatured', {
-                  flash: html.tag('i', data.name),
-                }),
-            }),
+            relations.featuredTracksList,
+          ]),
 
-          sec.featuredTracks.list,
-        ],
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'contributors'},
+                title: language.$('releaseInfo.contributors'),
+              }),
 
-        sec.contributors && [
-          sec.contributors.heading
-            .slots({
-              id: 'contributors',
-              title: language.$('releaseInfo.contributors'),
+            relations.contributorContributionList.slots({
+              chronologyKind: 'flash',
             }),
+          ]),
 
-          sec.contributors.list,
+          relations.artistCommentarySection,
         ],
 
-        sec.artistCommentary,
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {html: sec.nav.flashActLink.slot('color', false)},
-        {auto: 'current'},
-      ],
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.flashActLink.slot('color', false)},
+          {auto: 'current'},
+        ],
 
-      navBottomRowContent: sec.nav.flashNavAccent,
+        navBottomRowContent: relations.flashNavAccent,
 
-      leftSidebar: relations.sidebar,
-    });
-  },
+        leftSidebar: relations.sidebar,
+      })),
 };
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
index d07847c6..ceb54322 100644
--- a/src/content/dependencies/generateGroupGalleryPage.js
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -10,6 +10,7 @@ export default {
     'generateGroupSecondaryNav',
     'generateGroupSidebar',
     'generatePageLayout',
+    'generateQuickDescription',
     'image',
     'linkAlbum',
     'linkListing',
@@ -55,6 +56,9 @@ export default {
           .map(album => relation('image', album.artTags));
     }
 
+    relations.quickDescription =
+      relation('generateQuickDescription', group);
+
     relations.coverGrid =
       relation('generateCoverGrid');
 
@@ -107,10 +111,10 @@ export default {
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
-        title: language.$('groupGalleryPage.title', {group: data.name}),
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupGalleryPage', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title', {group: data.name}),
         headingMode: 'static',
 
         color: data.color,
@@ -128,8 +132,10 @@ export default {
                     image.slot('path', path)),
             }),
 
+          relations.quickDescription,
+
           html.tag('p', {class: 'quick-info'},
-            language.$('groupGalleryPage.infoLine', {
+            language.$(pageCapsule, 'infoLine', {
               tracks:
                 html.tag('b',
                   language.countTracks(data.numTracks, {
@@ -193,6 +199,5 @@ export default {
 
         secondaryNav:
           relations.secondaryNav ?? null,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
index b5b456aa..87f35656 100644
--- a/src/content/dependencies/generateGroupInfoPage.js
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -1,210 +1,82 @@
-import {empty, stitchArrays} from '#sugar';
-
 export default {
   contentDependencies: [
-    'generateAbsoluteDatetimestamp',
-    'generateColorStyleAttribute',
-    'generateContentHeading',
+    'generateGroupInfoPageAlbumsSection',
     'generateGroupNavLinks',
     'generateGroupSecondaryNav',
     'generateGroupSidebar',
     'generatePageLayout',
-    'linkAlbum',
     'linkExternal',
-    'linkGroupGallery',
-    'linkGroup',
     'transformContent',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({wikiInfo}) {
-    return {
-      enableGroupUI: wikiInfo.enableGroupUI,
-    };
-  },
-
-  query(sprawl, group) {
-    const albums =
-      group.albums;
-
-    const albumGroups =
-      albums
-        .map(album => album.groups);
-
-    const albumOtherCategory =
-      albumGroups
-        .map(groups => groups
-          .map(group => group.category)
-          .find(category => category !== group.category));
-
-    const albumOtherGroups =
-      stitchArrays({
-        groups: albumGroups,
-        category: albumOtherCategory,
-      }).map(({groups, category}) =>
-          groups
-            .filter(group => group.category === category));
-
-    return {albums, albumOtherGroups};
-  },
-
-  relations(relation, query, sprawl, group) {
-    const relations = {};
-    const sec = relations.sections = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.navLinks =
-      relation('generateGroupNavLinks', group);
-
-    if (sprawl.enableGroupUI) {
-      relations.secondaryNav =
-        relation('generateGroupSecondaryNav', group);
-
-      relations.sidebar =
-        relation('generateGroupSidebar', group);
-    }
+  sprawl: ({wikiInfo}) => ({
+    enableGroupUI:
+      wikiInfo.enableGroupUI,
+  }),
 
-    sec.info = {};
+  relations: (relation, sprawl, group) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    if (!empty(group.urls)) {
-      sec.info.visitLinks =
-        group.urls
-          .map(url => relation('linkExternal', url));
-    }
+    navLinks:
+      relation('generateGroupNavLinks', group),
 
-    if (group.description) {
-      sec.info.description =
-        relation('transformContent', group.description);
-    }
+    secondaryNav:
+      (sprawl.enableGroupUI
+        ? relation('generateGroupSecondaryNav', group)
+        : null),
 
-    if (!empty(query.albums)) {
-      sec.albums = {};
+    sidebar:
+      (sprawl.enableGroupUI
+        ? relation('generateGroupSidebar', group)
+        : null),
 
-      sec.albums.heading =
-        relation('generateContentHeading');
+    visitLinks:
+      group.urls
+        .map(url => relation('linkExternal', url)),
 
-      sec.albums.galleryLink =
-        relation('linkGroupGallery', group);
+    description:
+      relation('transformContent', group.description),
 
-      sec.albums.albumColorStyles =
-        query.albums
-          .map(album => relation('generateColorStyleAttribute', album.color));
+    albumSection:
+      relation('generateGroupInfoPageAlbumsSection', group),
+  }),
 
-      sec.albums.albumLinks =
-        query.albums
-          .map(album => relation('linkAlbum', album));
+  data: (_sprawl, group) => ({
+    name:
+      group.name,
 
-      sec.albums.otherGroupLinks =
-        query.albumOtherGroups
-          .map(groups => groups
-            .map(group => relation('linkGroup', group)));
+    color:
+      group.color,
+  }),
 
-      sec.albums.datetimestamps =
-        group.albums.map(album =>
-          (album.date
-            ? relation('generateAbsoluteDatetimestamp', album.date)
-            : null));
-    }
-
-    return relations;
-  },
-
-  data(query, sprawl, group) {
-    const data = {};
-
-    data.name = group.name;
-    data.color = group.color;
-
-    return data;
-  },
-
-  generate(data, relations, {html, language}) {
-    const {sections: sec} = relations;
-
-    return relations.layout
-      .slots({
-        title: language.$('groupInfoPage.title', {group: data.name}),
+  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: [
-          sec.info.visitLinks &&
-            html.tag('p',
-              language.$('releaseInfo.visitOn', {
-                links:
-                  language.formatDisjunctionList(
-                    sec.info.visitLinks
-                      .map(link => link.slot('context', 'group'))),
-              })),
+          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},
-            sec.info.description
-              ?.slot('mode', 'multiline')),
-
-          sec.albums && [
-            sec.albums.heading
-              .slots({
-                tag: 'h2',
-                title: language.$('groupInfoPage.albumList.title'),
-              }),
-
-            html.tag('p',
-              language.$('groupInfoPage.viewAlbumGallery', {
-                link:
-                  sec.albums.galleryLink
-                    .slot('content', language.$('groupInfoPage.viewAlbumGallery.link')),
-              })),
-
-            html.tag('ul',
-              stitchArrays({
-                albumLink: sec.albums.albumLinks,
-                otherGroupLinks: sec.albums.otherGroupLinks,
-                datetimestamp: sec.albums.datetimestamps,
-                albumColorStyle: sec.albums.albumColorStyles,
-              }).map(({
-                  albumLink,
-                  otherGroupLinks,
-                  datetimestamp,
-                  albumColorStyle,
-                }) => {
-                  const prefix = 'groupInfoPage.albumList.item';
-                  const parts = [prefix];
-                  const options = {};
-
-                  options.album =
-                    albumLink.slot('color', false);
-
-                  if (datetimestamp) {
-                    parts.push('withYear');
-                    options.yearAccent =
-                      language.$(prefix, 'yearAccent', {
-                        year:
-                          datetimestamp.slots({style: 'year', tooltip: true}),
-                      });
-                  }
-
-                  if (!empty(otherGroupLinks)) {
-                    parts.push('withOtherGroup');
-                    options.otherGroupAccent =
-                      html.tag('span', {class: 'other-group-accent'},
-                        language.$(prefix, 'otherGroupAccent', {
-                          groups:
-                            language.formatConjunctionList(
-                              otherGroupLinks.map(groupLink =>
-                                groupLink.slot('color', false))),
-                        }));
-                  }
-
-                  return (
-                    html.tag('li',
-                      albumColorStyle,
-                      language.$(...parts, options)));
-                })),
-          ],
+            relations.description.slot('mode', 'multiline')),
+
+          relations.albumSection,
         ],
 
         leftSidebar:
@@ -217,6 +89,5 @@ export default {
         navLinks: relations.navLinks.content,
 
         secondaryNav: relations.secondaryNav ?? null,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsSection.js b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js
new file mode 100644
index 00000000..8899e98e
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js
@@ -0,0 +1,136 @@
+import {empty} from '#sugar';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateColorStyleAttribute',
+    'generateContentHeading',
+    'linkAlbum',
+    'linkGroupGallery',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(group) {
+    const albums =
+      group.albums;
+
+    const albumGroups =
+      albums
+        .map(album => album.groups);
+
+    const albumOtherCategory =
+      albumGroups
+        .map(groups => groups
+          .map(group => group.category)
+          .find(category => category !== group.category));
+
+    const albumOtherGroups =
+      stitchArrays({
+        groups: albumGroups,
+        category: albumOtherCategory,
+      }).map(({groups, category}) =>
+          groups
+            .filter(group => group.category === category));
+
+    return {albums, albumOtherGroups};
+  },
+
+  relations: (relation, query, group) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+
+    galleryLink:
+      relation('linkGroupGallery', group),
+
+    albumColorStyles:
+      query.albums
+        .map(album => relation('generateColorStyleAttribute', album.color)),
+
+    albumLinks:
+      query.albums
+        .map(album => relation('linkAlbum', album)),
+
+    otherGroupLinks:
+      query.albumOtherGroups
+        .map(groups => groups
+          .map(group => relation('linkGroup', group))),
+
+    datetimestamps:
+      group.albums.map(album =>
+        (album.date
+          ? relation('generateAbsoluteDatetimestamp', album.date)
+          : null)),
+  }),
+
+  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', capsule =>
+              language.$(capsule, {
+                link:
+                  relations.galleryLink
+                    .slot('content', language.$(capsule, 'link')),
+              }))),
+
+          html.tag('ul',
+            {[html.onlyIfContent]: true},
+
+            stitchArrays({
+              albumLink: relations.albumLinks,
+              otherGroupLinks: relations.otherGroupLinks,
+              datetimestamp: relations.datetimestamps,
+              albumColorStyle: relations.albumColorStyles,
+            }).map(({
+                albumLink,
+                otherGroupLinks,
+                datetimestamp,
+                albumColorStyle,
+              }) =>
+                html.tag('li',
+                  albumColorStyle,
+
+                  language.encapsulate(listCapsule, 'item', itemCapsule =>
+                    language.encapsulate(itemCapsule, workingCapsule => {
+                      const workingOptions = {};
+
+                      workingOptions.album =
+                        albumLink.slot('color', false);
+
+                      if (datetimestamp) {
+                        workingCapsule += '.withYear';
+                        workingOptions.yearAccent =
+                          language.$(itemCapsule, 'yearAccent', {
+                            year:
+                              datetimestamp.slots({style: 'year', tooltip: true}),
+                          });
+                      }
+
+                      if (!empty(otherGroupLinks)) {
+                        workingCapsule += '.withOtherGroup';
+                        workingOptions.otherGroupAccent =
+                          html.tag('span', {class: 'other-group-accent'},
+                            language.$(itemCapsule, 'otherGroupAccent', {
+                              groups:
+                                language.formatConjunctionList(
+                                  otherGroupLinks.map(groupLink =>
+                                    groupLink.slot('color', false))),
+                            }));
+                      }
+
+                      return language.$(workingCapsule, workingOptions);
+                    }))))),
+        ]))),
+};
diff --git a/src/content/dependencies/generateGroupSidebarCategoryDetails.js b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
index 69de373b..d52c77b8 100644
--- a/src/content/dependencies/generateGroupSidebarCategoryDetails.js
+++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
@@ -46,37 +46,37 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {html, language}) {
-    return html.tag('details',
-      data.isCurrentCategory &&
-        {class: 'current', open: true},
-
-      [
-        html.tag('summary',
-          relations.colorStyle,
-
-          html.tag('span',
-            language.$('groupSidebar.groupList.category', {
-              category:
-                html.tag('span', {class: 'group-name'},
-                  data.name),
-            }))),
-
-        html.tag('ul',
-          stitchArrays(({
-            infoLink: relations.groupInfoLinks,
-            galleryLink: relations.groupGalleryLinks,
-          })).map(({infoLink, galleryLink}, index) =>
-                html.tag('li',
-                  index === data.currentGroupIndex &&
-                    {class: 'current'},
-
-                  language.$('groupSidebar.groupList.item', {
-                    group:
-                      (slots.currentExtra === 'gallery'
-                        ? galleryLink ?? infoLink
-                        : infoLink),
-                  })))),
-      ]);
-  },
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('groupSidebar.groupList', capsule =>
+      html.tag('details',
+        data.isCurrentCategory &&
+          {class: 'current', open: true},
+
+        [
+          html.tag('summary',
+            relations.colorStyle,
+
+            html.tag('span',
+              language.$(capsule, 'category', {
+                category:
+                  html.tag('span', {class: 'group-name'},
+                    data.name),
+              }))),
+
+          html.tag('ul',
+            stitchArrays(({
+              infoLink: relations.groupInfoLinks,
+              galleryLink: relations.groupGalleryLinks,
+            })).map(({infoLink, galleryLink}, index) =>
+                  html.tag('li',
+                    index === data.currentGroupIndex &&
+                      {class: 'current'},
+
+                    language.$(capsule, 'item', {
+                      group:
+                        (slots.currentExtra === 'gallery'
+                          ? galleryLink ?? infoLink
+                          : infoLink),
+                    })))),
+        ])),
 };
diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
index 43a78cb3..659cf4e5 100644
--- a/src/content/dependencies/generateListAllAdditionalFilesChunk.js
+++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
@@ -42,49 +42,50 @@ export default {
               additionalFileLinks,
               additionalFileFiles,
             }) =>
-              (additionalFileLinks.length === 1
-                ? html.tag('li',
-                    additionalFileLinks[0].slots({
-                      content:
-                        language.$('listingPage', slots.stringsKey, 'file', {
-                          title: additionalFileTitle,
-                        }),
-                    }))
+              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.$('listingPage', slots.stringsKey, 'file.withNoFiles', {
-                      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.$('listingPage', slots.stringsKey, 'file.withMultipleFiles', {
-                            title:
-                              html.tag('span', {class: 'group-name'},
-                                additionalFileTitle),
+                  : html.tag('li', {class: 'has-details'},
+                      html.tag('details', [
+                        html.tag('summary',
+                          html.tag('span',
+                            language.$(capsule, 'withMultipleFiles', {
+                              title:
+                                html.tag('span', {class: 'group-name'},
+                                  additionalFileTitle),
 
-                            files:
-                              language.countAdditionalFiles(
-                                additionalFileLinks.length,
-                                {unit: true}),
-                          }))),
+                              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.$('listingPage', slots.stringsKey, 'file', {
-                                    title: additionalFileFile,
-                                  }),
-                              })))),
-                    ])))))),
+                        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/generateListingPage.js b/src/content/dependencies/generateListingPage.js
index 23377afb..5f9a99a9 100644
--- a/src/content/dependencies/generateListingPage.js
+++ b/src/content/dependencies/generateListingPage.js
@@ -34,13 +34,15 @@ export default {
       relations.sameTargetListingLinks =
         listing.target.listings
           .map(listing => relation('linkListing', listing));
+    } else {
+      relations.sameTargetListingLinks = [];
     }
 
-    if (!empty(listing.seeAlso)) {
-      relations.seeAlsoLinks =
-        listing.seeAlso
-          .map(listing => relation('linkListing', listing));
-    }
+    relations.seeAlsoLinks =
+      (!empty(listing.seeAlso)
+        ? listing.seeAlso
+            .map(listing => relation('linkListing', listing))
+        : []);
 
     return relations;
   },
@@ -167,33 +169,37 @@ export default {
       headingMode: 'sticky',
 
       mainContent: [
-        relations.sameTargetListingLinks &&
-          html.tag('p',
-            language.$('listingPage.listingsFor', {
-              target:
-                language.$('listingPage.target', data.targetStringsKey),
-
-              listings:
-                language.formatUnitList(
-                  stitchArrays({
-                    link: relations.sameTargetListingLinks,
-                    stringsKey: data.sameTargetListingStringsKeys,
-                  }).map(({link, stringsKey}, index) =>
-                      html.tag('span',
-                        index === data.sameTargetListingsCurrentIndex &&
-                          {class: 'current'},
-
-                        link.slots({
-                          attributes: {class: 'nowrap'},
-                          content: language.$('listingPage', stringsKey, 'title.short'),
-                        })))),
-            })),
-
-        relations.seeAlsoLinks &&
-          html.tag('p',
-            language.$('listingPage.seeAlso', {
-              listings: language.formatUnitList(relations.seeAlsoLinks),
-            })),
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          language.$('listingPage.listingsFor', {
+            [language.onlyIfOptions]: ['listings'],
+
+            target:
+              language.$('listingPage.target', data.targetStringsKey),
+
+            listings:
+              language.formatUnitList(
+                stitchArrays({
+                  link: relations.sameTargetListingLinks,
+                  stringsKey: data.sameTargetListingStringsKeys,
+                }).map(({link, stringsKey}, index) =>
+                    html.tag('span',
+                      index === data.sameTargetListingsCurrentIndex &&
+                        {class: 'current'},
+
+                      link.slots({
+                        attributes: {class: 'nowrap'},
+                        content: language.$('listingPage', stringsKey, 'title.short'),
+                      })))),
+          })),
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          language.$('listingPage.seeAlso', {
+            [language.onlyIfOptions]: ['listings'],
+            listings:
+              language.formatUnitList(relations.seeAlsoLinks),
+          })),
 
         slots.content,
 
@@ -243,7 +249,7 @@ export default {
                   .clone()
                   .slots({
                     tag: 'dt',
-                    id,
+                    attributes: [id && {id}],
 
                     title:
                       formatListingString({
diff --git a/src/content/dependencies/generateNewsEntryPage.js b/src/content/dependencies/generateNewsEntryPage.js
index bcba7194..2c382cfa 100644
--- a/src/content/dependencies/generateNewsEntryPage.js
+++ b/src/content/dependencies/generateNewsEntryPage.js
@@ -91,41 +91,41 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title:
-        language.$('newsEntryPage.title', {
-          entry: data.name,
-        }),
-
-      headingMode: 'sticky',
-
-      mainClasses: ['long-content'],
-      mainContent: [
-        html.tag('p',
-          language.$('newsEntryPage.published', {
-            date: language.formatDate(data.date),
-          })),
-
-        relations.content,
-        relations.readAnotherLinks,
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {html: relations.newsIndexLink},
-        {
-          auto: 'current',
-          accent:
-            (relations.previousNextLinks
-              ? `(${language.formatUnitList(relations.previousNextLinks.slots({
-                  previousLink: relations.previousEntryNavLink ?? null,
-                  nextLink: relations.nextEntryNavLink ?? null,
-                }).content)})`
-              : null),
-        },
-      ],
-    });
-  },
+  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.previousNextLinks
+                ? `(${language.formatUnitList(relations.previousNextLinks.slots({
+                    previousLink: relations.previousEntryNavLink ?? null,
+                    nextLink: relations.nextEntryNavLink ?? null,
+                  }).content)})`
+                : null),
+          },
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateNewsIndexPage.js b/src/content/dependencies/generateNewsIndexPage.js
index 539af804..02964ce8 100644
--- a/src/content/dependencies/generateNewsIndexPage.js
+++ b/src/content/dependencies/generateNewsIndexPage.js
@@ -57,37 +57,38 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title: language.$('newsIndex.title'),
-      headingMode: 'sticky',
-
-      mainClasses: ['long-content', 'news-index'],
-      mainContent:
-        stitchArrays({
-          entryLink: relations.entryLinks,
-          viewRestLink: relations.viewRestLinks,
-          content: relations.entryContents,
-          date: data.entryDates,
-          directory: data.entryDirectories,
-        }).map(({entryLink, viewRestLink, content, date, directory}) =>
-            html.tag('article', {id: directory}, [
-              html.tag('h2', [
-                html.tag('time', language.formatDate(date)),
-                entryLink,
-              ]),
-
-              content,
-
-              viewRestLink
-                ?.slot('content', language.$('newsIndex.entry.viewRest')),
-            ])),
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {auto: 'current'},
-      ],
-    });
-  },
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('newsIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+        headingMode: 'sticky',
+
+        mainClasses: ['long-content', 'news-index'],
+        mainContent:
+          stitchArrays({
+            entryLink: relations.entryLinks,
+            viewRestLink: relations.viewRestLinks,
+            content: relations.entryContents,
+            date: data.entryDates,
+            directory: data.entryDirectories,
+          }).map(({entryLink, viewRestLink, content, date, directory}) =>
+              language.encapsulate(pageCapsule, 'entry', entryCapsule =>
+                html.tag('article', {id: directory}, [
+                  html.tag('h2', [
+                    html.tag('time', language.formatDate(date)),
+                    entryLink,
+                  ]),
+
+                  content,
+
+                  viewRestLink
+                    ?.slot('content', language.$(entryCapsule, 'viewRest')),
+                ]))),
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 51f9057b..7e9e49a0 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -5,12 +5,13 @@ export default {
   contentDependencies: [
     'generateColorStyleRules',
     'generateFooterLocalizationLinks',
+    'generatePageSidebar',
+    'generateSearchSidebarBox',
     'generateStickyHeadingContainer',
     'transformContent',
   ],
 
   extraDependencies: [
-    'cachebust',
     'getColors',
     'html',
     'language',
@@ -21,6 +22,7 @@ export default {
 
   sprawl({wikiInfo}) {
     return {
+      enableSearch: wikiInfo.enableSearch,
       footerContent: wikiInfo.footerContent,
       wikiColor: wikiInfo.color,
       wikiName: wikiInfo.nameShort,
@@ -43,6 +45,14 @@ export default {
     relations.stickyHeadingContainer =
       relation('generateStickyHeadingContainer');
 
+    relations.sidebar =
+      relation('generatePageSidebar');
+
+    if (sprawl.enableSearch) {
+      relations.searchBox =
+        relation('generateSearchSidebarBox');
+    }
+
     if (sprawl.footerContent) {
       relations.defaultFooterContent =
         relation('transformContent', sprawl.footerContent);
@@ -65,6 +75,11 @@ export default {
       default: true,
     },
 
+    showSearch: {
+      type: 'boolean',
+      default: true,
+    },
+
     additionalNames: {
       type: 'html',
       mutable: false,
@@ -209,7 +224,6 @@ export default {
   },
 
   generate(data, relations, slots, {
-    cachebust,
     getColors,
     html,
     language,
@@ -374,30 +388,66 @@ export default {
             slots.navContent),
         ]);
 
-    const getSidebar = (side, id) =>
-      (html.isBlank(slots[side])
-        ? html.blank()
-        : slots[side].slots({
-            attributes:
-              slots[side]
-                .getSlotValue('attributes')
-                .with({id}),
-          }));
+    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;
 
-    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left');
-    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right');
+    let showingSidebarLeft;
+    let showingSidebarRight;
+
+    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left', willShowSearch);
+    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right', false);
+
+    if (willShowSearch) {
+      if (html.isBlank(leftSidebar)) {
+        leftSidebar.setSlot('initiallyHidden', true);
+        showingSidebarLeft = false;
+      }
+
+      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))));
@@ -456,41 +506,43 @@ export default {
           html.tag('img', {id: 'image-overlay-image'}),
           html.tag('img', {id: 'image-overlay-image-thumb'}),
         ]),
-        html.tag('div', {id: 'image-overlay-action-container'}, [
-          html.tag('div', {id: 'image-overlay-action-content-without-size'},
-            language.$('releaseInfo.viewOriginalFile', {
-              link: html.tag('a', {class: 'image-overlay-view-original'},
-                language.$('releaseInfo.viewOriginalFile.link')),
-            })),
-
-          html.tag('div', {id: 'image-overlay-action-content-with-size'}, [
-            language.$('releaseInfo.viewOriginalFile.withSize', {
-              link:
-                html.tag('a', {class: 'image-overlay-view-original'},
-                  language.$('releaseInfo.viewOriginalFile.link')),
-
-              size:
-                html.tag('span',
-                  {[html.joinChildren]: ''},
-                  [
-                    html.tag('span', {id: 'image-overlay-file-size-kilobytes'},
-                      language.$('count.fileSize.kilobytes', {
-                        kilobytes:
-                          html.tag('span', {class: 'image-overlay-file-size-count'}),
-                      })),
-
-                    html.tag('span', {id: 'image-overlay-file-size-megabytes'},
-                      language.$('count.fileSize.megabytes', {
-                        megabytes:
-                          html.tag('span', {class: 'image-overlay-file-size-count'}),
-                      })),
-                  ]),
-            }),
 
-            html.tag('span', {id: 'image-overlay-file-size-warning'},
-              language.$('releaseInfo.viewOriginalFile.sizeWarning')),
-          ]),
-        ]),
+        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')),
+            ]),
+          ])),
       ]));
 
     const layoutHTML = [
@@ -528,6 +580,8 @@ export default {
         {'data-rebase-localized': to('localized.root')},
         {'data-rebase-shared': to('shared.root')},
         {'data-rebase-media': to('media.root')},
+        {'data-rebase-thumb': to('thumb.root')},
+        {'data-rebase-lib': to('staticLib.root')},
         {'data-rebase-data': to('data.root')},
 
         [
@@ -598,7 +652,7 @@ export default {
 
             html.tag('link', {
               rel: 'stylesheet',
-              href: to('shared.staticFile', 'site7.css', cachebust),
+              href: to('staticCSS.path', 'site.css'),
             }),
 
             html.tag('style', [
@@ -608,25 +662,29 @@ export default {
             ]),
 
             html.tag('script', {
-              src: to('shared.staticFile', 'lazy-loading.js', cachebust),
+              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.js'),
             }),
           ]),
 
           html.tag('body',
             [
               html.tag('div', {id: 'page-container'},
-                (hasSidebarLeft || hasSidebarRight
-                  ? {class: 'has-one-sidebar'}
-                  : {class: 'has-zero-sidebars'}),
+                showingSidebarLeft &&
+                  {class: 'showing-sidebar-left'},
 
-                hasSidebarLeft && hasSidebarRight &&
-                  {class: 'has-two-sidebars'},
-
-                hasSidebarLeft &&
-                  {class: 'has-sidebar-left'},
-
-                hasSidebarRight &&
-                  {class: 'has-sidebar-right'},
+                showingSidebarRight &&
+                  {class: 'showing-sidebar-right'},
 
                 [
                   skippersHTML,
@@ -635,11 +693,6 @@ export default {
 
               // infoCardHTML,
               imageOverlayHTML,
-
-              html.tag('script', {
-                type: 'module',
-                src: to('shared.staticFile', 'client4.js', cachebust),
-              }),
             ]),
         ])
     ]).toString();
diff --git a/src/content/dependencies/generatePageSidebar.js b/src/content/dependencies/generatePageSidebar.js
index 43015aa3..d3b55580 100644
--- a/src/content/dependencies/generatePageSidebar.js
+++ b/src/content/dependencies/generatePageSidebar.js
@@ -19,14 +19,13 @@ export default {
     // Sticky mode controls which sidebar sections, if any, follow the
     // scroll position, "sticking" to the top of the browser viewport.
     //
-    // 'last' - last or only sidebar box is sticky
     // 'column' - entire column, incl. multiple boxes from top, is sticky
     // '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('last', 'column', 'static'),
+      validate: v => v.is('column', 'static'),
       default: 'static',
     },
 
@@ -37,6 +36,16 @@ export default {
       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}) {
@@ -68,7 +77,11 @@ export default {
       attributes.add('class', 'all-boxes-collapsible');
     }
 
-    if (html.isBlank(slots.boxes)) {
+    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
index e11efc3f..26b30494 100644
--- a/src/content/dependencies/generatePageSidebarBox.js
+++ b/src/content/dependencies/generatePageSidebarBox.js
@@ -20,6 +20,8 @@ export default {
 
   generate: (slots, {html}) =>
     html.tag('div', {class: 'sidebar'},
+      {[html.onlyIfContent]: true},
+
       slots.collapsible &&
         {class: 'collapsible'},
 
diff --git a/src/content/dependencies/generateQuickDescription.js b/src/content/dependencies/generateQuickDescription.js
new file mode 100644
index 00000000..4c7c944a
--- /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('div', {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/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js
index 2e6c4709..3e96ed44 100644
--- a/src/content/dependencies/generateReleaseInfoContributionsLine.js
+++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js
@@ -17,10 +17,12 @@ export default {
   },
 
   slots: {
-    stringKey: {type: 'string'},
-
     showContribution: {type: 'boolean', default: true},
-    showIcons: {type: 'boolean', default: true},
+    showExternalLinks: {type: 'boolean', default: true},
+    showChronology: {type: 'boolean', default: true},
+
+    stringKey: {type: 'string'},
+    chronologyKind: {type: 'string'},
   },
 
   generate(relations, slots, {html, language}) {
@@ -34,8 +36,9 @@ export default {
           relations.contributionLinks.map(link =>
             link.slots({
               showContribution: slots.showContribution,
-              showIcons: slots.showIcons,
-              iconMode: 'tooltip',
+              showExternalLinks: slots.showExternalLinks,
+              showChronology: slots.showChronology,
+              chronologyKind: slots.chronologyKind,
             }))),
     });
   },
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/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js
index 9becfb26..7f271715 100644
--- a/src/content/dependencies/generateStickyHeadingContainer.js
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -22,10 +22,17 @@ export default {
         html.tag('div', {class: 'content-sticky-heading-row'}, [
           html.tag('h1', slots.title),
 
-          !html.isBlank(slots.cover) &&
-            html.tag('div', {class: 'content-sticky-heading-cover-container'},
-              html.tag('div', {class: 'content-sticky-heading-cover'},
-                slots.cover.slot('mode', 'thumbnail'))),
+          html.tag('div', {class: 'content-sticky-heading-cover-container'},
+            {[html.onlyIfContent]: true},
+
+            html.tag('div', {class: 'content-sticky-heading-cover'},
+              {[html.onlyIfContent]: true},
+
+              // TODO: We shouldn't need to do an isBlank check here,
+              // but a live blank value doesn't have a slot functions, so.
+              (html.isBlank(slots.cover)
+                ? html.blank()
+                : slots.cover.slot('mode', 'thumbnail')))),
         ]),
 
         html.tag('div', {class: 'content-sticky-subheading-row'},
diff --git a/src/content/dependencies/generateTooltip.js b/src/content/dependencies/generateTooltip.js
index 81f74aec..8314d33c 100644
--- a/src/content/dependencies/generateTooltip.js
+++ b/src/content/dependencies/generateTooltip.js
@@ -21,10 +21,13 @@ export default {
   generate: (slots, {html}) =>
     html.tag('span', {class: 'tooltip'},
       {[html.noEdgeWhitespace]: true},
+      {[html.onlyIfContent]: 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/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index f5324519..64ed0cb4 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -1,333 +1,142 @@
-import {sortAlbumsTracksChronologically, sortFlashesChronologically}
-  from '#sort';
-import {empty, stitchArrays} from '#sugar';
-
-import getChronologyRelations from '../util/getChronologyRelations.js';
-
 export default {
   contentDependencies: [
-    'generateAbsoluteDatetimestamp',
     'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
     'generateAlbumStyleRules',
-    'generateChronologyLinks',
-    'generateColorStyleAttribute',
     'generateCommentarySection',
     'generateContentHeading',
     'generateContributionList',
     'generatePageLayout',
-    'generateRelativeDatetimestamp',
     'generateTrackAdditionalNamesBox',
     'generateTrackCoverArtwork',
+    'generateTrackInfoPageFeaturedByFlashesList',
+    'generateTrackInfoPageOtherReleasesList',
     'generateTrackList',
     'generateTrackListDividedByGroups',
     'generateTrackReleaseInfo',
     'generateTrackSocialEmbed',
     'linkAlbum',
-    'linkArtist',
-    'linkFlash',
     'linkTrack',
     'transformContent',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({wikiInfo}) {
-    return {
-      divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups,
-      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
-    };
-  },
-
-  relations(relation, sprawl, track) {
-    const relations = {};
-    const sections = relations.sections = {};
-    const {album} = track;
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.albumStyleRules =
-      relation('generateAlbumStyleRules', track.album, track);
-
-    relations.socialEmbed =
-      relation('generateTrackSocialEmbed', track);
-
-    relations.artistChronologyContributions =
-      getChronologyRelations(track, {
-        contributions: [
-          ...track.artistContribs ?? [],
-          ...track.contributorContribs ?? [],
-        ],
-
-        linkArtist: artist => relation('linkArtist', artist),
-        linkThing: track => relation('linkTrack', track),
-
-        getThings(artist) {
-          const getDate = thing => thing.date;
-
-          const things = [
-            ...artist.tracksAsArtist,
-            ...artist.tracksAsContributor,
-          ].filter(getDate);
-
-          return sortAlbumsTracksChronologically(things, {getDate});
-        },
-      });
-
-    relations.coverArtistChronologyContributions =
-      getChronologyRelations(track, {
-        contributions: track.coverArtistContribs ?? [],
-
-        linkArtist: artist => relation('linkArtist', artist),
-
-        linkThing: trackOrAlbum =>
-          (trackOrAlbum.album
-            ? relation('linkTrack', trackOrAlbum)
-            : relation('linkAlbum', trackOrAlbum)),
-
-        getThings(artist) {
-          const getDate = thing => thing.coverArtDate ?? thing.date;
-
-          const things = [
-            ...artist.albumsAsCoverArtist,
-            ...artist.tracksAsCoverArtist,
-          ].filter(getDate);
-
-          return sortAlbumsTracksChronologically(things, {getDate});
-        },
-      }),
-
-    relations.albumLink =
-      relation('linkAlbum', track.album);
-
-    relations.trackLink =
-      relation('linkTrack', track);
-
-    relations.albumNavAccent =
-      relation('generateAlbumNavAccent', track.album, track);
-
-    relations.chronologyLinks =
-      relation('generateChronologyLinks');
-
-    relations.secondaryNav =
-      relation('generateAlbumSecondaryNav', track.album);
-
-    relations.sidebar =
-      relation('generateAlbumSidebar', track.album, track);
-
-    const additionalFilesSection = additionalFiles => ({
-      heading: relation('generateContentHeading'),
-      list: relation('generateAlbumAdditionalFilesList', album, additionalFiles),
-    });
+  sprawl: ({wikiInfo}) => ({
+    divideTrackListsByGroups:
+      wikiInfo.divideTrackListsByGroups,
+  }),
 
-    // This'll take care of itself being blank if there's nothing to show here.
-    relations.additionalNamesBox =
-      relation('generateTrackAdditionalNamesBox', track);
+  relations: (relation, sprawl, track) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    if (track.hasUniqueCoverArt || album.hasCoverArt) {
-      relations.cover =
-        relation('generateTrackCoverArtwork', track);
-    }
+    albumStyleRules:
+      relation('generateAlbumStyleRules', track.album, track),
 
-    // Section: Release info
+    socialEmbed:
+      relation('generateTrackSocialEmbed', track),
 
-    relations.releaseInfo =
-      relation('generateTrackReleaseInfo', track);
+    albumLink:
+      relation('linkAlbum', track.album),
 
-    // Section: Other releases
+    trackLink:
+      relation('linkTrack', track),
 
-    if (!empty(track.otherReleases)) {
-      const otherReleases = sections.otherReleases = {};
+    albumNavAccent:
+      relation('generateAlbumNavAccent', track.album, track),
 
-      otherReleases.heading =
-        relation('generateContentHeading');
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', track.album),
 
-      otherReleases.colorStyles =
-        track.otherReleases
-          .map(track => relation('generateColorStyleAttribute', track.color));
+    sidebar:
+      relation('generateAlbumSidebar', track.album, track),
 
-      otherReleases.trackLinks =
-        track.otherReleases
-          .map(track => relation('linkTrack', track));
+    additionalNamesBox:
+      relation('generateTrackAdditionalNamesBox', track),
 
-      otherReleases.albumLinks =
-        track.otherReleases
-          .map(track => relation('linkAlbum', track.album));
+    cover:
+      (track.hasUniqueCoverArt || track.album.hasCoverArt
+        ? relation('generateTrackCoverArtwork', track)
+        : null),
 
-      otherReleases.datetimestamps =
-        track.otherReleases.map(track2 =>
-          (track2.date
-            ? (track.date
-                ? relation('generateRelativeDatetimestamp',
-                    track2.date,
-                    track.date)
-                : relation('generateAbsoluteDatetimestamp',
-                    track2.date))
-            : null));
+    contentHeading:
+      relation('generateContentHeading'),
 
-      otherReleases.items =
-        track.otherReleases.map(track => ({
-          trackLink: relation('linkTrack', track),
-          albumLink: relation('linkAlbum', track.album),
-        }));
-    }
+    releaseInfo:
+      relation('generateTrackReleaseInfo', track),
 
-    // Section: Contributors
+    otherReleasesList:
+        relation('generateTrackInfoPageOtherReleasesList', track),
 
-    if (!empty(track.contributorContribs)) {
-      const contributors = sections.contributors = {};
+    contributorContributionList:
+      relation('generateContributionList', track.contributorContribs),
 
-      contributors.heading =
-        relation('generateContentHeading');
+    referencedTracksList:
+      relation('generateTrackList', track.referencedTracks),
 
-      contributors.list =
-        relation('generateContributionList', track.contributorContribs);
-    }
+    sampledTracksList:
+      relation('generateTrackList', track.sampledTracks),
 
-    // Section: Referenced tracks
+    referencedByTracksList:
+      relation('generateTrackListDividedByGroups',
+        track.referencedByTracks,
+        sprawl.divideTrackListsByGroups),
 
-    if (!empty(track.referencedTracks)) {
-      const references = sections.references = {};
+    sampledByTracksList:
+      relation('generateTrackListDividedByGroups',
+        track.sampledByTracks,
+        sprawl.divideTrackListsByGroups),
 
-      references.heading =
-        relation('generateContentHeading');
+    flashesThatFeatureList:
+      relation('generateTrackInfoPageFeaturedByFlashesList', track),
 
-      references.list =
-        relation('generateTrackList', track.referencedTracks);
-    }
+    lyrics:
+      relation('transformContent', track.lyrics),
 
-    // Section: Sampled tracks
+    sheetMusicFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        track.album,
+        track.sheetMusicFiles),
 
-    if (!empty(track.sampledTracks)) {
-      const samples = sections.samples = {};
+    midiProjectFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        track.album,
+        track.midiProjectFiles),
 
-      samples.heading =
-        relation('generateContentHeading');
+    additionalFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        track.album,
+        track.additionalFiles),
 
-      samples.list =
-        relation('generateTrackList', track.sampledTracks);
-    }
+    artistCommentarySection:
+      relation('generateCommentarySection', track.commentary),
+  }),
 
-    // Section: Tracks that reference
+  data: (sprawl, track) => ({
+    name:
+      track.name,
 
-    if (!empty(track.referencedByTracks)) {
-      const referencedBy = sections.referencedBy = {};
+    color:
+      track.color,
 
-      referencedBy.heading =
-        relation('generateContentHeading');
+    hasTrackNumbers:
+      track.album.hasTrackNumbers,
 
-      referencedBy.list =
-        relation('generateTrackListDividedByGroups',
-          track.referencedByTracks,
-          sprawl.divideTrackListsByGroups);
-    }
-
-    // Section: Tracks that sample
-
-    if (!empty(track.sampledByTracks)) {
-      const sampledBy = sections.sampledBy = {};
-
-      sampledBy.heading =
-        relation('generateContentHeading');
-
-      sampledBy.list =
-        relation('generateTrackListDividedByGroups',
-          track.sampledByTracks,
-          sprawl.divideTrackListsByGroups);
-    }
-
-    // Section: Flashes that feature
-
-    if (sprawl.enableFlashesAndGames) {
-      const sortedFeatures =
-        sortFlashesChronologically(
-          [track, ...track.otherReleases].flatMap(track =>
-            track.featuredInFlashes.map(flash => ({
-              // These aren't going to be exposed directly, they're processed
-              // into the appropriate relations after this sort.
-              flash, track,
-
-              // These properties are only used for the sort.
-              act: flash.act,
-              date: flash.date,
-            }))));
-
-      if (!empty(sortedFeatures)) {
-        const flashesThatFeature = sections.flashesThatFeature = {};
-
-        flashesThatFeature.heading =
-          relation('generateContentHeading');
-
-        flashesThatFeature.entries =
-          sortedFeatures.map(({flash, track: directlyFeaturedTrack}) =>
-            (directlyFeaturedTrack === track
-              ? {
-                  flashLink: relation('linkFlash', flash),
-                }
-              : {
-                  flashLink: relation('linkFlash', flash),
-                  trackLink: relation('linkTrack', directlyFeaturedTrack),
-                }));
-      }
-    }
-
-    // Section: Lyrics
-
-    if (track.lyrics) {
-      const lyrics = sections.lyrics = {};
-
-      lyrics.heading =
-        relation('generateContentHeading');
-
-      lyrics.content =
-        relation('transformContent', track.lyrics);
-    }
-
-    // Sections: Sheet music files, MIDI/proejct files, additional files
-
-    if (!empty(track.sheetMusicFiles)) {
-      sections.sheetMusicFiles = additionalFilesSection(track.sheetMusicFiles);
-    }
-
-    if (!empty(track.midiProjectFiles)) {
-      sections.midiProjectFiles = additionalFilesSection(track.midiProjectFiles);
-    }
-
-    if (!empty(track.additionalFiles)) {
-      sections.additionalFiles = additionalFilesSection(track.additionalFiles);
-    }
-
-    // Section: Artist commentary
-
-    if (track.commentary) {
-      sections.artistCommentary =
-        relation('generateCommentarySection', track.commentary);
-    }
-
-    return relations;
-  },
-
-  data(sprawl, track) {
-    return {
-      name: track.name,
-      color: track.color,
+    trackNumber:
+      track.album.tracks.indexOf(track) + 1,
+  }),
 
-      hasTrackNumbers: track.album.hasTrackNumbers,
-      trackNumber: track.album.tracks.indexOf(track) + 1,
-
-      numAdditionalFiles: track.additionalFiles.length,
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    const {sections: sec} = relations;
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('trackPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            track: data.name,
+          }),
 
-    return relations.layout
-      .slots({
-        title: language.$('trackPage.title', {track: data.name}),
         headingMode: 'sticky',
 
         additionalNames: relations.additionalNamesBox,
@@ -349,227 +158,232 @@ export default {
             {[html.onlyIfContent]: true},
             {[html.joinChildren]: html.tag('br')},
 
-            [
-              sec.sheetMusicFiles &&
-                language.$('releaseInfo.sheetMusicFiles.shortcut', {
-                  link: html.tag('a',
-                    {href: '#sheet-music-files'},
-                    language.$('releaseInfo.sheetMusicFiles.shortcut.link')),
-                }),
+            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')),
+                  })),
 
-              sec.midiProjectFiles &&
-                language.$('releaseInfo.midiProjectFiles.shortcut', {
-                  link: html.tag('a',
-                    {href: '#midi-project-files'},
-                    language.$('releaseInfo.midiProjectFiles.shortcut.link')),
-                }),
+              !html.isBlank(relations.midiProjectFilesList) &&
+                language.encapsulate(capsule, 'midiProjectFiles.shortcut', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#midi-project-files'},
+                        language.$(capsule, 'link')),
+                  })),
 
-              sec.additionalFiles &&
-                language.$('releaseInfo.additionalFiles.shortcut', {
-                  link: html.tag('a',
-                    {href: '#midi-project-files'},
-                    language.$('releaseInfo.additionalFiles.shortcut.link')),
-                }),
+              !html.isBlank(relations.additionalFilesList) &&
+                language.encapsulate(capsule, 'additionalFiles.shortcut', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#midi-project-files'},
+                        language.$(capsule, 'link')),
+                  })),
 
-              sec.artistCommentary &&
-                language.$('releaseInfo.readCommentary', {
-                  link: html.tag('a',
-                    {href: '#artist-commentary'},
-                    language.$('releaseInfo.readCommentary.link')),
-                }),
-            ]),
+              !html.isBlank(relations.artistCommentarySection) &&
+                language.encapsulate(capsule, 'readCommentary', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#artist-commentary'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
 
-          sec.otherReleases && [
-            sec.otherReleases.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
-                id: 'also-released-as',
+                attributes: {id: 'also-released-as'},
                 title: language.$('releaseInfo.alsoReleasedAs'),
               }),
 
-            html.tag('ul',
-              stitchArrays({
-                trackLink: sec.otherReleases.trackLinks,
-                albumLink: sec.otherReleases.albumLinks,
-                datetimestamp: sec.otherReleases.datetimestamps,
-                colorStyle: sec.otherReleases.colorStyles,
-              }).map(({
-                  trackLink,
-                  albumLink,
-                  datetimestamp,
-                  colorStyle,
-                }) => {
-                  const parts = ['releaseInfo.alsoReleasedAs.item'];
-                  const options = {};
-
-                  options.track = trackLink.slot('color', false);
-                  options.album = albumLink;
-
-                  if (datetimestamp) {
-                    parts.push('withYear');
-                    options.year =
-                      datetimestamp.slots({
-                        style: 'year',
-                        tooltip: true,
-                      });
-                  }
-
-                  return (
-                    html.tag('li',
-                      colorStyle,
-                      language.$(...parts, options)));
-                })),
-          ],
+            relations.otherReleasesList,
+          ]),
 
-          sec.contributors && [
-            sec.contributors.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
-                id: 'contributors',
+                attributes: {id: 'contributors'},
                 title: language.$('releaseInfo.contributors'),
               }),
 
-            sec.contributors.list,
-          ],
+            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'),
+                })),
 
-          sec.references && [
-            sec.references.heading
-              .slots({
-                id: 'references',
-                title:
-                  language.$('releaseInfo.tracksReferenced', {
-                    track: html.tag('i', data.name),
-                  }),
-              }),
+            relations.referencedTracksList,
+          ]),
 
-            sec.references.list,
-          ],
+          html.tags([
+            language.encapsulate('releaseInfo.tracksSampled', capsule =>
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'samples'},
 
-          sec.samples && [
-            sec.samples.heading
-              .slots({
-                id: 'samples',
-                title:
-                  language.$('releaseInfo.tracksSampled', {
-                    track: html.tag('i', data.name),
-                  }),
-              }),
+                  title:
+                    language.$(capsule, {
+                      track:
+                        html.tag('i', data.name),
+                    }),
 
-            sec.samples.list,
-          ],
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                })),
 
-          sec.referencedBy && [
-            sec.referencedBy.heading
-              .slots({
-                id: 'referenced-by',
-                title:
-                  language.$('releaseInfo.tracksThatReference', {
-                    track: html.tag('i', data.name),
-                  }),
-              }),
+            relations.sampledTracksList,
+          ]),
 
-            sec.referencedBy.list,
-          ],
+          language.encapsulate('releaseInfo.tracksThatReference', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'referenced-by'},
 
-          sec.sampledBy && [
-            sec.sampledBy.heading
-              .slots({
-                id: 'referenced-by',
-                title:
-                  language.$('releaseInfo.tracksThatSample', {
-                    track: html.tag('i', data.name),
-                  }),
-              }),
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
 
-            sec.sampledBy.list,
-          ],
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                }),
 
-          sec.flashesThatFeature && [
-            sec.flashesThatFeature.heading
-              .slots({
-                id: 'featured-in',
-                title:
-                  language.$('releaseInfo.flashesThatFeature', {
-                    track: html.tag('i', data.name),
-                  }),
-              }),
+              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,
+          ]),
 
-            html.tag('ul', sec.flashesThatFeature.entries.map(({flashLink, trackLink}) =>
-              (trackLink
-                ? html.tag('li', {class: 'rerelease'},
-                    language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
-                      flash: flashLink,
-                      track: trackLink,
-                    }))
-                : html.tag('li',
-                    language.$('releaseInfo.flashesThatFeature.item', {
-                      flash: flashLink,
-                    }))))),
-          ],
-
-          sec.lyrics && [
-            sec.lyrics.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
-                id: 'lyrics',
+                attributes: {id: 'lyrics'},
                 title: language.$('releaseInfo.lyrics'),
               }),
 
             html.tag('blockquote',
-              sec.lyrics.content
-                .slot('mode', 'lyrics')),
-          ],
+              {[html.onlyIfContent]: true},
+              relations.lyrics.slot('mode', 'lyrics')),
+          ]),
 
-          sec.sheetMusicFiles && [
-            sec.sheetMusicFiles.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
-                id: 'sheet-music-files',
+                attributes: {id: 'sheet-music-files'},
                 title: language.$('releaseInfo.sheetMusicFiles.heading'),
               }),
 
-            sec.sheetMusicFiles.list,
-          ],
+            relations.sheetMusicFilesList,
+          ]),
 
-          sec.midiProjectFiles && [
-            sec.midiProjectFiles.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
-                id: 'midi-project-files',
+                attributes: {id: 'midi-project-files'},
                 title: language.$('releaseInfo.midiProjectFiles.heading'),
               }),
 
-            sec.midiProjectFiles.list,
-          ],
+            relations.midiProjectFilesList,
+          ]),
 
-          sec.additionalFiles && [
-            sec.additionalFiles.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
-                id: 'additional-files',
-                title:
-                  language.$('releaseInfo.additionalFiles.heading', {
-                    additionalFiles:
-                      language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
-                  }),
+                attributes: {id: 'additional-files'},
+                title: language.$('releaseInfo.additionalFiles.heading'),
               }),
 
-            sec.additionalFiles.list,
-          ],
+            relations.additionalFilesList,
+          ]),
 
-          sec.artistCommentary,
+          relations.artistCommentarySection,
         ],
 
         navLinkStyle: 'hierarchical',
+
         navLinks: [
           {auto: 'home'},
+
           {html: relations.albumLink.slot('color', false)},
+
           {
             html:
-              (data.hasTrackNumbers
-                ? language.$('trackPage.nav.track.withNumber', {
-                    number: data.trackNumber,
-                    track: relations.trackLink
-                      .slot('attributes', {class: 'current'}),
-                  })
-                : language.$('trackPage.nav.track', {
-                    track: relations.trackLink
-                      .slot('attributes', {class: 'current'}),
-                  })),
+              language.encapsulate(pageCapsule, 'nav.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);
+              }),
           },
         ],
 
@@ -579,20 +393,6 @@ export default {
             showExtraLinks: false,
           }),
 
-        navContent:
-          relations.chronologyLinks.slots({
-            chronologyInfoSets: [
-              {
-                headingString: 'misc.chronology.heading.track',
-                contributions: relations.artistChronologyContributions,
-              },
-              {
-                headingString: 'misc.chronology.heading.coverArt',
-                contributions: relations.coverArtistChronologyContributions,
-              },
-            ],
-          }),
-
         secondaryNav:
           relations.secondaryNav
             .slot('mode', 'track'),
@@ -600,8 +400,7 @@ export default {
         leftSidebar: relations.sidebar,
 
         socialEmbed: relations.socialEmbed,
-      });
-  },
+      })),
 };
 
 /*
diff --git a/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js
new file mode 100644
index 00000000..5958be9a
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js
@@ -0,0 +1,62 @@
+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, ...track.otherReleases].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
+            : 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) {
+            attributes.add('class', 'rerelease');
+            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..004bba6d
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js
@@ -0,0 +1,80 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateColorStyleAttribute',
+    'generateRelativeDatetimestamp',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    colorStyles:
+      track.otherReleases
+        .map(track => relation('generateColorStyleAttribute', track.color)),
+
+    trackLinks:
+      track.otherReleases
+        .map(track => relation('linkTrack', track)),
+
+    albumLinks:
+      track.otherReleases
+        .map(track => relation('linkAlbum', track.album)),
+
+    datetimestamps:
+      track.otherReleases.map(track2 =>
+        (track2.date
+          ? (track.date
+              ? relation('generateRelativeDatetimestamp',
+                  track2.date,
+                  track.date)
+              : relation('generateAbsoluteDatetimestamp',
+                  track2.date))
+          : null)),
+
+    items:
+      track.otherReleases.map(track => ({
+        trackLink: relation('linkTrack', track),
+        albumLink: relation('linkAlbum', track.album),
+      })),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tag('ul',
+      {[html.onlyIfContent]: true},
+
+      stitchArrays({
+        trackLink: relations.trackLinks,
+        albumLink: relations.albumLinks,
+        datetimestamp: relations.datetimestamps,
+        colorStyle: relations.colorStyles,
+      }).map(({
+          trackLink,
+          albumLink,
+          datetimestamp,
+          colorStyle,
+        }) => {
+          const parts = ['releaseInfo.alsoReleasedAs.item'];
+          const options = {};
+
+          options.track = trackLink.slot('color', false);
+          options.album = albumLink;
+
+          if (datetimestamp) {
+            parts.push('withYear');
+            options.year =
+              datetimestamp.slots({
+                style: 'year',
+                tooltip: true,
+              });
+          }
+
+          return (
+            html.tag('li',
+              colorStyle,
+              language.$(...parts, options)));
+        })),
+};
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
index 3c36d248..7c3b11c1 100644
--- a/src/content/dependencies/generateTrackList.js
+++ b/src/content/dependencies/generateTrackList.js
@@ -5,55 +5,42 @@ export default {
 
   extraDependencies: ['html', 'language'],
 
-  relations(relation, tracks) {
-    if (empty(tracks)) {
-      return {};
-    }
-
-    return {
-      trackLinks:
-        tracks
-          .map(track => relation('linkTrack', track)),
-
-      contributionLinks:
-        tracks
-          .map(track =>
-            (empty(track.artistContribs)
-              ? null
-              : track.artistContribs
-                  .map(contrib => relation('linkContribution', contrib)))),
-    };
-  },
-
-  slots: {
-    showContribution: {type: 'boolean', default: false},
-    showIcons: {type: 'boolean', default: false},
-  },
-
-  generate(relations, slots, {html, language}) {
-    return (
-      html.tag('ul',
-        stitchArrays({
-          trackLink: relations.trackLinks,
-          contributionLinks: relations.contributionLinks,
-        }).map(({trackLink, contributionLinks}) =>
-            html.tag('li',
-              (empty(contributionLinks)
-                ? trackLink
-                : language.$('trackList.item.withArtists', {
-                    track: trackLink,
-                    by:
-                      html.tag('span', {class: 'by'},
-                        html.metatag('chunkwrap', {split: ','},
-                          language.$('trackList.item.withArtists.by', {
-                            artists:
-                              language.formatConjunctionList(
-                                contributionLinks.map(link =>
-                                  link.slots({
-                                    showContribution: slots.showContribution,
-                                    showIcons: slots.showIcons,
-                                  }))),
-                          }))),
-                  }))))));
-  },
+  relations: (relation, tracks) => ({
+    trackLinks:
+      tracks
+        .map(track => relation('linkTrack', track)),
+
+    contributionLinks:
+      tracks
+        .map(track =>
+          track.artistContribs
+            .map(contrib => relation('linkContribution', contrib))),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tag('ul',
+      {[html.onlyIfContent]: true},
+
+      stitchArrays({
+        trackLink: relations.trackLinks,
+        contributionLinks: relations.contributionLinks,
+      }).map(({trackLink, contributionLinks}) =>
+          html.tag('li',
+            language.encapsulate('trackList.item', itemCapsule =>
+              language.encapsulate(itemCapsule, workingCapsule => {
+                const workingOptions = {track: trackLink};
+
+                if (!empty(contributionLinks)) {
+                  workingCapsule += '.withArtists';
+                  workingOptions.by =
+                    html.tag('span', {class: 'by'},
+                      html.metatag('chunkwrap', {split: ','},
+                        language.$(itemCapsule, 'withArtists.by', {
+                          artists:
+                            language.formatConjunctionList(contributionLinks),
+                        })));
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              }))))),
 };
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
index e070ac35..3cba479e 100644
--- a/src/content/dependencies/generateTrackListDividedByGroups.js
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -1,53 +1,138 @@
-import {empty} from '#sugar';
-
-import groupTracksByGroup from '../util/groupTracksByGroup.js';
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
 
 export default {
-  contentDependencies: ['generateTrackList', 'linkGroup'],
+  contentDependencies: [
+    'generateContentHeading',
+    'generateTrackList',
+    'linkGroup',
+  ],
+
   extraDependencies: ['html', 'language'],
 
-  relations(relation, tracks, groups) {
-    if (empty(tracks)) {
-      return {};
+  query(tracks, dividingGroups) {
+    const groupings = new Map();
+    const ungroupedTracks = [];
+
+    // Entry order matters! Add blank lists for each group
+    // in the order that those groups are provided.
+    for (const group of dividingGroups) {
+      groupings.set(group, []);
     }
 
-    if (empty(groups)) {
-      return {
-        flatList:
-          relation('generateTrackList', tracks),
-      };
+    for (const track of tracks) {
+      const firstMatchingGroup =
+        dividingGroups.find(group => group.albums.includes(track.album));
+
+      if (firstMatchingGroup) {
+        groupings.get(firstMatchingGroup).push(track);
+      } else {
+        ungroupedTracks.push(track);
+      }
     }
 
-    const lists = groupTracksByGroup(tracks, groups);
+    const groups = Array.from(groupings.keys());
+    const groupedTracks = Array.from(groupings.values());
 
-    return {
-      groupedLists:
-        Array.from(lists.entries()).map(([groupOrOther, tracks]) => ({
-          ...(groupOrOther === 'other'
-                ? {other: true}
-                : {groupLink: relation('linkGroup', groupOrOther)}),
+    // Drop the empty lists, so just the groups which
+    // at least a single track matched are left.
+    filterMultipleArrays(
+      groups,
+      groupedTracks,
+      (_group, tracks) => !empty(tracks));
 
-          list:
-            relation('generateTrackList', tracks),
-        })),
-    };
+    return {groups, groupedTracks, ungroupedTracks};
   },
 
-  generate(relations, {html, language}) {
-    if (relations.flatList) {
-      return relations.flatList;
-    }
+  relations: (relation, query, tracks, groups) => ({
+    flatList:
+      (empty(groups)
+        ? 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) => ({
+    groupNames:
+      query.groups
+        .map(group => group.name),
+  }),
 
-    return html.tag('dl',
-      relations.groupedLists.map(({other, groupLink, list}) => [
-        html.tag('dt',
-          (other
-            ? language.$('trackList.group.fromOther')
-            : language.$('trackList.group', {
-                group: groupLink
-              }))),
-
-        html.tag('dd', list),
-      ]));
+  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/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 3bdeaa4f..8a081046 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -47,44 +47,51 @@ export default {
   },
 
   generate: (data, relations, {html, language}) =>
-    html.tags([
-      html.tag('p',
-        {[html.onlyIfContent]: true},
-        {[html.joinChildren]: html.tag('br')},
-
-        [
-          relations.artistContributionLinks
-            .slots({stringKey: 'releaseInfo.by'}),
+    language.encapsulate('releaseInfo', capsule =>
+      html.tags([
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.artistContributionLinks.slots({
+              stringKey: capsule + '.by',
+              chronologyKind: 'track',
+            }),
 
-          relations.coverArtistContributionsLine
-            ?.slots({stringKey: 'releaseInfo.coverArtBy'}),
+            relations.coverArtistContributionsLine?.slots({
+              stringKey: capsule + '.coverArtBy',
+              chronologyKind: 'trackArt',
+            }),
 
-          data.date &&
-            language.$('releaseInfo.released', {
+            language.$(capsule, 'released', {
+              [language.onlyIfOptions]: ['date'],
               date: language.formatDate(data.date),
             }),
 
-          data.coverArtDate &&
-            language.$('releaseInfo.artReleased', {
+            language.$(capsule, 'artReleased', {
+              [language.onlyIfOptions]: ['date'],
               date: language.formatDate(data.coverArtDate),
             }),
 
-          data.duration &&
-            language.$('releaseInfo.duration', {
+            language.$(capsule, 'duration', {
+              [language.onlyIfOptions]: ['duration'],
               duration: language.formatDuration(data.duration),
             }),
-        ]),
-
-      html.tag('p',
-        (relations.externalLinks
-          ? language.$('releaseInfo.listenOn', {
-              links:
-                language.formatDisjunctionList(
-                  relations.externalLinks
-                    .map(link => link.slot('context', 'track'))),
-            })
-          : language.$('releaseInfo.listenOn.noLinks', {
-              name: html.tag('i', data.name),
-            }))),
-    ]),
+          ]),
+
+        html.tag('p',
+          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
index 0337fc46..9868f0e2 100644
--- a/src/content/dependencies/generateTrackSocialEmbed.js
+++ b/src/content/dependencies/generateTrackSocialEmbed.js
@@ -39,35 +39,35 @@ export default {
     return data;
   },
 
-  generate(data, relations, {absoluteTo, language, urls}) {
-    return relations.socialEmbed.slots({
-      title:
-        language.$('trackPage.socialEmbed.title', {
-          track: data.trackName,
-        }),
+  generate: (data, relations, {absoluteTo, language, urls}) =>
+    language.encapsulate('trackPage.socialEmbed', embedCapsule =>
+      relations.socialEmbed.slots({
+        title:
+          language.$(embedCapsule, 'title', {
+            track: data.trackName,
+          }),
 
-      headingContent:
-        language.$('trackPage.socialEmbed.heading', {
-          album: data.albumName,
-        }),
+        headingContent:
+          language.$(embedCapsule, 'heading', {
+            album: data.albumName,
+          }),
 
-      headingLink:
-        absoluteTo('localized.album', data.albumDirectory),
+        headingLink:
+          absoluteTo('localized.album', data.albumDirectory),
 
-      imagePath:
-        (data.imageSource === 'album'
-          ? '/' +
-            urls
-              .from('shared.root')
-              .to('media.albumCover', data.albumDirectory, data.coverArtFileExtension)
-       : data.imageSource === 'track'
-          ? '/' +
-            urls
-              .from('shared.root')
-              .to('media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension)
-          : null),
-    });
-  },
+        imagePath:
+          (data.imageSource === 'album'
+            ? '/' +
+              urls
+                .from('shared.root')
+                .to('media.albumCover', data.albumDirectory, data.coverArtFileExtension)
+         : data.imageSource === 'track'
+            ? '/' +
+              urls
+                .from('shared.root')
+                .to('media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension)
+            : null),
+      })),
 };
 
 /*
diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js
index a19f104c..16c22bb3 100644
--- a/src/content/dependencies/generateWikiHomeAlbumsRow.js
+++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js
@@ -113,10 +113,10 @@ export default {
           image.slots({
             path,
             missingSourceContent:
-              name &&
-                language.$('misc.albumGrid.noCoverArt', {
-                  album: name,
-                }),
+              language.$('misc.albumGrid.noCoverArt', {
+                [language.onlyIfOptions]: ['album'],
+                album: name,
+              }),
             }));
 
     commonSlots.actionLinks =
diff --git a/src/content/dependencies/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js
index bd0e4797..83a27695 100644
--- a/src/content/dependencies/generateWikiHomeNewsBox.js
+++ b/src/content/dependencies/generateWikiHomeNewsBox.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -39,49 +39,48 @@ export default {
         .map(entry => entry.date),
   }),
 
-  generate(data, relations, {html, language}) {
-    if (empty(relations.entryContents)) {
-      return html.blank();
-    }
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('homepage.news', boxCapsule =>
+      relations.box.slots({
+        attributes: {class: 'latest-news-sidebar-box'},
+        collapsible: false,
 
-    return relations.box.slots({
-      attributes: {class: 'latest-news-sidebar-box'},
-      collapsible: false,
+        content: [
+          html.tag('h1',
+            {[html.onlyIfSiblings]: true},
+            language.$(boxCapsule, 'title')),
 
-      content: [
-        html.tag('h1', language.$('homepage.news.title')),
+          stitchArrays({
+            date: data.entryDates,
+            content: relations.entryContents,
+            mainLink: relations.entryMainLinks,
+            readMoreLink: relations.entryReadMoreLinks,
+          }).map(({
+              date,
+              content,
+              mainLink,
+              readMoreLink,
+            }, index) =>
+              language.encapsulate(boxCapsule, 'entry', entryCapsule =>
+                html.tag('article', {class: 'news-entry'},
+                  index === 0 &&
+                    {class: 'first-news-entry'},
 
-        stitchArrays({
-          date: data.entryDates,
-          content: relations.entryContents,
-          mainLink: relations.entryMainLinks,
-          readMoreLink: relations.entryReadMoreLinks,
-        }).map(({
-            date,
-            content,
-            mainLink,
-            readMoreLink,
-          }, index) =>
-            html.tag('article', {class: 'news-entry'},
-              index === 0 &&
-                {class: 'first-news-entry'},
+                  [
+                    html.tag('h2', [
+                      html.tag('time', language.formatDate(date)),
+                      mainLink,
+                    ]),
 
-              [
-                html.tag('h2', [
-                  html.tag('time', language.formatDate(date)),
-                  mainLink,
-                ]),
+                    content.slot('thumb', 'medium'),
 
-                content.slot('thumb', 'medium'),
-
-                html.tag('p',
-                  {[html.onlyIfContent]: true},
-                  readMoreLink
-                    ?.slots({
-                      content: language.$('homepage.news.entry.viewRest'),
-                    })),
-              ])),
-      ],
-    });
-  },
+                    html.tag('p',
+                      {[html.onlyIfContent]: true},
+                      readMoreLink
+                        ?.slots({
+                          content: language.$(entryCapsule, 'viewRest'),
+                        })),
+                  ]))),
+        ],
+      })),
 };
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index 822efe3f..b1f02819 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -3,7 +3,6 @@ import {empty} from '#sugar';
 
 export default {
   extraDependencies: [
-    'cachebust',
     'checkIfImagePathHasCachedThumbnails',
     'getDimensionsOfImagePath',
     'getSizeOfImagePath',
@@ -82,7 +81,6 @@ export default {
   },
 
   generate(data, relations, slots, {
-    cachebust,
     checkIfImagePathHasCachedThumbnails,
     getDimensionsOfImagePath,
     getSizeOfImagePath,
@@ -133,15 +131,8 @@ export default {
       !isMissingImageFile &&
       !empty(contentWarnings);
 
-    const hasBothDimensions =
-      !!(slots.dimensions &&
-         slots.dimensions[0] !== null &&
-         slots.dimensions[1] !== null);
-
     const willSquare =
-      (hasBothDimensions
-        ? slots.dimensions[0] === slots.dimensions[1]
-        : slots.square);
+      slots.square;
 
     const imgAttributes = html.attributes([
       {class: 'image'},
@@ -152,7 +143,7 @@ export default {
         {width: slots.dimensions[0]},
 
       slots.dimensions?.[1] &&
-        {width: slots.dimensions[1]},
+        {height: slots.dimensions[1]},
     ]);
 
     const isPlaceholder =
@@ -172,7 +163,7 @@ export default {
     if (willReveal) {
       reveal = [
         html.tag('img', {class: 'reveal-symbol'},
-          {src: to('shared.staticFile', 'warning.svg', cachebust)}),
+          {src: to('staticMisc.path', 'warning.svg')}),
 
         html.tag('br'),
 
diff --git a/src/content/dependencies/linkAnythingMan.js b/src/content/dependencies/linkAnythingMan.js
new file mode 100644
index 00000000..d4697403
--- /dev/null
+++ b/src/content/dependencies/linkAnythingMan.js
@@ -0,0 +1,25 @@
+export default {
+  contentDependencies: [
+    'linkAlbum',
+    'linkFlash',
+    'linkTrack',
+  ],
+
+  query: (thing) => ({
+    referenceType: thing.constructor[Symbol.for('Thing.referenceType')],
+  }),
+
+  relations: (relation, query, thing) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbum', thing)
+     : query.referenceType === 'flash'
+        ? relation('linkFlash', thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrack', thing)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.link,
+};
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index 1a51c387..26f0b2d7 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -1,145 +1,78 @@
-import {empty, stitchArrays} from '#sugar';
-
 export default {
   contentDependencies: [
+    'generateContributionTooltip',
     'generateTextWithTooltip',
-    'generateTooltip',
     'linkArtist',
-    'linkExternalAsIcon',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  relations(relation, contribution) {
-    const relations = {};
-
-    relations.artistLink =
-      relation('linkArtist', contribution.artist);
+  relations: (relation, contribution) => ({
+    artistLink:
+      relation('linkArtist', contribution.artist),
 
-    relations.textWithTooltip =
-      relation('generateTextWithTooltip');
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
 
-    relations.tooltip =
-      relation('generateTooltip');
+    tooltip:
+      relation('generateContributionTooltip', contribution),
+  }),
 
-    if (!empty(contribution.artist.urls)) {
-      relations.artistIcons =
-        contribution.artist.urls
-          .map(url => relation('linkExternalAsIcon', url));
-    }
-
-    return relations;
-  },
-
-  data(contribution) {
-    return {
-      contribution: contribution.annotation,
-      urls: contribution.artist.urls,
-    };
-  },
+  data: (contribution) => ({
+    contribution: contribution.annotation,
+    urls: contribution.artist.urls,
+  }),
 
   slots: {
     showContribution: {type: 'boolean', default: false},
-    showIcons: {type: 'boolean', default: false},
-    preventWrapping: {type: 'boolean', default: true},
+    showExternalLinks: {type: 'boolean', default: false},
+    showChronology: {type: 'boolean', default: false},
 
-    iconMode: {
-      validate: v => v.is('inline', 'tooltip'),
-      default: 'inline'
-    },
+    preventWrapping: {type: 'boolean', default: true},
+    chronologyKind: {type: 'string'},
   },
 
-  generate(data, relations, slots, {html, language}) {
-    const hasContribution = !!(slots.showContribution && data.contribution);
-    const hasExternalIcons = !!(slots.showIcons && relations.artistIcons);
-
-    const parts = ['misc.artistLink'];
-    const options = {};
-
-    options.artist =
-      (hasExternalIcons && slots.iconMode === 'tooltip'
-        ? relations.textWithTooltip.slots({
-            customInteractionCue: true,
-
-            text:
-              relations.artistLink.slots({
-                attributes: {class: 'text-with-tooltip-interaction-cue'},
-              }),
-
-            tooltip:
-              relations.tooltip.slots({
-                attributes:
-                  {class: ['icons', 'icons-tooltip']},
-
-                contentAttributes:
-                  {[html.joinChildren]: ''},
-
-                content:
-                  stitchArrays({
-                    icon: relations.artistIcons,
-                    url: data.urls,
-                  }).map(({icon, url}) => {
-                      icon.setSlots({
-                        context: 'artist',
-                        withText: true,
-                      });
-
-                      let platformText =
-                        language.formatExternalLink(url, {
-                          context: 'artist',
-                          style: '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.
-                      if (platformText.toString() === (new URL(url)).host) {
-                        platformText =
-                          language.$('misc.artistLink.noExternalLinkPlatformName');
-                      }
-
-                      const platformSpan =
-                        html.tag('span', {class: 'icon-platform'},
-                          platformText);
-
-                      return [icon, platformSpan];
-                    }),
-              }),
-          })
-        : relations.artistLink);
-
-    if (hasContribution) {
-      parts.push('withContribution');
-      options.contrib = data.contribution;
-    }
-
-    if (hasExternalIcons && slots.iconMode === 'inline') {
-      parts.push('withExternalLinks');
-      options.links =
-        html.tag('span', {class: ['icons', 'icons-inline']},
-          {[html.noEdgeWhitespace]: true},
-          language.formatUnitList(
-            relations.artistIcons
-              .slice(0, 4)
-              .map(icon => icon.slot('context', 'artist'))));
-    }
-
-    const contributionPart =
-      language.formatString(...parts, options);
-
-    if (!hasContribution && !hasExternalIcons) {
-      return contributionPart;
-    }
-
-    return (
-      html.tag('span', {class: 'contribution'},
-        {[html.noEdgeWhitespace]: true},
-
-        parts.length > 1 &&
-        slots.preventWrapping &&
-          {class: 'nowrap'},
-
-        contributionPart));
-  },
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('span', {class: 'contribution'},
+      {[html.noEdgeWhitespace]: true},
+
+      slots.preventWrapping &&
+        {class: 'nowrap'},
+
+      language.encapsulate('misc.artistLink', workingCapsule => {
+        const workingOptions = {};
+
+        relations.tooltip.setSlots({
+          showExternalLinks: slots.showExternalLinks,
+          showChronology: slots.showChronology,
+          chronologyKind: slots.chronologyKind,
+        });
+
+        workingOptions.artist =
+          (html.isBlank(relations.tooltip)
+            ? relations.artistLink
+            : relations.textWithTooltip.slots({
+                customInteractionCue: true,
+
+                text:
+                  relations.artistLink.slots({
+                    attributes: {class: 'text-with-tooltip-interaction-cue'},
+                  }),
+
+                tooltip:
+                  relations.tooltip.slots({
+                    showExternalLinks: slots.showExternalLinks,
+                    showChronology: slots.showChronology,
+                    chronologyKind: slots.chronologyKind,
+                  }),
+              }));
+
+        if (slots.showContribution && data.contribution) {
+          workingCapsule += '.withContribution';
+          workingOptions.contrib =
+            data.contribution;
+        }
+
+        return language.formatString(workingCapsule, workingOptions);
+      })),
 };
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
deleted file mode 100644
index 6f37529e..00000000
--- a/src/content/dependencies/linkExternalAsIcon.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import {isExternalLinkContext} from '#external-links';
-
-export default {
-  extraDependencies: ['html', 'language', 'to'],
-
-  data: (url) => ({url}),
-
-  slots: {
-    context: {
-      // This awkward syntax is because the slot descriptor validator can't
-      // differentiate between a function that returns a validator (the usual
-      // syntax) and a function that is itself a validator.
-      validate: () => isExternalLinkContext,
-      default: 'generic',
-    },
-
-    withText: {type: 'boolean'},
-  },
-
-  generate(data, slots, {html, language, to}) {
-    const format = style =>
-      language.formatExternalLink(data.url, {style, context: slots.context});
-
-    const platformText = format('platform');
-    const handleText = format('handle');
-    const iconId = format('icon-id');
-
-    return html.tag('a', {class: 'icon'},
-      {href: data.url},
-
-      slots.withText &&
-        {class: 'has-text'},
-
-      [
-        html.tag('svg', [
-          !slots.withText &&
-            html.tag('title', platformText),
-
-          html.tag('use', {
-            href: to('shared.staticIcon', iconId),
-          }),
-        ]),
-
-        slots.withText &&
-          html.tag('span', {class: 'icon-text'},
-            (html.isBlank(handleText)
-              ? platformText
-              : handleText)),
-      ]);
-  },
-};
diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js
index 0af586cd..41944959 100644
--- a/src/content/dependencies/listArtistsByContributions.js
+++ b/src/content/dependencies/listArtistsByContributions.js
@@ -1,6 +1,13 @@
 import {sortAlphabetically, sortByCount} from '#sort';
-import {empty, filterByCount, filterMultipleArrays, stitchArrays, unique}
-  from '#sugar';
+
+import {
+  accumulateSum,
+  empty,
+  filterByCount,
+  filterMultipleArrays,
+  stitchArrays,
+  unique,
+} from '#sugar';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist'],
@@ -38,26 +45,33 @@ export default {
       'artistsByTrackContributions',
       'countsByTrackContributions',
       artist =>
-        unique([
-          ...artist.tracksAsContributor,
-          ...artist.tracksAsArtist,
-        ]).length);
+        (unique(
+          ([
+            artist.trackArtistContributions,
+            artist.trackContributorContributions,
+          ]).flat()
+            .map(({thing}) => thing)
+        )).length);
 
     queryContributionInfo(
       'artistsByArtworkContributions',
       'countsByArtworkContributions',
       artist =>
-        artist.tracksAsCoverArtist.length +
-        artist.albumsAsCoverArtist.length +
-        artist.albumsAsWallpaperArtist.length +
-        artist.albumsAsBannerArtist.length);
+        accumulateSum(
+          [
+            artist.albumCoverArtistContributions,
+            artist.albumWallpaperArtistContributions,
+            artist.albumBannerArtistContributions,
+            artist.trackCoverArtistContributions,
+          ],
+          contribs => contribs.length));
 
     if (sprawl.enableFlashesAndGames) {
       queryContributionInfo(
         'artistsByFlashContributions',
         'countsByFlashContributions',
         artist =>
-          artist.flashesAsContributor.length);
+          artist.flashContributorContributions.length);
     }
 
     return query;
diff --git a/src/content/dependencies/listArtistsByDuration.js b/src/content/dependencies/listArtistsByDuration.js
index f677d82c..6b2a18a0 100644
--- a/src/content/dependencies/listArtistsByDuration.js
+++ b/src/content/dependencies/listArtistsByDuration.js
@@ -1,6 +1,5 @@
 import {sortAlphabetically, sortByCount} from '#sort';
 import {filterByCount, stitchArrays} from '#sugar';
-import {getTotalDuration} from '#wiki-data';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist'],
@@ -16,11 +15,7 @@ export default {
         artistData.filter(artist => !artist.isAlias));
 
     const durations =
-      artists.map(artist =>
-        getTotalDuration([
-          ...(artist.tracksAsArtist ?? []),
-          ...(artist.tracksAsContributor ?? []),
-        ], {originalReleasesOnly: true}));
+      artists.map(artist => artist.totalDuration);
 
     filterByCount(artists, durations);
     sortByCount(artists, durations, {greatestFirst: true});
diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js
index 30884d24..0bf9dd2d 100644
--- a/src/content/dependencies/listArtistsByGroup.js
+++ b/src/content/dependencies/listArtistsByGroup.js
@@ -1,6 +1,13 @@
 import {sortAlphabetically} from '#sort';
-import {empty, filterMultipleArrays, stitchArrays, unique} from '#sugar';
-import {getArtistNumContributions} from '#wiki-data';
+
+import {
+  empty,
+  filterByCount,
+  filterMultipleArrays,
+  stitchArrays,
+  transposeArrays,
+  unique,
+} from '#sugar';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
@@ -15,29 +22,69 @@ export default {
       sortAlphabetically(
         sprawl.artistData.filter(artist => !artist.isAlias));
 
-    const groups =
+    const interestingGroups =
       sprawl.wikiInfo.divideTrackListsByGroups;
 
-    if (empty(groups)) {
-      return {spec, artists};
+    if (empty(interestingGroups)) {
+      return {spec};
     }
 
-    const artistGroups =
+    // We don't actually care about *which* things belong to each group, only
+    // how many belong to each group. So we'll just compute a list of all the
+    // (interesting) groups that each of each artists' things belongs to.
+    const artistThingGroups =
       artists.map(artist =>
-        unique(
-          unique([
-            ...artist.albumsAsAny,
-            ...artist.tracksAsAny.map(track => track.album),
-          ]).flatMap(album => album.groups)))
-
-    const artistsByGroup =
-      groups.map(group =>
-        artists.filter((artist, index) => artistGroups[index].includes(group)));
-
-    filterMultipleArrays(groups, artistsByGroup,
-      (group, artists) => !empty(artists));
-
-    return {spec, groups, artistsByGroup};
+        ([
+          (unique(
+            ([
+              artist.albumArtistContributions,
+              artist.albumCoverArtistContributions,
+              artist.albumWallpaperArtistContributions,
+              artist.albumBannerArtistContributions,
+            ]).flat()
+              .map(({thing}) => thing)
+          )).map(album => album.groups),
+          (unique(
+            ([
+              artist.trackArtistContributions,
+              artist.trackContributorContributions,
+              artist.trackCoverArtistContributions,
+            ]).flat()
+              .map(({thing}) => thing)
+          )).map(track => track.album.groups),
+        ]).flat()
+          .map(groups => groups
+            .filter(group => interestingGroups.includes(group))));
+
+    const [artistsByGroup, countsByGroup] =
+      transposeArrays(interestingGroups.map(group => {
+        const counts =
+          artistThingGroups
+            .map(thingGroups => thingGroups
+              .filter(thingGroups => thingGroups.includes(group))
+              .length);
+
+        const filteredArtists = artists.slice();
+
+        filterByCount(filteredArtists, counts);
+
+        return [filteredArtists, counts];
+      }));
+
+    const groups = interestingGroups;
+
+    filterMultipleArrays(
+      groups,
+      artistsByGroup,
+      countsByGroup,
+      (_group, artists, _counts) => !empty(artists));
+
+    return {
+      spec,
+      groups,
+      artistsByGroup,
+      countsByGroup,
+    };
   },
 
   relations(relation, query) {
@@ -46,12 +93,6 @@ export default {
     relations.page =
       relation('generateListingPage', query.spec);
 
-    if (query.artists) {
-      relations.artistLinks =
-        query.artists
-          .map(artist => relation('linkArtist', artist));
-    }
-
     if (query.artistsByGroup) {
       relations.groupLinks =
         query.groups
@@ -69,65 +110,43 @@ export default {
   data(query) {
     const data = {};
 
-    if (query.artists) {
-      data.counts =
-        query.artists
-          .map(artist => getArtistNumContributions(artist));
-    }
-
     if (query.artistsByGroup) {
       data.groupDirectories =
         query.groups
           .map(group => group.directory);
 
       data.countsByGroup =
-        query.artistsByGroup
-          .map(artists => artists
-            .map(artist => getArtistNumContributions(artist)));
+        query.countsByGroup;
     }
 
     return data;
   },
 
-  generate(data, relations, {language}) {
-    return (
-      (relations.artistLinksByGroup
-        ? relations.page.slots({
-            type: 'chunks',
-
-            showSkipToSection: true,
-            chunkIDs:
-              data.groupDirectories
-                .map(directory => `contributed-to-${directory}`),
-
-            chunkTitles:
-              relations.groupLinks.map(groupLink => ({
-                group: groupLink,
-              })),
-
-            chunkRows:
-              stitchArrays({
-                artistLinks: relations.artistLinksByGroup,
-                counts: data.countsByGroup,
-              }).map(({artistLinks, counts}) =>
-                  stitchArrays({
-                    link: artistLinks,
-                    count: counts,
-                  }).map(({link, count}) => ({
-                      artist: link,
-                      contributions: language.countContributions(count, {unit: true}),
-                    }))),
-          })
-        : relations.page.slots({
-            type: 'rows',
-            rows:
-              stitchArrays({
-                link: relations.artistLinks,
-                count: data.counts,
-              }).map(({link, count}) => ({
-                  artist: link,
-                  contributions: language.countContributions(count, {unit: true}),
-                })),
-          })));
-  },
+  generate: (data, relations, {language}) =>
+    relations.page.slots({
+      type: 'chunks',
+
+      showSkipToSection: true,
+      chunkIDs:
+        data.groupDirectories
+          .map(directory => `contributed-to-${directory}`),
+
+      chunkTitles:
+        relations.groupLinks.map(groupLink => ({
+          group: groupLink,
+        })),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: relations.artistLinksByGroup,
+          counts: data.countsByGroup,
+        }).map(({artistLinks, counts}) =>
+            stitchArrays({
+              link: artistLinks,
+              count: counts,
+            }).map(({link, count}) => ({
+                artist: link,
+                contributions: language.countContributions(count, {unit: true}),
+              }))),
+    }),
 };
diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js
index ab2eca93..79bba441 100644
--- a/src/content/dependencies/listRandomPageLinks.js
+++ b/src/content/dependencies/listRandomPageLinks.js
@@ -74,20 +74,22 @@ export default {
   },
 
   generate(data, relations, {html, language}) {
+    const capsule = language.encapsulate('listingPage.other.randomPages');
+
     const miscellaneousChunkRows = [
-      {
+      language.encapsulate(capsule, 'chunk.item.randomArtist', capsule => ({
         stringsKey: 'randomArtist',
 
         mainLink:
           html.tag('a',
             {href: '#', 'data-random': 'artist'},
-            language.$('listingPage.other.randomPages.chunk.item.randomArtist.mainLink')),
+            language.$(capsule, 'mainLink')),
 
         atLeastTwoContributions:
           html.tag('a',
             {href: '#', 'data-random': 'artist-more-than-one-contrib'},
-            language.$('listingPage.other.randomPages.chunk.item.randomArtist.atLeastTwoContributions')),
-      },
+            language.$(capsule, 'atLeastTwoContributions')),
+      })),
 
       {stringsKey: 'randomAlbumWholeSite'},
       {stringsKey: 'randomTrackWholeSite'},
@@ -104,24 +106,25 @@ export default {
 
       content: [
         html.tag('p',
-          language.$('listingPage.other.randomPages.chooseLinkLine', {
-            fromPart:
-              (relations.groupLinks
-                ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups')
-                : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups')),
+          language.encapsulate(capsule, 'chooseLinkLine', capsule =>
+            language.$(capsule, {
+              fromPart:
+                (relations.groupLinks
+                  ? language.$(capsule, 'fromPart.dividedByGroups')
+                  : language.$(capsule, 'fromPart.notDividedByGroups')),
 
-            browserSupportPart:
-              language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'),
-          })),
+              browserSupportPart:
+                language.$(capsule, 'browserSupportPart'),
+            }))),
 
         html.tag('p', {id: 'data-loading-line'},
-          language.$('listingPage.other.randomPages.dataLoadingLine')),
+          language.$(capsule, 'dataLoadingLine')),
 
         html.tag('p', {id: 'data-loaded-line'},
-          language.$('listingPage.other.randomPages.dataLoadedLine')),
+          language.$(capsule, 'dataLoadedLine')),
 
         html.tag('p', {id: 'data-error-line'},
-          language.$('listingPage.other.randomPages.dataErrorLine')),
+          language.$(capsule, 'dataErrorLine')),
       ],
 
       showSkipToSection: true,
@@ -148,17 +151,18 @@ export default {
 
         ...
           (relations.groupLinks
-            ? relations.groupLinks.map(() => ({
-                randomAlbum:
-                  html.tag('a',
-                    {href: '#', 'data-random': 'album-in-group-dl'},
-                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomAlbum')),
-
-                randomTrack:
-                  html.tag('a',
-                    {href: '#', 'data-random': 'track-in-group-dl'},
-                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomTrack')),
-              }))
+            ? relations.groupLinks.map(() =>
+                language.encapsulate(capsule, 'chunk.title.fromGroup.accent', capsule => ({
+                  randomAlbum:
+                    html.tag('a',
+                      {href: '#', 'data-random': 'album-in-group-dl'},
+                      language.$(capsule, 'randomAlbum')),
+
+                  randomTrack:
+                    html.tag('a',
+                      {href: '#', 'data-random': 'track-in-group-dl'},
+                      language.$(capsule, 'randomTrack')),
+                })))
             : [null]),
       ],
 
diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js
index 01ce4e2d..0a2bfd6c 100644
--- a/src/content/dependencies/listTracksByDate.js
+++ b/src/content/dependencies/listTracksByDate.js
@@ -15,7 +15,8 @@ export default {
 
       chunks:
         chunkByProperties(
-          sortAlbumsTracksChronologically(trackData.slice()),
+          sortAlbumsTracksChronologically(
+            trackData.filter(track => track.date)),
           ['album', 'date']),
     };
   },
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 0904cde6..5f803a3b 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -45,7 +45,7 @@ export default {
   sprawl(wikiData, content) {
     const find = bindFind(wikiData);
 
-    const parsedNodes = parseInput(content);
+    const parsedNodes = parseInput(content ?? '');
 
     return {
       nodes: parsedNodes
@@ -262,6 +262,10 @@ export default {
                   height && {height},
                   style && {style},
 
+                  align === 'center' &&
+                  !link &&
+                    {class: 'align-center'},
+
                   pixelate &&
                     {class: 'pixelate'});
 
@@ -271,16 +275,20 @@ export default {
                     {href: link},
                     {target: '_blank'},
 
+                    align === 'center' &&
+                      {class: 'align-center'},
+
                     {title:
-                      language.$('misc.external.opensInNewTab', {
-                        link:
-                          language.formatExternalLink(link, {
-                            style: 'platform',
-                          }),
+                      language.encapsulate('misc.external.opensInNewTab', capsule =>
+                        language.$(capsule, {
+                          link:
+                            language.formatExternalLink(link, {
+                              style: 'platform',
+                            }),
 
-                        annotation:
-                          language.$('misc.external.opensInNewTab.annotation'),
-                      }).toString()},
+                          annotation:
+                            language.$(capsule, 'annotation'),
+                        }).toString())},
 
                     content);
               }
@@ -505,7 +513,11 @@ export default {
         addText(markedOutput.slice(parseFrom));
       }
 
-      return html.tags(tags, {[html.joinChildren]: ''});
+      return (
+        html.tags(tags, {
+          [html.joinChildren]: '',
+          [html.onlyIfContent]: true,
+        }));
     };
 
     if (slots.mode === 'inline') {
@@ -530,9 +542,9 @@ export default {
           // Expand line breaks which don't follow a list, quote,
           // or <br> / "  ", and which don't precede or follow
           // indented text (by at least two spaces).
-          .replace(/(?<!^ *-.*|^>.*|^  .*\n*|  $|<br>$)\n+(?!  |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
+          .replace(/(?<!^ *(?:-|\d\.).*|^>.*|^  .*\n*|  $|<br>$)\n+(?!  |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
           // Expand line breaks which are at the end of a list.
-          .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n')
+          .replace(/(?<=^ *(?:-|\d\.).*)\n+(?!^ *(?:-|\d\.))/gm, '\n\n')
           // Expand line breaks which are at the end of a quote.
           .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n');
 
diff --git a/src/content/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js
deleted file mode 100644
index c4a62dad..00000000
--- a/src/content/util/getChronologyRelations.js
+++ /dev/null
@@ -1,55 +0,0 @@
-export default function getChronologyRelations(thing, {
-  contributions,
-  linkArtist,
-  linkThing,
-  getThings,
-}) {
-  // One call to getChronologyRelations is considered "lumping" together all
-  // contributions as carrying equivalent meaning (for example, "artist"
-  // contributions and "contributor" contributions are bunched together in
-  // one call to getChronologyRelations, while "cover artist" contributions
-  // are a separate call). getChronologyRelations prevents duplicates that
-  // carry the same meaning by only using the first instance of each artist
-  // in the contributions array passed to it. It's expected that the string
-  // identifying which kind of contribution ("track" or "cover art") is
-  // shared and applied to all contributions, as providing them together
-  // in one call to getChronologyRelations implies they carry the same
-  // meaning.
-
-  const artistsSoFar = new Set();
-
-  contributions = contributions.filter(({artist}) => {
-    if (artistsSoFar.has(artist)) {
-      return false;
-    } else {
-      artistsSoFar.add(artist);
-      return true;
-    }
-  });
-
-  return contributions.map(({artist}) => {
-    const things = Array.from(new Set(getThings(artist)));
-
-    // Don't show a line if this contribution isn't part of the artist's
-    // chronology at all (usually because this thing isn't dated).
-    const index = things.indexOf(thing);
-    if (index === -1) {
-      return;
-    }
-
-    // Don't show a line if this contribution is the *only* item in the
-    // artist's chronology (since there's nothing to navigate there).
-    const previous = things[index - 1];
-    const next = things[index + 1];
-    if (!previous && !next) {
-      return;
-    }
-
-    return {
-      index: index + 1,
-      artistLink: linkArtist(artist),
-      previousLink: previous ? linkThing(previous) : null,
-      nextLink: next ? linkThing(next) : null,
-    };
-  }).filter(Boolean);
-}
diff --git a/src/content/util/groupTracksByGroup.js b/src/content/util/groupTracksByGroup.js
deleted file mode 100644
index 4e189007..00000000
--- a/src/content/util/groupTracksByGroup.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import {empty} from '#sugar';
-
-export default function groupTracksByGroup(tracks, groups) {
-  const lists = new Map(groups.map(group => [group, []]));
-  lists.set('other', []);
-
-  for (const track of tracks) {
-    const group = groups.find(group => group.albums.includes(track.album));
-    if (group) {
-      lists.get(group).push(track);
-    } else {
-      lists.get('other').push(track);
-    }
-  }
-
-  for (const [key, tracks] of lists.entries()) {
-    if (empty(tracks)) {
-      lists.delete(key);
-    }
-  }
-
-  return lists;
-}
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
index 4a89b774..010d967a 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -132,7 +132,11 @@ export default class CacheableObject {
         return;
       }
 
-      if ('default' in update) {
+      if (
+        typeof update === 'object' &&
+        update !== null &&
+        'default' in update
+      ) {
         this[property] = update?.default;
       } else {
         this[property] = null;
@@ -370,6 +374,18 @@ export default class CacheableObject {
 
     return object.#propertyUpdateValues[key] ?? null;
   }
+
+  static clone(object) {
+    const newObject = Reflect.construct(object.constructor, []);
+
+    this.copyUpdateValuesOnto(object, newObject);
+
+    return newObject;
+  }
+
+  static copyUpdateValuesOnto(source, target) {
+    Object.assign(target, source.#propertyUpdateValues);
+  }
 }
 
 export class CacheableObjectPropertyValueError extends Error {
diff --git a/src/data/checks.js b/src/data/checks.js
index f3741a19..ad621bab 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -95,6 +95,7 @@ export function reportDirectoryErrors(wikiData, {
   const seenDuplicateSets = new Map();
   const deduplicateDuplicateSets = [];
 
+  iterateSets:
   for (const set of duplicateSets) {
     if (seenDuplicateSets.has(set.directory)) {
       const placeLists = seenDuplicateSets.get(set.directory);
@@ -106,7 +107,7 @@ export function reportDirectoryErrors(wikiData, {
         // Two artists named Foodog aren't going to match two tracks named
         // Foodog.
         if (compareArrays(places, set.places, {checkOrder: false})) {
-          continue;
+          continue iterateSets;
         }
       }
 
diff --git a/src/data/composite.js b/src/data/composite.js
index 33d69a68..ea7a3480 100644
--- a/src/data/composite.js
+++ b/src/data/composite.js
@@ -758,6 +758,9 @@ export function compositeFrom(description) {
     anyStepsUseUpdateValue ||
     anyStepsUpdate;
 
+  const stepsFirstTimeCalling =
+    Array.from({length: steps.length}).fill(true);
+
   const stepEntries = stitchArrays({
     step: steps,
     stepComposes: stepsCompose,
@@ -977,8 +980,16 @@ export function compositeFrom(description) {
           (expectingTransform
             ? {[input.updateValue()]: valueSoFar}
             : {}),
-        [input.myself()]: initialDependencies?.['this'] ?? null,
-        [input.thisProperty()]: initialDependencies?.['thisProperty'] ?? null,
+
+        [input.myself()]:
+          (initialDependencies && Object.hasOwn(initialDependencies, 'this')
+            ? initialDependencies.this
+            : null),
+
+        [input.thisProperty()]:
+          (initialDependencies && Object.hasOwn(initialDependencies, 'thisProperty')
+            ? initialDependencies.thisProperty
+            : null),
       };
 
       const selectDependencies =
@@ -1028,7 +1039,123 @@ export function compositeFrom(description) {
       const naturalEvaluate = () => {
         const [name, ...argsLayout] = getExpectedEvaluation();
 
-        let args;
+        let args = argsLayout;
+
+        let effectiveDependencies;
+        let reviewAccessedDependencies;
+
+        if (stepsFirstTimeCalling[i]) {
+          const expressedDependencies =
+            selectDependencies;
+
+          const remainingDependencies =
+            new Set(expressedDependencies);
+
+          const unavailableDependencies = [];
+          const accessedDependencies = [];
+
+          effectiveDependencies =
+            new Proxy(filteredDependencies, {
+              get(target, key) {
+                accessedDependencies.push(key);
+                remainingDependencies.delete(key);
+
+                const value = target[key];
+
+                if (value === undefined) {
+                  unavailableDependencies.push(key);
+                }
+
+                return value;
+              },
+            });
+
+          reviewAccessedDependencies = () => {
+            const topAggregate =
+              openAggregate({
+                message: `Errors in accessed dependencies`,
+              });
+
+            const showDependency = dependency =>
+              (isInputToken(dependency)
+                ? getInputTokenShape(dependency) +
+                  `(` +
+                  inspect(getInputTokenValue(dependency), {compact: true}) +
+                  ')'
+                : dependency.toString());
+
+            let anyErrors = false;
+
+            for (const dependency of remainingDependencies) {
+              topAggregate.push(new Error(
+                `Expected to access ${showDependency(dependency)}`));
+
+              anyErrors = true;
+            }
+
+            for (const dependency of unavailableDependencies) {
+              const subAggregate =
+                openAggregate({
+                  message:
+                    `Accessed ${showDependency(dependency)}, which is unavailable`,
+                });
+
+              let reason = false;
+
+              if (!expressedDependencies.includes(dependency)) {
+                subAggregate.push(new Error(
+                  `Missing from step's expressed dependencies`));
+                reason = true;
+              }
+
+              if (filterableDependencies[dependency] === undefined) {
+                subAggregate.push(
+                  new Error(
+                    `Not available` +
+                    (isInputToken(dependency)
+                      ? ` in input()-type dependencies`
+                   : dependency.startsWith('#')
+                      ? ` in local dependencies`
+                      : ` on object dependencies`)));
+                reason = true;
+              }
+
+              if (!reason) {
+                subAggregate.push(new Error(
+                  `Not sure why this is unavailable, sorry!`));
+              }
+
+              topAggregate.call(subAggregate.close);
+
+              anyErrors = true;
+            }
+
+            if (anyErrors) {
+              topAggregate.push(new Error(
+                `These dependencies, in total, were accessed:` +
+                (empty(accessedDependencies)
+                  ? ` (none)`
+               : accessedDependencies.length === 1
+                  ? showDependency(accessedDependencies[0])
+                  : `\n` +
+                    accessedDependencies
+                      .map(showDependency)
+                      .map(line => `  - ${line}`)
+                      .join('\n'))));
+            }
+
+            topAggregate.close();
+          };
+        } else {
+          effectiveDependencies = filteredDependencies;
+          reviewAccessedDependencies = null;
+        }
+
+        args =
+          args.map(arg =>
+            (arg === filteredDependencies
+              ? effectiveDependencies
+              : arg));
 
         if (stepComposes) {
           let continuation;
@@ -1037,17 +1164,50 @@ export function compositeFrom(description) {
             _prepareContinuation(callingTransformForThisStep));
 
           args =
-            argsLayout.map(arg =>
+            args.map(arg =>
               (arg === continuationSymbol
                 ? continuation
                 : arg));
         } else {
           args =
-            argsLayout.filter(arg => arg !== continuationSymbol);
+            args.filter(arg => arg !== continuationSymbol);
         }
 
-        return expose[name](...args);
-      }
+        let stepError;
+        try {
+          return expose[name](...args);
+        } catch (error) {
+          stepError = error;
+        } finally {
+          stepsFirstTimeCalling[i] = false;
+
+          let reviewError;
+          if (reviewAccessedDependencies) {
+            try {
+              reviewAccessedDependencies();
+            } catch (error) {
+              reviewError = error;
+            }
+          }
+
+          const stepPart =
+            `step ${i+1}` +
+            (isBase
+              ? ` (base)`
+              : ` of ${steps.length}`) +
+            (step.annotation ? `, ${step.annotation}` : ``);
+
+          if (stepError && reviewError) {
+            throw new AggregateError(
+              [stepError, reviewError],
+              `Errors in ${stepPart}`);
+          } else if (stepError || reviewError) {
+            throw new Error(
+              `Error in ${stepPart}`,
+              {cause: stepError || reviewError});
+          }
+        }
+      };
 
       switch (step.cache) {
         // Warning! Highly WIP!
@@ -1223,6 +1383,7 @@ export function compositeFrom(description) {
           `Error computing composition` +
           (annotation ? ` ${annotation}` : ''));
         error.cause = thrownError;
+        error[Symbol.for('hsmusic.aggregate.translucent')] = true;
         throw error;
       }
     };
diff --git a/src/data/composite/control-flow/exposeWhetherDependencyAvailable.js b/src/data/composite/control-flow/exposeWhetherDependencyAvailable.js
new file mode 100644
index 00000000..a2fdd6b0
--- /dev/null
+++ b/src/data/composite/control-flow/exposeWhetherDependencyAvailable.js
@@ -0,0 +1,42 @@
+// Exposes true if a dependency is available, and false otherwise,
+// or the reverse if the `negate` input is set true.
+//
+// See withResultOfAvailabilityCheck for {mode} options.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js';
+
+export default templateCompositeFrom({
+  annotation: `exposeWhetherDependencyAvailable`,
+
+  compose: false,
+
+  inputs: {
+    dependency: input({acceptsNull: true}),
+
+    mode: inputAvailabilityCheckMode(),
+
+    negate: input({type: 'boolean', defaultValue: false}),
+  },
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('dependency'),
+      mode: input('mode'),
+    }),
+
+    {
+      dependencies: ['#availability', input('negate')],
+
+      compute: ({
+        ['#availability']: availability,
+        [input('negate')]: negate,
+      }) =>
+        (negate
+          ? !availability
+          : availability),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js
index 7fad88b2..6148d465 100644
--- a/src/data/composite/control-flow/index.js
+++ b/src/data/composite/control-flow/index.js
@@ -9,6 +9,7 @@ export {default as exposeConstant} from './exposeConstant.js';
 export {default as exposeDependency} from './exposeDependency.js';
 export {default as exposeDependencyOrContinue} from './exposeDependencyOrContinue.js';
 export {default as exposeUpdateValueOrContinue} from './exposeUpdateValueOrContinue.js';
+export {default as exposeWhetherDependencyAvailable} from './exposeWhetherDependencyAvailable.js';
 export {default as raiseOutputWithoutDependency} from './raiseOutputWithoutDependency.js';
 export {default as raiseOutputWithoutUpdateValue} from './raiseOutputWithoutUpdateValue.js';
 export {default as withResultOfAvailabilityCheck} from './withResultOfAvailabilityCheck.js';
diff --git a/src/data/composite/control-flow/withResultOfAvailabilityCheck.js b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js
index a6942014..1d90b324 100644
--- a/src/data/composite/control-flow/withResultOfAvailabilityCheck.js
+++ b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js
@@ -17,6 +17,7 @@
 //  - exitWithoutUpdateValue
 //  - exposeDependencyOrContinue
 //  - exposeUpdateValueOrContinue
+//  - exposeWhetherDependencyAvailable
 //  - raiseOutputWithoutDependency
 //  - raiseOutputWithoutUpdateValue
 //
diff --git a/src/data/composite/data/excludeFromList.js b/src/data/composite/data/excludeFromList.js
index d798dcdc..2a3e818e 100644
--- a/src/data/composite/data/excludeFromList.js
+++ b/src/data/composite/data/excludeFromList.js
@@ -5,11 +5,6 @@
 // See also:
 //  - fillMissingListItems
 //
-// More list utilities:
-//  - withFilteredList, withMappedList, withSortedList
-//  - withFlattenedList, withUnflattenedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 import {empty} from '#sugar';
diff --git a/src/data/composite/data/fillMissingListItems.js b/src/data/composite/data/fillMissingListItems.js
index 4f818a79..356b1119 100644
--- a/src/data/composite/data/fillMissingListItems.js
+++ b/src/data/composite/data/fillMissingListItems.js
@@ -4,11 +4,6 @@
 // See also:
 //  - excludeFromList
 //
-// More list utilities:
-//  - withFilteredList, withMappedList, withSortedList
-//  - withFlattenedList, withUnflattenedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 
diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js
index 256c0490..c80bb350 100644
--- a/src/data/composite/data/index.js
+++ b/src/data/composite/data/index.js
@@ -3,15 +3,32 @@
 // Entries here may depend on entries in #composite/control-flow.
 //
 
+// Utilities which act on generic objects
+
+export {default as withPropertiesFromObject} from './withPropertiesFromObject.js';
+export {default as withPropertyFromObject} from './withPropertyFromObject.js';
+
+// Utilities which act on generic lists
+
 export {default as excludeFromList} from './excludeFromList.js';
+
 export {default as fillMissingListItems} from './fillMissingListItems.js';
+export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js';
+
 export {default as withFilteredList} from './withFilteredList.js';
-export {default as withFlattenedList} from './withFlattenedList.js';
 export {default as withMappedList} from './withMappedList.js';
-export {default as withPropertiesFromList} from './withPropertiesFromList.js';
-export {default as withPropertiesFromObject} from './withPropertiesFromObject.js';
-export {default as withPropertyFromList} from './withPropertyFromList.js';
-export {default as withPropertyFromObject} from './withPropertyFromObject.js';
 export {default as withSortedList} from './withSortedList.js';
+
+export {default as withPropertyFromList} from './withPropertyFromList.js';
+export {default as withPropertiesFromList} from './withPropertiesFromList.js';
+
+export {default as withFlattenedList} from './withFlattenedList.js';
 export {default as withUnflattenedList} from './withUnflattenedList.js';
-export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js';
+
+export {default as withIndexInList} from './withIndexInList.js';
+export {default as withNearbyItemFromList} from './withNearbyItemFromList.js';
+
+// Utilities which act on slightly more particular data forms
+// (probably, containers of particular kinds of values)
+
+export {default as withSum} from './withSum.js';
diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js
index 82e56903..60fe66f4 100644
--- a/src/data/composite/data/withFilteredList.js
+++ b/src/data/composite/data/withFilteredList.js
@@ -16,12 +16,6 @@
 //  - withMappedList
 //  - withSortedList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFlattenedList, withUnflattenedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 
diff --git a/src/data/composite/data/withFlattenedList.js b/src/data/composite/data/withFlattenedList.js
index edfa3403..31b1a742 100644
--- a/src/data/composite/data/withFlattenedList.js
+++ b/src/data/composite/data/withFlattenedList.js
@@ -5,12 +5,6 @@
 // See also:
 //  - withUnflattenedList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFilteredList, withMappedList, withSortedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 
diff --git a/src/data/composite/data/withIndexInList.js b/src/data/composite/data/withIndexInList.js
new file mode 100644
index 00000000..b1af2033
--- /dev/null
+++ b/src/data/composite/data/withIndexInList.js
@@ -0,0 +1,38 @@
+// Gets the index of the provided item in the provided list. Note that this
+// will output -1 if the item is not found, and this may be detected using
+// any availability check with type: 'index'. If the list includes the item
+// twice, the output index will be of the first match.
+//
+// Both the list and item must be provided.
+//
+// See also:
+//  - withNearbyItemFromList
+//  - exitWithoutDependency
+//  - raiseOutputWithoutDependency
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withIndexInList`,
+
+  inputs: {
+    list: input({acceptsNull: false, type: 'array'}),
+    item: input({acceptsNull: false}),
+  },
+
+  outputs: ['#index'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('item')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('item')]: item,
+      }) => continuation({
+        ['#index']:
+          list.indexOf(item),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withMappedList.js b/src/data/composite/data/withMappedList.js
index e0a700b2..0bc63a92 100644
--- a/src/data/composite/data/withMappedList.js
+++ b/src/data/composite/data/withMappedList.js
@@ -5,12 +5,6 @@
 //  - withFilteredList
 //  - withSortedList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFlattenedList, withUnflattenedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 
diff --git a/src/data/composite/data/withNearbyItemFromList.js b/src/data/composite/data/withNearbyItemFromList.js
new file mode 100644
index 00000000..83a8cc21
--- /dev/null
+++ b/src/data/composite/data/withNearbyItemFromList.js
@@ -0,0 +1,73 @@
+// Gets a nearby (typically adjacent) item in a list, meaning the item which is
+// placed at a particular offset compared to the provided item. This is null if
+// the provided list doesn't include the provided item at all, and also if the
+// offset would read past either end of the list - except if configured:
+//
+//  - If the 'wrap' input is provided (as true), the offset will loop around
+//    and continue from the opposing end.
+//
+//  - If the 'valuePastEdge' input is provided, that value will be output
+//    instead of null.
+//
+// Both the list and item must be provided.
+//
+// See also:
+//  - withIndexInList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {atOffset} from '#sugar';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withIndexInList from './withIndexInList.js';
+
+export default templateCompositeFrom({
+  annotation: `withNearbyItemFromList`,
+
+  inputs: {
+    list: input({acceptsNull: false, type: 'array'}),
+    item: input({acceptsNull: false}),
+
+    offset: input({type: 'number'}),
+    wrap: input({type: 'boolean', defaultValue: false}),
+  },
+
+  outputs: ['#nearbyItem'],
+
+  steps: () => [
+    withIndexInList({
+      list: input('list'),
+      item: input('item'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#index',
+      mode: input.value('index'),
+
+      output: input.value({
+        ['#nearbyItem']:
+          null,
+      }),
+    }),
+
+    {
+      dependencies: [
+        input('list'),
+        input('offset'),
+        input('wrap'),
+        '#index',
+      ],
+
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('offset')]: offset,
+        [input('wrap')]: wrap,
+        ['#index']: index,
+      }) => continuation({
+        ['#nearbyItem']:
+          atOffset(list, index, offset, {wrap}),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertiesFromList.js b/src/data/composite/data/withPropertiesFromList.js
index 08907bab..fb4134bc 100644
--- a/src/data/composite/data/withPropertiesFromList.js
+++ b/src/data/composite/data/withPropertiesFromList.js
@@ -8,12 +8,6 @@
 //  - withPropertiesFromObject
 //  - withPropertyFromList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFilteredList, withMappedList, withSortedList
-//  - withFlattenedList, withUnflattenedList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 import {isString, validateArrayItems} from '#validators';
diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js
index a2c66d77..65ebf77b 100644
--- a/src/data/composite/data/withPropertyFromList.js
+++ b/src/data/composite/data/withPropertyFromList.js
@@ -9,12 +9,6 @@
 //  - withPropertiesFromList
 //  - withPropertyFromObject
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFilteredList, withMappedList, withSortedList
-//  - withFlattenedList, withUnflattenedList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 
diff --git a/src/data/composite/data/withPropertyFromObject.js b/src/data/composite/data/withPropertyFromObject.js
index b31bab15..4f240506 100644
--- a/src/data/composite/data/withPropertyFromObject.js
+++ b/src/data/composite/data/withPropertyFromObject.js
@@ -2,11 +2,15 @@
 // If the object itself is null, or the object doesn't have the listed property,
 // the provided dependency will also be null.
 //
+// If the `internal` input is true, this reads the CacheableObject update value
+// of the object rather than its exposed value.
+//
 // See also:
 //  - withPropertiesFromObject
 //  - withPropertyFromList
 //
 
+import CacheableObject from '#cacheable-object';
 import {input, templateCompositeFrom} from '#composite';
 
 export default templateCompositeFrom({
@@ -15,6 +19,7 @@ export default templateCompositeFrom({
   inputs: {
     object: input({type: 'object', acceptsNull: true}),
     property: input({type: 'string'}),
+    internal: input({type: 'boolean', defaultValue: false}),
   },
 
   outputs: ({
@@ -49,20 +54,35 @@ export default templateCompositeFrom({
 
     {
       dependencies: [
-        '#output',
         input('object'),
         input('property'),
+        input('internal'),
       ],
 
       compute: (continuation, {
-        ['#output']: output,
         [input('object')]: object,
         [input('property')]: property,
+        [input('internal')]: internal,
       }) => continuation({
-        [output]:
+        '#value':
           (object === null
             ? null
-            : object[property] ?? null),
+         : internal
+            ? CacheableObject.getUpdateValue(object, property)
+                ?? null
+            : object[property]
+                ?? null),
+      }),
+    },
+
+    {
+      dependencies: ['#output', '#value'],
+
+      compute: (continuation, {
+        ['#output']: output,
+        ['#value']: value,
+      }) => continuation({
+        [output]: value,
       }),
     },
   ],
diff --git a/src/data/composite/data/withSortedList.js b/src/data/composite/data/withSortedList.js
index dd810786..a7d21768 100644
--- a/src/data/composite/data/withSortedList.js
+++ b/src/data/composite/data/withSortedList.js
@@ -27,12 +27,6 @@
 //  - withFilteredList
 //  - withMappedList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFlattenedList, withUnflattenedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 
diff --git a/src/data/composite/data/withSum.js b/src/data/composite/data/withSum.js
new file mode 100644
index 00000000..484e9906
--- /dev/null
+++ b/src/data/composite/data/withSum.js
@@ -0,0 +1,33 @@
+// Gets the numeric total of adding all the values in a list together.
+// Values that are false, null, or undefined are skipped over.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isNumber, sparseArrayOf} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withSum`,
+
+  inputs: {
+    values: input({
+      validate: sparseArrayOf(isNumber),
+    }),
+  },
+
+  outputs: ['#sum'],
+
+  steps: () => [
+    {
+      dependencies: [input('values')],
+      compute: (continuation, {
+        [input('values')]: values,
+      }) => continuation({
+        ['#sum']:
+          values
+            .filter(item => typeof item === 'number')
+            .reduce(
+              (accumulator, value) => accumulator + value,
+              0),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withUnflattenedList.js b/src/data/composite/data/withUnflattenedList.js
index 39a666dc..820d628a 100644
--- a/src/data/composite/data/withUnflattenedList.js
+++ b/src/data/composite/data/withUnflattenedList.js
@@ -7,12 +7,6 @@
 // See also:
 //  - withFlattenedList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFilteredList, withMappedList, withSortedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 import {isWholeNumber, validateArrayItems} from '#validators';
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
index 8b5098f0..0ef91b87 100644
--- a/src/data/composite/things/album/index.js
+++ b/src/data/composite/things/album/index.js
@@ -1 +1,2 @@
+export {default as withTrackSections} from './withTrackSections.js';
 export {default as withTracks} from './withTracks.js';
diff --git a/src/data/composite/things/album/withTrackSections.js b/src/data/composite/things/album/withTrackSections.js
new file mode 100644
index 00000000..a56bda31
--- /dev/null
+++ b/src/data/composite/things/album/withTrackSections.js
@@ -0,0 +1,21 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import find from '#find';
+
+import {withResolvedReferenceList} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withTrackSections`,
+
+  outputs: ['#trackSections'],
+
+  steps: () => [
+    withResolvedReferenceList({
+      list: 'trackSections',
+      data: 'ownTrackSectionData',
+      find: input.value(find.unqualifiedTrackSection),
+    }).outputs({
+      ['#resolvedReferenceList']: '#trackSections',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js
index 3fe6dd2e..c8d27c4c 100644
--- a/src/data/composite/things/album/withTracks.js
+++ b/src/data/composite/things/album/withTracks.js
@@ -1,24 +1,17 @@
 import {input, templateCompositeFrom} from '#composite';
 
-import find from '#find';
-
-import {exitWithoutDependency} from '#composite/control-flow';
 import {withFlattenedList, withPropertyFromList} from '#composite/data';
 import {withResolvedReferenceList} from '#composite/wiki-data';
 
+import withTrackSections from './withTrackSections.js';
+
 export default templateCompositeFrom({
   annotation: `withTracks`,
 
   outputs: ['#tracks'],
 
   steps: () => [
-    withResolvedReferenceList({
-      list: 'trackSections',
-      data: 'ownTrackSectionData',
-      find: input.value(find.unqualifiedTrackSection),
-    }).outputs({
-      ['#resolvedReferenceList']: '#trackSections',
-    }),
+    withTrackSections(),
 
     withPropertyFromList({
       list: '#trackSections',
diff --git a/src/data/composite/things/artist/artistTotalDuration.js b/src/data/composite/things/artist/artistTotalDuration.js
new file mode 100644
index 00000000..ff709f28
--- /dev/null
+++ b/src/data/composite/things/artist/artistTotalDuration.js
@@ -0,0 +1,70 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withFilteredList, withPropertyFromList} from '#composite/data';
+import {withContributionListSums, withReverseContributionList}
+  from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `artistTotalDuration`,
+
+  compose: false,
+
+  steps: () => [
+    withReverseContributionList({
+      data: 'trackData',
+      list: input.value('artistContribs'),
+    }).outputs({
+      '#reverseContributionList': '#contributionsAsArtist',
+    }),
+
+    withReverseContributionList({
+      data: 'trackData',
+      list: input.value('contributorContribs'),
+    }).outputs({
+      '#reverseContributionList': '#contributionsAsContributor',
+    }),
+
+    {
+      dependencies: [
+        '#contributionsAsArtist',
+        '#contributionsAsContributor',
+      ],
+
+      compute: (continuation, {
+        ['#contributionsAsArtist']: artistContribs,
+        ['#contributionsAsContributor']: contributorContribs,
+      }) => continuation({
+        ['#allContributions']: [
+          ...artistContribs,
+          ...contributorContribs,
+        ],
+      }),
+    },
+
+    withPropertyFromList({
+      list: '#allContributions',
+      property: input.value('thing'),
+    }),
+
+    withPropertyFromList({
+      list: '#allContributions.thing',
+      property: input.value('isOriginalRelease'),
+    }),
+
+    withFilteredList({
+      list: '#allContributions',
+      filter: '#allContributions.thing.isOriginalRelease',
+    }).outputs({
+      '#filteredList': '#originalContributions',
+    }),
+
+    withContributionListSums({
+      list: '#originalContributions',
+    }),
+
+    exposeDependency({
+      dependency: '#contributionListDuration',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/artist/index.js b/src/data/composite/things/artist/index.js
new file mode 100644
index 00000000..55514c71
--- /dev/null
+++ b/src/data/composite/things/artist/index.js
@@ -0,0 +1 @@
+export {default as artistTotalDuration} from './artistTotalDuration.js';
diff --git a/src/data/composite/things/contribution/index.js b/src/data/composite/things/contribution/index.js
new file mode 100644
index 00000000..9b22be2e
--- /dev/null
+++ b/src/data/composite/things/contribution/index.js
@@ -0,0 +1,7 @@
+export {default as inheritFromContributionPresets} from './inheritFromContributionPresets.js';
+export {default as thingPropertyMatches} from './thingPropertyMatches.js';
+export {default as thingReferenceTypeMatches} from './thingReferenceTypeMatches.js';
+export {default as withContainingReverseContributionList} from './withContainingReverseContributionList.js';
+export {default as withContributionArtist} from './withContributionArtist.js';
+export {default as withContributionContext} from './withContributionContext.js';
+export {default as withMatchingContributionPresets} from './withMatchingContributionPresets.js';
diff --git a/src/data/composite/things/contribution/inheritFromContributionPresets.js b/src/data/composite/things/contribution/inheritFromContributionPresets.js
new file mode 100644
index 00000000..82425b9c
--- /dev/null
+++ b/src/data/composite/things/contribution/inheritFromContributionPresets.js
@@ -0,0 +1,61 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromList, withPropertyFromObject} from '#composite/data';
+
+import withMatchingContributionPresets
+  from './withMatchingContributionPresets.js';
+
+export default templateCompositeFrom({
+  annotation: `inheritFromContributionPresets`,
+
+  inputs: {
+    property: input({type: 'string'}),
+  },
+
+  steps: () => [
+    withMatchingContributionPresets().outputs({
+      '#matchingContributionPresets': '#presets',
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#presets',
+      mode: input.value('empty'),
+    }),
+
+    withPropertyFromList({
+      list: '#presets',
+      property: input('property'),
+    }),
+
+    {
+      dependencies: ['#values'],
+
+      compute: (continuation, {
+        ['#values']: values,
+      }) => continuation({
+        ['#index']:
+          values.findIndex(value =>
+            value !== undefined &&
+            value !== null),
+      }),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: '#index',
+      mode: input.value('index'),
+    }),
+
+    {
+      dependencies: ['#values', '#index'],
+
+      compute: (continuation, {
+        ['#values']: values,
+        ['#index']: index,
+      }) => continuation({
+        ['#value']:
+          values[index],
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/contribution/thingPropertyMatches.js b/src/data/composite/things/contribution/thingPropertyMatches.js
new file mode 100644
index 00000000..4a37f2cf
--- /dev/null
+++ b/src/data/composite/things/contribution/thingPropertyMatches.js
@@ -0,0 +1,33 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `thingPropertyMatches`,
+
+  compose: false,
+
+  inputs: {
+    value: input({type: 'string'}),
+  },
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: 'thingProperty',
+      value: input.value(false),
+    }),
+
+    {
+      dependencies: [
+        'thingProperty',
+        input('value'),
+      ],
+
+      compute: ({
+        ['thingProperty']: thingProperty,
+        [input('value')]: value,
+      }) =>
+        thingProperty === value,
+    },
+  ],
+});
diff --git a/src/data/composite/things/contribution/thingReferenceTypeMatches.js b/src/data/composite/things/contribution/thingReferenceTypeMatches.js
new file mode 100644
index 00000000..2ee811af
--- /dev/null
+++ b/src/data/composite/things/contribution/thingReferenceTypeMatches.js
@@ -0,0 +1,39 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `thingReferenceTypeMatches`,
+
+  compose: false,
+
+  inputs: {
+    value: input({type: 'string'}),
+  },
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: 'thing',
+      value: input.value(false),
+    }),
+
+    withPropertyFromObject({
+      object: 'thing',
+      property: input.value('constructor'),
+    }),
+
+    {
+      dependencies: [
+        '#thing.constructor',
+        input('value'),
+      ],
+
+      compute: ({
+        ['#thing.constructor']: constructor,
+        [input('value')]: value,
+      }) =>
+        constructor[Symbol.for('Thing.referenceType')] === value,
+    },
+  ],
+});
diff --git a/src/data/composite/things/contribution/withContainingReverseContributionList.js b/src/data/composite/things/contribution/withContainingReverseContributionList.js
new file mode 100644
index 00000000..56704c8b
--- /dev/null
+++ b/src/data/composite/things/contribution/withContainingReverseContributionList.js
@@ -0,0 +1,40 @@
+// Get the artist's contribution list containing this property.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withContributionArtist from './withContributionArtist.js';
+
+export default templateCompositeFrom({
+  annotation: `withContainingReverseContributionList`,
+
+  inputs: {
+    artistProperty: input({
+      defaultDependency: 'artistProperty',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#containingReverseContributionList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('artistProperty'),
+      output: input.value({
+        ['#containingReverseContributionList']:
+          null,
+      }),
+    }),
+
+    withContributionArtist(),
+
+    withPropertyFromObject({
+      object: '#artist',
+      property: input('artistProperty'),
+    }).outputs({
+      ['#value']: '#containingReverseContributionList',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/contribution/withContributionArtist.js b/src/data/composite/things/contribution/withContributionArtist.js
new file mode 100644
index 00000000..5a611c1a
--- /dev/null
+++ b/src/data/composite/things/contribution/withContributionArtist.js
@@ -0,0 +1,34 @@
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+
+import {withPropertyFromObject} from '#composite/data';
+import {withResolvedReference} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withContributionArtist`,
+
+  inputs: {
+    ref: input({
+      type: 'string',
+      defaultDependency: 'artist',
+    }),
+  },
+
+  outputs: ['#artist'],
+
+  steps: () => [
+    withPropertyFromObject({
+      object: 'thing',
+      property: input.value('artistData'),
+      internal: input.value(true),
+    }),
+
+    withResolvedReference({
+      ref: input('ref'),
+      data: '#thing.artistData',
+      find: input.value(find.artist),
+    }).outputs({
+      '#resolvedReference': '#artist',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/contribution/withContributionContext.js b/src/data/composite/things/contribution/withContributionContext.js
new file mode 100644
index 00000000..3c1c31c0
--- /dev/null
+++ b/src/data/composite/things/contribution/withContributionContext.js
@@ -0,0 +1,45 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `withContributionContext`,
+
+  outputs: [
+    '#contributionTarget',
+    '#contributionProperty',
+  ],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'thing',
+      output: input.value({
+        '#contributionTarget': null,
+        '#contributionProperty': null,
+      }),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: 'thingProperty',
+      output: input.value({
+        '#contributionTarget': null,
+        '#contributionProperty': null,
+      }),
+    }),
+
+    {
+      dependencies: ['thing', 'thingProperty'],
+
+      compute: (continuation, {
+        ['thing']: thing,
+        ['thingProperty']: thingProperty,
+      }) => continuation({
+        ['#contributionTarget']:
+          thing.constructor[Symbol.for('Thing.referenceType')],
+
+        ['#contributionProperty']:
+          thingProperty,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/contribution/withMatchingContributionPresets.js b/src/data/composite/things/contribution/withMatchingContributionPresets.js
new file mode 100644
index 00000000..09454164
--- /dev/null
+++ b/src/data/composite/things/contribution/withMatchingContributionPresets.js
@@ -0,0 +1,70 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withContributionContext from './withContributionContext.js';
+
+export default templateCompositeFrom({
+  annotation: `withMatchingContributionPresets`,
+
+  outputs: ['#matchingContributionPresets'],
+
+  steps: () => [
+    withPropertyFromObject({
+      object: 'thing',
+      property: input.value('wikiInfo'),
+      internal: input.value(true),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#thing.wikiInfo',
+      output: input.value({
+        '#matchingContributionPresets': null,
+      }),
+    }),
+
+    withPropertyFromObject({
+      object: '#thing.wikiInfo',
+      property: input.value('contributionPresets'),
+    }).outputs({
+      '#thing.wikiInfo.contributionPresets': '#contributionPresets',
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#contributionPresets',
+      mode: input.value('empty'),
+      output: input.value({
+        '#matchingContributionPresets': [],
+      }),
+    }),
+
+    withContributionContext(),
+
+    {
+      dependencies: [
+        '#contributionPresets',
+        '#contributionTarget',
+        '#contributionProperty',
+        'annotation',
+      ],
+
+      compute: (continuation, {
+        ['#contributionPresets']: presets,
+        ['#contributionTarget']: target,
+        ['#contributionProperty']: property,
+        ['annotation']: annotation,
+      }) => continuation({
+        ['#matchingContributionPresets']:
+          presets
+            .filter(preset =>
+              preset.context[0] === target &&
+              preset.context.slice(1).includes(property) &&
+              // For now, only match if the annotation is a complete match.
+              // Partial matches (e.g. because the contribution includes "two"
+              // annotations, separated by commas) don't count.
+              preset.annotation === annotation),
+      })
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js
index 8959de9f..714858a0 100644
--- a/src/data/composite/things/track/index.js
+++ b/src/data/composite/things/track/index.js
@@ -1,12 +1,16 @@
 export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js';
 export {default as inferredAdditionalNameList} from './inferredAdditionalNameList.js';
+export {default as inheritContributionListFromOriginalRelease} from './inheritContributionListFromOriginalRelease.js';
 export {default as inheritFromOriginalRelease} from './inheritFromOriginalRelease.js';
 export {default as sharedAdditionalNameList} from './sharedAdditionalNameList.js';
 export {default as trackReverseReferenceList} from './trackReverseReferenceList.js';
 export {default as withAlbum} from './withAlbum.js';
 export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js';
 export {default as withContainingTrackSection} from './withContainingTrackSection.js';
+export {default as withDate} from './withDate.js';
 export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js';
+export {default as withOriginalRelease} from './withOriginalRelease.js';
 export {default as withOtherReleases} from './withOtherReleases.js';
 export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js';
 export {default as withPropertyFromOriginalRelease} from './withPropertyFromOriginalRelease.js';
+export {default as withTrackArtDate} from './withTrackArtDate.js';
diff --git a/src/data/composite/things/track/inheritContributionListFromOriginalRelease.js b/src/data/composite/things/track/inheritContributionListFromOriginalRelease.js
new file mode 100644
index 00000000..f4ae3ddb
--- /dev/null
+++ b/src/data/composite/things/track/inheritContributionListFromOriginalRelease.js
@@ -0,0 +1,44 @@
+// Like inheritFromOriginalRelease, but tuned for contributions.
+// Recontextualized contributions for this track.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+import {withRecontextualizedContributionList, withRedatedContributionList}
+  from '#composite/wiki-data';
+
+import withDate from './withDate.js';
+import withPropertyFromOriginalRelease
+  from './withPropertyFromOriginalRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `inheritContributionListFromOriginalRelease`,
+
+  steps: () => [
+    withPropertyFromOriginalRelease({
+      property: input.thisProperty(),
+      notFoundValue: input.value([]),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#isRerelease',
+      mode: input.value('falsy'),
+    }),
+
+    withRecontextualizedContributionList({
+      list: '#originalValue',
+    }),
+
+    withDate(),
+
+    withRedatedContributionList({
+      list: '#originalValue',
+      date: '#date',
+    }),
+
+    exposeDependency({
+      dependency: '#originalValue',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js
index eaac14de..2c42709b 100644
--- a/src/data/composite/things/track/withContainingTrackSection.js
+++ b/src/data/composite/things/track/withContainingTrackSection.js
@@ -30,7 +30,6 @@ export default templateCompositeFrom({
 
       compute: (continuation, {
         [input.myself()]: track,
-        [input('notFoundMode')]: notFoundMode,
         ['#album.trackSections']: trackSections,
       }) => continuation({
         ['#trackSection']:
diff --git a/src/data/composite/things/track/withDate.js b/src/data/composite/things/track/withDate.js
new file mode 100644
index 00000000..b5a770e9
--- /dev/null
+++ b/src/data/composite/things/track/withDate.js
@@ -0,0 +1,34 @@
+// Gets the track's own date. This is either its dateFirstReleased property
+// or, if unset, the album's date.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withDate`,
+
+  outputs: ['#date'],
+
+  steps: () => [
+    {
+      dependencies: ['dateFirstReleased'],
+      compute: (continuation, {dateFirstReleased}) =>
+        (dateFirstReleased
+          ? continuation.raiseOutput({'#date': dateFirstReleased})
+          : continuation()),
+    },
+
+    withPropertyFromAlbum({
+      property: input.value('date'),
+    }),
+
+    {
+      dependencies: ['#album.date'],
+      compute: (continuation, {['#album.date']: albumDate}) =>
+        (albumDate
+          ? continuation.raiseOutput({'#date': albumDate})
+          : continuation.raiseOutput({'#date': null})),
+    },
+  ],
+})
diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js
index 96078d5f..f7e65f25 100644
--- a/src/data/composite/things/track/withHasUniqueCoverArt.js
+++ b/src/data/composite/things/track/withHasUniqueCoverArt.js
@@ -29,7 +29,10 @@ export default templateCompositeFrom({
           : continuation()),
     },
 
-    withResolvedContribs({from: 'coverArtistContribs'}),
+    withResolvedContribs({
+      from: 'coverArtistContribs',
+      date: input.value(null),
+    }),
 
     {
       dependencies: ['#resolvedContribs'],
diff --git a/src/data/composite/things/track/withTrackArtDate.js b/src/data/composite/things/track/withTrackArtDate.js
new file mode 100644
index 00000000..e2c4d8bc
--- /dev/null
+++ b/src/data/composite/things/track/withTrackArtDate.js
@@ -0,0 +1,80 @@
+// Gets the date of cover art release. This represents only the track's own
+// unique cover artwork, if any.
+//
+// If the 'fallback' option is false (the default), this will only output
+// the track's own coverArtDate or its album's trackArtDate. If 'fallback'
+// is set, and neither of these is available, it'll output the track's own
+// date instead.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isDate} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withDate from './withDate.js';
+import withHasUniqueCoverArt from './withHasUniqueCoverArt.js';
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withTrackArtDate`,
+
+  inputs: {
+    from: input({
+      validate: isDate,
+      defaultDependency: 'coverArtDate',
+      acceptsNull: true,
+    }),
+
+    fallback: input({
+      type: 'boolean',
+      defaultValue: false,
+    }),
+  },
+
+  outputs: ['#trackArtDate'],
+
+  steps: () => [
+    withHasUniqueCoverArt(),
+
+    raiseOutputWithoutDependency({
+      dependency: '#hasUniqueCoverArt',
+      mode: input.value('falsy'),
+      output: input.value({'#trackArtDate': null}),
+    }),
+
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: from,
+      }) =>
+        (from
+          ? continuation.raiseOutput({'#trackArtDate': from})
+          : continuation()),
+    },
+
+    withPropertyFromAlbum({
+      property: input.value('trackArtDate'),
+    }),
+
+    {
+      dependencies: [
+        '#album.trackArtDate',
+        input('fallback'),
+      ],
+
+      compute: (continuation, {
+        ['#album.trackArtDate']: albumTrackArtDate,
+        [input('fallback')]: fallback,
+      }) =>
+        (albumTrackArtDate
+          ? continuation.raiseOutput({'#trackArtDate': albumTrackArtDate})
+       : fallback
+          ? continuation()
+          : continuation.raiseOutput({'#trackArtDate': null})),
+    },
+
+    withDate().outputs({
+      '#date': '#trackArtDate',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/exitWithoutContribs.js b/src/data/composite/wiki-data/exitWithoutContribs.js
index 2c8219fc..cf52950d 100644
--- a/src/data/composite/wiki-data/exitWithoutContribs.js
+++ b/src/data/composite/wiki-data/exitWithoutContribs.js
@@ -24,6 +24,7 @@ export default templateCompositeFrom({
   steps: () => [
     withResolvedContribs({
       from: input('contribs'),
+      date: input.value(null),
     }),
 
     // TODO: Fairly certain exitWithoutDependency would be sufficient here.
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index 15ebaffa..5f17ca3a 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -6,9 +6,14 @@
 
 export {default as exitWithoutContribs} from './exitWithoutContribs.js';
 export {default as inputWikiData} from './inputWikiData.js';
+export {default as withClonedThings} from './withClonedThings.js';
+export {default as withContributionListSums} from './withContributionListSums.js';
+export {default as withCoverArtDate} from './withCoverArtDate.js';
 export {default as withDirectory} from './withDirectory.js';
 export {default as withDirectoryFromName} from './withDirectoryFromName.js';
 export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js';
+export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js';
+export {default as withRedatedContributionList} from './withRedatedContributionList.js';
 export {default as withResolvedContribs} from './withResolvedContribs.js';
 export {default as withResolvedReference} from './withResolvedReference.js';
 export {default as withResolvedReferenceList} from './withResolvedReferenceList.js';
diff --git a/src/data/composite/wiki-data/withClonedThings.js b/src/data/composite/wiki-data/withClonedThings.js
new file mode 100644
index 00000000..9af6aa84
--- /dev/null
+++ b/src/data/composite/wiki-data/withClonedThings.js
@@ -0,0 +1,68 @@
+// Clones all the things in a list. If the 'assign' input is provided,
+// all new things are assigned the same specified properties. If the
+// 'assignEach' input is provided, each new thing is assigned the
+// corresponding properties.
+
+import CacheableObject from '#cacheable-object';
+import {input, templateCompositeFrom} from '#composite';
+import {isObject, sparseArrayOf} from '#validators';
+
+import {withMappedList} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withClonedThings`,
+
+  inputs: {
+    things: input({type: 'array'}),
+
+    assign: input({
+      type: 'object',
+      defaultValue: null,
+    }),
+
+    assignEach: input({
+      validate: sparseArrayOf(isObject),
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ['#clonedThings'],
+
+  steps: () => [
+    {
+      dependencies: [input('assign'), input('assignEach')],
+      compute: (continuation, {
+        [input('assign')]: assign,
+        [input('assignEach')]: assignEach,
+      }) => continuation({
+        ['#assignmentMap']:
+          (index) =>
+            (assign && assignEach
+              ? {...assignEach[index] ?? {}, ...assign}
+           : assignEach
+              ? assignEach[index] ?? {}
+              : assign ?? {}),
+      }),
+    },
+
+    {
+      dependencies: ['#assignmentMap'],
+      compute: (continuation, {
+        ['#assignmentMap']: assignmentMap,
+      }) => continuation({
+        ['#cloningMap']:
+          (thing, index) =>
+            Object.assign(
+              CacheableObject.clone(thing),
+              assignmentMap(index)),
+      }),
+    },
+
+    withMappedList({
+      list: input('things'),
+      map: '#cloningMap',
+    }).outputs({
+      '#mappedList': '#clonedThings',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withContributionListSums.js b/src/data/composite/wiki-data/withContributionListSums.js
new file mode 100644
index 00000000..b4f36361
--- /dev/null
+++ b/src/data/composite/wiki-data/withContributionListSums.js
@@ -0,0 +1,95 @@
+// Gets the total duration and contribution count from a list of contributions,
+// respecting their `countInContributionTotals` and `countInDurationTotals`
+// flags.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {
+  withFilteredList,
+  withPropertiesFromList,
+  withPropertyFromList,
+  withSum,
+  withUniqueItemsOnly,
+} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withContributionListSums`,
+
+  inputs: {
+    list: input({type: 'array'}),
+  },
+
+  outputs: [
+    '#contributionListCount',
+    '#contributionListDuration',
+  ],
+
+  steps: () => [
+    withPropertiesFromList({
+      list: input('list'),
+      properties: input.value([
+        'countInContributionTotals',
+        'countInDurationTotals',
+      ]),
+    }),
+
+    withFilteredList({
+      list: input('list'),
+      filter: '#list.countInContributionTotals',
+    }).outputs({
+      '#filteredList': '#contributionsForCounting',
+    }),
+
+    withFilteredList({
+      list: input('list'),
+      filter: '#list.countInDurationTotals',
+    }).outputs({
+      '#filteredList': '#contributionsForDuration',
+    }),
+
+    {
+      dependencies: ['#contributionsForCounting'],
+      compute: (continuation, {
+        ['#contributionsForCounting']: contributionsForCounting,
+      }) => continuation({
+        ['#count']:
+          contributionsForCounting.length,
+      }),
+    },
+
+    withPropertyFromList({
+      list: '#contributionsForDuration',
+      property: input.value('thing'),
+    }),
+
+    // Don't double-up the durations for a track where the artist has multiple
+    // contributions.
+    withUniqueItemsOnly({
+      list: '#contributionsForDuration.thing',
+    }),
+
+    withPropertyFromList({
+      list: '#contributionsForDuration.thing',
+      property: input.value('duration'),
+    }).outputs({
+      '#contributionsForDuration.thing.duration': '#durationValues',
+    }),
+
+    withSum({
+      values: '#durationValues',
+    }).outputs({
+      '#sum': '#duration',
+    }),
+
+    {
+      dependencies: ['#count', '#duration'],
+      compute: (continuation, {
+        ['#count']: count,
+        ['#duration']: duration,
+      }) => continuation({
+        ['#contributionListCount']: count,
+        ['#contributionListDuration']: duration,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withCoverArtDate.js b/src/data/composite/wiki-data/withCoverArtDate.js
new file mode 100644
index 00000000..0c644c77
--- /dev/null
+++ b/src/data/composite/wiki-data/withCoverArtDate.js
@@ -0,0 +1,70 @@
+// Gets the current thing's coverArtDate, or, if the 'fallback' option is set,
+// the thing's date. This is always null if the thing doesn't actually have
+// any coverArtistContribs.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isDate} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withResolvedContribs from './withResolvedContribs.js';
+
+export default templateCompositeFrom({
+  annotation: `withCoverArtDate`,
+
+  inputs: {
+    from: input({
+      validate: isDate,
+      defaultDependency: 'coverArtDate',
+      acceptsNull: true,
+    }),
+
+    fallback: input({
+      type: 'boolean',
+      defaultValue: false,
+    }),
+  },
+
+  outputs: ['#coverArtDate'],
+
+  steps: () => [
+    withResolvedContribs({
+      from: 'coverArtistContribs',
+      date: input.value(null),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#resolvedContribs',
+      mode: input.value('empty'),
+      output: input.value({'#coverArtDate': null}),
+    }),
+
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: from,
+      }) =>
+        (from
+          ? continuation.raiseOutput({'#coverArtDate': from})
+          : continuation()),
+    },
+
+    {
+      dependencies: [input('fallback')],
+      compute: (continuation, {
+        [input('fallback')]: fallback,
+      }) =>
+        (fallback
+          ? continuation()
+          : continuation.raiseOutput({'#coverArtDate': null})),
+    },
+
+    {
+      dependencies: ['date'],
+      compute: (continuation, {date}) =>
+        (date
+          ? continuation.raiseOutput({'#coverArtDate': date})
+          : continuation.raiseOutput({'#coverArtDate': null})),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withRecontextualizedContributionList.js b/src/data/composite/wiki-data/withRecontextualizedContributionList.js
new file mode 100644
index 00000000..d2401eac
--- /dev/null
+++ b/src/data/composite/wiki-data/withRecontextualizedContributionList.js
@@ -0,0 +1,101 @@
+// Clones all the contributions in a list, with thing and thingProperty both
+// updated to match the current thing. Overwrites the provided dependency.
+// Optionally updates artistProperty as well. Doesn't do anything if
+// the provided dependency is null.
+//
+// See also:
+//  - withRedatedContributionList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isStringNonEmpty} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withClonedThings} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withRecontextualizedContributionList`,
+
+  inputs: {
+    list: input.staticDependency({
+      type: 'array',
+      acceptsNull: true,
+    }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list],
+
+  steps: () => [
+    // TODO: Is raiseOutputWithoutDependency workable here?
+    // Is it true that not specifying any output wouldn't overwrite
+    // the provided dependency?
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        input('list'),
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: dependency,
+        [input('list')]: list,
+      }) =>
+        (list
+          ? continuation()
+          : continuation.raiseOutput({
+              [dependency]: list,
+            })),
+    },
+
+    {
+      dependencies: [
+        input.myself(),
+        input.thisProperty(),
+        input('artistProperty'),
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: myself,
+        [input.thisProperty()]: thisProperty,
+        [input('artistProperty')]: artistProperty,
+      }) => continuation({
+        ['#assignment']:
+          Object.assign(
+            {thing: myself},
+            {thingProperty: thisProperty},
+
+            (artistProperty
+              ? {artistProperty}
+              : {})),
+      }),
+    },
+
+    withClonedThings({
+      things: input('list'),
+      assign: '#assignment',
+    }).outputs({
+      '#clonedThings': '#newContributions',
+    }),
+
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        '#newContributions',
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: listDependency,
+        ['#newContributions']: newContributions,
+      }) => continuation({
+        [listDependency]:
+          newContributions,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withRedatedContributionList.js b/src/data/composite/wiki-data/withRedatedContributionList.js
new file mode 100644
index 00000000..12f3e16b
--- /dev/null
+++ b/src/data/composite/wiki-data/withRedatedContributionList.js
@@ -0,0 +1,127 @@
+// Clones all the contributions in a list, with date updated to the provided
+// value. Overwrites the provided dependency. Doesn't do anything if the
+// provided dependency is null, or the provided date is null.
+//
+// If 'override' is true (the default), then so long as the provided date has
+// a value at all, it's always written onto the (cloned) contributions.
+//
+// If 'override' is false, and any of the contributions were already dated,
+// those will keep their existing dates.
+//
+// See also:
+//  - withRecontextualizedContributionList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isDate} from '#validators';
+
+import {withMappedList, withPropertyFromList} from '#composite/data';
+import {withClonedThings} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withRedatedContributionList`,
+
+  inputs: {
+    list: input.staticDependency({
+      type: 'array',
+      acceptsNull: true,
+    }),
+
+    date: input({
+      validate: isDate,
+      acceptsNull: true,
+    }),
+
+    override: input({
+      type: 'boolean',
+      defaultValue: true,
+    }),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list],
+
+  steps: () => [
+    // TODO: Is raiseOutputWithoutDependency workable here?
+    // Is it true that not specifying any output wouldn't overwrite
+    // the provided dependency?
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        input('list'),
+        input('date'),
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: dependency,
+        [input('list')]: list,
+        [input('date')]: date,
+      }) =>
+        (list && date
+          ? continuation()
+          : continuation.raiseOutput({
+              [dependency]: list,
+            })),
+    },
+
+    withPropertyFromList({
+      list: input('list'),
+      property: input.value('date'),
+    }).outputs({
+      '#list.date': '#existingDates',
+    }),
+
+    {
+      dependencies: [
+        input('date'),
+        input('override'),
+        '#existingDates',
+      ],
+
+      compute: (continuation, {
+        [input('date')]: date,
+        [input('override')]: override,
+        '#existingDates': existingDates,
+      }) => continuation({
+        ['#assignmentMap']:
+          // TODO: Should be mapping over withIndicesFromList
+          (_, index) =>
+            (!override && existingDates[index]
+              ? {date: existingDates[index]}
+           : date
+              ? {date}
+              : {}),
+      }),
+    },
+
+    withMappedList({
+      list: input('list'),
+      map: '#assignmentMap',
+    }).outputs({
+      '#mappedList': '#assignment',
+    }),
+
+    withClonedThings({
+      things: input('list'),
+      assignEach: '#assignment',
+    }).outputs({
+      '#clonedThings': '#newContributions',
+    }),
+
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        '#newContributions',
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: listDependency,
+        ['#newContributions']: newContributions,
+      }) => continuation({
+        [listDependency]:
+          newContributions,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js
index 95266382..b5d7255b 100644
--- a/src/data/composite/wiki-data/withResolvedContribs.js
+++ b/src/data/composite/wiki-data/withResolvedContribs.js
@@ -7,17 +7,11 @@
 import {input, templateCompositeFrom} from '#composite';
 import find from '#find';
 import {filterMultipleArrays, stitchArrays} from '#sugar';
-import {is, isContributionList} from '#validators';
+import thingConstructors from '#things';
+import {is, isContributionList, isDate, isStringNonEmpty} from '#validators';
 
-import {
-  raiseOutputWithoutDependency,
-} from '#composite/control-flow';
-
-import {
-  withPropertiesFromList,
-} from '#composite/data';
-
-import withResolvedReferenceList from './withResolvedReferenceList.js';
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertiesFromList} from '#composite/data';
 
 export default templateCompositeFrom({
   annotation: `withResolvedContribs`,
@@ -28,10 +22,25 @@ export default templateCompositeFrom({
       acceptsNull: true,
     }),
 
+    date: input({
+      validate: isDate,
+      acceptsNull: true,
+    }),
+
     notFoundMode: input({
       validate: is('exit', 'filter', 'null'),
       defaultValue: 'null',
     }),
+
+    thingProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
   },
 
   outputs: ['#resolvedContribs'],
@@ -45,34 +54,96 @@ export default templateCompositeFrom({
       }),
     }),
 
+    {
+      dependencies: [
+        input('thingProperty'),
+        input.staticDependency('from'),
+      ],
+
+      compute: (continuation, {
+        [input('thingProperty')]: thingProperty,
+        [input.staticDependency('from')]: fromDependency,
+      }) => continuation({
+        ['#thingProperty']:
+          (thingProperty
+            ? thingProperty
+         : !fromDependency?.startsWith('#')
+            ? fromDependency
+            : null),
+      }),
+    },
+
     withPropertiesFromList({
       list: input('from'),
       properties: input.value(['artist', 'annotation']),
       prefix: input.value('#contribs'),
     }),
 
-    withResolvedReferenceList({
-      list: '#contribs.artist',
-      data: 'artistData',
-      find: input.value(find.artist),
-      notFoundMode: input('notFoundMode'),
-    }).outputs({
-      ['#resolvedReferenceList']: '#contribs.artist',
-    }),
-
     {
-      dependencies: ['#contribs.artist', '#contribs.annotation'],
+      dependencies: [
+        '#contribs.artist',
+        '#contribs.annotation',
+        input('date'),
+      ],
 
       compute(continuation, {
         ['#contribs.artist']: artist,
         ['#contribs.annotation']: annotation,
+        [input('date')]: date,
       }) {
         filterMultipleArrays(artist, annotation, (artist, _annotation) => artist);
+
         return continuation({
-          ['#resolvedContribs']:
-            stitchArrays({artist, annotation}),
+          ['#details']:
+            stitchArrays({artist, annotation})
+              .map(details => ({
+                ...details,
+                date: date ?? null,
+              })),
         });
       },
     },
+
+    {
+      dependencies: [
+        '#details',
+        '#thingProperty',
+        input('artistProperty'),
+        input.myself(),
+      ],
+
+      compute: (continuation, {
+        ['#details']: details,
+        ['#thingProperty']: thingProperty,
+        [input('artistProperty')]: artistProperty,
+        [input.myself()]: myself,
+      }) => continuation({
+        ['#contributions']:
+          details.map(details => {
+            const contrib = new thingConstructors.Contribution();
+
+            Object.assign(contrib, {
+              ...details,
+              thing: myself,
+              thingProperty: thingProperty,
+              artistProperty: artistProperty,
+            });
+
+            return contrib;
+          }),
+      }),
+    },
+
+    {
+      dependencies: ['#contributions'],
+
+      compute: (continuation, {
+        ['#contributions']: contributions,
+      }) => continuation({
+        ['#resolvedContribs']:
+          contributions
+            .filter(contrib => contrib.artist),
+      }),
+    },
   ],
 });
diff --git a/src/data/composite/wiki-data/withReverseContributionList.js b/src/data/composite/wiki-data/withReverseContributionList.js
index 91e125e4..63e712bb 100644
--- a/src/data/composite/wiki-data/withReverseContributionList.js
+++ b/src/data/composite/wiki-data/withReverseContributionList.js
@@ -1,6 +1,6 @@
 // Analogous implementation for withReverseReferenceList, for contributions.
-// This is all duplicate code and both should be ported to the same underlying
-// data form later on.
+// This is mostly duplicate code and both should be ported to the same
+// underlying data form later on.
 //
 // This implementation uses a global cache (via WeakMap) to attempt to speed
 // up subsequent similar accesses.
@@ -10,9 +10,11 @@
 // is used, a fresh cache will always be created.
 
 import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
 
 import {exitWithoutDependency, raiseOutputWithoutDependency}
   from '#composite/control-flow';
+import {withFlattenedList, withMappedList} from '#composite/data';
 
 import inputWikiData from './inputWikiData.js';
 
@@ -33,6 +35,8 @@ export default templateCompositeFrom({
   outputs: ['#reverseContributionList'],
 
   steps: () => [
+    // Common behavior --
+
     // Early exit with an empty array if the data list isn't available.
     exitWithoutDependency({
       dependency: input('data'),
@@ -46,46 +50,122 @@ export default templateCompositeFrom({
       output: input.value({'#reverseContributionList': []}),
     }),
 
+    // Check for an existing cache record which corresponds to this
+    // input('list') and input('data'). If it exists, query it for the
+    // current thing, and raise that; if it doesn't, create it, put it
+    // where it needs to be, and provide it so the next steps can fill
+    // it in.
     {
-      dependencies: [input.myself(), input('data'), input('list')],
+      dependencies: [input('list'), input('data'), input.myself()],
 
       compute: (continuation, {
-        [input.myself()]: myself,
-        [input('data')]: data,
         [input('list')]: list,
+        [input('data')]: data,
+        [input.myself()]: myself,
       }) => {
         if (!caches.has(list)) {
-          caches.set(list, new WeakMap());
+          const cache = new WeakMap();
+          caches.set(list, cache);
+
+          const cacheRecord = new WeakMap();
+          cache.set(data, cacheRecord);
+
+          return continuation({
+            ['#cacheRecord']: cacheRecord,
+          });
         }
 
         const cache = caches.get(list);
 
         if (!cache.has(data)) {
           const cacheRecord = new WeakMap();
+          cache.set(data, cacheRecord);
+
+          return continuation({
+            ['#cacheRecord']: cacheRecord,
+          });
+        }
+
+        return continuation.raiseOutput({
+          ['#reverseContributionList']:
+            cache.get(data).get(myself) ?? [],
+        });
+      },
+    },
+
+    // Unique behavior for contribution lists --
+
+    {
+      dependencies: [input('list')],
+      compute: (continuation, {
+        [input('list')]: list,
+      }) => continuation({
+        ['#contributionListMap']:
+          thing => thing[list],
+      }),
+    },
+
+    withMappedList({
+      list: input('data'),
+      map: '#contributionListMap',
+    }).outputs({
+      '#mappedList': '#contributionLists',
+    }),
 
-          for (const referencingThing of data) {
-            const referenceList = referencingThing[list];
+    withFlattenedList({
+      list: '#contributionLists',
+    }).outputs({
+      '#flattenedList': '#referencingThings',
+    }),
 
-            // Destructuring {artist} is the only unique part of the
-            // withReverseContributionList implementation, compared to
-            // withReverseReferneceList.
-            for (const {artist: referencedThing} of referenceList) {
+    withMappedList({
+      list: '#referencingThings',
+      map: input.value(contrib => [contrib.artist]),
+    }).outputs({
+      '#mappedList': '#referencedThings',
+    }),
+
+    // Common behavior --
+
+    // Actually fill in the cache record. Since we're building up a *reverse*
+    // reference list, track connections in terms of the referenced thing.
+    // No newly-provided dependencies here since we're mutating the cache
+    // record, which is properly in store and will probably be reused in the
+    // future (and certainly in the next step).
+    {
+      dependencies: ['#cacheRecord', '#referencingThings', '#referencedThings'],
+      compute: (continuation, {
+        ['#cacheRecord']: cacheRecord,
+        ['#referencingThings']: referencingThings,
+        ['#referencedThings']: referencedThings,
+      }) => {
+        stitchArrays({
+          referencingThing: referencingThings,
+          referencedThings: referencedThings,
+        }).forEach(({referencingThing, referencedThings}) => {
+            for (const referencedThing of referencedThings) {
               if (cacheRecord.has(referencedThing)) {
                 cacheRecord.get(referencedThing).push(referencingThing);
               } else {
                 cacheRecord.set(referencedThing, [referencingThing]);
               }
             }
-          }
+          });
 
-          cache.set(data, cacheRecord);
-        }
-
-        return continuation({
-          ['#reverseContributionList']:
-            cache.get(data).get(myself) ?? [],
-        });
+        return continuation();
       },
     },
+
+    // Then just pluck out the current object from the now-filled cache record!
+    {
+      dependencies: ['#cacheRecord', input.myself()],
+      compute: (continuation, {
+        ['#cacheRecord']: cacheRecord,
+        [input.myself()]: myself,
+      }) => continuation({
+        ['#reverseContributionList']:
+          cacheRecord.get(myself) ?? [],
+      }),
+    },
   ],
 });
diff --git a/src/data/composite/wiki-data/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js
index 8cd540a5..1f8c082f 100644
--- a/src/data/composite/wiki-data/withReverseReferenceList.js
+++ b/src/data/composite/wiki-data/withReverseReferenceList.js
@@ -12,9 +12,11 @@
 // so any changes should be reflected there (until these are combined).
 
 import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
 
 import {exitWithoutDependency, raiseOutputWithoutDependency}
   from '#composite/control-flow';
+import {withMappedList} from '#composite/data';
 
 import inputWikiData from './inputWikiData.js';
 
@@ -35,6 +37,8 @@ export default templateCompositeFrom({
   outputs: ['#reverseReferenceList'],
 
   steps: () => [
+    // Common behavior --
+
     // Early exit with an empty array if the data list isn't available.
     exitWithoutDependency({
       dependency: input('data'),
@@ -48,42 +52,119 @@ export default templateCompositeFrom({
       output: input.value({'#reverseReferenceList': []}),
     }),
 
+    // Check for an existing cache record which corresponds to this
+    // input('list') and input('data'). If it exists, query it for the
+    // current thing, and raise that; if it doesn't, create it, put it
+    // where it needs to be, and provide it so the next steps can fill
+    // it in.
     {
-      dependencies: [input.myself(), input('data'), input('list')],
+      dependencies: [input('list'), input('data'), input.myself()],
 
       compute: (continuation, {
-        [input.myself()]: myself,
-        [input('data')]: data,
         [input('list')]: list,
+        [input('data')]: data,
+        [input.myself()]: myself,
       }) => {
         if (!caches.has(list)) {
-          caches.set(list, new WeakMap());
+          const cache = new WeakMap();
+          caches.set(list, cache);
+
+          const cacheRecord = new WeakMap();
+          cache.set(data, cacheRecord);
+
+          return continuation({
+            ['#cacheRecord']: cacheRecord,
+          });
         }
 
         const cache = caches.get(list);
 
         if (!cache.has(data)) {
           const cacheRecord = new WeakMap();
+          cache.set(data, cacheRecord);
+
+          return continuation({
+            ['#cacheRecord']: cacheRecord,
+          });
+        }
+
+        return continuation.raiseOutput({
+          ['#reverseReferenceList']:
+            cache.get(data).get(myself) ?? [],
+        });
+      },
+    },
+
+    // Unique behavior for reference lists --
+
+    {
+      dependencies: [input('list')],
+      compute: (continuation, {
+        [input('list')]: list,
+      }) => continuation({
+        ['#referenceMap']:
+          thing => thing[list],
+      }),
+    },
+
+    withMappedList({
+      list: input('data'),
+      map: '#referenceMap',
+    }).outputs({
+      '#mappedList': '#referencedThings',
+    }),
+
+    {
+      dependencies: [input('data')],
+      compute: (continuation, {
+        [input('data')]: data,
+      }) => continuation({
+        ['#referencingThings']:
+          data,
+      }),
+    },
+
+    // Common behavior --
 
-          for (const referencingThing of data) {
-            const referenceList = referencingThing[list];
-            for (const referencedThing of referenceList) {
+    // Actually fill in the cache record. Since we're building up a *reverse*
+    // reference list, track connections in terms of the referenced thing.
+    // No newly-provided dependencies here since we're mutating the cache
+    // record, which is properly in store and will probably be reused in the
+    // future (and certainly in the next step).
+    {
+      dependencies: ['#cacheRecord', '#referencingThings', '#referencedThings'],
+      compute: (continuation, {
+        ['#cacheRecord']: cacheRecord,
+        ['#referencingThings']: referencingThings,
+        ['#referencedThings']: referencedThings,
+      }) => {
+        stitchArrays({
+          referencingThing: referencingThings,
+          referencedThings: referencedThings,
+        }).forEach(({referencingThing, referencedThings}) => {
+            for (const referencedThing of referencedThings) {
               if (cacheRecord.has(referencedThing)) {
                 cacheRecord.get(referencedThing).push(referencingThing);
               } else {
                 cacheRecord.set(referencedThing, [referencingThing]);
               }
             }
-          }
+          });
 
-          cache.set(data, cacheRecord);
-        }
-
-        return continuation({
-          ['#reverseReferenceList']:
-            cache.get(data).get(myself) ?? [],
-        });
+        return continuation();
       },
     },
+
+    // Then just pluck out the current object from the now-filled cache record!
+    {
+      dependencies: ['#cacheRecord', input.myself()],
+      compute: (continuation, {
+        ['#cacheRecord']: cacheRecord,
+        [input.myself()]: myself,
+      }) => continuation({
+        ['#reverseReferenceList']:
+          cacheRecord.get(myself) ?? [],
+      }),
+    },
   ],
 });
diff --git a/src/data/composite/wiki-properties/contributionList.js b/src/data/composite/wiki-properties/contributionList.js
index aad12a2d..d9a6b417 100644
--- a/src/data/composite/wiki-properties/contributionList.js
+++ b/src/data/composite/wiki-properties/contributionList.js
@@ -15,7 +15,7 @@
 //
 
 import {input, templateCompositeFrom} from '#composite';
-import {isContributionList} from '#validators';
+import {isContributionList, isDate, isStringNonEmpty} from '#validators';
 
 import {exposeConstant, exposeDependencyOrContinue} from '#composite/control-flow';
 import {withResolvedContribs} from '#composite/wiki-data';
@@ -25,11 +25,34 @@ export default templateCompositeFrom({
 
   compose: false,
 
+  inputs: {
+    date: input({
+      validate: isDate,
+      acceptsNull: true,
+    }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+  },
+
   update: {validate: isContributionList},
 
   steps: () => [
-    withResolvedContribs({from: input.updateValue()}),
-    exposeDependencyOrContinue({dependency: '#resolvedContribs'}),
-    exposeConstant({value: input.value([])}),
+    withResolvedContribs({
+      from: input.updateValue(),
+      thingProperty: input.thisProperty(),
+      artistProperty: input('artistProperty'),
+      date: input('date'),
+    }),
+
+    exposeDependencyOrContinue({
+      dependency: '#resolvedContribs',
+    }),
+
+    exposeConstant({
+      value: input.value([]),
+    }),
   ],
 });
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
index 89cb6838..5328d17e 100644
--- a/src/data/composite/wiki-properties/index.js
+++ b/src/data/composite/wiki-properties/index.js
@@ -24,5 +24,6 @@ export {default as reverseReferenceList} from './reverseReferenceList.js';
 export {default as simpleDate} from './simpleDate.js';
 export {default as simpleString} from './simpleString.js';
 export {default as singleReference} from './singleReference.js';
+export {default as thing} from './thing.js';
 export {default as urls} from './urls.js';
 export {default as wikiData} from './wikiData.js';
diff --git a/src/data/composite/wiki-properties/thing.js b/src/data/composite/wiki-properties/thing.js
new file mode 100644
index 00000000..5b5d77dd
--- /dev/null
+++ b/src/data/composite/wiki-properties/thing.js
@@ -0,0 +1,31 @@
+// An individual Thing, provided directly rather than by reference.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isThingClass, validateThing} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `wikiData`,
+
+  compose: false,
+
+  inputs: {
+    class: input.staticValue({
+      validate: isThingClass,
+      defaultValue: null,
+    }),
+  },
+
+  update: ({
+    [input.staticValue('class')]: thingClass,
+  }) => ({
+    validate:
+      validateThing({
+        referenceType:
+          (thingClass
+            ? thingClass[Symbol.for('Thing.referenceType')]
+            : ''),
+      }),
+  }),
+
+  steps: () => [],
+});
diff --git a/src/data/things/album.js b/src/data/things/album.js
index e9f55b2c..a0021946 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -18,8 +18,13 @@ import {parseAdditionalFiles, parseContributors, parseDate, parseDimensions}
 import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
-import {exitWithoutContribs, withDirectory, withResolvedReference}
-  from '#composite/wiki-data';
+
+import {
+  exitWithoutContribs,
+  withDirectory,
+  withResolvedReference,
+  withCoverArtDate,
+} from '#composite/wiki-data';
 
 import {
   additionalFiles,
@@ -37,6 +42,7 @@ import {
   simpleDate,
   simpleString,
   singleReference,
+  thing,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
@@ -53,6 +59,7 @@ export class Album extends Thing {
     Group,
     Track,
     TrackSection,
+    WikiInfo,
   }) => ({
     // Update & expose
 
@@ -71,13 +78,16 @@ export class Album extends Thing {
     dateAddedToWiki: simpleDate(),
 
     coverArtDate: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
+      // TODO: Why does this fall back, but Track.coverArtDate doesn't?
+      withCoverArtDate({
+        from: input.updateValue({
+          validate: isDate,
+        }),
 
-      exposeUpdateValueOrContinue({
-        validate: input.value(isDate),
+        fallback: input.value(true),
       }),
 
-      exposeDependency({dependency: 'date'}),
+      exposeDependency({dependency: '#coverArtDate'}),
     ],
 
     coverArtFileExtension: [
@@ -130,11 +140,53 @@ export class Album extends Thing {
       find: input.value(find.unqualifiedTrackSection),
     }),
 
-    artistContribs: contributionList(),
-    coverArtistContribs: contributionList(),
-    trackCoverArtistContribs: contributionList(),
-    wallpaperArtistContribs: contributionList(),
-    bannerArtistContribs: contributionList(),
+    artistContribs: contributionList({
+      date: 'date',
+      artistProperty: input.value('albumArtistContributions'),
+    }),
+
+    coverArtistContribs: [
+      withCoverArtDate({
+        fallback: input.value(true),
+      }),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumCoverArtistContributions'),
+      }),
+    ],
+
+    trackCoverArtistContribs: contributionList({
+      // May be null, indicating cover art was added for tracks on the date
+      // each track specifies, or else the track's own release date.
+      date: 'trackArtDate',
+
+      // This is the "correct" value, but it gets overwritten - with the same
+      // value - regardless.
+      artistProperty: input.value('trackCoverArtistContributions'),
+    }),
+
+    wallpaperArtistContribs: [
+      withCoverArtDate({
+        fallback: input.value(true),
+      }),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumWallpaperArtistContributions'),
+      }),
+    ],
+
+    bannerArtistContribs: [
+      withCoverArtDate({
+        fallback: input.value(true),
+      }),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumBannerArtistContributions'),
+      }),
+    ],
 
     groups: referenceList({
       class: input.value(Group),
@@ -173,6 +225,10 @@ export class Album extends Thing {
       class: input.value(TrackSection),
     }),
 
+    wikiInfo: thing({
+      class: input.value(WikiInfo),
+    }),
+
     // Expose only
 
     commentatorArtists: commentatorArtists(),
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 841d652f..6d5e33c0 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -12,6 +12,7 @@ import Thing from '#thing';
 import {isName, validateArrayItems} from '#validators';
 import {getKebabCase} from '#wiki-data';
 
+import {exposeDependency} from '#composite/control-flow';
 import {withReverseContributionList} from '#composite/wiki-data';
 
 import {
@@ -27,6 +28,8 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
+import {artistTotalDuration} from '#composite/things/artist';
+
 export class Artist extends Thing {
   static [Thing.referenceType] = 'artist';
   static [Thing.wikiDataArray] = 'artistData';
@@ -77,146 +80,52 @@ export class Artist extends Thing {
 
     // Expose only
 
-    tracksAsArtist: reverseContributionList({
+    trackArtistContributions: reverseContributionList({
       data: 'trackData',
       list: input.value('artistContribs'),
     }),
 
-    tracksAsContributor: reverseContributionList({
+    trackContributorContributions: reverseContributionList({
       data: 'trackData',
       list: input.value('contributorContribs'),
     }),
 
-    tracksAsCoverArtist: reverseContributionList({
+    trackCoverArtistContributions: reverseContributionList({
       data: 'trackData',
       list: input.value('coverArtistContribs'),
     }),
 
-    tracksAsAny: [
-      withReverseContributionList({
-        data: 'trackData',
-        list: input.value('artistContribs'),
-      }).outputs({
-        '#reverseContributionList': '#tracksAsArtist',
-      }),
-
-      withReverseContributionList({
-        data: 'trackData',
-        list: input.value('contributorContribs'),
-      }).outputs({
-        '#reverseContributionList': '#tracksAsContributor',
-      }),
-
-      withReverseContributionList({
-        data: 'trackData',
-        list: input.value('coverArtistContribs'),
-      }).outputs({
-        '#reverseContributionList': '#tracksAsCoverArtist',
-      }),
-
-      {
-        dependencies: [
-          '#tracksAsArtist',
-          '#tracksAsContributor',
-          '#tracksAsCoverArtist',
-        ],
-
-        compute: ({
-          ['#tracksAsArtist']: tracksAsArtist,
-          ['#tracksAsContributor']: tracksAsContributor,
-          ['#tracksAsCoverArtist']: tracksAsCoverArtist,
-        }) =>
-          unique([
-            ...tracksAsArtist,
-            ...tracksAsContributor,
-            ...tracksAsCoverArtist,
-          ]),
-      },
-    ],
-
     tracksAsCommentator: reverseReferenceList({
       data: 'trackData',
       list: input.value('commentatorArtists'),
     }),
 
-    albumsAsAlbumArtist: reverseContributionList({
+    albumArtistContributions: reverseContributionList({
       data: 'albumData',
       list: input.value('artistContribs'),
     }),
 
-    albumsAsCoverArtist: reverseContributionList({
+    albumCoverArtistContributions: reverseContributionList({
       data: 'albumData',
       list: input.value('coverArtistContribs'),
     }),
 
-    albumsAsWallpaperArtist: reverseContributionList({
+    albumWallpaperArtistContributions: reverseContributionList({
       data: 'albumData',
       list: input.value('wallpaperArtistContribs'),
     }),
 
-    albumsAsBannerArtist: reverseContributionList({
+    albumBannerArtistContributions: reverseContributionList({
       data: 'albumData',
       list: input.value('bannerArtistContribs'),
     }),
 
-    albumsAsAny: [
-      withReverseContributionList({
-        data: 'albumData',
-        list: input.value('artistContribs'),
-      }).outputs({
-        '#reverseContributionList': '#albumsAsArtist',
-      }),
-
-      withReverseContributionList({
-        data: 'albumData',
-        list: input.value('coverArtistContribs'),
-      }).outputs({
-        '#reverseContributionList': '#albumsAsCoverArtist',
-      }),
-
-      withReverseContributionList({
-        data: 'albumData',
-        list: input.value('wallpaperArtistContribs'),
-      }).outputs({
-        '#reverseContributionList': '#albumsAsWallpaperArtist',
-      }),
-
-      withReverseContributionList({
-        data: 'albumData',
-        list: input.value('bannerArtistContribs'),
-      }).outputs({
-        '#reverseContributionList': '#albumsAsBannerArtist',
-      }),
-
-      {
-        dependencies: [
-          '#albumsAsArtist',
-          '#albumsAsCoverArtist',
-          '#albumsAsWallpaperArtist',
-          '#albumsAsBannerArtist',
-        ],
-
-        compute: ({
-          ['#albumsAsArtist']: albumsAsArtist,
-          ['#albumsAsCoverArtist']: albumsAsCoverArtist,
-          ['#albumsAsWallpaperArtist']: albumsAsWallpaperArtist,
-          ['#albumsAsBannerArtist']: albumsAsBannerArtist,
-        }) =>
-          unique([
-            ...albumsAsArtist,
-            ...albumsAsCoverArtist,
-            ...albumsAsWallpaperArtist,
-            ...albumsAsBannerArtist,
-          ]),
-      },
-    ],
-
     albumsAsCommentator: reverseReferenceList({
       data: 'albumData',
       list: input.value('commentatorArtists'),
     }),
 
-    flashesAsContributor: reverseContributionList({
+    flashContributorContributions: reverseContributionList({
       data: 'flashData',
       list: input.value('contributorContribs'),
     }),
@@ -225,6 +134,8 @@ export class Artist extends Thing {
       data: 'flashData',
       list: input.value('commentatorArtists'),
     }),
+
+    totalDuration: artistTotalDuration(),
   });
 
   static [Thing.getSerializeDescriptors] = ({
@@ -240,18 +151,8 @@ export class Artist extends Thing {
 
     aliasNames: S.id,
 
-    tracksAsArtist: S.toRefs,
-    tracksAsContributor: S.toRefs,
-    tracksAsCoverArtist: S.toRefs,
     tracksAsCommentator: S.toRefs,
-
-    albumsAsAlbumArtist: S.toRefs,
-    albumsAsCoverArtist: S.toRefs,
-    albumsAsWallpaperArtist: S.toRefs,
-    albumsAsBannerArtist: S.toRefs,
     albumsAsCommentator: S.toRefs,
-
-    flashesAsContributor: S.toRefs,
   });
 
   static [Thing.findSpecs] = {
diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js
new file mode 100644
index 00000000..79acf1e1
--- /dev/null
+++ b/src/data/things/contribution.js
@@ -0,0 +1,265 @@
+import {inspect} from 'node:util';
+
+import CacheableObject from '#cacheable-object';
+import {colors} from '#cli';
+import {input} from '#composite';
+import {empty} from '#sugar';
+import Thing from '#thing';
+import {isStringNonEmpty, isThing, validateReference} from '#validators';
+
+import {exitWithoutDependency, exposeDependency} from '#composite/control-flow';
+import {withNearbyItemFromList, withPropertyFromObject} from '#composite/data';
+import {withResolvedReference} from '#composite/wiki-data';
+import {flag, simpleDate} from '#composite/wiki-properties';
+
+import {
+  inheritFromContributionPresets,
+  thingPropertyMatches,
+  thingReferenceTypeMatches,
+  withContainingReverseContributionList,
+  withContributionArtist,
+  withContributionContext,
+  withMatchingContributionPresets,
+} from '#composite/things/contribution';
+
+export class Contribution extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: {
+      flags: {update: true, expose: true},
+      update: {validate: isThing},
+    },
+
+    thingProperty: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    artistProperty: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    date: simpleDate(),
+
+    artist: [
+      withContributionArtist({
+        ref: input.updateValue({
+          validate: validateReference('artist'),
+        }),
+      }),
+
+      exposeDependency({
+        dependency: '#artist',
+      }),
+    ],
+
+    annotation: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    countInContributionTotals: [
+      inheritFromContributionPresets({
+        property: input.thisProperty(),
+      }),
+
+      flag(true),
+    ],
+
+    countInDurationTotals: [
+      inheritFromContributionPresets({
+        property: input.thisProperty(),
+      }),
+
+      flag(true),
+    ],
+
+    // Expose only
+
+    context: [
+      withContributionContext(),
+
+      {
+        dependencies: [
+          '#contributionTarget',
+          '#contributionProperty',
+        ],
+
+        compute: ({
+          ['#contributionTarget']: target,
+          ['#contributionProperty']: property,
+        }) => ({
+          target,
+          property,
+        }),
+      },
+    ],
+
+    matchingPresets: [
+      withMatchingContributionPresets(),
+
+      exposeDependency({
+        dependency: '#matchingContributionPresets',
+      }),
+    ],
+
+    // All the contributions from the list which includes this contribution.
+    // Note that this list contains not only other contributions by the same
+    // artist, but also this very contribution. It doesn't mix contributions
+    // exposed on different properties.
+    associatedContributions: [
+      exitWithoutDependency({
+        dependency: 'thing',
+        value: input.value([]),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'thingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'thingProperty',
+      }),
+
+      exposeDependency({
+        dependency: '#value',
+      }),
+    ],
+
+    isArtistContribution: thingPropertyMatches({
+      value: input.value('artistContribs'),
+    }),
+
+    isContributorContribution: thingPropertyMatches({
+      value: input.value('contributorContribs'),
+    }),
+
+    isCoverArtistContribution: thingPropertyMatches({
+      value: input.value('coverArtistContribs'),
+    }),
+
+    isBannerArtistContribution: thingPropertyMatches({
+      value: input.value('bannerArtistContribs'),
+    }),
+
+    isWallpaperArtistContribution: thingPropertyMatches({
+      value: input.value('wallpaperArtistContribs'),
+    }),
+
+    isForTrack: thingReferenceTypeMatches({
+      value: input.value('track'),
+    }),
+
+    isForAlbum: thingReferenceTypeMatches({
+      value: input.value('album'),
+    }),
+
+    isForFlash: thingReferenceTypeMatches({
+      value: input.value('flash'),
+    }),
+
+    previousBySameArtist: [
+      withContainingReverseContributionList().outputs({
+        '#containingReverseContributionList': '#list',
+      }),
+
+      exitWithoutDependency({
+        dependency: '#list',
+      }),
+
+      withNearbyItemFromList({
+        list: '#list',
+        item: input.myself(),
+        offset: input.value(-1),
+      }),
+
+      exposeDependency({
+        dependency: '#nearbyItem',
+      }),
+    ],
+
+    nextBySameArtist: [
+      withContainingReverseContributionList().outputs({
+        '#containingReverseContributionList': '#list',
+      }),
+
+      exitWithoutDependency({
+        dependency: '#list',
+      }),
+
+      withNearbyItemFromList({
+        list: '#list',
+        item: input.myself(),
+        offset: input.value(+1),
+      }),
+
+      exposeDependency({
+        dependency: '#nearbyItem',
+      }),
+    ],
+  });
+
+  [inspect.custom](depth, options, inspect) {
+    const parts = [];
+    const accentParts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (this.annotation) {
+      accentParts.push(colors.green(`"${this.annotation}"`));
+    }
+
+    if (this.date) {
+      accentParts.push(colors.yellow(this.date.toLocaleDateString()));
+    }
+
+    let artistRef;
+    if (depth >= 0) {
+      let artist;
+      try {
+        artist = this.artist;
+      } catch (_error) {
+        // Computing artist might crash for any reason - don't distract from
+        // other errors as a result of inspecting this contribution.
+      }
+
+      if (artist) {
+        artistRef =
+          colors.blue(Thing.getReference(artist));
+      }
+    } else {
+      artistRef =
+        colors.green(CacheableObject.getUpdateValue(this, 'artist'));
+    }
+
+    if (artistRef) {
+      accentParts.push(`by ${artistRef}`);
+    }
+
+    if (this.thing) {
+      if (depth >= 0) {
+        const newOptions = {
+          ...options,
+          depth:
+            (options.depth === null
+              ? null
+              : options.depth - 1),
+        };
+
+        accentParts.push(`to ${inspect(this.thing, newOptions)}`);
+      } else {
+        accentParts.push(`to ${colors.blue(Thing.getReference(this.thing))}`);
+      }
+    }
+
+    if (!empty(accentParts)) {
+      parts.push(` (${accentParts.join(', ')})`);
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index ceed79f7..89e59fe7 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -7,7 +7,7 @@ import {sortFlashesChronologically} from '#sort';
 import Thing from '#thing';
 import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
   from '#validators';
-import {parseDate, parseContributors} from '#yaml';
+import {parseContributors, parseDate, parseDimensions} from '#yaml';
 
 import {withPropertyFromObject} from '#composite/data';
 
@@ -24,11 +24,13 @@ import {
   commentatorArtists,
   contentString,
   contributionList,
+  dimensions,
   directory,
   fileExtension,
   name,
   referenceList,
   simpleDate,
+  thing,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
@@ -39,7 +41,12 @@ import {withFlashSide} from '#composite/things/flash-act';
 export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
 
-  static [Thing.getPropertyDescriptors] = ({Artist, Track, FlashAct}) => ({
+  static [Thing.getPropertyDescriptors] = ({
+    Artist,
+    Track,
+    FlashAct,
+    WikiInfo,
+  }) => ({
     // Update & expose
 
     name: name('Unnamed Flash'),
@@ -89,7 +96,12 @@ export class Flash extends Thing {
 
     coverArtFileExtension: fileExtension('jpg'),
 
-    contributorContribs: contributionList(),
+    coverArtDimensions: dimensions(),
+
+    contributorContribs: contributionList({
+      date: 'date',
+      artistProperty: input.value('flashContributorContributions'),
+    }),
 
     featuredTracks: referenceList({
       class: input.value(Track),
@@ -115,6 +127,10 @@ export class Flash extends Thing {
       class: input.value(FlashAct),
     }),
 
+    wikiInfo: thing({
+      class: input.value(WikiInfo),
+    }),
+
     // Expose only
 
     commentatorArtists: commentatorArtists(),
@@ -171,6 +187,11 @@ export class Flash extends Thing {
 
       'Cover Art File Extension': {property: 'coverArtFileExtension'},
 
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
       'Featured Tracks': {property: 'featuredTracks'},
 
       'Contributors': {
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 4f87f492..f18e283a 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -11,6 +11,7 @@ import Thing from '#thing';
 import * as albumClasses from './album.js';
 import * as artTagClasses from './art-tag.js';
 import * as artistClasses from './artist.js';
+import * as contributionClasses from './contribution.js';
 import * as flashClasses from './flash.js';
 import * as groupClasses from './group.js';
 import * as homepageLayoutClasses from './homepage-layout.js';
@@ -24,6 +25,7 @@ const allClassLists = {
   'album.js': albumClasses,
   'art-tag.js': artTagClasses,
   'artist.js': artistClasses,
+  'contribution.js': contributionClasses,
   'flash.js': flashClasses,
   'group.js': groupClasses,
   'homepage-layout.js': homepageLayoutClasses,
diff --git a/src/data/things/language.js b/src/data/things/language.js
index dbe1ff3d..88f16ecb 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -127,6 +127,13 @@ export class Language extends Thing {
 
     // Expose only
 
+    onlyIfOptions: {
+      flags: {expose: true},
+      expose: {
+        compute: () => Symbol.for(`language.onlyIfOptions`),
+      },
+    },
+
     intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}),
     intl_number: this.#intlHelper(Intl.NumberFormat),
     intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}),
@@ -201,9 +208,7 @@ export class Language extends Thing {
       args.at(-1) !== null;
 
     const key =
-      (hasOptions ? args.slice(0, -1) : args)
-        .filter(Boolean)
-        .join('.');
+      this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args);
 
     const options =
       (hasOptions
@@ -218,18 +223,42 @@ export class Language extends Thing {
       throw new Error(`Invalid key ${key} accessed`);
     }
 
+    const constantCasify = name =>
+      name
+        .replace(/[A-Z]/g, '_$&')
+        .toUpperCase();
+
     // These will be filled up as we iterate over the template, slotting in
     // each option (if it's present).
     const missingOptionNames = new Set();
 
+    // These will also be filled. It's a bit different of an error, indicating
+    // a provided option was *expected,* but its value was null, undefined, or
+    // blank HTML content.
+    const valuelessOptionNames = new Set();
+
+    // These *might* be missing, and if they are, that's OK!! Instead of adding
+    // to the valueless set above, we'll just mark to return a blank for the
+    // whole string.
+    const expectedValuelessOptionNames =
+      new Set(
+        (options[this.onlyIfOptions] ?? [])
+          .map(constantCasify));
+
+    let seenExpectedValuelessOption = false;
+
+    const isValueless =
+      value =>
+        value === null ||
+        value === undefined ||
+        html.isBlank(value);
+
     // And this will have entries deleted as they're encountered in the
     // template. Leftover entries are misplaced.
     const optionsMap =
       new Map(
         Object.entries(options).map(([name, value]) => [
-          name
-            .replace(/[A-Z]/g, '_$&')
-            .toUpperCase(),
+          constantCasify(name),
           value,
         ]));
 
@@ -239,32 +268,48 @@ export class Language extends Thing {
       match: languageOptionRegex,
 
       insert: ({name: optionName}, canceledForming) => {
-        if (optionsMap.has(optionName)) {
-          let optionValue;
-
-          // We'll only need the option's value if we're going to use it as
-          // part of the formed output (see below).
-          if (!canceledForming) {
-            optionValue = optionsMap.get(optionName);
-          }
-
-          // But we always have to delete expected options off the provided
-          // option map, since the leftovers are what will be used to tell
-          // which are misplaced.
-          optionsMap.delete(optionName);
+        if (!optionsMap.has(optionName)) {
+          missingOptionNames.add(optionName);
 
-          if (canceledForming) {
-            return undefined;
-          } else {
-            return optionValue;
-          }
-        } else {
           // We don't need to continue forming the output if we've hit a
           // missing option name, since the end result of this formatString
           // call will be a thrown error, and formed output won't be needed.
-          missingOptionNames.add(optionName);
+          // Return undefined to mark canceledForming for the following
+          // iterations (and exit early out of this iteration).
+          return undefined;
+        }
+
+        // Even if we're not actually forming the output anymore, we'll still
+        // have to access this option's value to check if it is invalid.
+        const optionValue = optionsMap.get(optionName);
+
+        // We always have to delete expected options off the provided option
+        // map, since the leftovers are what will be used to tell which are
+        // misplaced - information you want even (or doubly so) if we've
+        // already stopped forming the output thanks to missing options.
+        optionsMap.delete(optionName);
+
+        // Just like if an option is missing, a valueless option cancels
+        // forming the rest of the output.
+        if (isValueless(optionValue)) {
+          // It's also an error, *except* if this option is one of the ones
+          // that we're indicated to *expect* might be valueless! In that case,
+          // we still need to stop forming the string (and mark a separate flag
+          // so that we return a blank), but it's not an error.
+          if (expectedValuelessOptionNames.has(optionName)) {
+            seenExpectedValuelessOption = true;
+          } else {
+            valuelessOptionNames.add(optionName);
+          }
+
           return undefined;
         }
+
+        if (canceledForming) {
+          return undefined;
+        }
+
+        return optionValue;
       },
     });
 
@@ -272,17 +317,30 @@ export class Language extends Thing {
       Array.from(optionsMap.keys());
 
     withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => {
+      const names = set => Array.from(set).join(', ');
+
       if (!empty(missingOptionNames)) {
-        const names = Array.from(missingOptionNames).join(`, `);
-        push(new Error(`Missing options: ${names}`));
+        push(new Error(
+          `Missing options: ${names(missingOptionNames)}`));
+      }
+
+      if (!empty(valuelessOptionNames)) {
+        push(new Error(
+          `Valueless options: ${names(valuelessOptionNames)}`));
       }
 
       if (!empty(misplacedOptionNames)) {
-        const names = Array.from(misplacedOptionNames).join(`, `);
-        push(new Error(`Unexpected options: ${names}`));
+        push(new Error(
+          `Unexpected options: ${names(misplacedOptionNames)}`));
       }
     });
 
+    // If an option was valueless as marked to expect, then that indicates
+    // the whole string should be treated as blank content.
+    if (seenExpectedValuelessOption) {
+      return html.blank();
+    }
+
     return output;
   }
 
@@ -416,11 +474,32 @@ export class Language extends Thing {
   }
 
   formatDate(date) {
+    // Null or undefined date is blank content.
+    if (date === null || date === undefined) {
+      return html.blank();
+    }
+
     this.assertIntlAvailable('intl_date');
     return this.intl_date.format(date);
   }
 
   formatDateRange(startDate, endDate) {
+    // formatDateRange expects both values to be present, but if both are null
+    // or both are undefined, that's just blank content.
+    const hasStart = startDate !== null && startDate !== undefined;
+    const hasEnd = endDate !== null && endDate !== undefined;
+    if (!hasStart || !hasEnd) {
+      if (startDate === endDate) {
+        return html.blank();
+      } else if (hasStart) {
+        throw new Error(`Expected both start and end of date range, got only start`);
+      } else if (hasEnd) {
+        throw new Error(`Expected both start and end of date range, got only end`);
+      } else {
+        throw new Error(`Got mismatched ${startDate}/${endDate} for start and end`);
+      }
+    }
+
     this.assertIntlAvailable('intl_date');
     return this.intl_date.formatRange(startDate, endDate);
   }
@@ -431,6 +510,17 @@ export class Language extends Thing {
     days: numDays = 0,
     approximate = false,
   }) {
+    // Give up if any of years, months, or days is null or undefined.
+    // These default to zero, so something's gone pretty badly wrong to
+    // pass in all or partial missing values.
+    if (
+      numYears === undefined || numYears === null ||
+      numMonths === undefined || numMonths === null ||
+      numDays === undefined || numDays === null
+    ) {
+      throw new Error(`Expected values or default zero for years, months, and days`);
+    }
+
     let basis;
 
     const years = this.countYears(numYears, {unit: true});
@@ -468,6 +558,14 @@ export class Language extends Thing {
     approximate = true,
     absolute = true,
   } = {}) {
+    // Give up if current and/or reference date is null or undefined.
+    if (
+      currentDate === undefined || currentDate === null ||
+      referenceDate === undefined || referenceDate === null
+    ) {
+      throw new Error(`Expected values for currentDate and referenceDate`);
+    }
+
     const currentInstant = toTemporalInstant.apply(currentDate);
     const referenceInstant = toTemporalInstant.apply(referenceDate);
 
@@ -528,6 +626,12 @@ export class Language extends Thing {
   }
 
   formatDuration(secTotal, {approximate = false, unit = false} = {}) {
+    // Null or undefined duration is blank content.
+    if (secTotal === null || secTotal === undefined) {
+      return html.blank();
+    }
+
+    // Zero duration is a "missing" string.
     if (secTotal === 0) {
       return this.formatString('count.duration.missing');
     }
@@ -565,6 +669,11 @@ export class Language extends Thing {
       throw new TypeError(`externalLinkSpec unavailable`);
     }
 
+    // Null or undefined url is blank content.
+    if (url === null || url === undefined) {
+      return html.blank();
+    }
+
     isExternalLinkContext(context);
 
     if (style === 'all') {
@@ -589,16 +698,31 @@ export class Language extends Thing {
   }
 
   formatIndex(value) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
     this.assertIntlAvailable('intl_pluralOrdinal');
     return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
   }
 
   formatNumber(value) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
     this.assertIntlAvailable('intl_number');
     return this.intl_number.format(value);
   }
 
   formatWordCount(value) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
     const num = this.formatNumber(
       value > 1000 ? Math.floor(value / 100) / 10 : value
     );
@@ -612,6 +736,11 @@ export class Language extends Thing {
   }
 
   #formatListHelper(array, processFn) {
+    // Empty lists, null, and undefined are blank content.
+    if (empty(array) || array === null || array === undefined) {
+      return html.blank();
+    }
+
     // Operate on "insertion markers" instead of the actual contents of the
     // array, because the process function (likely an Intl operation) is taken
     // to only operate on strings. We'll insert the contents of the array back
@@ -673,10 +802,22 @@ export class Language extends Thing {
 
   // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
   formatFileSize(bytes) {
-    if (!bytes) return '';
+    // Null or undefined bytes is blank content.
+    if (bytes === null || bytes === undefined) {
+      return html.blank();
+    }
+
+    // Zero bytes is blank content.
+    if (bytes === 0) {
+      return html.blank();
+    }
 
     bytes = parseInt(bytes);
-    if (isNaN(bytes)) return '';
+
+    // Non-number bytes is blank content! Wow.
+    if (isNaN(bytes)) {
+      return html.blank();
+    }
 
     const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10;
 
@@ -700,10 +841,42 @@ export class Language extends Thing {
       return this.formatString('count.fileSize.bytes', {bytes});
     }
   }
+
+  // Utility function to quickly provide a useful string key
+  // (generally a prefix) to stuff nested beneath it.
+  encapsulate(...args) {
+    const fn =
+      (typeof args.at(-1) === 'function'
+        ? args.at(-1)
+        : null);
+
+    const parts =
+      (fn
+        ? args.slice(0, -1)
+        : args);
+
+    const capsule =
+      this.#joinKeyParts(parts);
+
+    if (fn) {
+      return fn(capsule);
+    } else {
+      return capsule;
+    }
+  }
+
+  #joinKeyParts(parts) {
+    return parts.filter(Boolean).join('.');
+  }
 }
 
 const countHelper = (stringKey, optionName = stringKey) =>
   function(value, {unit = false} = {}) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
     return this.formatString(
       unit
         ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 725b1bb7..4aaf364c 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -18,7 +18,6 @@ import {
 } from '#yaml';
 
 import {withPropertyFromObject} from '#composite/data';
-import {withResolvedContribs} from '#composite/wiki-data';
 
 import {
   exitWithoutDependency,
@@ -26,9 +25,16 @@ import {
   exposeDependency,
   exposeDependencyOrContinue,
   exposeUpdateValueOrContinue,
+  exposeWhetherDependencyAvailable,
 } from '#composite/control-flow';
 
 import {
+  withRecontextualizedContributionList,
+  withRedatedContributionList,
+  withResolvedContribs,
+} from '#composite/wiki-data';
+
+import {
   additionalFiles,
   additionalNameList,
   commentary,
@@ -43,8 +49,9 @@ import {
   referenceList,
   reverseReferenceList,
   simpleDate,
-  singleReference,
   simpleString,
+  singleReference,
+  thing,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
@@ -52,21 +59,31 @@ import {
 import {
   exitWithoutUniqueCoverArt,
   inferredAdditionalNameList,
+  inheritContributionListFromOriginalRelease,
   inheritFromOriginalRelease,
   sharedAdditionalNameList,
   trackReverseReferenceList,
   withAlbum,
   withAlwaysReferenceByDirectory,
   withContainingTrackSection,
+  withDate,
   withHasUniqueCoverArt,
+  withOriginalRelease,
   withOtherReleases,
   withPropertyFromAlbum,
+  withTrackArtDate,
 } from '#composite/things/track';
 
 export class Track extends Thing {
   static [Thing.referenceType] = 'track';
 
-  static [Thing.getPropertyDescriptors] = ({Album, ArtTag, Artist, Flash}) => ({
+  static [Thing.getPropertyDescriptors] = ({
+    Album,
+    ArtTag,
+    Artist,
+    Flash,
+    WikiInfo,
+  }) => ({
     // Update & expose
 
     name: name('Unnamed Track'),
@@ -137,27 +154,14 @@ export class Track extends Thing {
       }),
     ],
 
-    // Date of cover art release. Like coverArtFileExtension, this represents
-    // only the track's own unique cover artwork, if any. This exposes only as
-    // the track's own coverArtDate or its album's trackArtDate, so if neither
-    // is specified, this value is null.
     coverArtDate: [
-      withHasUniqueCoverArt(),
-
-      exitWithoutDependency({
-        dependency: '#hasUniqueCoverArt',
-        mode: input.value('falsy'),
-      }),
-
-      exposeUpdateValueOrContinue({
-        validate: input.value(isDate),
-      }),
-
-      withPropertyFromAlbum({
-        property: input.value('trackArtDate'),
+      withTrackArtDate({
+        from: input.updateValue({
+          validate: isDate,
+        }),
       }),
 
-      exposeDependency({dependency: '#album.trackArtDate'}),
+      exposeDependency({dependency: '#trackArtDate'}),
     ],
 
     coverArtDimensions: [
@@ -192,12 +196,15 @@ export class Track extends Thing {
     }),
 
     artistContribs: [
-      inheritFromOriginalRelease({
-        notFoundValue: input.value([]),
-      }),
+      inheritContributionListFromOriginalRelease(),
+
+      withDate(),
 
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackArtistContributions'),
+        date: '#date',
       }).outputs({
         '#resolvedContribs': '#artistContribs',
       }),
@@ -211,15 +218,28 @@ export class Track extends Thing {
         property: input.value('artistContribs'),
       }),
 
+      withRecontextualizedContributionList({
+        list: '#album.artistContribs',
+        artistProperty: input.value('trackArtistContributions'),
+      }),
+
+      withRedatedContributionList({
+        list: '#album.artistContribs',
+        date: '#date',
+      }),
+
       exposeDependency({dependency: '#album.artistContribs'}),
     ],
 
     contributorContribs: [
-      inheritFromOriginalRelease({
-        notFoundValue: input.value([]),
-      }),
+      inheritContributionListFromOriginalRelease(),
 
-      contributionList(),
+      withDate(),
+
+      contributionList({
+        date: '#date',
+        artistProperty: input.value('trackContributorContributions'),
+      }),
     ],
 
     // Cover artists aren't inherited from the original release, since it
@@ -230,8 +250,15 @@ export class Track extends Thing {
         value: input.value([]),
       }),
 
+      withTrackArtDate({
+        fallback: input.value(true),
+      }),
+
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackCoverArtistContributions'),
+        date: '#trackArtDate',
       }).outputs({
         '#resolvedContribs': '#coverArtistContribs',
       }),
@@ -245,6 +272,16 @@ export class Track extends Thing {
         property: input.value('trackCoverArtistContribs'),
       }),
 
+      withRecontextualizedContributionList({
+        list: '#album.trackCoverArtistContribs',
+        artistProperty: input.value('trackCoverArtistContributions'),
+      }),
+
+      withRedatedContributionList({
+        list: '#album.trackCoverArtistContribs',
+        date: '#trackArtDate',
+      }),
+
       exposeDependency({dependency: '#album.trackCoverArtistContribs'}),
     ],
 
@@ -306,6 +343,10 @@ export class Track extends Thing {
       class: input.value(Track),
     }),
 
+    wikiInfo: thing({
+      class: input.value(WikiInfo),
+    }),
+
     // Expose only
 
     commentatorArtists: commentatorArtists(),
@@ -316,13 +357,8 @@ export class Track extends Thing {
     ],
 
     date: [
-      exposeDependencyOrContinue({dependency: 'dateFirstReleased'}),
-
-      withPropertyFromAlbum({
-        property: input.value('date'),
-      }),
-
-      exposeDependency({dependency: '#album.date'}),
+      withDate(),
+      exposeDependency({dependency: '#date'}),
     ],
 
     hasUniqueCoverArt: [
@@ -330,6 +366,23 @@ export class Track extends Thing {
       exposeDependency({dependency: '#hasUniqueCoverArt'}),
     ],
 
+    isOriginalRelease: [
+      withOriginalRelease(),
+
+      exposeWhetherDependencyAvailable({
+        dependency: '#originalRelease',
+        negate: input.value(true),
+      }),
+    ],
+
+    isRerelease: [
+      withOriginalRelease(),
+
+      exposeWhetherDependencyAvailable({
+        dependency: '#originalRelease',
+      }),
+    ],
+
     otherReleases: [
       withOtherReleases(),
       exposeDependency({dependency: '#otherReleases'}),
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 316bd3bb..b7b7b01c 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -3,8 +3,18 @@ export const WIKI_INFO_FILE = 'wiki-info.yaml';
 import {input} from '#composite';
 import find from '#find';
 import Thing from '#thing';
-import {isColor, isLanguageCode, isName, isURL} from '#validators';
-
+import {parseContributionPresets} from '#yaml';
+
+import {
+  isBoolean,
+  isColor,
+  isContributionPresetList,
+  isLanguageCode,
+  isName,
+  isURL,
+} from '#validators';
+
+import {exitWithoutDependency} from '#composite/control-flow';
 import {contentString, flag, name, referenceList, wikiData}
   from '#composite/wiki-properties';
 
@@ -57,6 +67,11 @@ export class WikiInfo extends Thing {
       data: 'groupData',
     }),
 
+    contributionPresets: {
+      flags: {update: true, expose: true},
+      update: {validate: isContributionPresetList},
+    },
+
     // Feature toggles
     enableFlashesAndGames: flag(false),
     enableListings: flag(false),
@@ -64,8 +79,26 @@ export class WikiInfo extends Thing {
     enableArtTagUI: flag(false),
     enableGroupUI: flag(false),
 
+    enableSearch: [
+      exitWithoutDependency({
+        dependency: 'searchDataAvailable',
+        mode: input.value('falsy'),
+        value: input.value(false),
+      }),
+
+      flag(true),
+    ],
+
     // Update only
 
+    searchDataAvailable: {
+      flags: {update: true},
+      update: {
+        validate: isBoolean,
+        default: false,
+      },
+    },
+
     groupData: wikiData({
       class: input.value(Group),
     }),
@@ -86,6 +119,11 @@ export class WikiInfo extends Thing {
       'Enable News': {property: 'enableNews'},
       'Enable Art Tag UI': {property: 'enableArtTagUI'},
       'Enable Group UI': {property: 'enableGroupUI'},
+
+      'Contribution Presets': {
+        property: 'contributionPresets',
+        transform: parseContributionPresets,
+      },
     },
   };
 
diff --git a/src/data/validators.js b/src/data/validators.js
index 5d681311..354de6fa 100644
--- a/src/data/validators.js
+++ b/src/data/validators.js
@@ -636,10 +636,65 @@ export function isThing(thing) {
 export const isContribution = validateProperties({
   artist: isArtistRef,
   annotation: optional(isStringNonEmpty),
+
+  countInDurationTotals: optional(isBoolean),
+  countInContributionTotals: optional(isBoolean),
 });
 
 export const isContributionList = validateArrayItems(isContribution);
 
+export const contributionPresetPropertySpec = {
+  album: [
+    'artistContribs',
+  ],
+
+  flash: [
+    'contributorContribs',
+  ],
+
+  track: [
+    'artistContribs',
+    'contributorContribs',
+  ],
+};
+
+// TODO: This validator basically constructs itself as it goes.
+// This is definitely some shenanigans!
+export function isContributionPresetContext(list) {
+  isArray(list);
+
+  if (empty(list)) {
+    throw new TypeError(`Expected at least one item`);
+  }
+
+  const isTarget =
+    is(...Object.keys(contributionPresetPropertySpec));
+
+  const [target, ...properties] = list;
+
+  isTarget(target);
+
+  const isProperty =
+    is(...contributionPresetPropertySpec[target]);
+
+  const isPropertyList =
+    validateArrayItems(isProperty);
+
+  isPropertyList(properties);
+
+  return true;
+}
+
+export const isContributionPreset = validateProperties({
+  annotation: isStringNonEmpty,
+  context: isContributionPresetContext,
+
+  countInDurationTotals: optional(isBoolean),
+  countInContributionTotals: optional(isBoolean),
+});
+
+export const isContributionPresetList = validateArrayItems(isContributionPreset);
+
 export const isAdditionalFile = validateProperties({
   title: isName,
   description: optional(isContentString),
diff --git a/src/data/yaml.js b/src/data/yaml.js
index c9ce5329..7a16341b 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -370,34 +370,42 @@ export function parseDuration(string) {
   }
 }
 
-export function parseAdditionalFiles(array) {
-  if (!Array.isArray(array)) {
-    // Error will be caught when validating against whatever this value is
-    return array;
-  }
-
-  return array.map((item) => ({
-    title: item['Title'],
-    description: item['Description'] ?? null,
-    files: item['Files'],
-  }));
-}
-
 export const extractAccentRegex =
   /^(?<main>.*?)(?: \((?<accent>.*)\))?$/;
 
 export const extractPrefixAccentRegex =
   /^(?:\((?<accent>.*)\) )?(?<main>.*?)$/;
 
-export function parseContributors(contributionStrings) {
+// TODO: Should this fit better within actual YAML loading infrastructure??
+export function parseArrayEntries(entries, mapFn) {
   // If this isn't something we can parse, just return it as-is.
   // The Thing object's validators will handle the data error better
   // than we're able to here.
-  if (!Array.isArray(contributionStrings)) {
-    return contributionStrings;
+  if (!Array.isArray(entries)) {
+    return entries;
+  }
+
+  // If the array is REALLY ACTUALLY empty (it's represented in YAML
+  // as literally an empty []), that's something we want to reflect.
+  if (empty(entries)) {
+    return entries;
   }
 
-  return contributionStrings.map(item => {
+  const nonNullEntries =
+    entries.filter(value => value !== null);
+
+  // On the other hand, if the array only contains null, it's just
+  // a placeholder, so skip over the field like it's not actually
+  // been put there yet.
+  if (empty(nonNullEntries)) {
+    return null;
+  }
+
+  return entries.map(mapFn);
+}
+
+export function parseContributors(entries) {
+  return parseArrayEntries(entries, item => {
     if (typeof item === 'object' && item['Who'])
       return {
         artist: item['Who'],
@@ -408,6 +416,9 @@ export function parseContributors(contributionStrings) {
       return {
         artist: item['Artist'],
         annotation: item['Annotation'] ?? null,
+
+        countInContributionTotals: item['Count In Contribution Totals'] ?? null,
+        countInDurationTotals: item['Count In Duration Totals'] ?? null,
       };
 
     if (typeof item !== 'string') return item;
@@ -422,12 +433,20 @@ export function parseContributors(contributionStrings) {
   });
 }
 
-export function parseAdditionalNames(additionalNameStrings) {
-  if (!Array.isArray(additionalNameStrings)) {
-    return additionalNameStrings;
-  }
+export function parseAdditionalFiles(entries) {
+  return parseArrayEntries(entries, item => {
+    if (typeof item !== 'object') return item;
 
-  return additionalNameStrings.map(item => {
+    return {
+      title: item['Title'],
+      description: item['Description'] ?? null,
+      files: item['Files'],
+    };
+  });
+}
+
+export function parseAdditionalNames(entries) {
+  return parseArrayEntries(entries, item => {
     if (typeof item === 'object' && item['Name'])
       return {name: item['Name'], annotation: item['Annotation'] ?? null};
 
@@ -466,6 +485,73 @@ export function parseDimensions(string) {
   return nums;
 }
 
+export const contributionPresetYAMLSpec = [
+  {from: 'Album', to: 'album', fields: [
+    {from: 'Artists', to: 'artistContribs'},
+  ]},
+
+  {from: 'Flash', to: 'flash', fields: [
+    {from: 'Contributors', to: 'contributorContribs'},
+  ]},
+
+  {from: 'Track', to: 'track', fields: [
+    {from: 'Artists', to: 'artistContribs'},
+    {from: 'Contributors', to: 'contributorContribs'},
+  ]},
+];
+
+export function parseContributionPresetContext(context) {
+  if (!Array.isArray(context)) {
+    return context;
+  }
+
+  const [target, ...fields] = context;
+
+  const targetEntry =
+    contributionPresetYAMLSpec
+      .find(({from}) => from === target);
+
+  if (!targetEntry) {
+    return context;
+  }
+
+  const properties =
+    fields.map(field => {
+      const fieldEntry =
+        targetEntry.fields
+          .find(({from}) => from === field);
+
+      if (!fieldEntry) return field;
+
+      return fieldEntry.to;
+    });
+
+  return [targetEntry.to, ...properties];
+}
+
+export function parseContributionPresets(list) {
+  if (!Array.isArray(list)) return list;
+
+  return list.map(item => {
+    if (typeof item !== 'object') return item;
+
+    return {
+      annotation:
+        item['Annotation'] ?? null,
+
+      context:
+        parseContributionPresetContext(
+          item['Context'] ?? null),
+
+      countInContributionTotals:
+        item['Count In Contribution Totals'] ?? null,
+
+      countInDurationTotals:
+        item['Count In Duration Totals'] ?? null,
+    };
+  });
+}
+
 // documentModes: Symbols indicating sets of behavior for loading and processing
 // data files.
 export const documentModes = {
@@ -1066,6 +1152,7 @@ export function linkWikiDataArrays(wikiData) {
       'artTagData',
       'artistData',
       'groupData',
+      'wikiInfo',
     ]],
 
     [wikiData.artTagData, [
@@ -1084,6 +1171,7 @@ export function linkWikiDataArrays(wikiData) {
       'artistData',
       'flashActData',
       'trackData',
+      'wikiInfo',
     ]],
 
     [wikiData.flashActData, [
@@ -1115,6 +1203,7 @@ export function linkWikiDataArrays(wikiData) {
       'artistData',
       'flashData',
       'trackData',
+      'wikiInfo',
     ]],
 
     [[wikiData.wikiInfo], [
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index c5c5ee4f..d08726c7 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -91,7 +91,8 @@ const WARNING_DELAY_TIME = 10000;
 //   this particular thumbtack will be regenerated, but any others (whose
 //   `tackbust` listed below is equal or below the cache-recorded bust) will be
 //   reused. (Zero is a special value that means this tack's spec is still the
-//   same as it would've been generated prior to thumbtack versioning.)
+//   same as it would've been generated prior to thumbtack versioning; any new
+//   kinds of thumbnails should start counting up from one.)
 //
 // * `size` is the maximum length of the image. It will be scaled down,
 //   keeping aspect ratio, to fit in this dimension.
@@ -132,6 +133,12 @@ const thumbnailSpec = {
     quality: 85,
   },
 
+  'adorb': {
+    tackbust: 1,
+    size: 64,
+    quality: 90,
+  },
+
   'mini': {
     tackbust: 2,
     size: 8,
@@ -630,6 +637,13 @@ export async function determineMediaCachePath({
     };
   }
 
+  if (!wikiCachePath) {
+    return {
+      annotation: 'wiki cache path not provided',
+      mediaCachePath: null,
+    };
+  }
+
   let mediaIncludesThumbnailCache;
 
   try {
@@ -648,24 +662,33 @@ export async function determineMediaCachePath({
 
   // Two inferred paths are possible - "adjacent" and "contained".
   // "Contained" is the preferred format and we'll create it if
-  // wikiCachePath is provided, but if it *isn't* we won't know
-  // where to create it. Since "adjacent" isn't preferred we don't
-  // ever generate it, and we'd prefer not to *newly* generate
-  // thumbs in-place with mediaPath, so give up - we've already
-  // determined mediaPath doesn't include in-place thumbs.
-
-  const adjacentInferredPath =
-    path.join(
-      path.dirname(mediaPath),
-      path.basename(mediaPath) + '-cache');
+  // neither of the inferred paths exists. (Of course, by this
+  // point we've already determined that the media path itself
+  // isn't doubling as the thumbnail cache.)
 
   const containedInferredPath =
     (wikiCachePath
       ? path.join(wikiCachePath, 'media-cache')
       : null);
 
-  let adjacentIncludesThumbnailCache;
+  const adjacentInferredPath =
+    path.join(
+      path.dirname(mediaPath),
+      path.basename(mediaPath) + '-cache');
+
   let containedIncludesThumbnailCache;
+  let adjacentIncludesThumbnailCache;
+
+  try {
+    const files = await readdir(containedInferredPath);
+    containedIncludesThumbnailCache = files.includes(CACHE_FILE);
+  } catch (error) {
+    if (error.code === 'ENOENT') {
+      containedIncludesThumbnailCache = null;
+    } else {
+      containedIncludesThumbnailCache = undefined;
+    }
+  }
 
   try {
     const files = await readdir(adjacentInferredPath);
@@ -678,19 +701,6 @@ export async function determineMediaCachePath({
     }
   }
 
-  if (wikiCachePath) {
-    try {
-      const files = await readdir(containedInferredPath);
-      containedIncludesThumbnailCache = files.includes(CACHE_FILE);
-    } catch (error) {
-      if (error.code === 'ENOENT') {
-        containedIncludesThumbnailCache = null;
-      } else {
-        containedIncludesThumbnailCache = undefined;
-      }
-    }
-  }
-
   // Go ahead with the contained path if it exists and contains a cache -
   // no other conditions matter.
   if (containedIncludesThumbnailCache === true) {
@@ -712,7 +722,7 @@ export async function determineMediaCachePath({
   // Throw a very high-priority tantrum if the contained cache exists but
   // isn't readable. It's the preferred cache and we can't tell if it's
   // available for use or not!
-  if (wikiCachePath && containedIncludesThumbnailCache === undefined) {
+  if (containedIncludesThumbnailCache === undefined) {
     return {
       annotation: `contained path not readable`,
       mediaCachePath: null,
@@ -764,28 +774,12 @@ export async function determineMediaCachePath({
     }
   }
 
-  // If wikiCachePath was provided and the contained cache just doesn't
-  // exist yet, we'll create it during this run.
-  if (wikiCachePath && containedIncludesThumbnailCache === null) {
-    return {
-      annotation: `contained path will be created`,
-      mediaCachePath: containedInferredPath,
-    };
-  }
-
-  // If the adjacent cache doesn't exist, too dang bad!
-  // We aren't interested in newly creating it, so
-  // don't count it as an option.
-
-  // Similarly, we've already established mediaPath isn't
-  // currently doubling as the thumbnail cache, and we won't
-  // newly start generating thumbnails here either.
-
-  // All options aside struck out, there's no way to continue.
-
+  // If we haven't found any information about either inferred
+  // location (and so have fallen back to this base case), we'll
+  // create the contained cache during this run.
   return {
-    annotation: `missing wiki cache to create media cache inside`,
-    mediaCachePath: null,
+    annotation: `contained path will be created`,
+    mediaCachePath: containedInferredPath,
   };
 }
 
diff --git a/src/listing-spec.js b/src/listing-spec.js
index 73fbee6d..bfea397c 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -67,7 +67,7 @@ listingSpec.push({
   contentFunction: 'listArtistsByDuration',
 });
 
-// TODO: hide if no groups...
+// TODO: hide if divideTrackListsByGroups empty...
 listingSpec.push({
   directory: 'artists/by-group',
   stringsKey: 'listArtists.byGroup',
diff --git a/src/page/artist.js b/src/page/artist.js
index f80bd906..b68cf05c 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -9,8 +9,8 @@ export function targets({wikiData}) {
 
 export function pathsForTarget(artist) {
   const hasGalleryPage =
-    !empty(artist.tracksAsCoverArtist) ||
-    !empty(artist.albumsAsCoverArtist);
+    !empty(artist.albumCoverArtistContributions) ||
+    !empty(artist.trackCoverArtistContributions);
 
   return [
     {
diff --git a/src/search.js b/src/search.js
new file mode 100644
index 00000000..a2dae9e1
--- /dev/null
+++ b/src/search.js
@@ -0,0 +1,119 @@
+'use strict';
+
+import {createHash} from 'node:crypto';
+import {mkdir, writeFile} from 'node:fs/promises';
+import * as path from 'node:path';
+
+import {compress} from 'compress-json';
+import FlexSearch from 'flexsearch';
+import {pack} from 'msgpackr';
+
+import {logWarn} from '#cli';
+import {makeSearchIndex, populateSearchIndex, searchSpec} from '#search-spec';
+import {stitchArrays} from '#sugar';
+import {checkIfImagePathHasCachedThumbnails, getThumbnailEqualOrSmaller}
+  from '#thumbs';
+
+async function serializeIndex(index) {
+  const results = {};
+
+  await index.export((key, data) => {
+    if (data === undefined) {
+      return;
+    }
+
+    if (typeof data !== 'string') {
+      logWarn`Got something besides a string from index.export(), skipping:`;
+      console.warn(key, data);
+      return;
+    }
+
+    results[key] = JSON.parse(data);
+  });
+
+  return results;
+}
+
+export async function writeSearchData({
+  thumbsCache,
+  urls,
+  wikiCachePath,
+  wikiData,
+}) {
+  if (!wikiCachePath) {
+    throw new Error(`Expected wikiCachePath to write into`);
+  }
+
+  // Basic flow is:
+  // 1. Define schema for type
+  // 2. Add documents to index
+  // 3. Save index to exportable json
+
+  const keys =
+    Object.keys(searchSpec);
+
+  const descriptors =
+    Object.values(searchSpec);
+
+  const indexes =
+    descriptors
+      .map(descriptor =>
+        makeSearchIndex(descriptor, {FlexSearch}));
+
+  stitchArrays({
+    index: indexes,
+    descriptor: descriptors,
+  }).forEach(({index, descriptor}) =>
+      populateSearchIndex(index, descriptor, {
+        checkIfImagePathHasCachedThumbnails,
+        getThumbnailEqualOrSmaller,
+        thumbsCache,
+        urls,
+        wikiData,
+      }));
+
+  const serializedIndexes =
+    await Promise.all(indexes.map(serializeIndex));
+
+  const packedIndexes =
+    serializedIndexes
+      .map(data => compress(data))
+      .map(data => pack(data));
+
+  const outputDirectory =
+    path.join(wikiCachePath, 'search');
+
+  const mainIndexFile =
+    path.join(outputDirectory, 'index.json');
+
+  const mainIndexJSON =
+    JSON.stringify(
+      Object.fromEntries(
+        stitchArrays({
+          key: keys,
+          buffer: packedIndexes,
+        }).map(({key, buffer}) => {
+          const md5 = createHash('md5');
+          md5.write(buffer);
+
+          const value = {
+            md5: md5.digest('hex'),
+          };
+
+          return [key, value];
+        })));
+
+
+  await mkdir(outputDirectory, {recursive: true});
+
+  await Promise.all(
+    stitchArrays({
+      key: keys,
+      buffer: packedIndexes,
+    }).map(({key, buffer}) =>
+        writeFile(
+          path.join(outputDirectory, key + '.json.msgpack'),
+          buffer)));
+
+  await writeFile(mainIndexFile, mainIndexJSON);
+}
diff --git a/src/static/site-basic.css b/src/static/css/site-basic.css
index 586f37b5..586f37b5 100644
--- a/src/static/site-basic.css
+++ b/src/static/css/site-basic.css
diff --git a/src/static/site7.css b/src/static/css/site.css
index c23acffb..80801c85 100644
--- a/src/static/site7.css
+++ b/src/static/css/site.css
@@ -172,6 +172,10 @@ body::before {
   flex-grow: 1;
 }
 
+.sidebar-column.initially-hidden {
+  display: none;
+}
+
 .sidebar-multiple {
   display: flex;
   flex-direction: column;
@@ -226,7 +230,7 @@ body {
 }
 
 body::before {
-  background-image: url("../media/bg.jpg");
+  background-image: url("../../media/bg.jpg");
   background-position: center;
   background-size: cover;
   opacity: 0.5;
@@ -255,6 +259,11 @@ body::before {
   font-weight: 800;
 }
 
+#page-container:not(.showing-sidebar-left) #skippers .skipper[data-for=sidebar-left],
+#page-container:not(.showing-sidebar-right) #skippers .skipper[data-for=sidebar-right] {
+  display: none;
+}
+
 #banner {
   background: black;
   background-color: var(--dim-color);
@@ -394,6 +403,36 @@ summary > span:hover {
   text-decoration-color: var(--primary-color);
 }
 
+summary > span:hover a {
+  text-decoration: none !important;
+}
+
+summary > span:hover:has(a:hover),
+summary > span:hover:has(a.nested-hover),
+summary.has-nested-hover > span {
+  text-decoration: none !important;
+}
+
+summary > span:hover:has(a:hover) a,
+summary > span:hover:has(a.nested-hover) a,
+summary.has-nested-hover > span a {
+  text-decoration: underline !important;
+}
+
+summary.underline-white > span:hover {
+  text-decoration-color: white;
+}
+
+/* This link isn't supposed to be underlined *at all*
+ * when the summary (and not link) is hovered, but
+ * for some reason Safari is still applying its colored
+ * and dotted(!) underline. Get around the apparent
+ * effect by just making it white.
+ */
+summary.underline-white > span:hover a:not(:hover) {
+  text-decoration-color: white;
+}
+
 summary .group-name {
   color: var(--primary-color);
 }
@@ -432,6 +471,283 @@ summary .group-name {
   font-weight: normal;
 }
 
+.sidebar-column.search-showing-results {
+  position: sticky;
+  top: 5px;
+  align-self: flex-start !important; /* pls */
+}
+
+.wiki-search-sidebar-box {
+  padding: 1px 0 0 0;
+
+  z-index: 100;
+  max-height: calc(100vh - 20px);
+
+  display: flex;
+  flex-direction: column;
+
+  background-color: #000000c0;
+
+  -webkit-backdrop-filter:
+    brightness(1.2) blur(4px);
+
+          backdrop-filter:
+    brightness(1.2) blur(4px);
+}
+
+.wiki-search-sidebar-box.showing-results {
+  box-shadow:
+    0 4px 16px -8px var(--primary-color),
+    0 10px 6px var(--bg-black-color),
+    0 6px 4px #00000040;
+}
+
+/* This is to say, any sidebar that's *not*
+ * the first sidebar after the search box.
+ */
+.wiki-search-sidebar-box.showing-results + .sidebar ~ .sidebar {
+  margin-top: 5px;
+}
+
+.wiki-search-sidebar-box.showing-results ~ .sidebar:not(:hover) {
+  opacity: 0.8;
+  filter: brightness(0.7);
+}
+
+.wiki-search-label {
+  width: calc(100% - 4px);
+  padding: 2px 4px;
+  margin: 2px 2px 3px 2px;
+  box-sizing: border-box;
+
+  display: flex;
+  flex-direction: row;
+
+  background: transparent;
+  border: 1px solid var(--dim-color);
+  border-radius: 3px;
+}
+
+.wiki-search-label::before {
+  display: inline-block;
+  padding-left: 3px;
+  padding-right: 3px;
+  margin-right: 3px;
+  width: 1.8em;
+  text-align: center;
+  content: '\1f50d\fe0e';
+}
+
+.wiki-search-input {
+  background: transparent;
+  border: transparent;
+  color: inherit;
+  flex-grow: 1;
+}
+
+.wiki-search-input::-webkit-search-cancel-button {
+  filter: grayscale(1) invert(1);
+}
+
+.wiki-search-label.disabled {
+  opacity: 0.6;
+}
+
+.wiki-search-label.disabled,
+.wiki-search-input[disabled] {
+  cursor: not-allowed;
+}
+
+.wiki-search-label:not(.disabled):hover,
+.wiki-search-label:focus-within {
+  background: var(--light-ghost-color);
+}
+
+.wiki-search-label:focus-within {
+  border-color: var(--primary-color);
+}
+
+.wiki-search-label:focus-within::before {
+  opacity: 0.7;
+}
+
+.wiki-search-input:focus {
+  border: none;
+  outline: none;
+}
+
+.wiki-search-input::placeholder {
+  color: var(--primary-color);
+  font-style: oblique;
+}
+
+.wiki-search-input:focus::placeholder {
+  color: var(--dim-color);
+}
+
+.wiki-search-sidebar-box hr {
+  border-color: var(--primary-color);
+  border-style: none none dotted none;
+  margin-top: 3px;
+  margin-bottom: 3px;
+}
+
+.wiki-search-progress-container {
+  padding: 2px 6px 4px 6px;
+  display: flex;
+  flex-direction: row;
+}
+
+.wiki-search-progress-label {
+  font-size: 0.9em;
+  font-style: oblique;
+  cursor: default;
+  margin-right: 1ch;
+}
+
+.wiki-search-progress-bar {
+  flex-grow: 1;
+}
+
+.wiki-search-failed-container {
+  padding: 2px 3px 4px 6px;
+}
+
+.wiki-search-failed-container p {
+  margin: 0;
+}
+
+.wiki-search-results-container {
+  margin-bottom: 0;
+  padding: 2px;
+}
+
+.wiki-search-no-results {
+  font-size: 0.9em;
+  padding: 2px 3px 4px 6px;
+  cursor: default;
+}
+
+.wiki-search-result {
+  position: relative;
+  display: flex;
+  padding: 4px 3px 4px 6px;
+}
+
+.wiki-search-result:hover {
+  text-decoration: none !important;
+}
+
+.wiki-search-result::before {
+  content: '';
+  position: absolute;
+  top: -2px;
+  bottom: -2px;
+  left: 0;
+  right: 0;
+
+  border: 1.5px solid var(--primary-color);
+  border-radius: 4px;
+  display: none;
+}
+
+.wiki-search-result.current-result {
+  background: var(--light-ghost-color);
+  border-top: 1px solid var(--dim-color);
+  border-bottom: 1px solid var(--dim-color);
+}
+
+.wiki-search-result:hover::before {
+  display: block;
+  background: var(--light-ghost-color);
+}
+
+.wiki-search-result.current-result:hover {
+  background: none;
+  border-color: transparent;
+}
+
+.wiki-search-result.current-result:hover .wiki-search-current-result-text {
+  filter: saturate(0.8) brightness(1.4);
+}
+
+.wiki-search-result-text-area {
+  align-self: center;
+  flex-grow: 1;
+  min-width: 0;
+  overflow-wrap: break-word;
+  padding-bottom: 2px;
+}
+
+.wiki-search-result-name {
+  margin-right: 0.25em;
+}
+
+.wiki-search-result:hover .wiki-search-result-name {
+  text-decoration: underline;
+}
+
+.wiki-search-current-result-text,
+.wiki-search-result-kind {
+  font-style: oblique;
+  opacity: 0.9;
+  display: inline-block;
+}
+
+.wiki-search-result-image-container {
+  align-self: flex-start;
+  flex-shrink: 0;
+  margin-right: 6px;
+  border-radius: 2px;
+  overflow: hidden;
+
+  background-color: var(--deep-color);
+  border: 2px solid var(--deep-color);
+}
+
+.wiki-search-results:not(:has(.wiki-search-result-image)) .wiki-search-result-image-container {
+  display: none;
+}
+
+.wiki-search-result-image,
+.wiki-search-result-image-placeholder {
+  display: block;
+  width: 1.8em;
+  height: 1.8em;
+  aspect-ratio: 1 / 1;
+  border-radius: 1.5px;
+}
+
+.wiki-search-result-image-placeholder {
+  background-color: #0004;
+  box-shadow: 0 1px 3px -1px #0008 inset;
+}
+
+.wiki-search-result-image.has-warning {
+  filter: blur(2px) brightness(0.8);
+}
+
+.wiki-search-end-search-line {
+  text-align: center;
+  margin-top: 6px;
+  margin-bottom: 2px;
+}
+
+.wiki-search-end-search-line a {
+  display: inline-block;
+  font-style: oblique;
+  opacity: 0.9;
+  padding: 3px 6px 4px 6px;
+  border-radius: 4px;
+  border: 1.5px solid transparent;
+}
+
+.wiki-search-end-search-line a:hover {
+  opacity: 1;
+  background: var(--light-ghost-color);
+  border-color: var(--deep-color);
+}
+
 #content {
   overflow-wrap: anywhere;
 }
@@ -476,6 +792,7 @@ a:not([href]):hover {
 
 .external-link.indicate-external::after {
   content: '\00a0➚';
+  font-style: normal;
 }
 
 .external-link.indicate-external:hover::after {
@@ -500,11 +817,6 @@ a:not([href]):hover {
   content: "\0020/\0020";
 }
 
-#header .chronology .heading,
-#header .chronology .buttons {
-  white-space: nowrap;
-}
-
 #secondary-nav {
   text-align: center;
 }
@@ -580,7 +892,7 @@ li:not(:first-child:last-child) .tooltip,
     0 -2px 4px -2px var(--primary-color) inset;
 }
 
-.icons-tooltip {
+.contribution-tooltip {
   padding: 3px 6px 6px 6px;
   left: -34px;
 }
@@ -601,7 +913,7 @@ li:not(:first-child:last-child) .tooltip,
   margin-right: -120px;
 }
 
-.icons-tooltip .tooltip-content {
+.contribution-tooltip .tooltip-content {
   padding: 6px 2px 2px 2px;
 
   -webkit-user-select: none;
@@ -612,42 +924,122 @@ li:not(:first-child:last-child) .tooltip,
   display: grid;
 
   grid-template-columns:
-    [icon-start] auto [icon-end domain-start] auto [domain-end];
+    [icon-start] 26px [icon-end handle-start] auto [handle-end platform-start] auto [platform-end];
 }
 
-.icons-tooltip .icon {
+.contribution-tooltip .external-link {
+  display: grid;
+  grid-column-start: icon-start;
+  grid-column-end: handle-end;
+  grid-template-columns: subgrid;
+
+  height: 1.4em;
+}
+
+.contribution-tooltip .chronology-link {
+  display: grid;
+  grid-column-start: icon-start;
+  grid-column-end: handle-end;
+  grid-template-columns: subgrid;
+
+  height: 1.2em;
+}
+
+.contribution-tooltip .external-icon,
+.contribution-tooltip .chronology-symbol {
   grid-column-start: icon-start;
   grid-column-end: icon-end;
 }
 
-.icons-tooltip .icon-platform {
+.contribution-tooltip .external-icon svg {
+  width: 18px;
+  height: 18px;
+  top: -0.1em;
+}
+
+.contribution-tooltip .chronology-symbol {
+  text-align: center;
+}
+
+.contribution-tooltip .external-handle,
+.contribution-tooltip .chronology-text {
+  grid-column-start: handle-start;
+  grid-column-end: handle-end;
+
+  width: max-content;
+  max-width: 200px;
+
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.contribution-tooltip .external-handle {
+  padding-right: 8px;
+}
+
+.contribution-tooltip .chronology-text {
+  padding-right: 6px;
+}
+
+.contribution-tooltip .chronology-text,
+.contribution-tooltip .chronology-info {
+  font-size: 0.85em;
+}
+
+.contribution-tooltip .tooltip-divider {
+  grid-column-start: icon-start;
+  grid-column-end: platform-end;
+
+  border-top: 1px dotted var(--primary-color);
+  margin-top: 3px;
+  margin-bottom: 4px;
+}
+
+.contribution-tooltip .external-platform,
+.contribution-tooltip .chronology-info {
   display: none;
 
-  grid-column-start: domain-start;
-  grid-column-end: domain-end;
+  grid-column-start: platform-start;
+  grid-column-end: platform-end;
 
-  --icon-platform-opacity: 0.8;
-  padding-right: 4px;
+  --external-platform-opacity: 0.8;
   opacity: 0.8;
+  padding-right: 4px;
+
+  white-space: nowrap;
 }
 
-.icons-tooltip.show-info .icon-platform {
+.contribution-tooltip.show-info .external-platform,
+.contribution-tooltip.show-info .chronology-info {
   display: inline;
-  animation: icon-platform 0.2s forwards linear;
+  animation: external-platform 0.2s forwards linear;
 }
 
-@keyframes icon-platform {
+@keyframes external-platform {
   from {
     opacity: 0;
   }
 
   to {
-    opacity: var(--icon-platform-opacity);
+    opacity: var(--external-platform-opacity);
   }
 }
 
-.icons-tooltip .icon:hover + .icon-platform {
-  --icon-platform-opacity: 1;
+.contribution-tooltip .external-link:hover,
+.contribution-tooltip .chronology-link:hover {
+  filter: brightness(1.4);
+  text-decoration: none;
+}
+
+.contribution-tooltip .external-link:hover .external-handle,
+.contribution-tooltip .chronology-link:hover .chronology-text {
+  text-decoration: underline;
+}
+
+.contribution-tooltip .external-link:hover + .external-platform,
+.contribution-tooltip .chronology-link:hover + .chronology-info {
+  --external-platform-opacity: 1;
   text-decoration: underline;
   text-decoration-color: #ffffffaa;
 }
@@ -663,27 +1055,15 @@ li:not(:first-child:last-child) .tooltip,
   padding: 3px 4.5px;
 }
 
-.icons {
-  font-style: normal;
-  white-space: nowrap;
-}
-
-.icons a:hover {
-  filter: brightness(1.4);
-}
-
-.icons a {
-  padding: 0 3px;
-}
-
-.icon {
+.external-icon {
   display: inline-block;
+  padding: 0 3px;
   width: 24px;
   height: 1em;
   position: relative;
 }
 
-.icon > svg {
+.external-icon svg {
   width: 24px;
   height: 24px;
   top: -0.25em;
@@ -691,23 +1071,6 @@ li:not(:first-child:last-child) .tooltip,
   fill: var(--primary-color);
 }
 
-.icon.has-text {
-  display: block;
-  width: unset;
-  height: 1.4em;
-}
-
-.icon.has-text > svg {
-  width: 18px;
-  height: 18px;
-  top: -0.1em;
-}
-
-.icon.has-text > .icon-text {
-  margin-left: 24px;
-  padding-right: 8px;
-}
-
 .rerelease,
 .other-group-accent {
   opacity: 0.7;
@@ -722,6 +1085,10 @@ li:not(:first-child:last-child) .tooltip,
   color: var(--page-primary-color);
 }
 
+progress {
+  accent-color: var(--primary-color);
+}
+
 .content-columns {
   columns: 2;
 }
@@ -803,6 +1170,10 @@ ul.image-details li {
   content: " \00b7 ";
 }
 
+#artist-commentary.first-entry-is-dated {
+  clear: right;
+}
+
 .commentary-entry-heading {
   margin-left: 15px;
   padding-left: 5px;
@@ -815,6 +1186,19 @@ ul.image-details li {
   font-style: oblique;
 }
 
+.commentary-entry-heading time {
+  float: right;
+  padding-left: 0.5ch;
+  padding-right: 0.25ch;
+  margin-left: 0.75ch;
+  border-left: 1px dotted transparent;
+  transition: border-left-color 0.15s;
+}
+
+.commentary-entry-heading time:hover {
+  border-left-color: white;
+}
+
 .commentary-art {
   float: right;
   width: 30%;
@@ -846,6 +1230,17 @@ ul.image-details li {
   margin-bottom: 1.5em;
 }
 
+a.align-center, img.align-center {
+  display: block;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+center {
+  margin-top: 1em;
+  margin-bottom: 1em;
+}
+
 .content-image {
   display: inline-block !important;
 }
@@ -921,11 +1316,15 @@ html[data-url-key="localized.home"] #content h1 {
 
 .quick-info {
   text-align: center;
+  padding-left: calc(var(--responsive-padding-ratio) * 100%);
+  padding-right: calc(var(--responsive-padding-ratio) * 100%);
+  line-height: 1.25em;
 }
 
 ul.quick-info {
   list-style: none;
   padding-left: 0;
+  padding-right: 0;
 }
 
 ul.quick-info li {
@@ -937,10 +1336,65 @@ ul.quick-info li:not(:last-child)::after {
   font-weight: 800;
 }
 
-.carousel-container + .quick-info {
+.carousel-container + .quick-info,
+.carousel-container + .quick-description {
   margin-top: 25px;
 }
 
+.quick-description:not(.has-external-links-only) {
+  --clamped-padding-ratio: max(var(--responsive-padding-ratio), 0.06);
+  margin-left: auto;
+  margin-right: auto;
+  padding-left: calc(0.40 * var(--clamped-padding-ratio) * 100%);
+  padding-right: calc(0.40 * var(--clamped-padding-ratio) * 100%);
+  max-width: 500px;
+
+  padding-top: 0.25em;
+  padding-bottom: 0.75em;
+  border-left: 1px solid var(--dim-color);
+  border-right: 1px solid var(--dim-color);
+  line-height: 1.25em;
+}
+
+.quick-description.has-external-links-only {
+  padding-left: 12%;
+  padding-right: 12%;
+}
+
+.quick-description.has-content-only {
+  padding-bottom: 0.5em;
+}
+
+.quick-description p {
+  text-align: center;
+}
+
+.quick-description .description-content.long hr ~ p {
+  text-align: left;
+}
+
+.quick-description > .description-content :first-child {
+  margin-top: 0;
+}
+
+.quick-description > .quick-description-actions,
+.quick-description.has-content-only .description-content :last-child {
+  margin-bottom: 0;
+}
+
+.quick-description:not(.collapsed) .description-content.short,
+.quick-description:not(.collapsed) .quick-description-actions.when-collapsed,
+.quick-description:not(.expanded) .description-content.long,
+.quick-description:not(.expanded) .quick-description-actions.when-expanded {
+  display: none;
+}
+
+.quick-description .quick-description-actions .expand-link,
+.quick-description .quick-description-actions .collapse-link {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
 #intro-menu {
   margin: 24px 0;
   padding: 10px;
@@ -997,8 +1451,23 @@ p code {
   margin-bottom: 0;
 }
 
+#content blockquote h2 {
+  font-size: 1em;
+  font-weight: 800;
+}
+
+#content blockquote h3 {
+  font-size: 1em;
+  font-weight: normal;
+  font-style: oblique;
+}
+
+main {
+  --responsive-padding-ratio: 0.10;
+}
+
 main.long-content {
-  --long-content-padding-ratio: 0.10;
+  --long-content-padding-ratio: var(--responsive-padding-ratio);
 }
 
 main.long-content .main-content-container,
@@ -1013,6 +1482,9 @@ dl dt {
 }
 
 dl dt {
+  /* Heads up, this affects the measurement
+   * for dl dt which are .content-heading!
+   */
   margin-bottom: 2px;
 }
 
@@ -1348,7 +1820,6 @@ img.pixelate, .pixelate img {
 
   font-size: 1.6em;
   opacity: 0.8;
-  background-image: url("warning.svg");
 }
 
 .reveal-interaction {
@@ -1839,6 +2310,13 @@ html[data-url-key="localized.home"] .carousel-container {
   animation-delay: 125ms;
 }
 
+dl dt.content-heading {
+  /* Basic margin-bottom for dt is 2px,
+   * so just subtract 3px from that.
+   */
+  margin-bottom: -1px;
+}
+
 h3.content-heading {
   clear: both;
 }
@@ -2032,40 +2510,40 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 
 /* Sticky sidebar */
 
-.sidebar-column.sidebar.sticky-column,
-.sidebar-column.sidebar.sticky-last,
-.sidebar-multiple.sticky-last > .sidebar:last-child,
-.sidebar-multiple.sticky-column {
-  position: sticky;
-  top: 10px;
-}
-
-.sidebar-multiple.sticky-last {
+.sidebar-column:not(.sticky-column) {
   align-self: stretch;
 }
 
-.sidebar-multiple.sticky-column {
+.sidebar-column.sticky-column {
+  position: sticky;
+  top: 10px;
   align-self: flex-start;
+  max-height: calc(100vh - 20px);
+  display: flex;
+  flex-direction: column;
 }
 
-.sidebar-column.sidebar.sticky-column {
-  max-height: calc(100vh - 20px);
-  align-self: start;
-  padding-bottom: 0;
-  box-sizing: border-box;
-  flex-basis: 275px;
-  padding-top: 0;
+.sidebar-multiple.sticky-column .sidebar:last-child {
+  flex-shrink: 1;
+  overflow-y: scroll;
+  scrollbar-width: thin;
+  scrollbar-color: var(--dark-color);
+}
+
+.wiki-search-sidebar-box .wiki-search-results-container {
   overflow-y: scroll;
   scrollbar-width: thin;
   scrollbar-color: var(--dark-color);
 }
 
-.sidebar-column.sidebar.sticky-column::-webkit-scrollbar {
+.sidebar-column.sticky-column .sidebar:last-child::-webkit-scrollbar,
+.wiki-search-sidebar-box .wiki-search-results-container::-webkit-scrollbar {
   background: var(--dark-color);
   width: 12px;
 }
 
-.sidebar-column.sidebar.sticky-column::-webkit-scrollbar-thumb {
+.sidebar-column.sticky-column .sidebar:last-child::-webkit-scrollbar-thumb,
+.wiki-search-sidebar-box .wiki-search-results-container::-webkit-scrollbar-thumb {
   transition: background 0.2s;
   background: rgba(255, 255, 255, 0.2);
   border: 3px solid transparent;
@@ -2101,6 +2579,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   left: 0;
   right: 0;
   bottom: 0;
+  z-index: 4000;
 
   background: rgba(0, 0, 0, 0.8);
   color: white;
@@ -2252,7 +2731,8 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
 /* Layout - Wide (most computers) */
 
 @media (min-width: 900px) {
-  #page-container:not(.has-zero-sidebars) #secondary-nav {
+  #page-container.showing-sidebar-left #secondary-nav,
+  #page-container.showing-sidebar-left #secondary-nav {
     display: none;
   }
 }
@@ -2269,8 +2749,9 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
    * don't apply the similar layout change of widening the long-content area
    * if this page doesn't have a sidebar to hide in the first place.
    */
-  #page-container:not(.has-zero-sidebars) main.long-content {
-    --long-content-padding-ratio: 0.06;
+  #page-container.showing-sidebar-left main,
+  #page-container.showing-sidebar-right main {
+    --responsive-padding-ratio: 0.06;
   }
 }
 
@@ -2301,12 +2782,12 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     z-index: 2;
   }
 
-  html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:not(:nth-child(n+7)) {
+  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+7)) {
     flex-basis: 23%;
     margin: 15px;
   }
 
-  html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:nth-child(n+7) {
+  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+7) {
     flex-basis: 18%;
     margin: 10px;
   }
@@ -2333,6 +2814,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
   }
 
   .sidebar-column {
+    position: static !important;
     max-width: unset !important;
     flex-basis: unset !important;
     margin-right: 0 !important;
@@ -2356,8 +2838,8 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     columns: 1;
   }
 
-  main.long-content {
-    --long-content-padding-ratio: 0.02;
+  main {
+    --responsive-padding-ratio: 0.02;
   }
 
   #cover-art-container {
diff --git a/src/static/client4.js b/src/static/js/client.js
index 729836b5..21c3911a 100644
--- a/src/static/client4.js
+++ b/src/static/js/client.js
@@ -5,8 +5,18 @@
 // that cannot 8e done at static-site compile time, 8y its fundamentally
 // ephemeral nature.
 
-import {accumulateSum, empty, filterMultipleArrays, stitchArrays}
-  from '../util/sugar.js';
+import {getColors} from '../shared-util/colors.js';
+
+import {
+  accumulateSum,
+  atOffset,
+  empty,
+  filterMultipleArrays,
+  promiseWithResolvers,
+  stitchArrays,
+  withEntries,
+} from '../shared-util/sugar.js';
+
 import {fetchWithProgress} from './xhr-util.js';
 
 const clientInfo = window.hsmusicClientInfo = Object.create(null);
@@ -25,7 +35,7 @@ function initInfo(infoKey, description) {
   for (const obj of [
     object,
     object.state,
-    object.setting,
+    object.settings,
     object.event,
   ]) {
     if (!obj) continue;
@@ -33,35 +43,117 @@ function initInfo(infoKey, description) {
   }
 
   if (object.session) {
-    const sessionDefaults = object.session;
+    const sessionSpecs = object.session;
 
     object.session = {};
 
-    for (const [key, defaultValue] of Object.entries(sessionDefaults)) {
+    for (const [key, spec] of Object.entries(sessionSpecs)) {
+      const hasSpec =
+        typeof spec === 'object' && spec !== null;
+
+      const defaultValue =
+        (hasSpec
+          ? spec.default ?? null
+          : spec);
+
+      let formatRead = value => value;
+      let formatWrite = value => value;
+      if (hasSpec && spec.type) {
+        switch (spec.type) {
+          case 'number':
+            formatRead = parseFloat;
+            formatWrite = String;
+            break;
+
+          case 'boolean':
+            formatRead = Boolean;
+            formatWrite = String;
+            break;
+
+          case 'string':
+            formatRead = String;
+            formatWrite = String;
+            break;
+
+          case 'json':
+            formatRead = JSON.parse;
+            formatWrite = JSON.stringify;
+            break;
+
+          default:
+            throw new Error(`Unknown type for session storage spec "${spec.type}"`);
+        }
+      }
+
+      let getMaxLength =
+        (!hasSpec
+          ? () => Infinity
+       : typeof spec.maxLength === 'function'
+          ? (object.settings
+              ? () => spec.maxLength(object.settings)
+              : () => spec.maxLength())
+          : () => spec.maxLength);
+
       const storageKey = `hsmusic.${infoKey}.${key}`;
 
       let fallbackValue = defaultValue;
 
       Object.defineProperty(object.session, key, {
         get: () => {
+          let value;
           try {
-            return sessionStorage.getItem(storageKey) ?? defaultValue;
+            value = sessionStorage.getItem(storageKey) ?? defaultValue;
           } catch (error) {
             if (error instanceof DOMException) {
-              return fallbackValue;
+              value = fallbackValue;
             } else {
               throw error;
             }
           }
+
+          if (value === null) {
+            return null;
+          }
+
+          return formatRead(value);
         },
 
         set: (value) => {
+          if (value !== null && value !== '') {
+            value = formatWrite(value);
+          }
+
+          if (value === null) {
+            value = '';
+          }
+
+          const maxLength = getMaxLength();
+          if (value.length > maxLength) {
+            console.warn(
+              `Requested to set session storage ${storageKey} ` +
+              `beyond maximum length ${maxLength}, ` +
+              `ignoring this value.`);
+            console.trace();
+            return;
+          }
+
+          let operation;
+          if (value === '') {
+            fallbackValue = null;
+            operation = () => {
+              sessionStorage.removeItem(storageKey);
+            };
+          } else {
+            fallbackValue = value;
+            operation = () => {
+              sessionStorage.setItem(storageKey, value);
+            };
+          }
+
           try {
-            sessionStorage.setItem(storageKey, value);
+            operation();
           } catch (error) {
-            if (error instanceof DOMException) {
-              fallbackValue = value;
-            } else {
+            if (!(error instanceof DOMException)) {
               throw error;
             }
           }
@@ -144,6 +236,18 @@ function cssProp(el, ...args) {
   }
 }
 
+function templateContent(el) {
+  if (el === null) {
+    return null;
+  }
+
+  if (el?.nodeName !== 'TEMPLATE') {
+    throw new Error(`Expected a <template> element`);
+  }
+
+  return el.content.cloneNode(true);
+}
+
 // Curry-style, so multiple points can more conveniently be tested at once.
 function pointIsOverAnyOf(elements) {
   return (clientX, clientY) => {
@@ -176,9 +280,12 @@ function getVisuallyContainingElement(child) {
 const getLinkHref = (type, directory) => rebase(`${type}/${directory}`);
 */
 
-const openAlbum = (d) => rebase(`album/${d}`);
-const openTrack = (d) => rebase(`track/${d}`);
-const openArtist = (d) => rebase(`artist/${d}`);
+const openAlbum = d => rebase(`album/${d}`);
+const openArtTag = d => rebase(`tag/${d}`);
+const openArtist = d => rebase(`artist/${d}`);
+const openFlash = d => rebase(`flash/${d}`);
+const openGroup = d => rebase(`group/${d}`);
+const openTrack = d => rebase(`track/${d}`);
 
 // TODO: This should also use urlSpec.
 
@@ -210,8 +317,8 @@ function dispatchInternalEvent(event, eventName, ...args) {
     try {
       results.push(listener(...args));
     } catch (error) {
-      console.warn(`Uncaught error in listener for ${infoName}.${eventName}`);
-      console.debug(error);
+      console.error(`Uncaught error in listener for ${infoName}.${eventName}`);
+      console.error(error);
       results.push(undefined);
     }
   }
@@ -1045,6 +1152,54 @@ if (
     });
 }
 
+// Links nested in summaries ------------------------------
+
+const summaryNestedLinksInfo = initInfo('summaryNestedLinksInfo', {
+  summaries: null,
+  links: null,
+});
+
+function getSummaryNestedLinksReferences() {
+  const info = summaryNestedLinksInfo;
+
+  info.summaries =
+    Array.from(document.getElementsByTagName('summary'));
+
+  info.links =
+    info.summaries
+      .map(summary =>
+        Array.from(summary.getElementsByTagName('a')));
+
+  filterMultipleArrays(
+    info.summaries,
+    info.links,
+    (_summary, links) => !empty(links));
+}
+
+function addSummaryNestedLinksPageListeners() {
+  const info = summaryNestedLinksInfo;
+
+  for (const {summary, links} of stitchArrays({
+    summary: info.summaries,
+    links: info.links,
+  })) {
+    for (const link of links) {
+      link.addEventListener('mouseover', () => {
+        link.classList.add('nested-hover');
+        summary.classList.add('has-nested-hover');
+      });
+
+      link.addEventListener('mouseout', () => {
+        link.classList.remove('nested-hover');
+        summary.classList.remove('has-nested-hover');
+      });
+    }
+  }
+}
+
+clientSteps.getPageReferences.push(getSummaryNestedLinksReferences);
+clientSteps.getPageReferences.push(addSummaryNestedLinksPageListeners);
+
 // Tooltip-style hover (infrastructure) -------------------
 
 const hoverableTooltipInfo = initInfo('hoverableTooltipInfo', {
@@ -1116,6 +1271,11 @@ const hoverableTooltipInfo = initInfo('hoverableTooltipInfo', {
     // from causing the current tooltip to be hidden.
     currentTouchIdentifiers: new Set(),
     touchIdentifiersBanishedByScrolling: new Set(),
+
+    // This is a two-item array that tracks the direction we've already
+    // dynamically placed the current tooltip. If we *reposition* the tooltip
+    // (because its dimensions changed), we'll try to follow this anchor first.
+    dynamicTooltipAnchorDirection: null,
   },
 
   event: {
@@ -1576,6 +1736,8 @@ function hideCurrentlyShownTooltip(intendingToReplace = false) {
   state.currentlyShownTooltip = null;
   state.currentlyActiveHoverable = null;
 
+  state.dynamicTooltipAnchorDirection = null;
+
   // Set this for one tick of the event cycle.
   state.tooltipWasJustHidden = true;
   setTimeout(() => {
@@ -1603,6 +1765,11 @@ function showTooltipFromHoverable(hoverable) {
 
   positionTooltipFromHoverableWithBrains(hoverable);
 
+  // After a tooltip is shown, if we *didn't* specify an anchor,
+  // assume it was shown in its default position - generally presented
+  // as down and to the right. Successive repositioning will base on this.
+  state.dynamicTooltipAnchorDirection ??= ['down', 'right'];
+
   cssProp(tooltip, 'display', 'block');
   tooltip.inert = false;
 
@@ -1637,10 +1804,23 @@ function peekTooltipClientRect(tooltip) {
   }
 }
 
+function repositionCurrentTooltip() {
+  const {state} = hoverableTooltipInfo;
+  const {currentlyActiveHoverable} = state;
+
+  if (!currentlyActiveHoverable) {
+    throw new Error(`No hoverable active to reposition tooltip from`);
+  }
+
+  positionTooltipFromHoverableWithBrains(currentlyActiveHoverable);
+}
+
 function positionTooltipFromHoverableWithBrains(hoverable) {
   const {state} = hoverableTooltipInfo;
   const {tooltip} = state.registeredHoverables.get(hoverable);
 
+  const anchorDirection = state.dynamicTooltipAnchorDirection;
+
   // Reset before doing anything else. We're going to adapt to
   // its natural placement, adjusted by CSS, which otherwise
   // could be obscured by a placement we've previously provided.
@@ -1662,23 +1842,42 @@ function positionTooltipFromHoverableWithBrains(hoverable) {
     return;
   }
 
-  let selectedRect = null;
-  for (let i = 0; i < numBaselineRects; i++) {
-    selectedRect = opportunities.right.down[i];
-    if (selectedRect) break;
+  const tryDirection = (dir1, dir2, i) => {
+    selectedRect = opportunities[dir1][dir2][i];
+    return !!selectedRect;
+  };
 
-    selectedRect = opportunities.left.down[i];
-    if (selectedRect) break;
+  let selectedRect = null;
+  selectRect: {
+    if (anchorDirection) {
+      for (let i = 0; i < numBaselineRects; i++) {
+        if (tryDirection(...anchorDirection, i)) {
+          break selectRect;
+        }
+      }
+    }
 
-    selectedRect = opportunities.right.up[i];
-    if (selectedRect) break;
+    for (let i = 0; i < numBaselineRects; i++) {
+      for (const [dir1, dir2] of [
+        ['right', 'down'],
+        ['left', 'down'],
+        ['right', 'up'],
+        ['left', 'up'],
+        ['down', 'right'],
+        ['down', 'left'],
+        ['up', 'right'],
+        ['up', 'left'],
+      ]) {
+        if (tryDirection(dir1, dir2, i)) {
+          state.dynamicTooltipAnchorDirection = [dir1, dir2];
+          break selectRect;
+        }
+      }
+    }
 
-    selectedRect = opportunities.left.up[i];
-    if (selectedRect) break;
+    selectedRect = baselineRect;
   }
 
-  selectedRect ??= baselineRect;
-
   positionTooltip(tooltip, selectedRect.x, selectedRect.y);
 }
 
@@ -1774,18 +1973,18 @@ function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) {
   const neededVerticalOverlap = 30;
   const neededHorizontalOverlap = 30;
 
+  const upTopDown =
+    WikiRect.beneath(
+      hoverableRect.top + neededVerticalOverlap - tooltipRect.height);
+
+  const downBottomUp =
+    WikiRect.above(
+      hoverableRect.bottom - neededVerticalOverlap + tooltipRect.height);
+
   // Please don't ask us to make this but horizontal?
   const prepareVerticalOrientationRects = (regionRects) => {
     const orientations = {};
 
-    const upTopDown =
-      WikiRect.beneath(
-        hoverableRect.top + neededVerticalOverlap - tooltipRect.height);
-
-    const downBottomUp =
-      WikiRect.above(
-        hoverableRect.bottom - neededVerticalOverlap + tooltipRect.height);
-
     const orientHorizontally = (rect, i) => {
       if (!rect) return null;
 
@@ -1841,9 +2040,67 @@ function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) {
     return orientations;
   };
 
+  const rightRightLeft =
+    WikiRect.leftOf(
+      hoverableRect.left - neededHorizontalOverlap + tooltipRect.width);
+
+  const leftLeftRight =
+    WikiRect.rightOf(
+      hoverableRect.left + neededHorizontalOverlap - tooltipRect.width);
+
+  // Oops.
+  const prepareHorizontalOrientationRects = (regionRects) => {
+    const orientations = {};
+
+    const orientVertically = (rect, i) => {
+      if (!rect) return null;
+
+      const regionRect = regionRects[i];
+
+      if (regionRect.height > 0) {
+        return rect;
+      } else {
+        return WikiRect.fromRect({
+          x: rect.x,
+          y: regionRect.bottom - tooltipRect.height,
+          width: rect.width,
+          height: rect.height,
+        });
+      }
+    };
+
+    orientations.left =
+      regionRects
+        .map(rect => rect?.intersectionWith(leftLeftRight))
+        .map(orientVertically)
+        .map(keepIfFits);
+
+    orientations.right =
+      regionRects
+        .map(rect => rect?.intersectionWith(rightRightLeft))
+        .map(rect =>
+          (rect
+            ? rect.intersectionWith(WikiRect.fromRect({
+                x: rect.right - tooltipRect.width,
+                y: rect.y,
+                width: rect.width,
+                height: tooltipRect.height,
+              }))
+            : null))
+        .map(orientVertically)
+        .map(keepIfFits);
+
+    // No analogous center because we don't actually use
+    // center alignment...
+
+    return orientations;
+  };
+
   const orientationRects = {
     left: prepareVerticalOrientationRects(regionRects.left),
     right: prepareVerticalOrientationRects(regionRects.right),
+    down: prepareHorizontalOrientationRects(regionRects.bottom),
+    up: prepareHorizontalOrientationRects(regionRects.top),
   };
 
   return {
@@ -2551,9 +2808,10 @@ function updateStickySubheadingContent(index) {
     }
 
     const textContainer =
-      closestHeading.querySelector('.content-heading-main-title')
-        // Just for compatibility with older builds of the site.
-        ?? closestHeading;
+      templateContent(
+        closestHeading.querySelector('.content-heading-sticky-title')) ??
+      closestHeading.querySelector('.content-heading-main-title') ??
+      closestHeading;
 
     for (const child of textContainer.childNodes) {
       if (child.tagName === 'A') {
@@ -3099,7 +3357,7 @@ function getArtistExternalLinkTooltipPageReferences() {
   const info = artistExternalLinkTooltipInfo;
 
   info.tooltips =
-    Array.from(document.getElementsByClassName('icons-tooltip'));
+    Array.from(document.getElementsByClassName('contribution-tooltip'));
 
   info.tooltipRows =
     info.tooltips.map(tooltip =>
@@ -3243,6 +3501,8 @@ function showArtistExternalLinkTooltipInfo() {
   for (const tooltip of info.tooltips) {
     tooltip.classList.add('show-info');
   }
+
+  repositionCurrentTooltip();
 }
 
 function hideArtistExternalLinkTooltipInfo() {
@@ -3260,6 +3520,1219 @@ clientSteps.getPageReferences.push(getArtistExternalLinkTooltipPageReferences);
 clientSteps.addInternalListeners.push(addArtistExternalLinkTooltipInternalListeners);
 clientSteps.addPageListeners.push(addArtistExternalLinkTooltipPageListeners);
 
+// Quick description --------------------------------------
+
+const quickDescriptionInfo = initInfo('quickDescriptionInfo', {
+  quickDescriptionContainers: null,
+
+  quickDescriptionsAreExpandable: null,
+
+  expandDescriptionLinks: null,
+  collapseDescriptionLinks: null,
+});
+
+function getQuickDescriptionReferences() {
+  const info = quickDescriptionInfo;
+
+  info.quickDescriptionContainers =
+    Array.from(document.querySelectorAll('#content .quick-description'));
+
+  info.quickDescriptionsAreExpandable =
+    info.quickDescriptionContainers
+      .map(container =>
+        container.querySelector('.quick-description-actions.when-expanded'));
+
+  info.expandDescriptionLinks =
+    info.quickDescriptionContainers
+      .map(container =>
+        container.querySelector('.quick-description-actions .expand-link'));
+
+  info.collapseDescriptionLinks =
+    info.quickDescriptionContainers
+      .map(container =>
+        container.querySelector('.quick-description-actions .collapse-link'));
+}
+
+function addQuickDescriptionListeners() {
+  const info = quickDescriptionInfo;
+
+  for (const {
+    isExpandable,
+    container,
+    expandLink,
+    collapseLink,
+  } of stitchArrays({
+    isExpandable: info.quickDescriptionsAreExpandable,
+    container: info.quickDescriptionContainers,
+    expandLink: info.expandDescriptionLinks,
+    collapseLink: info.collapseDescriptionLinks,
+  })) {
+    if (!isExpandable) continue;
+
+    expandLink.addEventListener('click', event => {
+      event.preventDefault();
+      container.classList.add('expanded');
+      container.classList.remove('collapsed');
+    });
+
+    collapseLink.addEventListener('click', event => {
+      event.preventDefault();
+      container.classList.add('collapsed');
+      container.classList.remove('expanded');
+    });
+  }
+}
+
+clientSteps.getPageReferences.push(getQuickDescriptionReferences);
+clientSteps.addPageListeners.push(addQuickDescriptionListeners);
+
+// Internal search functionality --------------------------
+
+const wikiSearchInfo = initInfo('wikiSearchInfo', {
+  state: {
+    worker: null,
+
+    workerReadyPromise: null,
+    workerReadyPromiseResolvers: null,
+
+    workerActionCounter: 0,
+    workerActionPromiseResolverMap: new Map(),
+
+    downloads: Object.create(null),
+  },
+
+  event: {
+    whenWorkerAlive: [],
+    whenWorkerReady: [],
+    whenWorkerFailsToInitialize: [],
+    whenWorkerHasRuntimeError: [],
+
+    whenDownloadBegins: [],
+    whenDownloadsBegin: [],
+    whenDownloadProgresses: [],
+    whenDownloadEnds: [],
+  },
+});
+
+async function initializeSearchWorker() {
+  const {state} = wikiSearchInfo;
+
+  if (state.worker) {
+    return await state.workerReadyPromise;
+  }
+
+  state.worker =
+    new Worker(
+      import.meta.resolve('./search-worker.js'),
+      {type: 'module'});
+
+  state.worker.onmessage = handleSearchWorkerMessage;
+
+  const {promise, resolve, reject} = promiseWithResolvers();
+
+  state.workerReadyPromiseResolvers = {resolve, reject};
+
+  return await (state.workerReadyPromise = promise);
+}
+
+function handleSearchWorkerMessage(message) {
+  switch (message.data.kind) {
+    case 'status':
+      handleSearchWorkerStatusMessage(message);
+      break;
+
+    case 'result':
+      handleSearchWorkerResultMessage(message);
+      break;
+
+    case 'download-begun':
+      handleSearchWorkerDownloadBegunMessage(message);
+      break;
+
+    case 'download-progress':
+      handleSearchWorkerDownloadProgressMessage(message);
+      break;
+
+    case 'download-complete':
+      handleSearchWorkerDownloadCompleteMessage(message);
+      break;
+
+    default:
+      console.warn(`Unknown message kind "${message.data.kind}" <- from search worker`);
+      break;
+  }
+}
+
+function handleSearchWorkerStatusMessage(message) {
+  const {state, event} = wikiSearchInfo;
+
+  switch (message.data.status) {
+    case 'alive':
+      console.debug(`Search worker is alive, but not yet ready.`);
+      dispatchInternalEvent(event, 'whenWorkerAlive');
+      break;
+
+    case 'ready':
+      console.debug(`Search worker has loaded corpuses and is ready.`);
+      state.workerReadyPromiseResolvers.resolve(state.worker);
+      dispatchInternalEvent(event, 'whenWorkerReady');
+      break;
+
+    case 'setup-error':
+      console.debug(`Search worker failed to initialize.`);
+      state.workerReadyPromiseResolvers.reject(new Error('Received "setup-error" status from worker'));
+      dispatchInternalEvent(event, 'whenWorkerFailsToInitialize');
+      break;
+
+    case 'runtime-error':
+      console.debug(`Search worker had an uncaught runtime error.`);
+      dispatchInternalEvent(event, 'whenWorkerHasRuntimeError');
+      break;
+
+    default:
+      console.warn(`Unknown status "${message.data.status}" <- from search worker`);
+      break;
+  }
+}
+
+function handleSearchWorkerResultMessage(message) {
+  const {state} = wikiSearchInfo;
+  const {id} = message.data;
+
+  if (!id) {
+    console.warn(`Result without id <- from search worker:`, message.data);
+    return;
+  }
+
+  if (!state.workerActionPromiseResolverMap.has(id)) {
+    console.warn(`Runaway result id <- from search worker:`, message.data);
+    return;
+  }
+
+  const {resolve, reject} =
+    state.workerActionPromiseResolverMap.get(id);
+
+  switch (message.data.status) {
+    case 'resolve':
+      resolve(message.data.value);
+      break;
+
+    case 'reject':
+      reject(message.data.value);
+      break;
+
+    default:
+      console.warn(`Unknown result status "${message.data.status}" <- from search worker`);
+      return;
+  }
+
+  state.workerActionPromiseResolverMap.delete(id);
+}
+
+function handleSearchWorkerDownloadBegunMessage(message) {
+  const {event} = wikiSearchInfo;
+  const {context: contextKey, keys} = message.data;
+
+  const context = getSearchWorkerDownloadContext(contextKey, true);
+
+  for (const key of keys) {
+    context[key] = 0.00;
+
+    dispatchInternalEvent(event, 'whenDownloadBegins', {
+      context: contextKey,
+      key,
+    });
+  }
+
+  dispatchInternalEvent(event, 'whenDownloadsBegin', {
+    context: contextKey,
+    keys,
+  });
+}
+
+function handleSearchWorkerDownloadProgressMessage(message) {
+  const {event} = wikiSearchInfo;
+  const {context: contextKey, key, progress} = message.data;
+
+  const context = getSearchWorkerDownloadContext(contextKey);
+
+  context[key] = progress;
+
+  dispatchInternalEvent(event, 'whenDownloadProgresses', {
+    context: contextKey,
+    key,
+    progress,
+  });
+}
+
+function handleSearchWorkerDownloadCompleteMessage(message) {
+  const {event} = wikiSearchInfo;
+  const {context: contextKey, key} = message.data;
+
+  const context = getSearchWorkerDownloadContext(contextKey);
+
+  context[key] = 1.00;
+
+  dispatchInternalEvent(event, 'whenDownloadEnds', {
+    context: contextKey,
+    key,
+  });
+}
+
+function getSearchWorkerDownloadContext(context, initialize = false) {
+  const {state} = wikiSearchInfo;
+
+  if (context in state.downloads) {
+    return state.downloads[context];
+  }
+
+  if (!initialize) {
+    return null;
+  }
+
+  return state.downloads[context] = Object.create(null);
+}
+
+async function postSearchWorkerAction(action, options) {
+  const {state} = wikiSearchInfo;
+
+  const worker = await initializeSearchWorker();
+  const id = ++state.workerActionCounter;
+
+  const {promise, resolve, reject} = promiseWithResolvers();
+
+  state.workerActionPromiseResolverMap.set(id, {resolve, reject});
+
+  worker.postMessage({
+    kind: 'action',
+    action: action,
+    id,
+    options,
+  });
+
+  return await promise;
+}
+
+async function searchAll(query, options = {}) {
+  return await postSearchWorkerAction('search', {
+    query,
+    options,
+  });
+}
+
+// Sidebar search box -------------------------------------
+
+const sidebarSearchInfo = initInfo('sidebarSearchInfo', {
+  pageContainer: null,
+
+  searchSidebarColumn: null,
+  searchBox: null,
+  searchLabel: null,
+  searchInput: null,
+
+  progressRule: null,
+  progressContainer: null,
+  progressLabel: null,
+  progressBar: null,
+
+  failedRule: null,
+  failedContainer: null,
+
+  resultsRule: null,
+  resultsContainer: null,
+  results: null,
+
+  endSearchRule: null,
+  endSearchLine: null,
+  endSearchLink: null,
+
+  preparingString: null,
+  loadingDataString: null,
+  searchingString: null,
+  failedString: null,
+
+  noResultsString: null,
+  currentResultString: null,
+  endSearchString: null,
+
+  albumResultKindString: null,
+  artistResultKindString: null,
+  groupResultKindString: null,
+  tagResultKindString: null,
+
+  state: {
+    sidebarColumnShownForSearch: null,
+
+    tidiedSidebar: null,
+    collapsedDetailsForTidiness: null,
+
+    workerStatus: null,
+    searchStage: null,
+
+    stoppedTypingTimeout: null,
+    stoppedScrollingTimeout: null,
+
+    indexDownloadStatuses: Object.create(null),
+  },
+
+  session: {
+    activeQuery: {
+      type: 'string',
+    },
+
+    activeQueryResults: {
+      type: 'json',
+      maxLength: settings => settings.maxActiveResultsStorage,
+    },
+
+    repeatQueryOnReload: {
+      type: 'boolean',
+      default: false,
+    },
+
+    resultsScrollOffset: {
+      type: 'number',
+    },
+  },
+
+  settings: {
+    stoppedTypingDelay: 800,
+    stoppedScrollingDelay: 200,
+
+    maxActiveResultsStorage: 100000,
+  },
+});
+
+function getSidebarSearchReferences() {
+  const info = sidebarSearchInfo;
+
+  info.pageContainer =
+    document.getElementById('page-container');
+
+  info.searchBox =
+    document.querySelector('.wiki-search-sidebar-box');
+
+  if (!info.searchBox) {
+    return;
+  }
+
+  info.searchLabel =
+    info.searchBox.querySelector('.wiki-search-label');
+
+  info.searchInput =
+    info.searchBox.querySelector('.wiki-search-input');
+
+  info.searchSidebarColumn =
+    info.searchBox.closest('.sidebar-column');
+
+  const findString = classPart =>
+    info.searchBox.querySelector(`.wiki-search-${classPart}-string`);
+
+  info.preparingString =
+    findString('preparing');
+
+  info.loadingDataString =
+    findString('loading-data');
+
+  info.searchingString =
+    findString('searching');
+
+  info.failedString =
+    findString('failed');
+
+  info.noResultsString =
+    findString('no-results');
+
+  info.currentResultString =
+    findString('current-result');
+
+  info.endSearchString =
+    findString('end-search');
+
+  info.albumResultKindString =
+    findString('album-result-kind');
+
+  info.artistResultKindString =
+    findString('artist-result-kind');
+
+  info.groupResultKindString =
+    findString('group-result-kind');
+
+  info.tagResultKindString =
+    findString('tag-result-kind');
+}
+
+function addSidebarSearchInternalListeners() {
+  const info = sidebarSearchInfo;
+
+  if (!info.searchBox) return;
+
+  wikiSearchInfo.event.whenWorkerAlive.push(
+    trackSidebarSearchWorkerAlive,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenWorkerReady.push(
+    trackSidebarSearchWorkerReady,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenWorkerFailsToInitialize.push(
+    trackSidebarSearchWorkerFailsToInitialize,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenWorkerHasRuntimeError.push(
+    trackSidebarSearchWorkerHasRuntimeError,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenDownloadsBegin.push(
+    trackSidebarSearchDownloadsBegin,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenDownloadProgresses.push(
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenDownloadEnds.push(
+    trackSidebarSearchDownloadEnds,
+    updateSidebarSearchStatus);
+}
+
+function mutateSidebarSearchContent() {
+  const info = sidebarSearchInfo;
+
+  if (!info.searchBox) return;
+
+  // Progress section
+
+  info.progressRule =
+    document.createElement('hr');
+
+  info.progressContainer =
+    document.createElement('div');
+
+  info.progressContainer.classList.add('wiki-search-progress-container');
+
+  cssProp(info.progressRule, 'display', 'none');
+  cssProp(info.progressContainer, 'display', 'none');
+
+  info.progressLabel =
+    document.createElement('label');
+
+  info.progressLabel.classList.add('wiki-search-progress-label');
+  info.progressLabel.htmlFor = 'wiki-search-progress-bar';
+
+  info.progressBar =
+    document.createElement('progress');
+
+  info.progressBar.classList.add('wiki-search-progress-bar');
+  info.progressBar.id = 'wiki-search-progress-bar';
+
+  info.progressContainer.appendChild(info.progressLabel);
+  info.progressContainer.appendChild(info.progressBar);
+
+  info.searchBox.appendChild(info.progressRule);
+  info.searchBox.appendChild(info.progressContainer);
+
+  // Search failed section
+
+  info.failedRule =
+    document.createElement('hr');
+
+  info.failedContainer =
+    document.createElement('div');
+
+  info.failedContainer.classList.add('wiki-search-failed-container');
+
+  {
+    const p = document.createElement('p');
+    p.appendChild(templateContent(info.failedString));
+    info.failedContainer.appendChild(p);
+  }
+
+  cssProp(info.failedRule, 'display', 'none');
+  cssProp(info.failedContainer, 'display', 'none');
+
+  info.searchBox.appendChild(info.failedRule);
+  info.searchBox.appendChild(info.failedContainer);
+
+  // Results section
+
+  info.resultsRule =
+    document.createElement('hr');
+
+  info.resultsContainer =
+    document.createElement('div');
+
+  info.resultsContainer.classList.add('wiki-search-results-container');
+
+  cssProp(info.resultsRule, 'display', 'none');
+  cssProp(info.resultsContainer, 'display', 'none');
+
+  info.results =
+    document.createElement('div');
+
+  info.results.classList.add('wiki-search-results');
+
+  info.resultsContainer.appendChild(info.results);
+
+  info.searchBox.appendChild(info.resultsRule);
+  info.searchBox.appendChild(info.resultsContainer);
+
+  // End search section
+
+  info.endSearchRule =
+    document.createElement('hr');
+
+  info.endSearchLine =
+    document.createElement('p');
+
+  info.endSearchLink =
+    document.createElement('a');
+
+  {
+    const p = info.endSearchLine;
+    const a = info.endSearchLink;
+    p.classList.add('wiki-search-end-search-line');
+    a.setAttribute('href', '#');
+    a.appendChild(templateContent(info.endSearchString));
+    p.appendChild(a);
+  }
+
+  cssProp(info.endSearchRule, 'display', 'none');
+  cssProp(info.endSearchLine, 'display', 'none');
+
+  info.searchBox.appendChild(info.endSearchRule);
+  info.searchBox.appendChild(info.endSearchLine);
+}
+
+function addSidebarSearchListeners() {
+  const info = sidebarSearchInfo;
+
+  if (!info.searchInput) return;
+
+  info.searchInput.addEventListener('change', domEvent => {
+    if (info.searchInput.value) {
+      activateSidebarSearch(info.searchInput.value);
+    }
+  });
+
+  info.searchInput.addEventListener('input', domEvent => {
+    const {settings, state} = info;
+
+    if (!info.searchInput.value) {
+      clearSidebarSearch();
+      return;
+    }
+
+    if (state.stoppedTypingTimeout) {
+      clearTimeout(state.stoppedTypingTimeout);
+    }
+
+    state.stoppedTypingTimeout =
+      setTimeout(() => {
+        activateSidebarSearch(info.searchInput.value);
+      }, settings.stoppedTypingDelay);
+  });
+
+  info.endSearchLink.addEventListener('click', domEvent => {
+    domEvent.preventDefault();
+    clearSidebarSearch();
+    possiblyHideSearchSidebarColumn();
+    restoreSidebarSearchColumn();
+  });
+
+  info.resultsContainer.addEventListener('scroll', () => {
+    const {settings, state} = info;
+
+    if (state.stoppedScrollingTimeout) {
+      clearTimeout(state.stoppedScrollingTimeout);
+    }
+
+    state.stoppedScrollingTimeout =
+      setTimeout(() => {
+        saveSidebarSearchResultsScrollOffset();
+      }, settings.stoppedScrollingDelay);
+  });
+}
+
+function initializeSidebarSearchState() {
+  const info = sidebarSearchInfo;
+  const {session} = info;
+
+  if (!info.searchInput) return;
+
+  if (session.activeQuery) {
+    info.searchInput.value = session.activeQuery;
+    if (session.repeatQueryOnReload) {
+      activateSidebarSearch(session.activeQuery);
+    } else if (session.activeQueryResults) {
+      showSidebarSearchResults(session.activeQueryResults);
+    }
+  }
+}
+
+function trackSidebarSearchWorkerAlive() {
+  const {state} = sidebarSearchInfo;
+
+  state.workerStatus = 'alive';
+}
+
+function trackSidebarSearchWorkerReady() {
+  const {state} = sidebarSearchInfo;
+
+  state.workerStatus = 'ready';
+  state.searchStage = 'searching';
+}
+
+function trackSidebarSearchWorkerFailsToInitialize() {
+  const {state} = sidebarSearchInfo;
+
+  state.workerStatus = 'failed';
+  state.searchStage = 'failed';
+}
+
+function trackSidebarSearchWorkerHasRuntimeError() {
+  const {state} = sidebarSearchInfo;
+
+  state.workerStatus = 'failed';
+  state.searchStage = 'failed';
+}
+
+function trackSidebarSearchDownloadsBegin(event) {
+  const {state} = sidebarSearchInfo;
+
+  if (event.context === 'search-indexes') {
+    for (const key of event.keys) {
+      state.indexDownloadStatuses[key] = 'active';
+    }
+  }
+}
+
+function trackSidebarSearchDownloadEnds(event) {
+  const {state} = sidebarSearchInfo;
+
+  if (event.context === 'search-indexes') {
+    state.indexDownloadStatuses[event.key] = 'complete';
+
+    const statuses = Object.values(state.indexDownloadStatuses);
+    if (statuses.every(status => status === 'complete')) {
+      for (const key of Object.keys(state.indexDownloadStatuses)) {
+        delete state.indexDownloadStatuses[key];
+      }
+    }
+  }
+}
+
+async function activateSidebarSearch(query) {
+  const {session, settings, state} = sidebarSearchInfo;
+
+  if (state.stoppedTypingTimeout) {
+    clearTimeout(state.stoppedTypingTimeout);
+    state.stoppedTypingTimeout = null;
+  }
+
+  state.searchStage =
+    (state.workerStatus === 'ready'
+      ? 'searching'
+      : 'preparing');
+  updateSidebarSearchStatus();
+
+  let results;
+  try {
+    results = await searchAll(query, {enrich: true});
+  } catch (error) {
+    console.error(`There was an error performing a sidebar search:`);
+    console.error(error);
+    showSidebarSearchFailed();
+    return;
+  }
+
+  state.searchStage = 'complete';
+  updateSidebarSearchStatus();
+
+  session.activeQuery = query;
+  session.activeQueryResults = results;
+  session.resultsScrollOffset = 0;
+
+  showSidebarSearchResults(results);
+}
+
+function clearSidebarSearch() {
+  const info = sidebarSearchInfo;
+  const {session, state} = info;
+
+  if (state.stoppedTypingTimeout) {
+    clearTimeout(state.stoppedTypingTimeout);
+    state.stoppedTypingTimeout = null;
+  }
+
+  info.searchBox.classList.remove('showing-results');
+  info.searchSidebarColumn.classList.remove('search-showing-results');
+
+  info.searchInput.value = '';
+
+  state.searchStage = null;
+
+  session.activeQuery = null;
+  session.activeQueryResults = null;
+  session.resultsScrollOffset = null;
+
+  hideSidebarSearchResults();
+}
+
+function updateSidebarSearchStatus() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  if (state.searchStage === 'failed') {
+    hideSidebarSearchResults();
+    showSidebarSearchFailed();
+
+    return;
+  }
+
+  const searchIndexDownloads =
+    getSearchWorkerDownloadContext('search-indexes');
+
+  const downloadProgressValues =
+    Object.values(searchIndexDownloads ?? {});
+
+  if (downloadProgressValues.some(v => v < 1.00)) {
+    const total = Object.keys(state.indexDownloadStatuses).length;
+    const sum = accumulateSum(downloadProgressValues);
+    showSidebarSearchProgress(
+      sum / total,
+      templateContent(info.loadingDataString));
+
+    return;
+  }
+
+  if (state.searchStage === 'preparing') {
+    showSidebarSearchProgress(
+      null,
+      templateContent(info.preparingString));
+
+    return;
+  }
+
+  if (state.searchStage === 'searching') {
+    showSidebarSearchProgress(
+      null,
+      templateContent(info.searchingString));
+
+    return;
+  }
+
+  hideSidebarSearchProgress();
+}
+
+function showSidebarSearchProgress(progress, label) {
+  const info = sidebarSearchInfo;
+
+  cssProp(info.progressRule, 'display', null);
+  cssProp(info.progressContainer, 'display', null);
+
+  if (progress === null) {
+    info.progressBar.removeAttribute('value');
+  } else {
+    info.progressBar.value = progress;
+  }
+
+  while (info.progressLabel.firstChild) {
+    info.progressLabel.firstChild.remove();
+  }
+
+  info.progressLabel.appendChild(label);
+}
+
+function hideSidebarSearchProgress() {
+  const info = sidebarSearchInfo;
+
+  cssProp(info.progressRule, 'display', 'none');
+  cssProp(info.progressContainer, 'display', 'none');
+}
+
+function showSidebarSearchFailed() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  hideSidebarSearchProgress();
+  hideSidebarSearchResults();
+
+  cssProp(info.failedRule, 'display', null);
+  cssProp(info.failedContainer, 'display', null);
+
+  info.searchLabel.classList.add('disabled');
+  info.searchInput.disabled = true;
+
+  if (state.stoppedTypingTimeout) {
+    clearTimeout(state.stoppedTypingTimeout);
+    state.stoppedTypingTimeout = null;
+  }
+}
+
+function showSidebarSearchResults(results) {
+  const info = sidebarSearchInfo;
+
+  console.debug(`Showing search results:`, results);
+
+  showSearchSidebarColumn();
+
+  const flatResults =
+    Object.entries(results)
+      .filter(([index]) => index === 'generic')
+      .flatMap(([index, results]) => results
+        .flatMap(({doc, id}) => ({
+          index,
+          reference: id ?? null,
+          referenceType: (id ? id.split(':')[0] : null),
+          directory: (id ? id.split(':')[1] : null),
+          data: doc,
+        })));
+
+  info.searchBox.classList.add('showing-results');
+  info.searchSidebarColumn.classList.add('search-showing-results');
+
+  while (info.results.firstChild) {
+    info.results.firstChild.remove();
+  }
+
+  cssProp(info.resultsRule, 'display', 'block');
+  cssProp(info.resultsContainer, 'display', 'block');
+
+  if (empty(flatResults)) {
+    const p = document.createElement('p');
+    p.classList.add('wiki-search-no-results');
+    p.appendChild(templateContent(info.noResultsString));
+    info.results.appendChild(p);
+  }
+
+  for (const result of flatResults) {
+    const el = generateSidebarSearchResult(result);
+    if (!el) continue;
+
+    info.results.appendChild(el);
+  }
+
+  if (!empty(flatResults)) {
+    cssProp(info.endSearchRule, 'display', 'block');
+    cssProp(info.endSearchLine, 'display', 'block');
+
+    tidySidebarSearchColumn();
+  }
+
+  restoreSidebarSearchResultsScrollOffset();
+}
+
+function generateSidebarSearchResult(result) {
+  const info = sidebarSearchInfo;
+
+  const preparedSlots = {
+    color:
+      result.data.color ?? null,
+
+    name:
+      result.data.name ?? result.data.primaryName ?? null,
+
+    imageSource:
+      getSearchResultImageSource(result),
+  };
+
+  switch (result.referenceType) {
+    case 'album': {
+      preparedSlots.href =
+        openAlbum(result.directory);
+
+      preparedSlots.kindString =
+        info.albumResultKindString;
+
+      break;
+    }
+
+    case 'artist': {
+      preparedSlots.href =
+        openArtist(result.directory);
+
+      preparedSlots.kindString =
+        info.artistResultKindString;
+
+      break;
+    }
+
+    case 'group': {
+      preparedSlots.href =
+        openGroup(result.directory);
+
+      preparedSlots.kindString =
+        info.groupResultKindString;
+
+      break;
+    }
+
+    case 'flash': {
+      preparedSlots.href =
+        openFlash(result.directory);
+
+      break;
+    }
+
+    case 'tag': {
+      preparedSlots.href =
+        openArtTag(result.directory);
+
+      preparedSlots.kindString =
+        info.tagResultKindString;
+
+      break;
+    }
+
+    case 'track': {
+      preparedSlots.href =
+        openTrack(result.directory);
+
+      break;
+    }
+
+    default:
+      return null;
+  }
+
+  return generateSidebarSearchResultTemplate(preparedSlots);
+}
+
+function getSearchResultImageSource(result) {
+  const {artwork} = result.data;
+  if (!artwork) return null;
+
+  return (
+    rebase(
+      artwork.replace('<>', result.directory),
+      'rebaseThumb'));
+}
+
+function generateSidebarSearchResultTemplate(slots) {
+  const info = sidebarSearchInfo;
+
+  const link = document.createElement('a');
+  link.classList.add('wiki-search-result');
+
+  if (slots.href) {
+    link.setAttribute('href', slots.href);
+  }
+
+  if (slots.color) {
+    cssProp(link, '--primary-color', slots.color);
+
+    try {
+      const colors = getColors(slots.color, {chroma});
+      cssProp(link, '--light-ghost-color', colors.lightGhost);
+      cssProp(link, '--deep-color', colors.deep);
+    } catch (error) {
+      console.warn(error);
+    }
+  }
+
+  const imgContainer = document.createElement('span');
+  imgContainer.classList.add('wiki-search-result-image-container');
+
+  if (slots.imageSource) {
+    const img = document.createElement('img');
+    img.classList.add('wiki-search-result-image');
+    img.setAttribute('src', slots.imageSource);
+    imgContainer.appendChild(img);
+    if (slots.imageSource.endsWith('.mini.jpg')) {
+      img.classList.add('has-warning');
+    }
+  } else {
+    const placeholder = document.createElement('span');
+    placeholder.classList.add('wiki-search-result-image-placeholder');
+    imgContainer.appendChild(placeholder);
+  }
+
+  link.appendChild(imgContainer);
+
+  const text = document.createElement('span');
+  text.classList.add('wiki-search-result-text-area');
+
+  if (slots.name) {
+    const span = document.createElement('span');
+    span.classList.add('wiki-search-result-name');
+    span.appendChild(document.createTextNode(slots.name));
+    text.appendChild(span);
+  }
+
+  let accentSpan = null;
+
+  if (link.href) {
+    const here = location.href.replace(/\/$/, '');
+    const there = link.href.replace(/\/$/, '');
+    if (here === there) {
+      link.classList.add('current-result');
+      accentSpan = document.createElement('span');
+      accentSpan.classList.add('wiki-search-current-result-text');
+      accentSpan.appendChild(templateContent(info.currentResultString));
+    }
+  }
+
+  if (!accentSpan && slots.kindString) {
+    accentSpan = document.createElement('span');
+    accentSpan.classList.add('wiki-search-result-kind');
+    accentSpan.appendChild(templateContent(slots.kindString));
+  }
+
+  if (accentSpan) {
+    text.appendChild(document.createTextNode(' '));
+    text.appendChild(accentSpan);
+  }
+
+  link.appendChild(text);
+
+  link.addEventListener('click', () => {
+    saveSidebarSearchResultsScrollOffset();
+  });
+
+  return link;
+}
+
+function hideSidebarSearchResults() {
+  const info = sidebarSearchInfo;
+
+  cssProp(info.resultsRule, 'display', 'none');
+  cssProp(info.resultsContainer, 'display', 'none');
+
+  while (info.results.firstChild) {
+    info.results.firstChild.remove();
+  }
+
+  cssProp(info.endSearchRule, 'display', 'none');
+  cssProp(info.endSearchLine, 'display', 'none');
+}
+
+function saveSidebarSearchResultsScrollOffset() {
+  const info = sidebarSearchInfo;
+  const {session} = info;
+
+  session.resultsScrollOffset = info.resultsContainer.scrollTop;
+}
+
+function restoreSidebarSearchResultsScrollOffset() {
+  const info = sidebarSearchInfo;
+  const {session} = info;
+
+  if (session.resultsScrollOffset) {
+    info.resultsContainer.scrollTop = session.resultsScrollOffset;
+  }
+}
+
+function showSearchSidebarColumn() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  if (!info.searchSidebarColumn) {
+    return;
+  }
+
+  if (!info.searchSidebarColumn.classList.contains('initially-hidden')) {
+    return;
+  }
+
+  info.searchSidebarColumn.classList.remove('initially-hidden');
+
+  if (info.searchSidebarColumn.id === 'sidebar-left') {
+    info.pageContainer.classList.add('showing-sidebar-left');
+  } else if (info.searchSidebarColumn.id === 'sidebar-right') {
+    info.pageContainer.classList.add('showing-sidebar-right');
+  }
+
+  state.sidebarColumnShownForSearch = true;
+}
+
+function possiblyHideSearchSidebarColumn() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  if (!info.searchSidebarColumn) {
+    return;
+  }
+
+  if (!state.sidebarColumnShownForSearch) {
+    return;
+  }
+
+  info.searchSidebarColumn.classList.add('initially-hidden');
+
+  if (info.searchSidebarColumn.id === 'sidebar-left') {
+    info.pageContainer.classList.remove('showing-sidebar-left');
+  } else if (info.searchSidebarColumn.id === 'sidebar-right') {
+    info.pageContainer.classList.remove('showing-sidebar-right');
+  }
+
+  state.sidebarColumnShownForSearch = null;
+}
+
+// This should be called after results are shown, since it checks the
+// elements added to understand the current search state.
+function tidySidebarSearchColumn() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  // Don't *re-tidy* the sidebar if we've already tidied it to display
+  // some results. This flag will get cleared if the search is dismissed
+  // altogether (and the pre-tidy state is restored).
+  if (state.tidiedSidebar) {
+    return;
+  }
+
+  const here = location.href.replace(/\/$/, '');
+  const currentPageIsResult =
+    Array.from(info.results.querySelectorAll('a'))
+      .some(link => {
+        const there = link.href.replace(/\/$/, '');
+        return here === there;
+      });
+
+  // Don't tidy the sidebar if you've navigated to some other page than
+  // what's in the current result list.
+  if (!currentPageIsResult) {
+    return;
+  }
+
+  state.tidiedSidebar = true;
+  state.collapsedDetailsForTidiness = [];
+
+  for (const box of info.searchSidebarColumn.querySelectorAll('.sidebar')) {
+    if (box === info.searchBox) {
+      continue;
+    }
+
+    for (const details of box.getElementsByTagName('details')) {
+      if (details.open) {
+        details.removeAttribute('open');
+        state.collapsedDetailsForTidiness.push(details);
+      }
+    }
+  }
+}
+
+function restoreSidebarSearchColumn() {
+  const {state} = sidebarSearchInfo;
+
+  if (!state.tidiedSidebar) {
+    return;
+  }
+
+  for (const details of state.collapsedDetailsForTidiness) {
+    details.setAttribute('open', '');
+  }
+
+  state.collapsedDetailsForTidiness = [];
+  state.tidiedSidebar = null;
+}
+
+clientSteps.getPageReferences.push(getSidebarSearchReferences);
+clientSteps.addInternalListeners.push(addSidebarSearchInternalListeners);
+clientSteps.mutatePageContent.push(mutateSidebarSearchContent);
+clientSteps.addPageListeners.push(addSidebarSearchListeners);
+clientSteps.initializeState.push(initializeSidebarSearchState);
+
 // Sticky commentary sidebar ------------------------------
 
 const albumCommentarySidebarInfo = initInfo('albumCommentarySidebarInfo', {
@@ -3476,8 +4949,8 @@ for (const [key, steps] of Object.entries(clientSteps)) {
     try {
       step();
     } catch (error) {
-      console.warn(`During ${key}, failed to run ${step.name}`);
-      console.debug(error);
+      console.error(`During ${key}, failed to run ${step.name}`);
+      console.error(error);
     }
   }
 }
diff --git a/src/static/lazy-loading.js b/src/static/js/lazy-loading.js
index 1df56f08..1df56f08 100644
--- a/src/static/lazy-loading.js
+++ b/src/static/js/lazy-loading.js
diff --git a/src/static/js/module-import-shims.js b/src/static/js/module-import-shims.js
new file mode 100644
index 00000000..e7e1e0cc
--- /dev/null
+++ b/src/static/js/module-import-shims.js
@@ -0,0 +1,27 @@
+export const loadDependency = {
+  async fromWindow(modulePath) {
+    globalThis.window = {};
+
+    await import(modulePath);
+
+    const exports = globalThis.window;
+
+    delete globalThis.window;
+
+    return exports;
+  },
+
+  async fromModuleExports(modulePath) {
+    globalThis.exports = {};
+    globalThis.module = {exports: globalThis.exports};
+
+    await import(modulePath);
+
+    const exports = globalThis.exports;
+
+    delete globalThis.module;
+    delete globalThis.exports;
+
+    return exports;
+  },
+};
diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js
new file mode 100644
index 00000000..8d987a74
--- /dev/null
+++ b/src/static/js/search-worker.js
@@ -0,0 +1,621 @@
+/* eslint-env worker */
+
+import FlexSearch from '../lib/flexsearch/flexsearch.bundle.module.min.js';
+
+import {makeSearchIndex, searchSpec} from '../shared-util/search-spec.js';
+
+import {
+  empty,
+  groupArray,
+  promiseWithResolvers,
+  stitchArrays,
+  unique,
+  withEntries,
+} from '../shared-util/sugar.js';
+
+import {loadDependency} from './module-import-shims.js';
+import {fetchWithProgress} from './xhr-util.js';
+
+// Will be loaded from dependencies.
+let decompress;
+let unpack;
+
+let idb;
+
+let status = null;
+let indexes = null;
+
+onmessage = handleWindowMessage;
+onerror = handleRuntimeError;
+onunhandledrejection = handleRuntimeError;
+postStatus('alive');
+
+Promise.all([
+  loadDependencies(),
+  loadDatabase(),
+]).then(main)
+  .then(
+    () => {
+      postStatus('ready');
+    },
+    error => {
+      console.error(`Search worker setup error:`, error);
+      postStatus('setup-error');
+    });
+
+async function loadDependencies() {
+  const {compressJSON} =
+    await loadDependency.fromWindow('../lib/compress-json/bundle.min.js');
+
+  const msgpackr =
+    await loadDependency.fromModuleExports('../lib/msgpackr/index.js');
+
+  ({decompress} = compressJSON);
+  ({unpack} = msgpackr);
+}
+
+async function promisifyIDBRequest(request) {
+  const {promise, resolve, reject} = promiseWithResolvers();
+
+  request.addEventListener('success', () => resolve(request.result));
+  request.addEventListener('error', () => reject(request.error));
+
+  return promise;
+}
+
+async function* iterateIDBObjectStore(store, query) {
+  const request =
+    store.openCursor(query);
+
+  let promise, resolve, reject;
+  let cursor;
+
+  request.onsuccess = () => {
+    cursor = request.result;
+    if (cursor) {
+      resolve({done: false, value: [cursor.key, cursor.value]});
+    } else {
+      resolve({done: true});
+    }
+  };
+
+  request.onerror = () => {
+    reject(request.error);
+  };
+
+  do {
+    ({promise, resolve, reject} = promiseWithResolvers());
+
+    const result = await promise;
+
+    if (result.done) {
+      return;
+    }
+
+    yield result.value;
+
+    cursor.continue();
+  } while (true);
+}
+
+async function loadCachedIndexFromIDB() {
+  if (!idb) return null;
+
+  const transaction =
+    idb.transaction(['indexes'], 'readwrite');
+
+  const store =
+    transaction.objectStore('indexes');
+
+  const result = {};
+
+  for await (const [key, object] of iterateIDBObjectStore(store)) {
+    result[key] = object;
+  }
+
+  return result;
+}
+
+async function loadDatabase() {
+  const request =
+    globalThis.indexedDB.open('hsmusicSearchDatabase', 4);
+
+  request.addEventListener('upgradeneeded', () => {
+    const idb = request.result;
+
+    idb.createObjectStore('indexes', {
+      keyPath: 'key',
+    });
+  });
+
+  try {
+    idb = await promisifyIDBRequest(request);
+  } catch (error) {
+    console.warn(`Couldn't load search IndexedDB - won't use an internal cache.`);
+    console.warn(request.error);
+    idb = null;
+  }
+}
+
+function rebase(path) {
+  return `/search-data/` + path;
+}
+
+async function prepareIndexData() {
+  return Promise.all([
+    fetch(rebase('index.json'))
+      .then(resp => resp.json()),
+
+    loadCachedIndexFromIDB(),
+  ]).then(
+      ([indexData, idbIndexData]) =>
+        ({indexData, idbIndexData}));
+}
+
+function fetchIndexes(keysNeedingFetch) {
+  if (!empty(keysNeedingFetch)) {
+    postMessage({
+      kind: 'download-begun',
+      context: 'search-indexes',
+      keys: keysNeedingFetch,
+    });
+  }
+
+  return (
+    keysNeedingFetch.map(key =>
+      fetchWithProgress(
+        rebase(key + '.json.msgpack'),
+        progress => {
+          postMessage({
+            kind: 'download-progress',
+            context: 'search-indexes',
+            progress: progress / 1.00,
+            key,
+          });
+        }).then(response => {
+            postMessage({
+              kind: 'download-complete',
+              context: 'search-indexes',
+              key,
+            });
+
+            return response;
+          })));
+}
+
+async function main() {
+  const prepareIndexDataPromise = prepareIndexData();
+
+  indexes =
+    withEntries(searchSpec, entries => entries
+      .map(([key, descriptor]) => [
+        key,
+        makeSearchIndex(descriptor, {FlexSearch}),
+      ]));
+
+  const {indexData, idbIndexData} = await prepareIndexDataPromise;
+
+  const keysNeedingFetch =
+    (idbIndexData
+      ? Object.keys(indexData)
+          .filter(key =>
+            indexData[key].md5 !==
+            idbIndexData[key]?.md5)
+      : Object.keys(indexData));
+
+  const keysFromCache =
+    Object.keys(indexData)
+      .filter(key => !keysNeedingFetch.includes(key))
+
+  const cacheArrayBufferPromises =
+    keysFromCache
+      .map(key => idbIndexData[key])
+      .map(({cachedBinarySource}) =>
+        cachedBinarySource.arrayBuffer());
+
+  const fetchPromises =
+    fetchIndexes(keysNeedingFetch);
+
+  const fetchBlobPromises =
+    fetchPromises
+      .map(promise => promise
+        .then(response => response.blob()));
+
+  const fetchArrayBufferPromises =
+    fetchBlobPromises
+      .map(promise => promise
+        .then(blob => blob.arrayBuffer()));
+
+  function arrayBufferToJSON(data) {
+    data = new Uint8Array(data);
+    data = unpack(data);
+    data = decompress(data);
+    return data;
+  }
+
+  function importIndexes(keys, jsons) {
+    stitchArrays({key: keys, json: jsons})
+      .forEach(({key, json}) => {
+        importIndex(key, json);
+      });
+  }
+
+  if (idb) {
+    console.debug(`Reusing indexes from search cache:`, keysFromCache);
+    console.debug(`Fetching indexes anew:`, keysNeedingFetch);
+  }
+
+  await Promise.all([
+    async () => {
+      const cacheArrayBuffers =
+        await Promise.all(cacheArrayBufferPromises);
+
+      const cacheJSONs =
+        cacheArrayBuffers
+          .map(arrayBufferToJSON);
+
+      importIndexes(keysFromCache, cacheJSONs);
+    },
+
+    async () => {
+      const fetchArrayBuffers =
+        await Promise.all(fetchArrayBufferPromises);
+
+      const fetchJSONs =
+        fetchArrayBuffers
+          .map(arrayBufferToJSON);
+
+      importIndexes(keysNeedingFetch, fetchJSONs);
+    },
+
+    async () => {
+      if (!idb) return;
+
+      const fetchBlobs =
+        await Promise.all(fetchBlobPromises);
+
+      const transaction =
+        idb.transaction(['indexes'], 'readwrite');
+
+      const store =
+        transaction.objectStore('indexes');
+
+      for (const {key, blob} of stitchArrays({
+        key: keysNeedingFetch,
+        blob: fetchBlobs,
+      })) {
+        const value = {
+          key,
+          md5: indexData[key].md5,
+          cachedBinarySource: blob,
+        };
+
+        try {
+          await promisifyIDBRequest(store.put(value));
+        } catch (error) {
+          console.warn(`Error saving ${key} to internal search cache:`, value);
+          console.warn(error);
+          continue;
+        }
+      }
+    },
+  ].map(fn => fn()));
+}
+
+function importIndex(indexKey, indexData) {
+  // If this fails, it's because an outdated index was cached.
+  // TODO: If this fails, try again once with a cache busting url.
+  for (const [key, value] of Object.entries(indexData)) {
+    indexes[indexKey].import(key, JSON.stringify(value));
+  }
+}
+
+function handleRuntimeError() {
+  postStatus('runtime-error');
+}
+
+function handleWindowMessage(message) {
+  switch (message.data.kind) {
+    case 'action':
+      handleWindowActionMessage(message);
+      break;
+
+    default:
+      console.warn(`Unknown message kind -> to search worker:`, message.data);
+      break;
+  }
+}
+
+async function handleWindowActionMessage(message) {
+  const {id} = message.data;
+
+  if (!id) {
+    console.warn(`Action without id -> to search worker:`, message.data);
+    return;
+  }
+
+  if (status !== 'ready') {
+    return postActionResult(id, 'reject', 'not ready');
+  }
+
+  let value;
+
+  switch (message.data.action) {
+    case 'search':
+      value = await performSearchAction(message.data.options);
+      break;
+
+    default:
+      console.warn(`Unknown action "${message.data.action}" -> to search worker:`, message.data);
+      return postActionResult(id, 'reject', 'unknown action');
+  }
+
+  await postActionResult(id, 'resolve', value);
+}
+
+function postStatus(newStatus) {
+  status = newStatus;
+  postMessage({
+    kind: 'status',
+    status: newStatus,
+  });
+}
+
+function postActionResult(id, status, value) {
+  postMessage({
+    kind: 'result',
+    id,
+    status,
+    value,
+  });
+}
+
+function performSearchAction({query, options}) {
+  const {generic, ...otherIndexes} = indexes;
+
+  const genericResults =
+    queryGenericIndex(generic, query, options);
+
+  const otherResults =
+    withEntries(otherIndexes, entries => entries
+      .map(([indexName, index]) => [
+        indexName,
+        index.search(query, options),
+      ]));
+
+  return {
+    generic: genericResults,
+    ...otherResults,
+  };
+}
+
+function queryGenericIndex(index, query, options) {
+  const interestingFieldCombinations = [
+    ['primaryName', 'parentName', 'groups'],
+    ['primaryName', 'parentName'],
+    ['primaryName', 'groups', 'contributors'],
+    ['primaryName', 'groups', 'artTags'],
+    ['primaryName', 'groups'],
+    ['primaryName', 'contributors'],
+    ['primaryName', 'artTags'],
+    ['parentName', 'groups', 'artTags'],
+    ['parentName', 'artTags'],
+    ['groups', 'contributors'],
+    ['groups', 'artTags'],
+
+    // This prevents just matching *everything* tagged "john" if you
+    // only search "john", but it actually supports matching more than
+    // *two* tags at once: "john rose lowas" works! This is thanks to
+    // flexsearch matching multiple field values in a single query.
+    ['artTags', 'artTags'],
+
+    ['contributors', 'parentName'],
+    ['contributors', 'groups'],
+    ['primaryName', 'contributors'],
+    ['primaryName'],
+  ];
+
+  const interestingFields =
+    unique(interestingFieldCombinations.flat());
+
+  const {genericTerms, queriedKind} =
+    processTerms(query);
+
+  const particles =
+    particulate(genericTerms);
+
+  const groupedParticles =
+    groupArray(particles, ({length}) => length);
+
+  const queriesBy = keys =>
+    (groupedParticles.get(keys.length) ?? [])
+      .flatMap(permutations)
+      .map(values => values.map(({terms}) => terms.join(' ')))
+      .map(values =>
+        stitchArrays({
+          field: keys,
+          query: values,
+        }));
+
+  const boilerplate = queryBoilerplate(index);
+
+  const particleResults =
+    Object.fromEntries(
+      interestingFields.map(field => [
+        field,
+        Object.fromEntries(
+          particles.flat()
+            .map(({terms}) => terms.join(' '))
+            .map(query => [
+              query,
+              new Set(
+                boilerplate
+                  .query(query, {
+                    ...options,
+                    field,
+                    limit: Infinity,
+                  })
+                  .fieldResults[field]),
+            ])),
+      ]));
+
+  const results = new Set();
+
+  for (const interestingFieldCombination of interestingFieldCombinations) {
+    for (const query of queriesBy(interestingFieldCombination)) {
+      const idToMatchingFieldsMap = new Map();
+      for (const {field, query: fieldQuery} of query) {
+        for (const id of particleResults[field][fieldQuery]) {
+          if (idToMatchingFieldsMap.has(id)) {
+            idToMatchingFieldsMap.get(id).push(field);
+          } else {
+            idToMatchingFieldsMap.set(id, [field]);
+          }
+        }
+      }
+
+      const commonAcrossFields =
+        Array.from(idToMatchingFieldsMap.entries())
+          .filter(([id, matchingFields]) =>
+            matchingFields.length === interestingFieldCombination.length)
+          .map(([id]) => id);
+
+      for (const result of commonAcrossFields) {
+        results.add(result);
+      }
+    }
+  }
+
+  const constituted =
+    boilerplate.constitute(results);
+
+  const constitutedAndFiltered =
+    constituted
+      .filter(({id}) =>
+        (queriedKind
+          ? id.split(':')[0] === queriedKind
+          : true));
+
+  return constitutedAndFiltered;
+}
+
+function processTerms(query) {
+  const kindTermSpec = [
+    {kind: 'album', terms: ['album']},
+    {kind: 'artist', terms: ['artist']},
+    {kind: 'flash', terms: ['flash']},
+    {kind: 'group', terms: ['group']},
+    {kind: 'tag', terms: ['art tag', 'tag']},
+    {kind: 'track', terms: ['track']},
+  ];
+
+  const genericTerms = [];
+  let queriedKind = null;
+
+  const termRegexp =
+    new RegExp(
+      String.raw`(?<kind>${kindTermSpec.flatMap(spec => spec.terms).join('|')})` +
+      String.raw`|\S+`,
+      'gi');
+
+  for (const match of query.matchAll(termRegexp)) {
+    const {groups} = match;
+
+    if (groups.kind && !queriedKind) {
+      queriedKind =
+        kindTermSpec
+          .find(({terms}) => terms.includes(groups.kind.toLowerCase()))
+          .kind;
+
+      continue;
+    }
+
+    genericTerms.push(match[0]);
+  }
+
+  return {genericTerms, queriedKind};
+}
+
+function particulate(terms) {
+  if (empty(terms)) return [];
+
+  const results = [];
+
+  for (let slice = 1; slice <= 2; slice++) {
+    if (slice === terms.length) {
+      break;
+    }
+
+    const front = terms.slice(0, slice);
+    const back = terms.slice(slice);
+
+    results.push(...
+      particulate(back)
+        .map(result => [
+          {terms: front},
+          ...result
+        ]));
+  }
+
+  results.push([{terms}]);
+
+  return results;
+}
+
+// This function doesn't even come close to "performant",
+// but it only operates on small data here.
+function permutations(array) {
+  switch (array.length) {
+    case 0:
+      return [];
+
+    case 1:
+      return [array];
+
+    default:
+      return array.flatMap((item, index) => {
+        const behind = array.slice(0, index);
+        const ahead = array.slice(index + 1);
+        return (
+          permutations([...behind, ...ahead])
+            .map(rest => [item, ...rest]));
+      });
+  }
+}
+
+function queryBoilerplate(index) {
+  const idToDoc = {};
+
+  return {
+    idToDoc,
+
+    constitute: (ids) =>
+      Array.from(ids)
+        .map(id => ({id, doc: idToDoc[id]})),
+
+    query: (query, options) => {
+      const rawResults =
+        index.search(query, options);
+
+      const fieldResults =
+        Object.fromEntries(
+          rawResults
+            .map(({field, result}) => [
+              field,
+              result.map(result =>
+                (typeof result === 'string'
+                  ? result
+                  : result.id)),
+            ]));
+
+      Object.assign(
+        idToDoc,
+        Object.fromEntries(
+          rawResults
+            .flatMap(({result}) => result)
+            .map(({id, doc}) => [id, doc])));
+
+      return {rawResults, fieldResults};
+    },
+  };
+}
diff --git a/src/static/xhr-util.js b/src/static/js/xhr-util.js
index 8a43072c..8a43072c 100644
--- a/src/static/xhr-util.js
+++ b/src/static/js/xhr-util.js
diff --git a/src/static/icons.svg b/src/static/misc/icons.svg
index 8c9a80a9..8c9a80a9 100644
--- a/src/static/icons.svg
+++ b/src/static/misc/icons.svg
diff --git a/src/static/warning.svg b/src/static/misc/warning.svg
index 92e55778..92e55778 100644
--- a/src/static/warning.svg
+++ b/src/static/misc/warning.svg
diff --git a/src/static/shared-util/README.md b/src/static/shared-util/README.md
new file mode 100644
index 00000000..d21c0e6b
--- /dev/null
+++ b/src/static/shared-util/README.md
@@ -0,0 +1,11 @@
+# `src/static/shared-util`
+
+Module imports under `src/static/js` may appear to be pointing to files that aren't at quite the right place. For example, the import:
+
+    import {empty} from '../shared-util/sugar.js';
+
+...is reading a file that doesn't exist here, under `shared-util`. This isn't an error!
+
+This folder (`src/shared-util`) does not actually exist in a build of the website; instead, the folder `src/util` is symlinked in its place. So, all files under `src/util` are actually available at (e.g.) `/static/shared-util/` online.
+
+The above import would actually import from the bindings in `src/util/sugar.js`.
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 301fd5f2..26107c0b 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -273,17 +273,41 @@ releaseInfo:
       _: "{TRACK} ({ALBUM})"
       withYear: "({YEAR}) {TRACK} ({ALBUM})"
 
-  tracksReferenced: "Tracks that {TRACK} references:"
-  tracksThatReference: "Tracks that reference {TRACK}:"
-  tracksSampled: "Tracks that {TRACK} samples:"
-  tracksThatSample: "Tracks that sample {TRACK}:"
+  tracksReferenced:
+    _: "Tracks that {TRACK} references:"
+    sticky: "Tracks that this one references:"
+
+  tracksSampled:
+    _: "Tracks that {TRACK} samples:"
+    sticky: "Tracks that this one samples:"
+
+  tracksThatReference:
+    _: "Tracks that reference {TRACK}:"
+
+    sticky:
+      _: "Tracks that reference this one:"
+      fromGroup: "Tracks from {GROUP} that reference this one:"
+      fromOther: "Tracks from somewhere else that reference this one:"
+
+  tracksThatSample:
+    _: "Tracks that sample {TRACK}:"
+
+    sticky:
+      _: "Tracks that sample this one:"
+      fromGroup: "Tracks from {GROUP} that sample this one:"
+      fromOther: "Tracks from somewhere else that sample this one:"
 
   flashesThatFeature:
     _: "Flashes & games that feature {TRACK}:"
+    sticky: "Flashes & games that feature this track:"
+
     item:
       _: "{FLASH}"
       asDifferentRelease: "{FLASH} (as {TRACK})"
 
+  # Note that there's no sticky variant here,
+  # such as "Tracks that this flash features",
+  # because not all flashes are *called* flashes!
   tracksFeatured: "Tracks that {FLASH} features:"
 
   # Actions
@@ -321,7 +345,7 @@ releaseInfo:
     link: "artist commentary"
 
   additionalFiles:
-    heading: "View or download {ADDITIONAL_FILES}:"
+    heading: "View or download additional files:"
 
     entry:
       _: "{TITLE}"
@@ -364,10 +388,10 @@ trackList:
   section:
     _: "{SECTION}:"
     withDuration: "{SECTION}: ({DURATION})"
+    sticky: "{SECTION}:"
 
-  group:
-    _: "From {GROUP}:"
-    fromOther: "From somewhere else:"
+  fromGroup: "From {GROUP}:"
+  fromOther: "From somewhere else:"
 
   item:
     _: "{TRACK}"
@@ -430,12 +454,15 @@ misc:
     entry:
       title:
         _: "{ARTISTS}:"
+
         noArtists: "Unknown artist"
+
         withAccent: "{ARTISTS}: {ACCENT}"
+
         accent:
           withAnnotation: "({ANNOTATION})"
-          withDate: ({DATE})"
-          withAnnotation.withDate: "({ANNOTATION}, {DATE})"
+
+        date: "{DATE}"
 
       seeOriginalRelease: "See {ORIGINAL}!"
 
@@ -449,16 +476,33 @@ misc:
     # Contribution to a track, artwork, or other thing.
     withContribution: "{ARTIST} ({CONTRIB})"
 
-    # External links to visit the artist's own websites or profiles.
-    withExternalLinks: "{ARTIST} ({LINKS})"
-
-    # Combination of above.
-    withContribution.withExternalLinks: "{ARTIST} ({CONTRIB}) ({LINKS})"
-
     # Displayed in an artist's tooltip, if one of their URLs
     # isn't a specially detected web platform.
     noExternalLinkPlatformName: "Other"
 
+    chronology:
+      previous:
+        symbol: "←"
+        info:
+          _: "Previous by this artist"
+          withKind: "Previous {KIND} by this artist"
+
+      next:
+        symbol: "→"
+        info:
+          _: "Next by this artist"
+          withKind: "Next {KIND} by this artist"
+
+      kind:
+        album: "album"
+        bannerArt: "banner art"
+        coverArt: "cover art"
+        flash: "flash"
+        track: "track"
+        trackArt: "track art"
+        trackContribution: "track contribution"
+        wallpaperArt: "wallpaper art"
+
   # chronology:
   #
   #   "Chronology links" are a section that appear in the nav bar for
@@ -490,6 +534,8 @@ misc:
       coverArt: "{INDEX} cover art by {ARTIST}"
       flash: "{INDEX} flash/game by {ARTIST}"
       track: "{INDEX} track by {ARTIST}"
+      trackArt: "{INDEX} track art by {ARTIST}"
+      onlyIndex: "Only"
 
   # external:
   #   Links which will generally bring you somewhere off of the wiki.
@@ -596,6 +642,32 @@ misc:
 
   missingLinkContent: "(Missing link content)"
 
+  # quickDescription:
+  #   Toggleable display where a shorter blurb from a description is
+  #   initially visible, and a button can be clicked to display the
+  #   rest. May also display "read more" links (to external sites).
+
+  quickDescription:
+    moreInfo:
+      _: "({LINK})"
+      link: "More info..."
+
+    expandDescription:
+      _: "({EXPAND})"
+      expand: "Expand description..."
+
+    expandDescription.orReadMore:
+      _: "({EXPAND}, or read more on {LINKS})"
+      expand: "Expand description"
+
+    collapseDescription:
+      _: "({COLLAPSE})"
+      collapse: "Collapse description"
+
+    collapseDescription.orReadMore:
+      _: "({COLLAPSE}, or read more on {LINKS})"
+      collapse: "Collapse description"
+
   # nav:
   #   Generic navigational elements. These usually only appear in the
   #   wiki's nav bar, at the top of the page.
@@ -614,6 +686,35 @@ misc:
     _: "{TITLE}"
     withWikiName: "{TITLE} | {WIKI_NAME}"
 
+  # search:
+  #   Strings to do with the search bar!
+
+  search:
+    placeholder: "Search for anything"
+
+    preparing: "Preparing..."
+    loadingData: "Loading data..."
+    searching: "Searching..."
+
+    failed: >-
+      There was an internal error,
+      and your search couldn't be processed.
+      Reloading this page and trying again may help.
+      Sorry for the trouble!
+
+    noResults: >-
+      No results for this query, sorry!
+      Check spelling and use complete words.
+
+    currentResult: "(you are here)"
+    endSearch: "(OK, I'm done searching now.)"
+
+    resultKind:
+      album: "(album)"
+      artTag: "(art tag)"
+      artist: "(artist)"
+      group: "(group)"
+
   # skippers:
   #
   #   These are navigational links that only show up when you're
@@ -873,6 +974,7 @@ albumCommentaryPage:
     title:
       albumCommentary:
         _: "{ALBUM}"
+        sticky: "{ALBUM} (album commentary)"
         accent: "Listen on: {LISTENING_LINKS}"
 
       trackCommentary:
@@ -1841,6 +1943,12 @@ trackPage:
       _: "{TRACK}"
       withNumber: "{NUMBER}. {TRACK}"
 
+    chronology:
+      scope:
+        title: "Chronology links {SCOPE}"
+        wiki: "across this wiki"
+        album: "within this album"
+
   socialEmbed:
     heading: "{ALBUM}"
     title: "{TRACK}"
diff --git a/src/upd8.js b/src/upd8.js
index f35f9e5f..9e4ef4fb 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -34,7 +34,7 @@
 import '#import-heck';
 
 import {execSync} from 'node:child_process';
-import {readdir, readFile} from 'node:fs/promises';
+import {readdir, readFile, stat} from 'node:fs/promises';
 import * as path from 'node:path';
 import {fileURLToPath} from 'node:url';
 
@@ -47,8 +47,8 @@ import {bindFind, getAllFindSpecs} from '#find';
 import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile}
   from '#language';
 import {isMain, traverse} from '#node-utils';
+import {writeSearchData} from '#search';
 import {sortByName} from '#sort';
-import {empty, withEntries} from '#sugar';
 import {generateURLs, urlSpec} from '#urls';
 import {identifyAllWebRoutes} from '#web-routes';
 
@@ -61,6 +61,7 @@ import {
   logError,
   parseOptions,
   progressCallAll,
+  showHelpForOptions as unboundShowHelpForOptions,
 } from '#cli';
 
 import {
@@ -69,6 +70,13 @@ import {
   reportContentTextErrors,
 } from '#data-checks';
 
+import {
+  bindOpts,
+  empty,
+  indentWrap as unboundIndentWrap,
+  withEntries,
+} from '#sugar';
+
 import genThumbs, {
   CACHE_FILE as thumbsCacheFile,
   defaultMagickThreads,
@@ -93,8 +101,6 @@ import * as buildModes from './write/build-modes/index.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
-const CACHEBUST = 23;
-
 let COMMIT;
 try {
   COMMIT = execSync('git log --format="%h %B" -n 1 HEAD', {cwd: __dirname}).toString().trim();
@@ -121,69 +127,109 @@ let showStepStatusSummary = false;
 async function main() {
   Error.stackTraceLimit = Infinity;
 
+  let paragraph = true;
+
   stepStatusSummary = {
     determineMediaCachePath:
-      {...defaultStepStatus, name: `determine media cache path`},
+      {...defaultStepStatus, name: `determine media cache path`,
+        for: ['thumbs', 'build']},
 
     migrateThumbnails:
-      {...defaultStepStatus, name: `migrate thumbnails`},
+      {...defaultStepStatus, name: `migrate thumbnails`,
+        for: ['thumbs']},
 
     loadThumbnailCache:
-      {...defaultStepStatus, name: `load thumbnail cache file`},
+      {...defaultStepStatus, name: `load thumbnail cache file`,
+        for: ['thumbs', 'build']},
 
     generateThumbnails:
-      {...defaultStepStatus, name: `generate thumbnails`},
+      {...defaultStepStatus, name: `generate thumbnails`,
+        for: ['thumbs']},
 
     loadDataFiles:
-      {...defaultStepStatus, name: `load and process data files`},
+      {...defaultStepStatus, name: `load and process data files`,
+        for: ['build']},
 
     linkWikiDataArrays:
-      {...defaultStepStatus, name: `link wiki data arrays`},
+      {...defaultStepStatus, name: `link wiki data arrays`,
+        for: ['build']},
 
     precacheCommonData:
-      {...defaultStepStatus, name: `precache common data`},
+      {...defaultStepStatus, name: `precache common data`,
+        for: ['build']},
 
     reportDirectoryErrors:
-      {...defaultStepStatus, name: `report directory errors`},
+      {...defaultStepStatus, name: `report directory errors`,
+        for: ['verify']},
 
     filterReferenceErrors:
-      {...defaultStepStatus, name: `filter reference errors`},
+      {...defaultStepStatus, name: `filter reference errors`,
+        for: ['verify']},
 
     reportContentTextErrors:
-      {...defaultStepStatus, name: `report content text errors`},
+      {...defaultStepStatus, name: `report content text errors`,
+        for: ['verify']},
 
     sortWikiDataArrays:
-      {...defaultStepStatus, name: `sort wiki data arrays`},
+      {...defaultStepStatus, name: `sort wiki data arrays`,
+        for: ['build']},
 
     precacheAllData:
-      {...defaultStepStatus, name: `precache nearly all data`},
+      {...defaultStepStatus, name: `precache nearly all data`,
+        for: ['build']},
 
     // TODO: This should be split into load/watch steps.
     loadInternalDefaultLanguage:
-      {...defaultStepStatus, name: `load internal default language`},
+      {...defaultStepStatus, name: `load internal default language`,
+        for: ['build']},
 
     loadLanguageFiles:
-      {...defaultStepStatus, name: `statically load custom language files`},
+      {...defaultStepStatus, name: `statically load custom language files`,
+        for: ['build']},
 
     watchLanguageFiles:
-      {...defaultStepStatus, name: `watch custom language files`},
+      {...defaultStepStatus, name: `watch custom language files`,
+        for: ['build']},
 
     initializeDefaultLanguage:
-      {...defaultStepStatus, name: `initialize default language`},
+      {...defaultStepStatus, name: `initialize default language`,
+        for: ['build']},
 
     verifyImagePaths:
-      {...defaultStepStatus, name: `verify missing/misplaced image paths`},
+      {...defaultStepStatus, name: `verify missing/misplaced image paths`,
+        for: ['verify']},
 
     preloadFileSizes:
-      {...defaultStepStatus, name: `preload file sizes`},
+      {...defaultStepStatus, name: `preload file sizes`,
+        for: ['build']},
+
+    buildSearchIndex:
+      {...defaultStepStatus, name: `generate search index`,
+        for: ['build', 'search']},
 
     identifyWebRoutes:
-      {...defaultStepStatus, name: `identify web routes`},
+      {...defaultStepStatus, name: `identify web routes`,
+        for: ['build']},
 
     performBuild:
-      {...defaultStepStatus, name: `perform selected build mode`},
+      {...defaultStepStatus, name: `perform selected build mode`,
+        for: ['build']},
   };
 
+  const stepsWhich = condition =>
+    Object.entries(stepStatusSummary)
+      .filter(([_key, value]) => condition(value))
+      .map(([key]) => key);
+
+  /* eslint-disable-next-line no-unused-vars */
+  const stepsFor = (...which) =>
+    stepsWhich(step =>
+      which.some(w => step.for?.includes(w)));
+
+  const stepsNotFor = (...which) =>
+    stepsWhich(step =>
+      which.every(w => !step.for?.includes(w)));
+
   const defaultQueueSize = 500;
 
   const buildModeFlagOptions = (
@@ -200,21 +246,24 @@ async function main() {
     }));
 
   let selectedBuildModeFlag;
-  let usingDefaultBuildMode;
 
   if (empty(selectedBuildModeFlags)) {
-    selectedBuildModeFlag = 'static-build';
-    usingDefaultBuildMode = true;
+    // No build mode selected. This is not a valid state for building the wiki,
+    // but we want to let access to --help, so we'll show a message about what
+    // to do later.
+    selectedBuildModeFlag = null;
   } else if (selectedBuildModeFlags.length > 1) {
     logError`Building multiple modes (${selectedBuildModeFlags.join(', ')}) at once not supported.`;
-    logError`Please specify a maximum of one build mode.`;
+    logError`Please specify one build mode.`;
     return false;
   } else {
     selectedBuildModeFlag = selectedBuildModeFlags[0];
-    usingDefaultBuildMode = false;
   }
 
-  const selectedBuildMode = buildModes[selectedBuildModeFlag];
+  const selectedBuildMode =
+    (selectedBuildModeFlag
+      ? buildModes[selectedBuildModeFlag]
+      : null);
 
   // This is about to get a whole lot more stuff put in it.
   const wikiData = {
@@ -222,7 +271,10 @@ async function main() {
     listingTargetSpec,
   };
 
-  const buildOptions = selectedBuildMode.getCLIOptions();
+  const buildOptions =
+    (selectedBuildMode
+      ? selectedBuildMode.getCLIOptions()
+      : {});
 
   const commonOptions = {
     'help': {
@@ -234,7 +286,7 @@ async function main() {
     // and like a jillion other things too. Pretty much everything which
     // makes an individual wiki what it is goes here!
     'data-path': {
-      help: `Specify path to data directory, including YAML files that cover all info about wiki content, layout, and structure\n\nAlways required for wiki building, but may be provided via the HSMUSIC_DATA environment variable instead`,
+      help: `Specify path to data directory, including YAML files that cover all info about wiki content, layout, and structure\n\nAlways required for wiki building; may be provided via the HSMUSIC_DATA environment variable`,
       type: 'value',
     },
 
@@ -242,17 +294,17 @@ async function main() {
     // categorized; check out MEDIA_ALBUM_ART_DIRECTORY and other constants
     // near the top of this file (upd8.js).
     'media-path': {
-      help: `Specify path to media directory, including album artwork and additional files, as well as custom site layout media and other media files for reference or linking in wiki content\n\nAlways required for wiki building, but may be provided via the HSMUSIC_MEDIA environment variable instead`,
+      help: `Specify path to media directory, including album artwork and additional files, as well as custom site layout media and other media files for reference or linking in wiki content\n\nAlways required for wiki building; may be provided via the HSMUSIC_MEDIA environment variable`,
       type: 'value',
     },
 
     'media-cache-path': {
-      help: `Specify path to media cache directory, including automatically generated thumbnails\n\nThis usually doesn't need to be provided, and will be inferred either by loading "media-cache" from --cache-path, or by adding "-cache" to the end of the media directory\n\nAlso may be provided via the HSMUSIC_MEDIA_CACHE environment variable`,
+      help: `Specify path to media cache directory, including automatically generated thumbnails\n\nThis usually doesn't need to be provided, and will be inferred either by loading "media-cache" from --cache-path, or by adding "-cache" to the end of the media directory\n\nMay be provided via the HSMUSIC_MEDIA_CACHE environment variable`,
       type: 'value',
     },
 
     'cache-path': {
-      help: `Specify path to general cache directory, usually containing generated thumbnails and assorted files reused between builds\n\nRequired for some features and may always be required if you're starting a new workspace\n\nAlso may be provided via the HSMUSIC_CACHE environment varaible`,
+      help: `Specify path to general cache directory, usually containing generated thumbnails and assorted files reused between builds\n\nAlways required for wiki building; may be provided via the HSMUSIC_CACHE environment varaible`,
       type: 'value',
     },
 
@@ -311,6 +363,16 @@ async function main() {
       type: 'flag',
     },
 
+    'refresh-search': {
+      help: `Generate the text search index this build, instead of waiting for the automatic delay`,
+      type: 'flag',
+    },
+
+    'skip-search': {
+      help: `Skip creation of the text search index no matter what, even if it'd normally be scheduled for now`,
+      type: 'flag',
+    },
+
     // Just working on data entries and not interested in actually
     // generating site HTML yet? This flag will cut execution off right
     // 8efore any site 8uilding actually happens.
@@ -390,6 +452,18 @@ async function main() {
     },
   };
 
+  const indentWrap =
+    bindOpts(unboundIndentWrap, {
+      wrap,
+    });
+
+  const showHelpForOptions =
+    bindOpts(unboundShowHelpForOptions, {
+      [bindOpts.bindIndex]: 0,
+      indentWrap,
+      sort: sortByName,
+    });
+
   const cliOptions = await parseOptions(process.argv.slice(2), {
     // We don't want to error when we receive these options, so specify them
     // here, even though we won't be doing anything with them later.
@@ -400,89 +474,71 @@ async function main() {
     ...buildOptions,
   });
 
-  if (cliOptions['help']) {
-    const indentWrap = (spaces, str) => wrap(str, {width: 60 - spaces, indent: ' '.repeat(spaces)});
-
-    const showOptions = (msg, options) => {
-      console.log(colors.bright(msg));
-
-      const entries = Object.entries(options);
-      const sortedOptions = sortByName(entries
-        .map(([name, descriptor]) => ({name, descriptor})));
-
-      if (!sortedOptions.length) {
-        console.log(`(No options available)`)
-      }
-
-      let justInsertedPaddingLine = false;
-
-      for (const {name, descriptor} of sortedOptions) {
-        if (descriptor.alias) {
-          continue;
-        }
-
-        const aliases = entries
-          .filter(([_name, {alias}]) => alias === name)
-          .map(([name]) => name);
-
-        let wrappedHelp, wrappedHelpLines = 0;
-        if (descriptor.help) {
-          wrappedHelp = indentWrap(4, descriptor.help);
-          wrappedHelpLines = wrappedHelp.split('\n').length;
-        }
-
-        if (wrappedHelpLines > 0 && !justInsertedPaddingLine) {
-          console.log('');
-        }
-
-        console.log(colors.bright(` --` + name) +
-          (aliases.length
-            ? ` (or: ${aliases.map(alias => colors.bright(`--` + alias)).join(', ')})`
-            : '') +
-          (descriptor.help
-            ? ''
-            : colors.dim('  (no help provided)')));
-
-        if (wrappedHelp) {
-          console.log(wrappedHelp);
-        }
-
-        if (wrappedHelpLines > 1) {
-          console.log('');
-          justInsertedPaddingLine = true;
-        } else {
-          justInsertedPaddingLine = false;
-        }
-      }
-
-      if (!justInsertedPaddingLine) {
-        console.log(``);
-      }
-    };
+  showStepStatusSummary = cliOptions['show-step-summary'] ?? false;
 
+  if (cliOptions['help']) {
     console.log(
-      colors.bright(`hsmusic (aka. Homestuck Music Wiki)\n`) +
+      colors.bright(`hsmusic (aka. Homestuck Music Wiki, HSMusic Wiki)\n`) +
       `static wiki software cataloguing collaborative creation\n`);
 
-    console.log(indentWrap(0,
-      `The \`hsmusic\` command provides basic control over all parts of generating user-visible HTML pages and website content/structure from provided data, media, and language directories.\n` +
+    console.log(indentWrap(
+      `The \`hsmusic\` command provides basic control over ` +
+      `all parts of generating user-visible HTML pages ` +
+      `and website content/structure ` +
+      `from provided data, media, and language directories.\n` +
       `\n` +
       `CLI options are divided into three groups:\n`));
-    console.log(` 1) ` + indentWrap(4,
-      `Common options: These are shared by all build modes and always have the same essential behavior`).trim());
-    console.log(` 2) ` + indentWrap(4,
-      `Build mode selection: One build mode may be selected (or else the default, --static-build, is used), and it decides which entire set of behavior to use for providing site content to the user`).trim());
-    console.log(` 3) ` + indentWrap(4,
-      `Build options: Each build mode has a set of unique options which customize behavior for that build mode`).trim());
+
+    console.log(` 1) ` + indentWrap(
+      `Common options: ` +
+      `These are shared by all build modes ` +
+      `and always have the same essential behavior`,
+      {spaces: 4, bullet: true}));
+
+    console.log(` 2) ` + indentWrap(
+      `Build mode selection: ` +
+      `One build mode should be selected, ` +
+      `and it decides the main set of behavior to use ` +
+      `for presenting or interacting with site content`,
+      {spaces: 4, bullet: true}));
+
+    console.log(` 3) ` + indentWrap(
+      `Build options: ` +
+      `Each build mode has a set of unique options ` +
+      `which customize behavior for that build mode`,
+      {spaces: 4, bullet: true}));
+
     console.log(``);
 
-    showOptions(`Common options`, commonOptions);
-    showOptions(`Build mode selection`, buildModeFlagOptions);
+    showHelpForOptions({
+      heading: `Common options`,
+      options: commonOptions,
+      wrap,
+    });
+
+    showHelpForOptions({
+      heading: `Build mode selection`,
+      options: buildModeFlagOptions,
+      wrap,
+    });
 
-    if (buildOptions) {
-      showOptions(`Build options for --${selectedBuildModeFlag} (${
-        usingDefaultBuildMode ? 'default' : 'selected'
-      })`, buildOptions);
+    if (selectedBuildMode) {
+      showHelpForOptions({
+        heading: `Build options for --${selectedBuildModeFlag}`,
+        options: buildOptions,
+        wrap,
+      });
+    } else {
+      console.log(
+        `Specify a build mode and run with ${colors.bright('--help')} again for info\n` +
+        `about the options for that build mode.`);
+    }
+
+    for (const step of Object.values(stepStatusSummary)) {
+      Object.assign(step, {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `--help provided`,
+      });
     }
 
     return true;
@@ -496,8 +552,6 @@ async function main() {
   const thumbsOnly = cliOptions['thumbs-only'] ?? false;
   const noInput = cliOptions['no-input'] ?? false;
 
-  showStepStatusSummary = cliOptions['show-step-summary'] ?? false;
-
   const showAggregateTraces = cliOptions['show-traces'] ?? false;
 
   const precacheMode = cliOptions['precache-mode'] ?? 'common';
@@ -519,10 +573,10 @@ async function main() {
   }
 
   if (!wikiCachePath) {
-    logWarn`No --cache-path option nor HSMUSIC_CACHE set; provide for more features`;
+    logError`${`Expected --cache-path option or HSMUSIC_CACHE to be set`}`;
   }
 
-  if (!dataPath || !mediaPath) {
+  if (!dataPath || !mediaPath || !wikiCachePath) {
     return false;
   }
 
@@ -533,65 +587,86 @@ async function main() {
       status: STATUS_NOT_APPLICABLE,
       annotation: `--no-build provided`,
     });
-  } else {
-    if (usingDefaultBuildMode) {
-      logInfo`No build mode specified, will use default: ${selectedBuildModeFlag}`;
-    } else {
-      logInfo`Will use specified build mode: ${selectedBuildModeFlag}`;
-    }
   }
 
   // Finish setting up defaults by combining information from all options.
 
   const _fallbackStep = (stepKey, {
     default: defaultValue,
+    cli: cliArg,
+    buildConfig: buildConfigKey = null,
+  }) => {
+    const buildConfig = selectedBuildMode?.config?.[buildConfigKey];
+    const {[stepKey]: step} = stepStatusSummary;
+
+    const cliEntries =
+      (cliArg === null || cliArg === undefined
+        ? []
+     : Array.isArray(cliArg)
+        ? cliArg
+        : [cliArg]);
 
-    cli: {
+    for (const {
       flag: cliFlag = null,
       negate: cliFlagNegates = false,
       warn: cliFlagWarning = null,
-    } = {},
-
-    buildConfig: buildConfigKey,
-  }) => {
-    const {[buildConfigKey]: buildConfig} = selectedBuildMode.config;
-    const {[stepKey]: step} = stepStatusSummary;
+      disable: cliFlagDisablesSteps = [],
+    } of cliEntries) {
+      if (!cliOptions[cliFlag]) {
+        continue;
+      }
 
-    if (cliFlag && cliOptions[cliFlag]) {
       const cliPart = `--` + cliFlag;
       const modePart = `--` + selectedBuildModeFlag;
+
       if (buildConfig?.applicable === false) {
         if (cliFlagNegates) {
           logWarn`${cliPart} provided, but ${modePart} already skips this step`;
           logWarn`Redundant option ${cliPart}`;
+          continue;
         } else {
           logWarn`${cliPart} provided, but this step isn't applicable for ${modePart}`;
           logWarn`Ignoring option ${cliPart}`;
+          continue;
         }
-      } else if (buildConfig?.required === true) {
+      }
+
+      if (buildConfig?.required === true) {
         if (cliFlagNegates) {
           logWarn`${cliPart} provided, but ${modePart} requires this step`;
           logWarn`Ignoring option ${cliPart}`;
+          continue;
         } else {
           logWarn`${cliPart} provided, but ${modePart} already requires this step`;
           logWarn`Redundant option ${cliPart}`;
+          continue;
         }
-      } else {
-        step.status =
-          (cliFlagNegates
-            ? STATUS_NOT_APPLICABLE
-            : STATUS_NOT_STARTED);
+      }
 
-        step.annotation = `--${cliFlag} provided`;
+      step.status =
+        (cliFlagNegates
+          ? STATUS_NOT_APPLICABLE
+          : STATUS_NOT_STARTED);
 
-        if (cliFlagWarning) {
-          for (const line of cliFlagWarning.split('\n')) {
-            logWarn(line);
-          }
+      step.annotation = `--${cliFlag} provided`;
+
+      if (cliFlagWarning) {
+        for (const line of cliFlagWarning.split('\n')) {
+          logWarn(line);
         }
+      }
 
-        return;
+      for (const step of cliFlagDisablesSteps) {
+        const summary = stepStatusSummary[step];
+        if (summary.status === STATUS_NOT_APPLICABLE && summary.annotation) {
+          stepStatusSummary.performBuild.annotation += `; --${cliFlag} provided`;
+        } else {
+          summary.status = STATUS_NOT_APPLICABLE;
+          summary.annotation = `--${cliFlag} provided`;
+        }
       }
+
+      return;
     }
 
     if (buildConfig?.required === true) {
@@ -619,12 +694,22 @@ async function main() {
     }
 
     switch (defaultValue) {
-      case 'skip':
+      case 'skip': {
         step.status = STATUS_NOT_APPLICABLE;
-        if (cliFlag && !cliFlagNegates) {
-          step.annotation = `--${cliFlag} not provided`;
+
+        const enablingFlags =
+          cliEntries
+            .filter(({negate}) => !negate)
+            .map(({flag}) => flag);
+
+        if (!empty(enablingFlags)) {
+          step.annotation =
+            enablingFlags.map(flag => `--${flag}`).join(', ') +
+            ` not provided`;
         }
+
         break;
+      }
 
       case 'perform':
         break;
@@ -649,7 +734,6 @@ async function main() {
 
     fallbackStep('filterReferenceErrors', {
       default: 'perform',
-      buildConfig: null,
       cli: {
         flag: 'skip-reference-validation',
         negate: true,
@@ -662,17 +746,20 @@ async function main() {
     fallbackStep('generateThumbnails', {
       default: 'perform',
       buildConfig: 'thumbs',
-      cli: {
-        flag: 'skip-thumbs',
-        negate: true,
-      },
+      cli: [
+        {flag: 'thumbs-only', disable: stepsNotFor('thumbs')},
+        {flag: 'skip-thumbs', negate: true},
+      ],
     });
 
     fallbackStep('migrateThumbnails', {
       default: 'skip',
-      buildConfig: null,
       cli: {
         flag: 'migrate-thumbs',
+        disable: [
+          ...stepsNotFor('thumbs'),
+          'generateThumbnails',
+        ],
       },
     });
 
@@ -686,10 +773,98 @@ async function main() {
     });
 
     fallbackStep('identifyWebRoutes', {
-      default: 'skip',
+      default: 'perform',
       buildConfig: 'webRoutes',
     });
 
+    decideBuildSearchIndex: {
+      fallbackStep('buildSearchIndex', {
+        default: 'skip',
+        buildConfig: 'search',
+        cli: [
+          {flag: 'refresh-search'},
+          {flag: 'skip-search', negate: true},
+        ],
+      });
+
+      if (cliOptions['refresh-search'] || cliOptions['skip-search']) {
+        if (cliOptions['refresh-search']) {
+          logInfo`${'--refresh-search'} provided, will generate search fresh this build.`;
+        }
+
+        break decideBuildSearchIndex;
+      }
+
+      if (stepStatusSummary.buildSearchIndex.status !== STATUS_NOT_APPLICABLE) {
+        break decideBuildSearchIndex;
+      }
+
+      if (selectedBuildMode?.config?.search?.default === 'skip') {
+        break decideBuildSearchIndex;
+      }
+
+      // TODO: OK this is a little silly.
+      if (stepStatusSummary.buildSearchIndex.annotation?.startsWith('N/A')) {
+        break decideBuildSearchIndex;
+      }
+
+      const indexFile = path.join(wikiCachePath, 'search', 'index.json')
+      let stats;
+      try {
+        stats = await stat(indexFile);
+      } catch (error) {
+        if (error.code === 'ENOENT') {
+          Object.assign(stepStatusSummary.buildSearchIndex, {
+            status: STATUS_NOT_STARTED,
+            annotation: `search/index.json not present, will create`,
+          });
+
+          logInfo`Looks like the search cache doesn't exist.`;
+          logInfo`It'll be generated fresh, this build!`;
+        } else {
+          Object.assign(stepStatusSummary.buildSearchIndex, {
+            status: STATUS_NOT_APPLICABLE,
+            annotation: `error getting search index stats`,
+          });
+
+          if (!paragraph) console.log('');
+          console.error(error);
+
+          logWarn`There was an error checking the search index file, located at:`;
+          logWarn`${indexFile}`;
+          logWarn`You may want to toss out the "search" folder; it'll be generated`;
+          logWarn`anew, if you do, and may fix this error.`;
+        }
+
+        paragraph = false;
+        break decideBuildSearchIndex;
+      }
+
+      const delta = Date.now() - stats.mtimeMs;
+      const minute = 60 * 1000;
+      const delay = 45 * minute;
+
+      const whenst = duration => `~${Math.ceil(duration / minute)} min`;
+
+      if (delta < delay) {
+        logInfo`Search index was generated recently, skipping for this build.`;
+        logInfo`Next scheduled is in ${whenst(delay - delta)}, or by using ${'--refresh-search'}.`;
+        Object.assign(stepStatusSummary.buildSearchIndex, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `earlier than scheduled based on file mtime`,
+        });
+      } else {
+        logInfo`Search index hasn't been generated for a little while.`;
+        logInfo`It'll be generated this build, then again in ${whenst(delay)}.`;
+        Object.assign(stepStatusSummary.buildSearchIndex, {
+          status: STATUS_NOT_STARTED,
+          annotation: `past when shceduled based on file mtime`,
+        });
+      }
+
+      paragraph = false;
+    }
+
     fallbackStep('verifyImagePaths', {
       default: 'perform',
       buildConfig: 'mediaValidation',
@@ -730,33 +905,43 @@ async function main() {
     });
   }
 
+  // TODO: These should error if the option was actually provided but
+  // the relevant steps were already disabled for some other reason.
   switch (precacheMode) {
     case 'common':
-      Object.assign(stepStatusSummary.precacheAllData, {
-        status: STATUS_NOT_APPLICABLE,
-        annotation: `--precache-mode is common, not all`,
-      });
+      if (stepStatusSummary.precacheAllData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheAllData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is common, not all`,
+        });
+      }
 
       break;
 
     case 'all':
-      Object.assign(stepStatusSummary.precacheCommonData, {
-        status: STATUS_NOT_APPLICABLE,
-        annotation: `--precache-mode is all, not common`,
-      });
+      if (stepStatusSummary.precacheCommonData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheCommonData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is all, not common`,
+        });
+      }
 
       break;
 
     case 'none':
-      Object.assign(stepStatusSummary.precacheCommonData, {
-        status: STATUS_NOT_APPLICABLE,
-        annotation: `--precache-mode is none`,
-      });
+      if (stepStatusSummary.precacheCommonData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheCommonData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is none`,
+        });
+      }
 
-      Object.assign(stepStatusSummary.precacheAllData, {
-        status: STATUS_NOT_APPLICABLE,
-        annotation: `--precache-mode is none`,
-      });
+      if (stepStatusSummary.precacheAllData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheAllData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is none`,
+        });
+      }
 
       break;
   }
@@ -778,6 +963,44 @@ async function main() {
     return false;
   }
 
+  // If we're going to require a build mode and none is specified,
+  // exit and show what to do. This must not precede anything that might
+  // disable the build (e.g. changing its status to STATUS_NOT_APPLICABLE).
+
+  if (stepStatusSummary.performBuild.status === STATUS_NOT_STARTED) {
+    if (selectedBuildMode) {
+      logInfo`Will use specified build mode: ${selectedBuildModeFlag}`;
+    } else {
+      showHelpForOptions({
+        heading: `Please specify a build mode:`,
+        options: buildModeFlagOptions,
+      });
+
+      console.log(
+        `(Use ${colors.bright('--help')} for general info and all options, or specify\n` +
+        ` a build mode alongside ${colors.bright('--help')} for that mode's options!`);
+
+      for (const step of Object.values(stepStatusSummary)) {
+        Object.assign(step, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `no build mode provided`,
+        });
+      }
+
+      return false;
+    }
+  } else if (selectedBuildMode) {
+    if (stepStatusSummary.performBuild.annotation) {
+      logError`You've specified a build mode, ${selectedBuildModeFlag}, but it won't be used,`;
+      logError`according to the message: ${`"${stepStatusSummary.performBuild.annotation}"`}`;
+    } else {
+      logError`You've specified a build mode, ${selectedBuildModeFlag}, but it won't be used,`;
+      logError`probably because of another option you've provided.`;
+    }
+    logError`Please remove ${'--' + selectedBuildModeFlag} or the conflicting option.`;
+    return false;
+  }
+
   Object.assign(stepStatusSummary.determineMediaCachePath, {
     status: STATUS_STARTED_NOT_DONE,
     timeStart: Date.now(),
@@ -885,17 +1108,15 @@ async function main() {
         logError`a new path with ${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}.`;
         break;
 
-      case `missing wiki cache to create media cache inside`:
+      case `media path not provided`: /* unreachable */
         console.error('');
-        logError`It looks like you're starting totally fresh, so please`;
-        logError`create a ${'cache'} folder and provide it with ${'--cache-path'}`;
-        logError`or ${'HSMUSIC_CACHE'}. The media cache will automatically be`;
-        logError`generated inside of this folder!`;
+        logError`It seems a ${'--media-path'} (or ${'HSMUSIC_MEDIA'}) wasn't provided.`;
+        logError`Make sure one of these is actually pointing to a path that exists.`;
         break;
 
-      case `media path not provided`: /* unreachable */
+      case `cache path not provided`: /* unreachable */
         console.error('');
-        logError`It seems a ${'--media-path'} (or ${'HSMUSIC_MEDIA'}) wasn't provided.`;
+        logError`It seems a ${'--cache-path'} (or ${'HSMUSIC_CACHE'}) wasn't provided.`;
         logError`Make sure one of these is actually pointing to a path that exists.`;
         break;
     }
@@ -1066,8 +1287,6 @@ async function main() {
     CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true;
   }
 
-  let paragraph = false;
-
   Object.assign(stepStatusSummary.loadDataFiles, {
     status: STATUS_STARTED_NOT_DONE,
     timeStart: Date.now(),
@@ -1324,14 +1543,31 @@ async function main() {
       ]),
     };
 
-    for (const [wikiDataKey, properties] of Object.entries(commonDataMap)) {
-      const thingData = wikiData[wikiDataKey];
-      const allProperties = new Set(['name', 'directory', ...properties]);
-      for (const thing of thingData) {
-        for (const property of allProperties) {
-          void thing[property];
+    try {
+      for (const [wikiDataKey, properties] of Object.entries(commonDataMap)) {
+        const thingData = wikiData[wikiDataKey];
+        const allProperties = new Set(['name', 'directory', ...properties]);
+        for (const thing of thingData) {
+          for (const property of allProperties) {
+            void thing[property];
+          }
         }
       }
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
+      console.log('');
+
+      logError`There was an error precaching internal data objects.`;
+      fileIssue();
+
+      Object.assign(stepStatusSummary.precacheCommonData, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `see log for details`,
+        timeEnd: Date.now(),
+      });
+
+      return false;
     }
 
     Object.assign(stepStatusSummary.precacheCommonData, {
@@ -1340,6 +1576,8 @@ async function main() {
     });
   }
 
+  const urls = generateURLs(urlSpec);
+
   // Filter out any things with duplicate directories throughout the data,
   // warning about them too.
 
@@ -1847,8 +2085,6 @@ async function main() {
     timeEnd: Date.now(),
   });
 
-  const urls = generateURLs(urlSpec);
-
   let missingImagePaths;
 
   if (stepStatusSummary.verifyImagePaths.status === STATUS_NOT_APPLICABLE) {
@@ -1998,7 +2234,49 @@ async function main() {
     }
   }
 
-  let webRoutes = null;
+  if (stepStatusSummary.buildSearchIndex.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.buildSearchIndex, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    try {
+      await writeSearchData({
+        thumbsCache,
+        urls,
+        wikiCachePath,
+        wikiData,
+      });
+
+      logInfo`Search data successfully written - nice!`;
+      paragraph = false;
+
+      Object.assign(stepStatusSummary.buildSearchIndex, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
+
+      logError`There was an error preparing or writing search data.`;
+      fileIssue();
+      logWarn`Any existing search data will be reused, and search may be`;
+      logWarn`generally dysfunctional. The site should work otherwise, though!`;
+
+      console.log('');
+      paragraph = true;
+
+      Object.assign(stepStatusSummary.buildSearchIndex, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `see log for details`,
+        timeEnd: Date.now(),
+      });
+    }
+  }
+
+  let webRouteSources = null;
+  let preparedWebRoutes = null;
 
   if (stepStatusSummary.identifyWebRoutes.status === STATUS_NOT_STARTED) {
     Object.assign(stepStatusSummary.identifyWebRoutes, {
@@ -2009,7 +2287,7 @@ async function main() {
     const fromRoot = urls.from('shared.root');
 
     try {
-      const webRouteSources = await identifyAllWebRoutes({
+      webRouteSources = await identifyAllWebRoutes({
         mediaCachePath,
         mediaPath,
         wikiCachePath,
@@ -2025,7 +2303,7 @@ async function main() {
           {message: `Errors computing effective web route paths`},);
 
       aggregate.close();
-      webRoutes = result;
+      preparedWebRoutes = result;
     } catch (error) {
       if (!paragraph) console.log('');
       niceShowAggregate(error);
@@ -2045,7 +2323,7 @@ async function main() {
       return false;
     }
 
-    logInfo`Successfully determined web routes.`;
+    logInfo`Successfully determined web routes - nice!`;
     paragraph = false;
 
     Object.assign(stepStatusSummary.identifyWebRoutes, {
@@ -2054,6 +2332,12 @@ async function main() {
     });
   }
 
+  wikiData.wikiInfo.searchDataAvailable =
+    (webRouteSources
+      ? webRouteSources
+          .some(({to}) => to[0].startsWith('searchData'))
+      : null);
+
   if (stepStatusSummary.performBuild.status === STATUS_NOT_APPLICABLE) {
     return true;
   }
@@ -2104,6 +2388,7 @@ async function main() {
       dataPath,
       mediaPath,
       mediaCachePath,
+      wikiCachePath,
       queueSize,
       srcRootPath: __dirname,
 
@@ -2113,10 +2398,9 @@ async function main() {
       thumbsCache,
       urls,
       urlSpec,
-      webRoutes,
+      webRoutes: preparedWebRoutes,
       wikiData,
 
-      cachebust: '?' + CACHEBUST,
       closeLanguageWatchers,
       developersComment,
       getSizeOfAdditionalFile,
diff --git a/src/url-spec.js b/src/url-spec.js
index ec971c0c..56366ed4 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -1,5 +1,11 @@
 import {withEntries} from '#sugar';
 
+// Static files are all grouped under a `static-${STATIC_VERSION}` folder as
+// part of a build. This is so that multiple builds of a wiki can coexist
+// served from the same server / file system root: older builds' HTML files
+// refer to earlier values of STATIC_VERSION, avoiding name collisions.
+const STATIC_VERSION = '3p2';
+
 const genericPaths = {
   root: '',
   path: '<>',
@@ -63,19 +69,37 @@ const urlSpec = {
   },
 
   shared: {
-    paths: {
-      ...genericPaths,
+    paths: genericPaths,
+  },
 
-      utilityRoot: 'util',
-      staticRoot: 'static',
+  staticCSS: {
+    prefix: `static-${STATIC_VERSION}/css/`,
+    paths: genericPaths,
+  },
+
+  staticJS: {
+    prefix: `static-${STATIC_VERSION}/js/`,
+    paths: genericPaths,
+  },
 
-      utilityFile: 'util/<>',
-      staticFile: 'static/<>?<>',
+  staticLib: {
+    prefix: `static-${STATIC_VERSION}/lib/`,
+    paths: genericPaths,
+  },
 
-      staticIcon: 'static/icons.svg#icon-<>',
+  staticMisc: {
+    prefix: `static-${STATIC_VERSION}/misc/`,
+    paths: {
+      ...genericPaths,
+      icon: 'icons.svg#icon-<>',
     },
   },
 
+  staticSharedUtil: {
+    prefix: `static-${STATIC_VERSION}/shared-util/`,
+    paths: genericPaths,
+  },
+
   media: {
     prefix: 'media/',
 
@@ -100,13 +124,8 @@ const urlSpec = {
     paths: genericPaths,
   },
 
-  static: {
-    prefix: 'static/',
-    paths: genericPaths,
-  },
-
-  util: {
-    prefix: 'util/',
+  searchData: {
+    prefix: 'search-data/',
     paths: genericPaths,
   },
 };
diff --git a/src/util/aggregate.js b/src/util/aggregate.js
index 3ad8bdba..e8f45f3b 100644
--- a/src/util/aggregate.js
+++ b/src/util/aggregate.js
@@ -373,7 +373,10 @@ export function _withAggregate(mode, aggregateOpts, fn) {
 
 export const unhelpfulTraceLines = [
   /sugar/,
+  /sort/,
   /aggregate/,
+  /composite/,
+  /cacheable-object/,
   /node:/,
   /<anonymous>/,
 ];
diff --git a/src/util/cli.js b/src/util/cli.js
index ce513f08..72979d3f 100644
--- a/src/util/cli.js
+++ b/src/util/cli.js
@@ -201,6 +201,79 @@ export async function parseOptions(options, optionDescriptorMap) {
   return result;
 }
 
+// Takes precisely the same sort of structure as `parseOptions` above,
+// and displays associated help messages. Radical!
+//
+// 'indentWrap' should be the function from '#sugar', with its wrap option
+//   already bound.
+//
+// 'sort' should take care of sorting a list of {name, descriptor} entries.
+export function showHelpForOptions({
+  heading,
+  options,
+  indentWrap,
+  sort = entries => entries,
+}) {
+  if (heading) {
+    console.log(colors.bright(heading));
+  }
+
+  const sortedOptions =
+    sort(
+      Object.entries(options)
+        .map(([name, descriptor]) => ({name, descriptor})));
+
+  if (!sortedOptions.length) {
+    console.log(`(No options available)`)
+  }
+
+  let justInsertedPaddingLine = false;
+
+  for (const {name, descriptor} of sortedOptions) {
+    if (descriptor.alias) {
+      continue;
+    }
+
+    const aliases =
+      Object.entries(options)
+        .filter(([_name, {alias}]) => alias === name)
+        .map(([name]) => name);
+
+    let wrappedHelp, wrappedHelpLines = 0;
+    if (descriptor.help) {
+      wrappedHelp = indentWrap(descriptor.help, {spaces: 4});
+      wrappedHelpLines = wrappedHelp.split('\n').length;
+    }
+
+    if (wrappedHelpLines > 0 && !justInsertedPaddingLine) {
+      console.log('');
+    }
+
+    console.log(colors.bright(` --` + name) +
+      (aliases.length
+        ? ` (or: ${aliases.map(alias => colors.bright(`--` + alias)).join(', ')})`
+        : '') +
+      (descriptor.help
+        ? ''
+        : colors.dim('  (no help provided)')));
+
+    if (wrappedHelp) {
+      console.log(wrappedHelp);
+    }
+
+    if (wrappedHelpLines > 1) {
+      console.log('');
+      justInsertedPaddingLine = true;
+    } else {
+      justInsertedPaddingLine = false;
+    }
+  }
+
+  if (!justInsertedPaddingLine) {
+    console.log(``);
+  }
+}
+
 export const handleDashless = Symbol();
 export const handleUnknown = Symbol();
 
diff --git a/src/util/colors.js b/src/util/colors.js
index 50339cd3..7298c46a 100644
--- a/src/util/colors.js
+++ b/src/util/colors.js
@@ -15,6 +15,7 @@ export function getColors(themeColor, {
   const deep = primary.saturate(1.2).luminance(0.035);
   const deepGhost = deep.alpha(0.8);
   const light = chroma.average(['#ffffff', primary], 'rgb', [4, 1]);
+  const lightGhost = primary.luminance(0.8).saturate(4).alpha(0.08);
 
   const bg = primary.luminance(0.008).desaturate(3.5).alpha(0.8);
   const bgBlack = primary.saturate(1).luminance(0.0025).alpha(0.8);
@@ -31,6 +32,7 @@ export function getColors(themeColor, {
     deep: deep.hex(),
     deepGhost: deepGhost.hex(),
     light: light.hex(),
+    lightGhost: lightGhost.hex(),
 
     bg: bg.hex(),
     bgBlack: bgBlack.hex(),
diff --git a/src/util/html.js b/src/util/html.js
index 9e07f9ba..6efedb31 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -53,11 +53,18 @@ export const attributeSpec = {
   },
 };
 
-// Pass to tag() as an attributes key to make tag() return a 8lank string if the
+// Pass to tag() as an attributes key to make tag() return a 8lank tag if the
 // provided content is empty. Useful for when you'll only 8e showing an element
 // according to the presence of content that would 8elong there.
 export const onlyIfContent = Symbol();
 
+// Pass to tag() as an attributes key to make tag() return a blank tag if
+// this tag doesn't get shown beside any siblings! (I.e, siblings who don't
+// also have the [html.onlyIfSiblings] attribute.) Since they'd just be blank,
+// tags with [html.onlyIfSiblings] never make the difference in counting as
+// content for [html.onlyIfContent]. Useful for <summary> and such.
+export const onlyIfSiblings = Symbol();
+
 // Pass to tag() as an attributes key to make children be joined together by the
 // provided string. This is handy, for example, for joining lines by <br> tags,
 // or putting some other divider between each child. Note this will only have an
@@ -124,13 +131,18 @@ function isBlankArrayHelper(content) {
   // content of tags marked onlyIfContent) into one array,
   // and templates into another. And if there's anything
   // else, that's a non-blank condition we'll detect now.
+  // We'll flat-out skip items marked onlyIfSiblings,
+  // since they could never count as content alone
+  // (some other item will have to count).
 
   const arrayContent = [];
   const templateContent = [];
 
   for (const item of nonStringContent) {
     if (item instanceof Tag) {
-      if (item.onlyIfContent || item.contentOnly) {
+      if (item.onlyIfSiblings) {
+        continue;
+      } else if (item.onlyIfContent || item.contentOnly) {
         arrayContent.push(item.content);
       } else {
         return false;
@@ -205,9 +217,17 @@ export function isBlank(content) {
     // could include content. These need to be checked too.
     // Check each of the templates one at a time.
     for (const template of result) {
-      if (!template.blank) {
-        return false;
+      const content = template.content;
+
+      if (content instanceof Tag && content.onlyIfSiblings) {
+        continue;
+      }
+
+      if (isBlank(content)) {
+        continue;
       }
+
+      return false;
     }
 
     // If none of the templates included content either,
@@ -416,6 +436,10 @@ export class Tag {
   }
 
   get blank() {
+    // Tags don't have a reference to their parent, so this only evinces
+    // something about this tag's own content or attributes. It does *not*
+    // account for [html.onlyIfSiblings]!
+
     if (this.onlyIfContent && isBlank(this.content)) {
       return true;
     }
@@ -477,6 +501,14 @@ export class Tag {
     return this.#getAttributeFlag(onlyIfContent);
   }
 
+  set onlyIfSiblings(value) {
+    this.#setAttributeFlag(onlyIfSiblings, value);
+  }
+
+  get onlyIfSiblings() {
+    return this.#getAttributeFlag(onlyIfSiblings);
+  }
+
   set joinChildren(value) {
     this.#setAttributeString(joinChildren, value);
   }
@@ -593,6 +625,8 @@ export class Tag {
     let content = '';
     let blockwrapClosers = '';
 
+    let seenSiblingIndependentContent = false;
+
     const chunkwrapSplitter =
       (this.chunkwrap
         ? this.#getAttributeString('split')
@@ -615,10 +649,12 @@ export class Tag {
     }
 
     for (const [index, item] of contentItems.entries()) {
-      let itemContent;
+      const nonTemplateItem =
+        Template.resolve(item);
 
+      let itemContent;
       try {
-        itemContent = item.toString();
+        itemContent = nonTemplateItem.toString();
       } catch (caughtError) {
         const indexPart = colors.yellow(`child #${index + 1}`);
 
@@ -647,8 +683,12 @@ export class Tag {
         continue;
       }
 
+      if (!(nonTemplateItem instanceof Tag) || !nonTemplateItem.onlyIfSiblings) {
+        seenSiblingIndependentContent = true;
+      }
+
       const chunkwrapChunks =
-        (typeof item === 'string' && chunkwrapSplitter
+        (typeof nonTemplateItem === 'string' && chunkwrapSplitter
           ? itemContent.split(chunkwrapSplitter)
           : null);
 
@@ -658,28 +698,25 @@ export class Tag {
           : null);
 
       if (content) {
-        if (itemIncludesChunkwrapSplit) {
-          if (!seenChunkwrapSplitter) {
-            // The first time we see a chunkwrap splitter, backtrack and wrap
-            // the content *so far* in a chunk.
-            content = `<span class="chunkwrap">` + content;
-          }
-
-          // Close the existing chunk. We'll add the new chunks after the
-          // (normal) joiner.
-          content += `</span>`;
+        if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) {
+          // The first time we see a chunkwrap splitter, backtrack and wrap
+          // the content *so far* in a chunk. This will be treated just like
+          // any other open chunkwrap, and closed after the first chunk of
+          // this item! (That means the existing content is part of the same
+          // chunk as the first chunk included in this content, which makes
+          // sense, because that first chink is really just more text that
+          // precedes the first split.)
+          content = `<span class="chunkwrap">` + content;
         }
 
         content += joiner;
-      } else {
+      } else if (itemIncludesChunkwrapSplit) {
         // We've encountered a chunkwrap split before any other content.
         // This means there's no content to wrap, no existing chunkwrap
         // to close, and no reason to add a joiner, but we *do* need to
         // enter a chunkwrap wrapper *now*, so the first chunk of this
         // item will be properly wrapped.
-        if (itemIncludesChunkwrapSplit) {
-          content = `<span class="chunkwrap">`;
-        }
+        content = `<span class="chunkwrap">`;
       }
 
       if (itemIncludesChunkwrapSplit) {
@@ -691,7 +728,7 @@ export class Tag {
       // itemContent check. They also never apply at the very start of content,
       // because at that point there aren't any preceding words from which the
       // blockwrap would differentiate its content.
-      if (item instanceof Tag && item.blockwrap && content) {
+      if (nonTemplateItem instanceof Tag && nonTemplateItem.blockwrap && content) {
         content += `<span class="blockwrap">`;
         blockwrapClosers += `</span>`;
       }
@@ -700,6 +737,10 @@ export class Tag {
         if (itemIncludesChunkwrapSplit) {
           for (const [index, chunk] of chunkwrapChunks.entries()) {
             if (index === 0) {
+              // The first chunk isn't actually a chunk all on its own, it's
+              // text that should be appended to the previous chunk. We will
+              // close this chunk as the first appended content as we process
+              // the next chunk.
               content += chunk;
             } else {
               const whitespace = chunk.match(/^\s+/) ?? '';
@@ -718,6 +759,12 @@ export class Tag {
       }
     }
 
+    // If we've only seen sibling-dependent content (or just no content),
+    // then the content in total is blank.
+    if (!seenSiblingIndependentContent) {
+      return '';
+    }
+
     if (chunkwrapSplitter) {
       if (seenChunkwrapSplitter) {
         content += '</span>';
diff --git a/src/util/search-spec.js b/src/util/search-spec.js
new file mode 100644
index 00000000..bc24e1a1
--- /dev/null
+++ b/src/util/search-spec.js
@@ -0,0 +1,259 @@
+// Index structures shared by client and server, and relevant interfaces.
+
+function getArtworkPath(thing) {
+  switch (thing.constructor[Symbol.for('Thing.referenceType')]) {
+    case 'album': {
+      return [
+        'media.albumCover',
+        thing.directory,
+        thing.coverArtFileExtension,
+      ];
+    }
+
+    case 'flash': {
+      return [
+        'media.flashArt',
+        thing.directory,
+        thing.coverArtFileExtension,
+      ];
+    }
+
+    case 'track': {
+      if (thing.hasUniqueCoverArt) {
+        return [
+          'media.trackCover',
+          thing.album.directory,
+          thing.directory,
+          thing.coverArtFileExtension,
+        ];
+      } else if (thing.album.hasCoverArt) {
+        return [
+          'media.albumCover',
+          thing.album.directory,
+          thing.album.coverArtFileExtension,
+        ];
+      } else {
+        return null;
+      }
+    }
+
+    default:
+      return null;
+  }
+}
+
+function prepareArtwork(thing, {
+  checkIfImagePathHasCachedThumbnails,
+  getThumbnailEqualOrSmaller,
+  urls,
+}) {
+  const hasWarnings =
+    thing.artTags?.some(artTag => artTag.isContentWarning);
+
+  const artworkPath =
+    getArtworkPath(thing);
+
+  if (!artworkPath) {
+    return undefined;
+  }
+
+  const mediaSrc =
+    urls
+      .from('media.root')
+      .to(...artworkPath);
+
+  if (!checkIfImagePathHasCachedThumbnails(mediaSrc)) {
+    return undefined;
+  }
+
+  const selectedSize =
+    getThumbnailEqualOrSmaller(
+      (hasWarnings ? 'mini' : 'adorb'),
+      mediaSrc);
+
+  const mediaSrcJpeg =
+    mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`);
+
+  const displaySrc =
+    urls
+      .from('thumb.root')
+      .to('thumb.path', mediaSrcJpeg);
+
+  const serializeSrc =
+    displaySrc.replace(thing.directory, '<>');
+
+  return serializeSrc;
+}
+
+export const searchSpec = {
+  generic: {
+    query: ({
+      albumData,
+      artTagData,
+      artistData,
+      flashData,
+      groupData,
+      trackData,
+    }) => [
+      albumData,
+
+      artTagData,
+
+      artistData
+        .filter(artist => !artist.isAlias),
+
+      flashData,
+
+      groupData,
+
+      trackData
+        // Exclude rereleases - there's no reasonable way to differentiate
+        // them from the main release as part of this query.
+        .filter(track => !track.originalReleaseTrack),
+    ].flat(),
+
+    process(thing, opts) {
+      const fields = {};
+
+      fields.primaryName =
+        thing.name;
+
+      const kind =
+        thing.constructor[Symbol.for('Thing.referenceType')];
+
+      fields.parentName =
+        (kind === 'track'
+          ? thing.album.name
+       : kind === 'group'
+          ? thing.category.name
+       : kind === 'flash'
+          ? thing.act.name
+          : null);
+
+      fields.color =
+        thing.color;
+
+      fields.artTags =
+        (Object.hasOwn(thing, 'artTags')
+          ? thing.artTags.map(artTag => artTag.nameShort)
+          : []);
+
+      fields.additionalNames =
+        (Object.hasOwn(thing, 'additionalNames')
+          ? thing.additionalNames.map(entry => entry.name)
+       : Object.hasOwn(thing, 'aliasNames')
+          ? thing.aliasNames
+          : []);
+
+      const contribKeys = [
+        'artistContribs',
+        'bannerArtistContribs',
+        'contributorContribs',
+        'coverArtistContribs',
+        'wallpaperArtistContribs',
+      ];
+
+      const contributions =
+        contribKeys
+          .filter(key => Object.hasOwn(thing, key))
+          .flatMap(key => thing[key]);
+
+      fields.contributors =
+        contributions
+          .flatMap(({artist}) => [
+            artist.name,
+            ...artist.aliasNames,
+          ]);
+
+      const groups =
+         (Object.hasOwn(thing, 'groups')
+           ? thing.groups
+        : Object.hasOwn(thing, 'album')
+           ? thing.album.groups
+           : []);
+
+      const mainContributorNames =
+        contributions
+          .map(({artist}) => artist.name);
+
+      fields.groups =
+        groups
+          .filter(group => !mainContributorNames.includes(group.name))
+          .map(group => group.name);
+
+      fields.artwork =
+        prepareArtwork(thing, opts);
+
+      return fields;
+    },
+
+    index: [
+      'primaryName',
+      'parentName',
+      'artTags',
+      'additionalNames',
+      'contributors',
+      'groups',
+    ],
+
+    store: [
+      'primaryName',
+      'artwork',
+      'color',
+    ],
+  },
+};
+
+export function makeSearchIndex(descriptor, {FlexSearch}) {
+  return new FlexSearch.Document({
+    id: 'reference',
+    index: descriptor.index,
+    store: descriptor.store,
+  });
+}
+
+// TODO: This function basically mirrors bind-utilities.js, which isn't
+// exactly robust, but... binding might need some more thought across the
+// codebase in *general.*
+function bindSearchUtilities({
+  checkIfImagePathHasCachedThumbnails,
+  getThumbnailEqualOrSmaller,
+  thumbsCache,
+  urls,
+}) {
+  const bound = {
+    urls,
+  };
+
+  bound.checkIfImagePathHasCachedThumbnails =
+    (imagePath) =>
+      checkIfImagePathHasCachedThumbnails(imagePath, thumbsCache);
+
+  bound.getThumbnailEqualOrSmaller =
+    (preferred, imagePath) =>
+      getThumbnailEqualOrSmaller(preferred, imagePath, thumbsCache);
+
+  return bound;
+}
+
+export function populateSearchIndex(index, descriptor, opts) {
+  const {wikiData} = opts;
+  const bound = bindSearchUtilities(opts);
+
+  const collection = descriptor.query(wikiData);
+
+  for (const thing of collection) {
+    const reference = thing.constructor.getReference(thing);
+
+    let processed;
+    try {
+      processed = descriptor.process(thing, bound);
+    } catch (caughtError) {
+      throw new Error(
+        `Failed to process searchable thing ${reference}`,
+        {cause: caughtError});
+    }
+
+    index.add({reference, ...processed});
+  }
+}
diff --git a/src/util/sort.js b/src/util/sort.js
index b3a90812..ea1e024a 100644
--- a/src/util/sort.js
+++ b/src/util/sort.js
@@ -388,7 +388,8 @@ export function sortFlashesChronologically(data, {
   getDate,
 } = {}) {
   // Group flashes by act...
-  sortByDirectory(data, {
+  sortAlphabetically(data, {
+    getName: flash => flash.act.name,
     getDirectory: flash => flash.act.directory,
   });
 
@@ -403,3 +404,35 @@ export function sortFlashesChronologically(data, {
 
   return data;
 }
+
+export function sortContributionsChronologically(data, sortThings, {
+  latestFirst = false,
+} = {}) {
+  // Contributions only have one date property (which is provided when
+  // the contribution is created). They're sorted by this most primarily,
+  // but otherwise use the same sort as is provided.
+
+  const entries =
+    data.map(contrib => ({
+      entry: contrib,
+      thing: contrib.thing,
+    }));
+
+  sortEntryThingPairs(
+    entries,
+    things =>
+      sortThings(things, {latestFirst}));
+
+  const contribs =
+    entries
+      .map(({entry: contrib}) => contrib);
+
+  sortByDate(contribs, {latestFirst});
+
+  // We're not actually operating on the original data array at any point,
+  // so since this is meant to be a mutating function like any other, splice
+  // the sorted contribs into the original array.
+  data.splice(0, data.length, ...contribs);
+
+  return data;
+}
diff --git a/src/util/sugar.js b/src/util/sugar.js
index e060f458..3fa3fb46 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -136,6 +136,23 @@ export function stitchArrays(keyToArray) {
   return results;
 }
 
+// Like Map.groupBy! Collects the items of an unsorted array into buckets
+// according to a per-item computed value.
+export function groupArray(items, fn) {
+  const buckets = new Map();
+
+  for (const [index, item] of Array.prototype.entries.call(items)) {
+    const key = fn(item, index);
+    if (buckets.has(key)) {
+      buckets.get(key).push(item);
+    } else {
+      buckets.set(key, [item]);
+    }
+  }
+
+  return buckets;
+}
+
 // Turns this:
 //
 //   [
@@ -183,8 +200,14 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
     : arr1.every((x) => arr2.includes(x)));
 
 // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice.
-export const withEntries = (obj, fn) =>
-  Object.fromEntries(fn(Object.entries(obj)));
+export const withEntries = (obj, fn) => {
+  const result = fn(Object.entries(obj));
+  if (result instanceof Promise) {
+    return result.then(entries => Object.fromEntries(entries));
+  } else {
+    return Object.fromEntries(result);
+  }
+}
 
 export function setIntersection(set1, set2) {
   const intersection = new Set();
@@ -260,6 +283,16 @@ export function delay(ms) {
   return new Promise((res) => setTimeout(res, ms));
 }
 
+export function promiseWithResolvers() {
+  let obj = {};
+
+  obj.promise =
+    new Promise((...opts) =>
+      ([obj.resolve, obj.reject] = opts));
+
+  return obj;
+}
+
 // Stolen from here: https://stackoverflow.com/a/3561711
 //
 // There's a proposal for a native JS function like this, 8ut it's not even
@@ -315,6 +348,27 @@ export function cutStart(text, length = 40) {
   }
 }
 
+// Wrapper function around wrap(), ha, ha - this requires the Node module
+// 'node-wrap'.
+export function indentWrap(str, {
+  wrap,
+  spaces = 0,
+  width = 60,
+  bullet = false,
+}) {
+  const wrapped =
+    wrap(str, {
+      width: width - spaces,
+      indent: ' '.repeat(spaces),
+    });
+
+  if (bullet) {
+    return wrapped.trimStart();
+  } else {
+    return wrapped;
+  }
+}
+
 // Annotates {index, length} results from another iterator with contextual
 // details, including:
 //
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
index f8ab3ef3..c0cb5418 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -1,6 +1,6 @@
 // Utility functions for interacting with wiki data.
 
-import {accumulateSum, empty} from './sugar.js';
+import {accumulateSum, empty, unique} from './sugar.js';
 import {sortByDate} from './sort.js';
 
 // This is a duplicate binding of filterMultipleArrays that's included purely
@@ -138,11 +138,20 @@ export function getAllTracks(albumData) {
 }
 
 export function getArtistNumContributions(artist) {
-  return (
-    (artist.tracksAsAny?.length ?? 0) +
-    (artist.albumsAsCoverArtist?.length ?? 0) +
-    (artist.flashesAsContributor?.length ?? 0)
-  );
+  return accumulateSum(
+    [
+      unique(
+        ([
+          artist.trackArtistContributions,
+          artist.trackContributorContributions,
+          artist.trackCoverArtistContributions,
+        ]).flat()
+          .map(({thing}) => thing)),
+
+      artist.albumCoverArtistContributions,
+      artist.flashContributorContributions,
+    ],
+    ({length}) => length);
 }
 
 export function getFlashCover(flash, {to}) {
diff --git a/src/web-routes.js b/src/web-routes.js
index efd86ca1..762b26c3 100644
--- a/src/web-routes.js
+++ b/src/web-routes.js
@@ -8,25 +8,77 @@ const codeSrcPath = __dirname;
 const codeRootPath = path.resolve(codeSrcPath, '..');
 
 function getNodeDependencyRootPath(dependencyName) {
-  const packageJSON =
-    import.meta.resolve(dependencyName + '/package.json');
-
-  return path.dirname(fileURLToPath(packageJSON));
+  return (
+    path.dirname(
+      fileURLToPath(
+        import.meta.resolve(dependencyName))));
 }
 
 export const stationaryCodeRoutes = [
   {
-    from: path.join(codeSrcPath, 'static'),
-    to: ['static.root'],
+    from: path.join(codeSrcPath, 'static', 'css'),
+    to: ['staticCSS.root'],
+    statically: 'copy',
+  },
+
+  {
+    from: path.join(codeSrcPath, 'static', 'js'),
+    to: ['staticJS.root'],
+    statically: 'copy',
+  },
+
+  {
+    from: path.join(codeSrcPath, 'static', 'misc'),
+    to: ['staticMisc.root'],
+    statically: 'copy',
   },
 
   {
     from: path.join(codeSrcPath, 'util'),
-    to: ['util.root'],
+    to: ['staticSharedUtil.root'],
+    statically: 'copy',
   },
 ];
 
-export const dependencyRoutes = [];
+function quickNodeDependency({
+  name,
+  path: subpath = '',
+}) {
+  const root = getNodeDependencyRootPath(name);
+
+  return [
+    {
+      from:
+        (subpath
+          ? path.join(root, subpath)
+          : root),
+
+      to: ['staticLib.path', name],
+
+      statically: 'copy',
+    },
+  ];
+}
+
+export const dependencyRoutes = [
+  quickNodeDependency({
+    name: 'chroma-js',
+  }),
+
+  quickNodeDependency({
+    name: 'compress-json',
+    path: '..', // exit dist, access bundle.js
+  }),
+
+  quickNodeDependency({
+    name: 'flexsearch',
+  }),
+
+  quickNodeDependency({
+    name: 'msgpackr',
+    path: 'dist',
+  }),
+].flat();
 
 export const allStaticWebRoutes = [
   ...stationaryCodeRoutes,
@@ -40,9 +92,35 @@ export async function identifyDynamicWebRoutes({
 }) {
   const routeFunctions = [
     () => Promise.resolve([
-      {from: path.resolve(mediaPath), to: ['media.root']},
-      {from: path.resolve(mediaCachePath), to: ['thumb.root']},
+      {
+        from: path.resolve(mediaPath),
+        to: ['media.root'],
+        statically: 'symlink',
+      },
+
+      {
+        from: path.resolve(mediaCachePath),
+        to: ['thumb.root'],
+        statically: 'symlink',
+      },
     ]),
+
+    () => {
+      if (!wikiCachePath) return [];
+
+      const from =
+        path.resolve(path.join(wikiCachePath, 'search'));
+
+      return (
+        readdir(from).then(
+          () => [
+            {
+              from,
+              to: ['searchData.root'],
+              statically: 'copy',
+            }],
+          () => []));
+    },
   ];
 
   const routeCheckPromises =
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
index 3d4ecc7a..8dd08dba 100644
--- a/src/write/bind-utilities.js
+++ b/src/write/bind-utilities.js
@@ -19,7 +19,6 @@ import {
 
 export function bindUtilities({
   absoluteTo,
-  cachebust,
   defaultLanguage,
   getSizeOfAdditionalFile,
   getSizeOfImagePath,
@@ -36,7 +35,6 @@ export function bindUtilities({
 
   Object.assign(bound, {
     absoluteTo,
-    cachebust,
     defaultLanguage,
     getSizeOfAdditionalFile,
     getSizeOfImagePath,
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index 03ef6049..b018bc1c 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -24,7 +24,7 @@ import {generateRandomLinkDataJSON, generateRedirectHTML} from '../common-templa
 const defaultHost = '0.0.0.0';
 const defaultPort = 8002;
 
-export const description = `Hosts a local HTTP server which generates page content as it is requested, instead of all at once; reacts to changes in data files, so new reloads will be up-to-date with on-disk YAML data (<- not implemented yet, check back soon!)\n\nIntended for local development ONLY; this custom HTTP server is NOT rigorously tested and almost certainly has security flaws`;
+export const description = `Hosts a local HTTP server which generates page content as it is requested, instead of all at once\n\nIntended for local development ONLY; this custom HTTP server is NOT rigorously tested and almost certainly has security flaws`;
 
 export const config = {
   fileSizes: {
@@ -103,7 +103,6 @@ export async function go({
   webRoutes,
   wikiData,
 
-  cachebust,
   developersComment: _developersComment,
   getSizeOfAdditionalFile,
   getSizeOfImagePath,
@@ -338,7 +337,7 @@ export async function go({
     if (!Object.hasOwn(urlToPageMap, pathnameKey)) {
       response.writeHead(404, contentTypePlain);
       response.end(`No page found for: ${pathnameKey}\n`);
-      if (loudResponses) console.log(`${requestHead} [404] ${pathname}`);
+      if (loudResponses) console.log(`${requestHead} [404] ${pathname} (no page)`);
       return;
     }
 
@@ -399,7 +398,6 @@ export async function go({
 
       const bound = bindUtilities({
         absoluteTo,
-        cachebust,
         defaultLanguage,
         getSizeOfAdditionalFile,
         getSizeOfImagePath,
diff --git a/src/write/build-modes/repl.js b/src/write/build-modes/repl.js
index b300e8e8..faba8a34 100644
--- a/src/write/build-modes/repl.js
+++ b/src/write/build-modes/repl.js
@@ -13,6 +13,10 @@ export const config = {
     default: 'skip',
   },
 
+  search: {
+    default: 'skip',
+  },
+
   thumbs: {
     applicable: false,
   },
@@ -51,6 +55,7 @@ export async function getContextAssignments({
   missingImagePaths,
   thumbsCache,
   urls,
+  webRoutes,
   wikiData,
 
   getSizeOfAdditionalFile,
@@ -78,6 +83,7 @@ export async function getContextAssignments({
     missingImagePaths,
     thumbsCache,
     urls,
+    webRoutes,
 
     wikiData,
     ...wikiData,
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index 68cf0949..86e3da0f 100644
--- a/src/write/build-modes/static-build.js
+++ b/src/write/build-modes/static-build.js
@@ -2,6 +2,7 @@ import * as path from 'node:path';
 
 import {
   copyFile,
+  cp,
   mkdir,
   stat,
   symlink,
@@ -9,6 +10,8 @@ import {
   unlink,
 } from 'node:fs/promises';
 
+import {rimraf} from 'rimraf';
+
 import {quickLoadContentDependencies} from '#content-dependencies';
 import {quickEvaluate} from '#content-function';
 import * as html from '#html';
@@ -49,6 +52,10 @@ export const config = {
     default: 'perform',
   },
 
+  search: {
+    default: 'perform',
+  },
+
   thumbs: {
     default: 'perform',
   },
@@ -115,7 +122,6 @@ export async function go({
   webRoutes,
   wikiData,
 
-  cachebust,
   developersComment: _developersComment,
   getSizeOfAdditionalFile,
   getSizeOfImagePath,
@@ -156,6 +162,11 @@ export async function go({
     webRoutes,
   });
 
+  await writeWebRouteCopies({
+    outputPath,
+    webRoutes,
+  });
+
   if (writeAll) {
     await writeFavicon({
       mediaPath,
@@ -306,7 +317,6 @@ export async function go({
 
         const bound = bindUtilities({
           absoluteTo,
-          cachebust,
           defaultLanguage,
           getSizeOfAdditionalFile,
           getSizeOfImagePath,
@@ -436,8 +446,11 @@ function writeWebRouteSymlinks({
   outputPath,
   webRoutes,
 }) {
+  const symlinkRoutes =
+    webRoutes.filter(route => route.statically === 'symlink');
+
   const promises =
-    webRoutes.map(async route => {
+    symlinkRoutes.map(async route => {
       const parts = route.to.split('/');
       const parentDirectoryParts = parts.slice(0, -1);
       const symlinkNamePart = parts.at(-1);
@@ -469,6 +482,113 @@ function writeWebRouteSymlinks({
   return progressPromiseAll(`Writing web route symlinks.`, promises);
 }
 
+async function writeWebRouteCopies({
+  outputPath,
+  webRoutes,
+}) {
+  const copyRoutes =
+    webRoutes.filter(route => route.statically === 'copy');
+
+  const promises =
+    copyRoutes.map(async route => {
+      const permissionName = '__hsmusic-ok-for-deletion.txt';
+
+      const parts = route.to.split('/');
+      const parentDirectoryParts = parts.slice(0, -1);
+      const copyNamePart = parts.at(-1);
+
+      const parentDirectory = path.join(outputPath, ...parentDirectoryParts);
+      const copyPath = path.join(parentDirectory, copyNamePart);
+
+      // We're going to do a rimraf call! This is freaking terrifying,
+      // so nope out on a couple important conditions.
+
+      let needsDelete;
+      try {
+        await stat(copyPath);
+        needsDelete = true;
+      } catch (error) {
+        if (error.code === 'ENOENT') {
+          needsDelete = false;
+        } else {
+          throw error;
+        }
+      }
+
+      if (needsDelete) {
+        // First remove it directly, in case it's a symlink.
+        try {
+          await unlink(copyPath);
+          needsDelete = false;
+        } catch (error) {
+          // EPERM is POSIX, but libuv may or may not flat-out just raise
+          // the system error (which is ostensibly EISDIR on Linux).
+          // https://github.com/nodejs/node-v0.x-archive/issues/5791
+          // https://man7.org/linux/man-pages/man2/unlink.2.html
+          //
+          // Both of these indidcate "a directory, probably" and we'll
+          // still check for the deletion permission file where we expect
+          // it before actually touching anything.
+          if (error.code !== 'EPERM' && error.code !== 'EISDIR') {
+            throw error;
+          }
+        }
+      }
+
+      if (needsDelete) {
+        // Then check that the deletion permission file exists
+        // where we expect it.
+        try {
+          await stat(path.join(copyPath, permissionName));
+        } catch (error) {
+          if (error.code === 'ENOENT') {
+            throw new Error(`Couldn't find ${permissionName} in ${copyPath} - please delete or move away this folder manually`);
+          } else {
+            throw error;
+          }
+        }
+
+        // And *then* actually delete that directory.
+        await rimraf(copyPath);
+      }
+
+      // Actually copy the source path where it's wanted.
+      await cp(route.from, copyPath, {recursive: true});
+
+      // And certify that it's OK to delete this path, next time around.
+      await writeFile(path.join(copyPath, permissionName),
+        `The presence of this file (by its name, not its contents)\n` +
+        `indicates hsmusic may delete everything contained in this\n` +
+        `directory (the one which directly contains this file, *not*\n` +
+        `any further-up parent directories).\n` +
+        `\n` +
+        `If you make edits, or add any files, they will be deleted or\n` +
+        `overwritten the next time you run the build.\n` +
+        `\n` +
+        `If you delete *this* file, hsmusic will error during the next\n` +
+        `build, and will ask that you delete the containing directory\n` +
+        `yourself.\n`);
+    });
+
+  const results =
+    await Promise.allSettled(promises);
+
+  const errors =
+    results
+      .filter(({status}) => status === 'rejected')
+      .map(({reason}) => reason)
+      .map(err =>
+        (err.message.startsWith(`Couldn't find`)
+          ? err.message
+          : err));
+
+  if (empty(errors)) {
+    logInfo`Wrote web route copies.`;
+  } else {
+    throw new AggregateError(errors, `Errors copying internal files ("web routes")`);
+  }
+}
+
 async function writeFavicon({
   mediaPath,
   outputPath,