« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.json7
-rw-r--r--LICENSE.txt2
-rw-r--r--README.md131
-rw-r--r--data-tests/index.js3
-rw-r--r--package-lock.json3226
-rw-r--r--package.json44
-rw-r--r--src/aggregate.js (renamed from src/util/aggregate.js)159
-rw-r--r--src/cli.js (renamed from src/util/cli.js)129
-rw-r--r--src/common-util/colors.js (renamed from src/util/colors.js)2
-rw-r--r--src/common-util/search-spec.js259
-rw-r--r--src/common-util/serialize.js (renamed from src/util/serialize.js)6
-rw-r--r--src/common-util/sort.js (renamed from src/util/sort.js)62
-rw-r--r--src/common-util/sugar.js (renamed from src/util/sugar.js)176
-rw-r--r--src/common-util/wiki-data.js (renamed from src/util/wiki-data.js)119
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js2
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunk.js59
-rw-r--r--src/content/dependencies/generateAdditionalFilesShortcut.js27
-rw-r--r--src/content/dependencies/generateAdditionalNamesBox.js22
-rw-r--r--src/content/dependencies/generateAdditionalNamesBoxItem.js35
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js6
-rw-r--r--src/content/dependencies/generateAlbumArtInfoBox.js39
-rw-r--r--src/content/dependencies/generateAlbumArtworkColumn.js38
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js266
-rw-r--r--src/content/dependencies/generateAlbumCommentarySidebar.js73
-rw-r--r--src/content/dependencies/generateAlbumCoverArtwork.js26
-rw-r--r--src/content/dependencies/generateAlbumGalleryAlbumGrid.js90
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js251
-rw-r--r--src/content/dependencies/generateAlbumGalleryTrackGrid.js122
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js327
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js164
-rw-r--r--src/content/dependencies/generateAlbumReferencedArtworksPage.js58
-rw-r--r--src/content/dependencies/generateAlbumReferencingArtworksPage.js58
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js81
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNav.js233
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNavGroupPart.js94
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js94
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js150
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js66
-rw-r--r--src/content/dependencies/generateAlbumSidebarSeriesBox.js102
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js119
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbed.js64
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbedDescription.js77
-rw-r--r--src/content/dependencies/generateAlbumStyleRules.js47
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js69
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js153
-rw-r--r--src/content/dependencies/generateAlbumTrackListMissingDuration.js33
-rw-r--r--src/content/dependencies/generateArtTagAncestorDescendantMapList.js153
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js221
-rw-r--r--src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js23
-rw-r--r--src/content/dependencies/generateArtTagGalleryPageShowingLine.js22
-rw-r--r--src/content/dependencies/generateArtTagInfoPage.js281
-rw-r--r--src/content/dependencies/generateArtTagNavLinks.js81
-rw-r--r--src/content/dependencies/generateArtTagSidebar.js124
-rw-r--r--src/content/dependencies/generateArtistArtworkColumn.js13
-rw-r--r--src/content/dependencies/generateArtistCredit.js180
-rw-r--r--src/content/dependencies/generateArtistCreditWikiEditsPart.js55
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js166
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js190
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js542
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunk.js50
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js72
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js285
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunk.js53
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkItem.js93
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkedList.js11
-rw-r--r--src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js123
-rw-r--r--src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js75
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunk.js34
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js34
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js177
-rw-r--r--src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js35
-rw-r--r--src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js61
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunk.js67
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkItem.js146
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkedList.js334
-rw-r--r--src/content/dependencies/generateArtistNavLinks.js136
-rw-r--r--src/content/dependencies/generateBackToAlbumLink.js15
-rw-r--r--src/content/dependencies/generateBackToTrackLink.js15
-rw-r--r--src/content/dependencies/generateChronologyLinks.js82
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js2
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js124
-rw-r--r--src/content/dependencies/generateCommentaryEntryDate.js93
-rw-r--r--src/content/dependencies/generateCommentaryIndexPage.js86
-rw-r--r--src/content/dependencies/generateCommentarySection.js29
-rw-r--r--src/content/dependencies/generateContentHeading.js24
-rw-r--r--src/content/dependencies/generateContributionList.js32
-rw-r--r--src/content/dependencies/generateContributionTooltip.js48
-rw-r--r--src/content/dependencies/generateContributionTooltipChronologySection.js129
-rw-r--r--src/content/dependencies/generateContributionTooltipExternalLinkSection.js70
-rw-r--r--src/content/dependencies/generateCoverArtwork.js189
-rw-r--r--src/content/dependencies/generateCoverArtworkArtTagDetails.js50
-rw-r--r--src/content/dependencies/generateCoverArtworkArtistDetails.js25
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js98
-rw-r--r--src/content/dependencies/generateCoverArtworkReferenceDetails.js60
-rw-r--r--src/content/dependencies/generateCoverCarousel.js13
-rw-r--r--src/content/dependencies/generateCoverGrid.js51
-rw-r--r--src/content/dependencies/generateDatetimestampTemplate.js8
-rw-r--r--src/content/dependencies/generateDotSwitcherTemplate.js41
-rw-r--r--src/content/dependencies/generateExternalHandle.js20
-rw-r--r--src/content/dependencies/generateExternalIcon.js26
-rw-r--r--src/content/dependencies/generateExternalPlatform.js20
-rw-r--r--src/content/dependencies/generateFlashActGalleryPage.js84
-rw-r--r--src/content/dependencies/generateFlashActNavAccent.js83
-rw-r--r--src/content/dependencies/generateFlashActSidebarCurrentActBox.js9
-rw-r--r--src/content/dependencies/generateFlashActSidebarSideMapBox.js4
-rw-r--r--src/content/dependencies/generateFlashArtworkColumn.js11
-rw-r--r--src/content/dependencies/generateFlashCoverArtwork.js12
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js140
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js268
-rw-r--r--src/content/dependencies/generateFlashNavAccent.js81
-rw-r--r--src/content/dependencies/generateGridActionLinks.js20
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js58
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js327
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js47
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js87
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListItem.js136
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsSection.js93
-rw-r--r--src/content/dependencies/generateGroupNavAccent.js53
-rw-r--r--src/content/dependencies/generateGroupNavLinks.js131
-rw-r--r--src/content/dependencies/generateGroupSecondaryNav.js102
-rw-r--r--src/content/dependencies/generateGroupSecondaryNavCategoryPart.js79
-rw-r--r--src/content/dependencies/generateGroupSidebar.js25
-rw-r--r--src/content/dependencies/generateGroupSidebarCategoryDetails.js65
-rw-r--r--src/content/dependencies/generateImageOverlay.js50
-rw-r--r--src/content/dependencies/generateInterpageDotSwitcher.js31
-rw-r--r--src/content/dependencies/generateIntrapageDotSwitcher.js49
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesChunk.js78
-rw-r--r--src/content/dependencies/generateListingIndexList.js4
-rw-r--r--src/content/dependencies/generateListingPage.js72
-rw-r--r--src/content/dependencies/generateListingSidebar.js16
-rw-r--r--src/content/dependencies/generateLyricsEntry.js25
-rw-r--r--src/content/dependencies/generateLyricsSection.js81
-rw-r--r--src/content/dependencies/generateNewsEntryNavAccent.js40
-rw-r--r--src/content/dependencies/generateNewsEntryPage.js172
-rw-r--r--src/content/dependencies/generateNewsIndexPage.js67
-rw-r--r--src/content/dependencies/generateNextLink.js13
-rw-r--r--src/content/dependencies/generatePageLayout.js406
-rw-r--r--src/content/dependencies/generatePageSidebar.js79
-rw-r--r--src/content/dependencies/generatePageSidebarBox.js10
-rw-r--r--src/content/dependencies/generatePageSidebarConjoinedBox.js6
-rw-r--r--src/content/dependencies/generatePreviousLink.js13
-rw-r--r--src/content/dependencies/generatePreviousNextLink.js58
-rw-r--r--src/content/dependencies/generatePreviousNextLinks.js50
-rw-r--r--src/content/dependencies/generateQuickDescription.js134
-rw-r--r--src/content/dependencies/generateReferencedArtworksPage.js100
-rw-r--r--src/content/dependencies/generateReferencingArtworksPage.js100
-rw-r--r--src/content/dependencies/generateReleaseInfoContributionsLine.js53
-rw-r--r--src/content/dependencies/generateSearchSidebarBox.js62
-rw-r--r--src/content/dependencies/generateSecondaryNav.js16
-rw-r--r--src/content/dependencies/generateSecondaryNavParentSiblingsPart.js115
-rw-r--r--src/content/dependencies/generateSocialEmbed.js21
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js53
-rw-r--r--src/content/dependencies/generateTextWithTooltip.js9
-rw-r--r--src/content/dependencies/generateTooltip.js4
-rw-r--r--src/content/dependencies/generateTrackAdditionalNamesBox.js53
-rw-r--r--src/content/dependencies/generateTrackArtistCommentarySection.js157
-rw-r--r--src/content/dependencies/generateTrackArtworkColumn.js33
-rw-r--r--src/content/dependencies/generateTrackCoverArtwork.js34
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js751
-rw-r--r--src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js63
-rw-r--r--src/content/dependencies/generateTrackInfoPageOtherReleasesList.js42
-rw-r--r--src/content/dependencies/generateTrackList.js73
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js170
-rw-r--r--src/content/dependencies/generateTrackListItem.js106
-rw-r--r--src/content/dependencies/generateTrackListMissingDuration.js35
-rw-r--r--src/content/dependencies/generateTrackNavLinks.js64
-rw-r--r--src/content/dependencies/generateTrackReferencedArtworksPage.js47
-rw-r--r--src/content/dependencies/generateTrackReferencingArtworksPage.js47
-rw-r--r--src/content/dependencies/generateTrackReleaseBox.js46
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js72
-rw-r--r--src/content/dependencies/generateTrackSocialEmbed.js66
-rw-r--r--src/content/dependencies/generateTrackSocialEmbedDescription.js73
-rw-r--r--src/content/dependencies/generateUnsafeMunchy.js10
-rw-r--r--src/content/dependencies/generateWikiHomeAlbumsRow.js150
-rw-r--r--src/content/dependencies/generateWikiHomeContentRow.js28
-rw-r--r--src/content/dependencies/generateWikiHomeNewsBox.js85
-rw-r--r--src/content/dependencies/generateWikiHomePage.js115
-rw-r--r--src/content/dependencies/generateWikiHomepageActionsRow.js22
-rw-r--r--src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js22
-rw-r--r--src/content/dependencies/generateWikiHomepageAlbumGridRow.js78
-rw-r--r--src/content/dependencies/generateWikiHomepageNewsBox.js86
-rw-r--r--src/content/dependencies/generateWikiHomepagePage.js97
-rw-r--r--src/content/dependencies/generateWikiHomepageSection.js39
-rw-r--r--src/content/dependencies/image.js153
-rw-r--r--src/content/dependencies/linkAlbumDynamically.js59
-rw-r--r--src/content/dependencies/linkAlbumReferencedArtworks.js8
-rw-r--r--src/content/dependencies/linkAlbumReferencingArtworks.js8
-rw-r--r--src/content/dependencies/linkAnythingMan.js28
-rw-r--r--src/content/dependencies/linkArtTagDynamically.js14
-rw-r--r--src/content/dependencies/linkArtTagGallery.js8
-rw-r--r--src/content/dependencies/linkArtTagInfo.js (renamed from src/content/dependencies/linkArtTag.js)2
-rw-r--r--src/content/dependencies/linkArtwork.js20
-rw-r--r--src/content/dependencies/linkContribution.js192
-rw-r--r--src/content/dependencies/linkExternal.js15
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js51
-rw-r--r--src/content/dependencies/linkFlashAct.js26
-rw-r--r--src/content/dependencies/linkFlashSide.js22
-rw-r--r--src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js62
-rw-r--r--src/content/dependencies/linkPathFromMedia.js57
-rw-r--r--src/content/dependencies/linkReferencedArtworks.js24
-rw-r--r--src/content/dependencies/linkReferencingArtworks.js24
-rw-r--r--src/content/dependencies/linkTemplate.js24
-rw-r--r--src/content/dependencies/linkTrackDynamically.js4
-rw-r--r--src/content/dependencies/linkTrackReferencedArtworks.js8
-rw-r--r--src/content/dependencies/linkTrackReferencingArtworks.js8
-rw-r--r--src/content/dependencies/linkWikiHomepage.js (renamed from src/content/dependencies/linkWikiHome.js)0
-rw-r--r--src/content/dependencies/listArtTagNetwork.js367
-rw-r--r--src/content/dependencies/listArtTagsByName.js (renamed from src/content/dependencies/listTagsByName.js)15
-rw-r--r--src/content/dependencies/listArtTagsByUses.js54
-rw-r--r--src/content/dependencies/listArtistsByContributions.js36
-rw-r--r--src/content/dependencies/listArtistsByDuration.js7
-rw-r--r--src/content/dependencies/listArtistsByGroup.js176
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js8
-rw-r--r--src/content/dependencies/listGroupsByDuration.js2
-rw-r--r--src/content/dependencies/listRandomPageLinks.js56
-rw-r--r--src/content/dependencies/listTagsByUses.js59
-rw-r--r--src/content/dependencies/listTracksByDate.js74
-rw-r--r--src/content/dependencies/listTracksWithLyrics.js2
-rw-r--r--src/content/dependencies/transformContent.js253
-rw-r--r--src/content/util/getChronologyRelations.js55
-rw-r--r--src/content/util/groupTracksByGroup.js23
-rw-r--r--src/data/cacheable-object.js445
-rw-r--r--src/data/checks.js229
-rw-r--r--src/data/composite.js300
-rw-r--r--src/data/composite/control-flow/exposeWhetherDependencyAvailable.js42
-rw-r--r--src/data/composite/control-flow/helpers/performAvailabilityCheck.js19
-rw-r--r--src/data/composite/control-flow/index.js2
-rw-r--r--src/data/composite/control-flow/raiseOutputWithoutDependency.js2
-rw-r--r--src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js2
-rw-r--r--src/data/composite/control-flow/withAvailabilityFilter.js40
-rw-r--r--src/data/composite/control-flow/withResultOfAvailabilityCheck.js33
-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.js30
-rw-r--r--src/data/composite/data/withFilteredList.js28
-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.js26
-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.js28
-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/withStretchedList.js36
-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.js2
-rw-r--r--src/data/composite/things/album/withHasCoverArt.js64
-rw-r--r--src/data/composite/things/album/withTrackSections.js127
-rw-r--r--src/data/composite/things/album/withTracks.js46
-rw-r--r--src/data/composite/things/art-tag/index.js2
-rw-r--r--src/data/composite/things/art-tag/withAllDescendantArtTags.js44
-rw-r--r--src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js46
-rw-r--r--src/data/composite/things/artist/artistTotalDuration.js69
-rw-r--r--src/data/composite/things/artist/index.js1
-rw-r--r--src/data/composite/things/artwork/index.js1
-rw-r--r--src/data/composite/things/artwork/withDate.js41
-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.js46
-rw-r--r--src/data/composite/things/contribution/thingReferenceTypeMatches.js66
-rw-r--r--src/data/composite/things/contribution/withContainingReverseContributionList.js80
-rw-r--r--src/data/composite/things/contribution/withContributionArtist.js26
-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/flash-act/withFlashSide.js6
-rw-r--r--src/data/composite/things/flash/withFlashAct.js6
-rw-r--r--src/data/composite/things/track-section/index.js3
-rw-r--r--src/data/composite/things/track-section/withAlbum.js20
-rw-r--r--src/data/composite/things/track-section/withContinueCountingFrom.js25
-rw-r--r--src/data/composite/things/track-section/withStartCountingFrom.js64
-rw-r--r--src/data/composite/things/track/index.js16
-rw-r--r--src/data/composite/things/track/inferredAdditionalNameList.js67
-rw-r--r--src/data/composite/things/track/inheritContributionListFromMainRelease.js44
-rw-r--r--src/data/composite/things/track/inheritFromMainRelease.js41
-rw-r--r--src/data/composite/things/track/inheritFromOriginalRelease.js50
-rw-r--r--src/data/composite/things/track/sharedAdditionalNameList.js38
-rw-r--r--src/data/composite/things/track/trackReverseReferenceList.js38
-rw-r--r--src/data/composite/things/track/withAlbum.js22
-rw-r--r--src/data/composite/things/track/withAllReleases.js47
-rw-r--r--src/data/composite/things/track/withAlwaysReferenceByDirectory.js49
-rw-r--r--src/data/composite/things/track/withContainingTrackSection.js36
-rw-r--r--src/data/composite/things/track/withCoverArtistContribs.js73
-rw-r--r--src/data/composite/things/track/withDate.js34
-rw-r--r--src/data/composite/things/track/withDirectorySuffix.js36
-rw-r--r--src/data/composite/things/track/withHasUniqueCoverArt.js77
-rw-r--r--src/data/composite/things/track/withMainRelease.js (renamed from src/data/composite/things/track/withOriginalRelease.js)40
-rw-r--r--src/data/composite/things/track/withOtherReleases.js29
-rw-r--r--src/data/composite/things/track/withPropertyFromAlbum.js18
-rw-r--r--src/data/composite/things/track/withPropertyFromMainRelease.js86
-rw-r--r--src/data/composite/things/track/withSuffixDirectoryFromAlbum.js53
-rw-r--r--src/data/composite/things/track/withTrackArtDate.js60
-rw-r--r--src/data/composite/things/track/withTrackNumber.js50
-rw-r--r--src/data/composite/wiki-data/exitWithoutContribs.js1
-rw-r--r--src/data/composite/wiki-data/gobbleSoupyFind.js39
-rw-r--r--src/data/composite/wiki-data/gobbleSoupyReverse.js39
-rw-r--r--src/data/composite/wiki-data/helpers/withDirectoryFromName.js41
-rw-r--r--src/data/composite/wiki-data/helpers/withResolvedReverse.js40
-rw-r--r--src/data/composite/wiki-data/helpers/withSimpleDirectory.js52
-rw-r--r--src/data/composite/wiki-data/index.js18
-rw-r--r--src/data/composite/wiki-data/inputNotFoundMode.js9
-rw-r--r--src/data/composite/wiki-data/inputSoupyFind.js28
-rw-r--r--src/data/composite/wiki-data/inputSoupyReverse.js32
-rw-r--r--src/data/composite/wiki-data/inputWikiData.js2
-rw-r--r--src/data/composite/wiki-data/processContentEntryDates.js181
-rw-r--r--src/data/composite/wiki-data/raiseResolvedReferenceList.js96
-rw-r--r--src/data/composite/wiki-data/withClonedThings.js68
-rw-r--r--src/data/composite/wiki-data/withConstitutedArtwork.js57
-rw-r--r--src/data/composite/wiki-data/withContributionListSums.js95
-rw-r--r--src/data/composite/wiki-data/withCoverArtDate.js51
-rw-r--r--src/data/composite/wiki-data/withDirectory.js62
-rw-r--r--src/data/composite/wiki-data/withParsedCommentaryEntries.js104
-rw-r--r--src/data/composite/wiki-data/withParsedContentEntries.js111
-rw-r--r--src/data/composite/wiki-data/withParsedLyricsEntries.js157
-rw-r--r--src/data/composite/wiki-data/withRecontextualizedContributionList.js100
-rw-r--r--src/data/composite/wiki-data/withRedatedContributionList.js127
-rw-r--r--src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js100
-rw-r--r--src/data/composite/wiki-data/withResolvedContribs.js140
-rw-r--r--src/data/composite/wiki-data/withResolvedReference.js26
-rw-r--r--src/data/composite/wiki-data/withResolvedReferenceList.js103
-rw-r--r--src/data/composite/wiki-data/withResolvedSeriesList.js130
-rw-r--r--src/data/composite/wiki-data/withReverseContributionList.js83
-rw-r--r--src/data/composite/wiki-data/withReverseReferenceList.js73
-rw-r--r--src/data/composite/wiki-data/withUniqueReferencingThing.js42
-rw-r--r--src/data/composite/wiki-properties/annotatedReferenceList.js64
-rw-r--r--src/data/composite/wiki-properties/commentary.js8
-rw-r--r--src/data/composite/wiki-properties/constitutibleArtwork.js68
-rw-r--r--src/data/composite/wiki-properties/constitutibleArtworkList.js70
-rw-r--r--src/data/composite/wiki-properties/contributionList.js41
-rw-r--r--src/data/composite/wiki-properties/directory.js56
-rw-r--r--src/data/composite/wiki-properties/helpers/reference-list-helpers.js44
-rw-r--r--src/data/composite/wiki-properties/index.js12
-rw-r--r--src/data/composite/wiki-properties/lyrics.js36
-rw-r--r--src/data/composite/wiki-properties/referenceList.js29
-rw-r--r--src/data/composite/wiki-properties/referencedArtworkList.js32
-rw-r--r--src/data/composite/wiki-properties/reverseContributionList.js24
-rw-r--r--src/data/composite/wiki-properties/reverseReferenceList.js12
-rw-r--r--src/data/composite/wiki-properties/seriesList.js31
-rw-r--r--src/data/composite/wiki-properties/singleReference.js6
-rw-r--r--src/data/composite/wiki-properties/soupyFind.js14
-rw-r--r--src/data/composite/wiki-properties/soupyReverse.js37
-rw-r--r--src/data/composite/wiki-properties/thing.js40
-rw-r--r--src/data/composite/wiki-properties/thingList.js44
-rw-r--r--src/data/composite/wiki-properties/wallpaperParts.js9
-rw-r--r--src/data/language.js24
-rw-r--r--src/data/serialize.js5
-rw-r--r--src/data/thing.js56
-rw-r--r--src/data/things/album.js677
-rw-r--r--src/data/things/art-tag.js125
-rw-r--r--src/data/things/artist.js228
-rw-r--r--src/data/things/artwork.js399
-rw-r--r--src/data/things/contribution.js302
-rw-r--r--src/data/things/flash.js142
-rw-r--r--src/data/things/group.js98
-rw-r--r--src/data/things/homepage-layout.js313
-rw-r--r--src/data/things/index.js45
-rw-r--r--src/data/things/language.js251
-rw-r--r--src/data/things/sorting-rule.js386
-rw-r--r--src/data/things/static-page.js9
-rw-r--r--src/data/things/track.js467
-rw-r--r--src/data/things/wiki-info.js60
-rw-r--r--src/data/yaml.js1603
-rw-r--r--src/external-links.js (renamed from src/util/external-links.js)37
-rw-r--r--src/file-size-preloader.js57
-rw-r--r--src/find-reverse.js144
-rw-r--r--src/find.js431
-rw-r--r--src/gen-thumbs.js183
-rw-r--r--src/html.js (renamed from src/util/html.js)239
-rw-r--r--src/import-heck.js9
-rw-r--r--src/listing-spec.js38
-rw-r--r--src/node-utils.js (renamed from src/util/node-utils.js)0
-rw-r--r--src/page/album.js50
-rw-r--r--src/page/art-tag.js (renamed from src/page/tag.js)14
-rw-r--r--src/page/artist-alias.js28
-rw-r--r--src/page/artist.js10
-rw-r--r--src/page/group.js39
-rw-r--r--src/page/homepage.js2
-rw-r--r--src/page/index.js2
-rw-r--r--src/page/static.js1
-rw-r--r--src/page/track.js30
-rw-r--r--src/replacer.js (renamed from src/util/replacer.js)247
-rw-r--r--src/reverse.js141
-rw-r--r--src/search.js119
-rw-r--r--src/static/client3.js3483
-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/site6.css)1609
-rw-r--r--src/static/js/client-util.js129
-rw-r--r--src/static/js/client/additional-names-box.js150
-rw-r--r--src/static/js/client/album-commentary-sidebar.js212
-rw-r--r--src/static/js/client/art-tag-gallery-filter.js151
-rw-r--r--src/static/js/client/art-tag-network.js147
-rw-r--r--src/static/js/client/artist-external-link-tooltip.js196
-rw-r--r--src/static/js/client/css-compatibility-assistant.js30
-rw-r--r--src/static/js/client/datetimestamp-tooltip.js36
-rw-r--r--src/static/js/client/dragged-link.js62
-rw-r--r--src/static/js/client/hash-link.js146
-rw-r--r--src/static/js/client/hoverable-tooltip.js1102
-rw-r--r--src/static/js/client/image-overlay.js385
-rw-r--r--src/static/js/client/index.js235
-rw-r--r--src/static/js/client/intrapage-dot-switcher.js82
-rw-r--r--src/static/js/client/live-mouse-position.js21
-rw-r--r--src/static/js/client/quick-description.js62
-rw-r--r--src/static/js/client/scripted-link.js285
-rw-r--r--src/static/js/client/sidebar-search.js1147
-rw-r--r--src/static/js/client/sticky-heading.js345
-rw-r--r--src/static/js/client/summary-nested-link.js48
-rw-r--r--src/static/js/client/text-with-tooltip.js34
-rw-r--r--src/static/js/client/wiki-search.js239
-rw-r--r--src/static/js/group-contributions-table.js35
-rw-r--r--src/static/js/info-card.js181
-rw-r--r--src/static/js/lazy-loading.js (renamed from src/static/lazy-loading.js)0
-rw-r--r--src/static/js/localization-nonsense.js30
-rw-r--r--src/static/js/module-import-shims.js27
-rw-r--r--src/static/js/rectangles.js555
-rw-r--r--src/static/js/search-worker.js621
-rw-r--r--src/static/js/xhr-util.js64
-rw-r--r--src/static/misc/icons.svg (renamed from src/static/icons.svg)2
-rw-r--r--src/static/misc/image.svg11
-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.yaml715
-rwxr-xr-xsrc/upd8.js2366
-rw-r--r--src/url-spec.js334
-rw-r--r--src/urls-default.yaml144
-rw-r--r--src/urls.js (renamed from src/util/urls.js)208
-rw-r--r--src/validators.js (renamed from src/data/validators.js)342
-rw-r--r--src/web-routes.js140
-rw-r--r--src/write/bind-utilities.js10
-rw-r--r--src/write/build-modes/index.js1
-rw-r--r--src/write/build-modes/live-dev-server.js295
-rw-r--r--src/write/build-modes/repl.js43
-rw-r--r--src/write/build-modes/sort.js76
-rw-r--r--src/write/build-modes/static-build.js243
-rw-r--r--tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs14
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs2
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs37
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs19
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs29
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs6
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs103
-rw-r--r--tap-snapshots/test/snapshot/generateCoverArtwork.js.test.cjs37
-rw-r--r--tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs28
-rw-r--r--tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs99
-rw-r--r--tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs50
-rw-r--r--tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs10
-rw-r--r--tap-snapshots/test/snapshot/image.js.test.cjs14
-rw-r--r--tap-snapshots/test/snapshot/linkContribution.js.test.cjs233
-rw-r--r--tap-snapshots/test/snapshot/transformContent.js.test.cjs10
-rw-r--r--test/lib/composite.js33
-rw-r--r--test/lib/content-function.js18
-rw-r--r--test/lib/index.js1
-rw-r--r--test/lib/wiki-data.js115
-rw-r--r--test/snapshot/generateAdditionalFilesShortcut.js36
-rw-r--r--test/snapshot/generateAlbumCoverArtwork.js36
-rw-r--r--test/snapshot/generateAlbumReleaseInfo.js14
-rw-r--r--test/snapshot/generateAlbumSecondaryNav.js47
-rw-r--r--test/snapshot/generateAlbumTrackList.js25
-rw-r--r--test/snapshot/generateCoverArtwork.js31
-rw-r--r--test/snapshot/generatePreviousNextLinks.js35
-rw-r--r--test/snapshot/generateTrackAdditionalNamesBox.js107
-rw-r--r--test/snapshot/generateTrackCoverArtwork.js61
-rw-r--r--test/snapshot/generateTrackReleaseInfo.js4
-rw-r--r--test/snapshot/image.js13
-rw-r--r--test/snapshot/linkContribution.js69
-rw-r--r--test/snapshot/linkThing.js2
-rw-r--r--test/snapshot/transformContent.js10
-rw-r--r--test/unit/content/dependencies/generateAlbumTrackList.js3
-rw-r--r--test/unit/content/dependencies/linkContribution.js107
-rw-r--r--test/unit/data/cacheable-object.js2
-rw-r--r--test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js18
-rw-r--r--test/unit/data/composite/data/withPropertiesFromObject.js59
-rw-r--r--test/unit/data/composite/data/withPropertyFromObject.js107
-rw-r--r--test/unit/data/composite/data/withUniqueItemsOnly.js23
-rw-r--r--test/unit/data/composite/things/track/withAlbum.js40
-rw-r--r--test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js275
-rw-r--r--test/unit/data/things/album.js320
-rw-r--r--test/unit/data/things/art-tag.js49
-rw-r--r--test/unit/data/things/flash.js39
-rw-r--r--test/unit/data/things/track.js465
-rw-r--r--test/unit/data/validators.js (renamed from test/unit/data/things/validators.js)16
480 files changed, 37644 insertions, 16479 deletions
diff --git a/.eslintrc.json b/.eslintrc.json
index 5568290a..ac1a4e63 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -10,12 +10,17 @@
   },
   "rules": {
     "indent": ["off"],
+    "no-empty": ["error", {
+      "allowEmptyCatch": true
+    }],
     "no-unexpected-multiline": ["off"],
     "no-unused-labels": ["off"],
     "no-unused-vars": ["error", {
       "argsIgnorePattern": "^_",
       "destructuredArrayIgnorePattern": "^"
     }],
-    "no-cond-assign": ["off"]
+    "no-cond-assign": ["off"],
+    "no-constant-condition": ["off"],
+    "no-unsafe-finally": ["off"]
   }
 }
diff --git a/LICENSE.txt b/LICENSE.txt
index 58729853..0eb3a676 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -1,4 +1,4 @@
-Copyright 2019-2023 Quasar Nebula et al <qznebula@protonmail.com>
+Copyright 2019-2024 Quasar Nebula et al <qznebula@protonmail.com>
 
 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
 
diff --git a/README.md b/README.md
index 06480dee..22f6eb55 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,20 @@
 # HSMusic
 
-HSMusic, short for the *Homestuck Music Wiki*, is a revitalization and reimagining of [earlier][fandom] [projects][nsnd] archiving and celebrating the expansive history of Homestuck official and fan music. Roughly periodic releases of the website are released at [hsmusic.wiki][hsmusic]; all development occurs in this public Git repository, which can be accessed at [github.com][github].
+HSMusic, short for the *Homestuck Music Wiki*, is a revitalization and reimagining of [earlier][fandom] [projects][nsnd] archiving and celebrating the expansive history of Homestuck official and fan music. Roughly periodic releases of the website are released at [hsmusic.wiki][hsmusic]; all development occurs in a few different public Git repositories:
+
+- hsmusic-wiki ([GitHub][github-code], [Notabug][notabug-code], [cgit][cgit-code]): all the code used to run hsmusic on your own computer, and the canonical reference for all of the wiki's software behavior
+- hsmusic-data ([GitHub][github-data], [Notabug][notabug-data], [cgit][cgit-data]): all the data representing the contents of the wiki; collaborative additions and improvements to wiki content all end up here
+- hsmusic-media ([GitHub][github-media]): media files referenced by content in the data repository; includes all album assets, commentary images, additional files, etc
+- hsmusic-lang ([GitHub][github-lang]): localization files for presenting the wiki's user interface in different languages
 
 ## Quick Start
 
 Install dependencies:
 
-- [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; development is generally tested on latest but 20.x LTS should also work
+- [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; during development we generally test on 22.x LTS
 - [ImageMagick](https://imagemagick.org/) - check your package manager if it's available (e.g. apt or homebrew) or follow [installation info right from the official website](https://imagemagick.org/script/download.php)
 
-Make a new empty folder for storing all your HSMusic repositories, then clone 'em with git:
+Make a new empty folder for storing all your wiki repositories, then clone 'em with git:
 
 ```
 $ cd /path/to/my/projects/
@@ -40,7 +45,7 @@ $ npm link
 # working out the details yourself), you can just move on.
 ```
 
-Go back to the main directory (containing all the repos) and make an empty folder for the first and subsequent builds:
+Go back to the main directory (containing all the repos) and make a couple of empty folders that will be useful during builds:
 
 ```
 $ cd ..
@@ -53,131 +58,99 @@ code/  data/  media/
 # Just do cd /path/to/my/projects/hsmusic (with whatever path you created
 # the main directory in) to get back.
 
+$ mkdir cache
 $ mkdir out
 ```
 
-Then build the site:
+**Anytime a command shows `hsmusic` in the following examples,** if your `npm link` command didn't work or you get a "command not found" error, you can just replace `hsmusic` with `node code`.
 
-```
-# If you used npm link:
-$ hsmusic --data-path data --media-path media --out-path out
+The wiki uses thumbnails, but these aren't included in the media repository you downloaded. The wiki will automatically generate new thumbnails as you add them to the media repository (as part of each build), but the first time, you should just generate the thumbnails.
 
-# If you didn't:
-$ node code/src/upd8.js --data-path data --media-path media --out-path out
+```
+$ hsmusic --data-path data --media-path media --cache-path cache --thumbs-only
 ```
 
-You should get a bunch of info eventually showing the site building! It may take a while (especially since HSMusic has a lot of data nowadays).
+Provided you've got ImageMagick installed, this should go more or less error-free, although it may take a while (for the Homestuck Music Wiki, typically 40-80 minutes). It may fail to generate a few thumbnails, and will show an error message, if so. Just run the command again, and they should work the second time around.
 
-If all goes according to plan and there aren't any errors, all the site HTML should have been written to the `out` directory. Use a simple HTTP server to view it in your browser:
+Then build the site. There are two methods for this. **If you're publishing to the web** (or just want a complete, static build of the site for your own purposes), use `--static-build`, as below:
 
 ```
-$ cd site
-
-# choose your favorite HTTP server
-$ npx http-server -p 8002
-$ python3 -m http.server 8002
-$ python2 -m SimpleHTTPServer 8002
+$ hsmusic --static-build --data-path data --media-path media --cache-path cache --out-path out
 ```
 
-If you don't have access to an HTTP server or lack device permissions to run one, you can also just view the generated HTML files in your browser and *most* features should still work. (Try `--append-index-html` in the `hsmusic`/`upd8.js` command to make generated links more direct.) This isn't an officially supported way to develop, so there might be bugs, but most of the site should still work.
-
-**If you encounter any errors along the way, or would like help getting the wiki working,** please feel welcomed to reach out through the [HSMusic Community Discord Server][discord]. We're a fairly active group there and are always happy to help! **This also applies if you don't have much experience with Git, GitHub, Node, or any of the necessary tooling, and want help getting used to them.**
+The site's contents will generate in the specified `out` folder. For the Homestuck Music Wiki, this generally takes around 40-60 minutes. You can upload these to a web server if you'd like to publish the site online. Or run your HTTP server of choice (`npx http-server -p 8002`, `python3 -m http.server 8002`) to view the build locally.
 
-## Project Structure
+**If you're testing out your changes** (for example, before filing a pull request), use `--live-dev-server`, as below:
 
-### General build process
+```
+$ hsmusic --live-dev-server --data-path data --media-path media --cache-path cache
+```
 
-When you run HSMusic to build the wiki, several processes happen in succession. Any errors along the way will be reported - we hope with human-readable feedback, but [pop by the Discord][discord] if you have any questions or need help understanding errors or parts of the code.
+Once initial loading is complete (usually 8-16 seconds), the site will generate pages *as you open them in your web browser.* Open http://localhost:8002/ when hsmusic instructs you to. You have to restart the server to refresh its data and see any data changes you've saved; hold control and press C (^C) to cancel the build, then run the command again to restart the server.
 
-1. Update thumbnails in the media repo so that any new images automatically get thumbnails.
-2. Locate and read data files, processing them into relatively usable JS object-style formats.
-3. Validate the data and show any errors caught during processing.
-4. Create symlinks for static files and generate the basic directory structure for the site.
-5. Generate and write HTML files (and any supporting files) containing all content.
+### Help! It's not working
 
-### Multiple repositories
+**If you encounter any errors along the way, or would like help getting the wiki working,** please feel welcomed to reach out through the [HSMusic Community Discord Server][discord]. We're a fairly active group there and are always happy to help! **This also applies if you don't have much experience with Git, GitHub, Node, or any of the necessary tooling, and want help getting used to them.**
 
-HSMusic works using a number of repositories in tandem:
+### Building without writing `--data-path` (etc) every time
 
-- [`hsmusic-wiki`][github-code] (colloquially "code"): The code repository, including all behavior required to process data and content from the other repositories and turn it into an actual website. This is probably the repo you're viewing right now.
-  - Code is written entirely in modern JavaScript, with the actual website a static combination of HTML and CSS (with inexhaustive JavaScript for certain features).
-  - More details about the code repository below.
-- [`hsmusic-data`][github-data]: The data repository, comprising all the data which makes a given wiki what it *is*. The repository linked here is for the [Homestuck Music Wiki][hsmusic] itself, but it may be swapped out for other data repos to build other completely different wikis.
-  - This repo covers albums, tracks, artists, groups, and a variety of other things which make up the content of a music wiki.
-  - The data repo also contains all the metadata which makes one wiki unique from another: layout info, static pages (like "About & Credits"), whether or not certain site features are enabled (like "Flashes & Games" or UI for browsing groups), and so on.
-  - All data is written and accessed in the YAML format, and every file follows a specific structure described within this (code) repository. See below and the `src/data` directory for details.
-- [`hsmusic-media`][github-media]: The media repository, holding all album, track, and layout media used across the site in one place. Media and organization directly corresponds to entries in the data repository; generally the data and media repositories go together and are swapped out for another together.
-- [`hsmusic-lang`][github-lang]: The language repository, holding up-to-date strings and other localization info for HSMusic.
-  - Strings and language info are stored in top-level YAML files within this repository. They're based off the `src/strings-default.yaml` file within the code repo (and don't need to provide translations for all strings to be used for site building).
+(These specific instructions apply only for bash and zsh. If you're using another shell, e.g. on Windows, you can probably adapt the principles, but we don't have a ready-to-go script, yet. Sorry!)
 
-The code repository as well as the data and media repositories are require for site building, with the language repo optionally provided to add localization support to the wiki build.
+It can be mildly inconvenient to write (or remember to write, or copy-paste) the `--data-path data` option, and similar options, every time. hsmusic will also detect and use environment variables for these; if you specify them this way, you don't need to provide the corresponding command line options.
 
-The path to each repo may be specified respectively by the `--data-path`, `--media-path`, and `--lang-path` arguments (when building the site or using e.g. data-related CLI tools). If you find it inconvenient to type or keep track of these values, you may alternatively set environment variables `HSMUSIC_DATA`, `HSMUSIC_MEDIA`, and `HSMUSIC_LANG` to provide the same values. One convenient layout for locally organizing the HSMusic repositories is shown below:
+Suppose you've locally organized your wiki repositories as below:
 
     path/to/my/projects/
       hsmusic/
+        cache/  <empty directory, or cached generated files>
         code/   <clone of hsmusic-wiki>
         data/   <clone of hsmusic-data>
         media/  <clone of hsmusic-media>
-        out/    <empty directory> (will be overwritten)
-        env.sh
+        out/    <empty directory, or a static build>
 
-The `env.sh` script shown above is a straightforward utility for loading those variables into the envronment, so you don't need to type path arguments every time:
+Create an `env.sh` file inside the top-level `hsmusic` folder, containing `data`, `media`, etc. If your shell is **bash,** enter these contents:
 
     #!/bin/bash
     base="$(realpath "$(dirname ${BASH_SOURCE[0]})")"
+    export HSMUSIC_CACHE="$base/cache/"
     export HSMUSIC_DATA="$base/data/"
     export HSMUSIC_MEDIA="$base/media/"
-    # export HSMUSIC_LANG="$base/lang/" # uncomment if present
     export HSMUSIC_OUT="$base/out/"
 
-Then use `source env.sh` when starting work from the CLI to get access to all the convenient environment variables. (This setup is written for Bash of course, but you can use the same idea to export env variables with your own shell's syntax.)
+If your shell is **zsh,** enter these contents:
 
-### Code repository source structure
+    #!/usr/bin/env zsh
+    base=${0:a:h}
+    export HSMUSIC_CACHE="$base/cache/"
+    export HSMUSIC_DATA="$base/data/"
+    export HSMUSIC_MEDIA="$base/media/"
+    export HSMUSIC_OUT="$base/out/"
 
-The source code for HSMusic is divided across a number of source files, loosely grouped together in a number of directories:
+Then use `source env.sh` when starting work from the CLI to get access to all the convenient environment variables.
 
-- `src/`
-  - `data/`
-    - `things/`: Descriptors for individual types of data objects used across the wiki, notably including:
-      - `cacheable-object.js`: Backbone of how data objects (colloquially "things") store, share, and compute their properties
-      - `thing.js`: Common superclass for most data objects, with a bunch of utilities and common behavior
-      - `validators.js`: Convenient error-throwing utilities which help ensure properties set on data objects follow the right format
-    - `yaml.js`: Mappings from YAML documents (the format used in `hsmusic-data`) to things (actual data objects), and a full suite of utilities used to actually load that data from scratch
-  - `content/`: Functions which generate HTML content; these go from bite-sized, commonly reused utilities (like `linkTemplate`) all the way up to entire page definitions (like `generateArtistInfoPage`)
-  - `page/`: Definitions for page paths, mapping data objects to paths served over an HTTP server (or written to an output folder) and to the functions which actually generate those pages' content
-  - `write/`: Common utilities and output methods for controlling what hsmusic does to turn data and media into something you actually visit; these each define a variety of command-line arguments and are basically the interchangeable  "second half" of upd8.js
-    - `live-dev-server.js`: Gets the site available for viewing as quickly as possible, generating and serving pages as they are requested from a local HTTP server; reacts live to code changes in the `content` directory
-    - `static-build.js`: Builds the entire site at once, writing all the output to one self-contained folder which can be uploaded to a static file server
-    - `repl.js`: Provides a convenient REPL to run filters and transformations on data objects right from the Node.js command line
-  - `static/`: Purely client-side files are kept here, e.g. site CSS, icon SVGs, and client-side JS
-  - `util/`: Common utilities which generally may be accessed from both Node.js or the client (web browser)
-  - `upd8.js`: Main entry point which controls and directs site generation from start to finish
-  - `gen-thumbs.js`: Standalone utility also called every time HSMusic is run (unless `--skip-thumbs` is provided) which keeps a persistent cache of media MD5s and (re)generates thumbnails for new or updated image files
-  - `listing-spec.js`: Descriptors for computations and HTML templates used for the Listings part of the site
-  - `url-spec.js`: Index of output paths where generated HTML ends up; also controls where `<a>`, `<img>`, etc tags link
-  - `file-size-preloader.js`: Simple utility for calculating size of files in media directory
-  - `strings-default.json`: Template for localization strings and index of default (English) strings used all across the site layout
+## Notes for developers
 
-## Forking
+### Which platform features are OK for hsmusic-wiki!?
 
-hsmusic is a relatively generic music wiki software, so you're more than encouraged to create a fork for your own archival or cataloguing purposes! You're encouraged to [drop us a link][feedback] if you do - we'd love to hear from you.
+Here's how to decide when new programming language features are ready for use in hsmusic-wiki:
 
-## Pull Requests
+- **Build-code JS** (anything *not* in the browser): Whatever is supported in the latest LTS release of node.js. We hung out on 20.x for a needlessly long time; now we're calling that we can update to the latest LTS at any point.
 
-As mentioned, part of the focus of the hsmusic.wiki release, as well as most development since, has been to create a more modular and developer-friendly repository. So, on the curious chance anyone would like to contribute code to the repo, that's more possible now than it used to be!
+- **Client JS:** This one's mucky, but target stuff available across latest browsers since three years ago (to the month). It's generally possible for individual client modules to fail and even for graceful fallbacks where we notice, so it's not the end of the world for stuff to slip through a little early.
 
-Still, for larger additions, we encourage you to [drop the main devs an email][feedback] or, better yet, [pop by the Discord][discord] before writing all the implementation code: besides code tips which might make your life a bit easier (questions are welcome), we also love to discuss feature designs and values while they're still being brainstormed! That way, nobody has to tell you there are fundamental ideas or implementation details that should be rebuilt from the ground up - the last thing we want is anyone putting hours into code that has to be replaced by another implementation before it ever ends up part of the wiki!
+- **Client HTML and CSS:** Firmer focus on only using what was available across browsers three years ago (to the month). Utterly ornamental CSS can use newer features, but only if it's flat-out necessary and does not impact the usability of the site at all (i.e. if the CSS fails then it's just as though that ornament weren't added).
 
-As ever, feedback is always welcome, and may be shared via the usual links. Thank you for checking the repository out!
+We haven't noted whether caniuse and MDN are based on different data sets or not, but *out of convenience,* we usually use MDN now that it shows this "Baseline" box for a lot of features, with month-year compat info up front. We're aware that these resources are never perfect, so pay closer attention before committing to huge new JS APIs, etc.
 
   [discord]: https://hsmusic.wiki/discord/
   [fandom]: https://homestuck-and-mspa-music.fandom.com/wiki/Homestuck_and_MSPA_Music_Wiki
-  [feedback]: https://hsmusic.wiki/feedback/
-  [github]: https://github.com/hsmusic/hsmusic-wiki
+  [cgit-code]: https://nebula.ed1.club/git/hsmusic-wiki
+  [cgit-data]: https://nebula.ed1.club/git/hsmusic-data
   [github-code]: https://github.com/hsmusic/hsmusic-wiki
   [github-data]: https://github.com/hsmusic/hsmusic-data
   [github-lang]: https://github.com/hsmusic/hsmusic-lang
   [github-media]: https://github.com/hsmusic/hsmusic-media
   [hsmusic]: https://hsmusic.wiki
+  [notabug-code]: https://notabug.org/towerofnix/hsmusic-wiki
+  [notabug-data]: https://notabug.org/towerofnix/hsmusic-data
   [nsnd]: https://homestuck.net/music/references.html
diff --git a/data-tests/index.js b/data-tests/index.js
index 3901db07..91fd7f4d 100644
--- a/data-tests/index.js
+++ b/data-tests/index.js
@@ -4,7 +4,7 @@ import {fileURLToPath} from 'node:url';
 import chokidar from 'chokidar';
 
 import {colors, logError, logInfo, logWarn, parseOptions} from '#cli';
-import {bindFind, getAllFindSpecs} from '#find';
+import find, {bindFind, getAllFindSpecs} from '#find';
 import {isMain} from '#node-utils';
 import {getContextAssignments} from '#repl';
 import {bindOpts, showAggregate} from '#sugar';
@@ -33,6 +33,7 @@ async function main() {
   const watcher = chokidar.watch(metaDirname);
 
   const wikiData = await quickLoadAllFromYAML(dataPath, {
+    find,
     bindFind,
     getAllFindSpecs,
 
diff --git a/package-lock.json b/package-lock.json
index ad7c5abf..11bc6a41 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,16 +7,20 @@
         "": {
             "name": "hsmusic-wiki",
             "version": "0.1.0",
-            "license": "GPL-3.0",
+            "license": "MIT",
             "dependencies": {
                 "@js-temporal/polyfill": "^0.4.4",
                 "chroma-js": "^2.4.2",
                 "command-exists": "^1.2.9",
+                "compress-json": "^3.0.5",
                 "eslint": "^8.37.0",
+                "flexsearch": "^0.7.43",
                 "he": "^1.2.0",
                 "image-size": "^1.0.2",
                 "js-yaml": "^4.1.0",
                 "marked": "^10.0.0",
+                "msgpackr": "^1.10.2",
+                "rimraf": "^5.0.7",
                 "striptags": "^4.0.0-alpha.4",
                 "word-wrap": "^1.2.3"
             },
@@ -25,7 +29,7 @@
             },
             "devDependencies": {
                 "chokidar": "^3.5.3",
-                "tap": "^18.4.0",
+                "tap": "^19.0.2",
                 "tcompare": "^6.0.0"
             },
             "engines": {
@@ -167,7 +171,6 @@
             "version": "8.0.2",
             "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
             "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
-            "dev": true,
             "dependencies": {
                 "string-width": "^5.1.2",
                 "string-width-cjs": "npm:string-width@^4.2.0",
@@ -184,7 +187,6 @@
             "version": "6.0.1",
             "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
             "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
-            "dev": true,
             "engines": {
                 "node": ">=12"
             },
@@ -196,7 +198,6 @@
             "version": "7.1.0",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
             "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-            "dev": true,
             "dependencies": {
                 "ansi-regex": "^6.0.1"
             },
@@ -208,9 +209,9 @@
             }
         },
         "node_modules/@isaacs/ts-node-temp-fork-for-pr-2009": {
-            "version": "10.9.5",
-            "resolved": "https://registry.npmjs.org/@isaacs/ts-node-temp-fork-for-pr-2009/-/ts-node-temp-fork-for-pr-2009-10.9.5.tgz",
-            "integrity": "sha512-hEDlwpHhIabtB+Urku8muNMEkGui0LVGlYLS3KoB9QBDf0Pw3r7q0RrfoQmFuk8CvRpGzErO3/vLQd9Ys+/g4g==",
+            "version": "10.9.7",
+            "resolved": "https://registry.npmjs.org/@isaacs/ts-node-temp-fork-for-pr-2009/-/ts-node-temp-fork-for-pr-2009-10.9.7.tgz",
+            "integrity": "sha512-9f0bhUr9TnwwpgUhEpr3FjxSaH/OHaARkE2F9fM0lS4nIs2GNerrvGwQz493dk0JKlTaGYVrKbq36vA/whZ34g==",
             "dev": true,
             "dependencies": {
                 "@cspotcode/source-map-support": "^0.8.0",
@@ -266,9 +267,9 @@
             }
         },
         "node_modules/@jridgewell/resolve-uri": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
-            "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+            "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
             "dev": true,
             "engines": {
                 "node": ">=6.0.0"
@@ -302,6 +303,78 @@
                 "node": ">=12"
             }
         },
+        "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.2.tgz",
+            "integrity": "sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==",
+            "cpu": [
+                "arm64"
+            ],
+            "optional": true,
+            "os": [
+                "darwin"
+            ]
+        },
+        "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.2.tgz",
+            "integrity": "sha512-lwriRAHm1Yg4iDf23Oxm9n/t5Zpw1lVnxYU3HnJPTi2lJRkKTrps1KVgvL6m7WvmhYVt/FIsssWay+k45QHeuw==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "darwin"
+            ]
+        },
+        "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.2.tgz",
+            "integrity": "sha512-MOI9Dlfrpi2Cuc7i5dXdxPbFIgbDBGgKR5F2yWEa6FVEtSWncfVNKW5AKjImAQ6CZlBK9tympdsZJ2xThBiWWA==",
+            "cpu": [
+                "arm"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.2.tgz",
+            "integrity": "sha512-FU20Bo66/f7He9Fp9sP2zaJ1Q8L9uLPZQDub/WlUip78JlPeMbVL8546HbZfcW9LNciEXc8d+tThSJjSC+tmsg==",
+            "cpu": [
+                "arm64"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.2.tgz",
+            "integrity": "sha512-gsWNDCklNy7Ajk0vBBf9jEx04RUxuDQfBse918Ww+Qb9HCPoGzS+XJTLe96iN3BVK7grnLiYghP/M4L8VsaHeA==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.2.tgz",
+            "integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "win32"
+            ]
+        },
         "node_modules/@nodelib/fs.scandir": {
             "version": "2.1.5",
             "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -335,25 +408,25 @@
             }
         },
         "node_modules/@npmcli/agent": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.0.tgz",
-            "integrity": "sha512-2yThA1Es98orMkpSLVqlDZAMPK3jHJhifP2gnNUdk1754uZ8yI5c+ulCoVG+WlntQA6MzhrURMXjSd9Z7dJ2/Q==",
+            "version": "2.2.2",
+            "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz",
+            "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==",
             "dev": true,
             "dependencies": {
                 "agent-base": "^7.1.0",
                 "http-proxy-agent": "^7.0.0",
                 "https-proxy-agent": "^7.0.1",
                 "lru-cache": "^10.0.1",
-                "socks-proxy-agent": "^8.0.1"
+                "socks-proxy-agent": "^8.0.3"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
             }
         },
         "node_modules/@npmcli/fs": {
-            "version": "3.1.0",
-            "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz",
-            "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==",
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz",
+            "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==",
             "dev": true,
             "dependencies": {
                 "semver": "^7.3.5"
@@ -363,15 +436,15 @@
             }
         },
         "node_modules/@npmcli/git": {
-            "version": "5.0.4",
-            "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.4.tgz",
-            "integrity": "sha512-nr6/WezNzuYUppzXRaYu/W4aT5rLxdXqEFupbh6e/ovlYFQ8hpu1UUPV3Ir/YTl+74iXl2ZOMlGzudh9ZPUchQ==",
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.7.tgz",
+            "integrity": "sha512-WaOVvto604d5IpdCRV2KjQu8PzkfE96d50CQGKgywXh2GxXmDeUO5EWcBC4V57uFyrNqx83+MewuJh3WTR3xPA==",
             "dev": true,
             "dependencies": {
                 "@npmcli/promise-spawn": "^7.0.0",
                 "lru-cache": "^10.0.1",
                 "npm-pick-manifest": "^9.0.0",
-                "proc-log": "^3.0.0",
+                "proc-log": "^4.0.0",
                 "promise-inflight": "^1.0.1",
                 "promise-retry": "^2.0.1",
                 "semver": "^7.3.5",
@@ -406,16 +479,16 @@
             }
         },
         "node_modules/@npmcli/installed-package-contents": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz",
-            "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==",
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.1.0.tgz",
+            "integrity": "sha512-c8UuGLeZpm69BryRykLuKRyKFZYJsZSCT4aVY5ds4omyZqJ172ApzgfKJ5eV/r3HgLdUYgFVe54KSFVjKoe27w==",
             "dev": true,
             "dependencies": {
                 "npm-bundled": "^3.0.0",
                 "npm-normalize-package-bin": "^3.0.0"
             },
             "bin": {
-                "installed-package-contents": "lib/index.js"
+                "installed-package-contents": "bin/index.js"
             },
             "engines": {
                 "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
@@ -430,10 +503,74 @@
                 "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
             }
         },
+        "node_modules/@npmcli/package-json": {
+            "version": "5.1.1",
+            "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.1.1.tgz",
+            "integrity": "sha512-uTq5j/UqUzbOaOxVy+osfOhpqOiLfUZ0Ut33UbcyyAPJbZcJsf4Mrsyb8r58FoIFlofw0iOFsuCA/oDK14VDJQ==",
+            "dev": true,
+            "dependencies": {
+                "@npmcli/git": "^5.0.0",
+                "glob": "^10.2.2",
+                "hosted-git-info": "^7.0.0",
+                "json-parse-even-better-errors": "^3.0.0",
+                "normalize-package-data": "^6.0.0",
+                "proc-log": "^4.0.0",
+                "semver": "^7.5.3"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@npmcli/package-json/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/@npmcli/package-json/node_modules/glob": {
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
+            "dev": true,
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.18"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/@npmcli/package-json/node_modules/minimatch": {
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
+            "dev": true,
+            "dependencies": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
         "node_modules/@npmcli/promise-spawn": {
-            "version": "7.0.1",
-            "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz",
-            "integrity": "sha512-P4KkF9jX3y+7yFUxgcUdDtLy+t4OlDGuEBLNs57AZsfSfg+uV6MLndqGpnl4831ggaEdXwR50XFoZP4VFtHolg==",
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz",
+            "integrity": "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==",
             "dev": true,
             "dependencies": {
                 "which": "^4.0.0"
@@ -466,16 +603,25 @@
                 "node": "^16.13.0 || >=18.0.0"
             }
         },
+        "node_modules/@npmcli/redact": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-1.1.0.tgz",
+            "integrity": "sha512-PfnWuOkQgu7gCbnSsAisaX7hKOdZ4wSAhAzH3/ph5dSGau52kCRrMMGbiSQLwyTZpgldkZ49b0brkOr1AzGBHQ==",
+            "dev": true,
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
         "node_modules/@npmcli/run-script": {
-            "version": "7.0.3",
-            "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.3.tgz",
-            "integrity": "sha512-ZMWGLHpzMq3rBGIwPyeaoaleaLMvrBrH8nugHxTi5ACkJZXTxXPtVuEH91ifgtss5hUwJQ2VDnzDBWPmz78rvg==",
+            "version": "7.0.4",
+            "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.4.tgz",
+            "integrity": "sha512-9ApYM/3+rBt9V80aYg6tZfzj3UWdiYyCt7gJUD1VJKvWF5nwKDSICXbYIQbspFTq6TOpbsEtIC0LArB8d9PFmg==",
             "dev": true,
             "dependencies": {
                 "@npmcli/node-gyp": "^3.0.0",
+                "@npmcli/package-json": "^5.0.0",
                 "@npmcli/promise-spawn": "^7.0.0",
                 "node-gyp": "^10.0.0",
-                "read-package-json-fast": "^3.0.0",
                 "which": "^4.0.0"
             },
             "engines": {
@@ -510,79 +656,104 @@
             "version": "0.11.0",
             "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
             "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
-            "dev": true,
             "optional": true,
             "engines": {
                 "node": ">=14"
             }
         },
         "node_modules/@sigstore/bundle": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz",
-            "integrity": "sha512-89uOo6yh/oxaU8AeOUnVrTdVMcGk9Q1hJa7Hkvalc6G3Z3CupWk4Xe9djSgJm9fMkH69s0P0cVHUoKSOemLdng==",
+            "version": "2.3.2",
+            "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.3.2.tgz",
+            "integrity": "sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA==",
             "dev": true,
             "dependencies": {
-                "@sigstore/protobuf-specs": "^0.2.1"
+                "@sigstore/protobuf-specs": "^0.3.2"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
             }
         },
+        "node_modules/@sigstore/core": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-1.1.0.tgz",
+            "integrity": "sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg==",
+            "dev": true,
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
         "node_modules/@sigstore/protobuf-specs": {
-            "version": "0.2.1",
-            "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.2.1.tgz",
-            "integrity": "sha512-XTWVxnWJu+c1oCshMLwnKvz8ZQJJDVOlciMfgpJBQbThVjKTCG8dwyhgLngBD2KN0ap9F/gOV8rFDEx8uh7R2A==",
+            "version": "0.3.2",
+            "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.2.tgz",
+            "integrity": "sha512-c6B0ehIWxMI8wiS/bj6rHMPqeFvngFV7cDU/MY+B16P9Z3Mp9k8L93eYZ7BYzSickzuqAQqAq0V956b3Ju6mLw==",
             "dev": true,
             "engines": {
-                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+                "node": "^16.14.0 || >=18.0.0"
             }
         },
         "node_modules/@sigstore/sign": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.2.0.tgz",
-            "integrity": "sha512-AAbmnEHDQv6CSfrWA5wXslGtzLPtAtHZleKOgxdQYvx/s76Fk6T6ZVt7w2IGV9j1UrFeBocTTQxaXG2oRrDhYA==",
+            "version": "2.3.2",
+            "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.3.2.tgz",
+            "integrity": "sha512-5Vz5dPVuunIIvC5vBb0APwo7qKA4G9yM48kPWJT+OEERs40md5GoUR1yedwpekWZ4m0Hhw44m6zU+ObsON+iDA==",
             "dev": true,
             "dependencies": {
-                "@sigstore/bundle": "^2.1.0",
-                "@sigstore/protobuf-specs": "^0.2.1",
-                "make-fetch-happen": "^13.0.0"
+                "@sigstore/bundle": "^2.3.2",
+                "@sigstore/core": "^1.0.0",
+                "@sigstore/protobuf-specs": "^0.3.2",
+                "make-fetch-happen": "^13.0.1",
+                "proc-log": "^4.2.0",
+                "promise-retry": "^2.0.1"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
             }
         },
         "node_modules/@sigstore/tuf": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.2.0.tgz",
-            "integrity": "sha512-KKATZ5orWfqd9ZG6MN8PtCIx4eevWSuGRKQvofnWXRpyMyUEpmrzg5M5BrCpjM+NfZ0RbNGOh5tCz/P2uoRqOA==",
+            "version": "2.3.4",
+            "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.4.tgz",
+            "integrity": "sha512-44vtsveTPUpqhm9NCrbU8CWLe3Vck2HO1PNLw7RIajbB7xhtn5RBPm1VNSCMwqGYHhDsBJG8gDF0q4lgydsJvw==",
             "dev": true,
             "dependencies": {
-                "@sigstore/protobuf-specs": "^0.2.1",
-                "tuf-js": "^2.1.0"
+                "@sigstore/protobuf-specs": "^0.3.2",
+                "tuf-js": "^2.2.1"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@sigstore/verify": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-1.2.1.tgz",
+            "integrity": "sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g==",
+            "dev": true,
+            "dependencies": {
+                "@sigstore/bundle": "^2.3.2",
+                "@sigstore/core": "^1.1.0",
+                "@sigstore/protobuf-specs": "^0.3.2"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
             }
         },
         "node_modules/@tapjs/after": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/after/-/after-1.1.17.tgz",
-            "integrity": "sha512-14qeP+mHZ8nIMDGtdCwTgvKclLlHxfARMTasb9fw//tmF/8ZDZhTemtCDxAP75wihxy5P7nzVZo/6TpVeOZrwg==",
+            "version": "1.1.24",
+            "resolved": "https://registry.npmjs.org/@tapjs/after/-/after-1.1.24.tgz",
+            "integrity": "sha512-Qys3CtftkfHGC7thDGm9TBzRCBLAoJKrXufF1zQxI1oNUjclWZP/s8CtHH0mwUTISOTehmBLV3wPPHSslD67Ng==",
             "dev": true,
             "dependencies": {
-                "is-actual-promise": "^1.0.0"
+                "is-actual-promise": "^1.0.1"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/after-each": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/after-each/-/after-each-1.1.17.tgz",
-            "integrity": "sha512-ia8sr00Wilni+2+wO4MKYCYikeRwUC41HamV8EPN63R2UmiBEOe/cMSf+KYADIh56JvxAiH7Xa0+GSFU+N2FQQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/after-each/-/after-each-2.0.1.tgz",
+            "integrity": "sha512-3JXIJ4g9LPjyXmn/1VuIMC0vh7uBgUpQPksjffxv0rL8wq4C8lvmqt8Qu/fVImJucqzA+WrRqVG1b2Ab0ocDOw==",
             "dev": true,
             "dependencies": {
                 "function-loop": "^4.0.0"
@@ -591,18 +762,18 @@
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/asserts": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/asserts/-/asserts-1.1.17.tgz",
-            "integrity": "sha512-eKmbWBORDXu9bUHtPTu7qFrXNj5UeeH2nABJeP9BGHIn2ydmTgMEWCO3E+ljf7tisHchY5/x672lr99+O/mbTQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/asserts/-/asserts-2.0.1.tgz",
+            "integrity": "sha512-v2xYDLUwMGt8pzoY5LIjDCaw2NM+G01NW4pC3RcpsZLZbzQv1x/phi2RAX0ixI0nCmZZybqRygFKuMcJamS+gg==",
             "dev": true,
             "dependencies": {
-                "@tapjs/stack": "1.2.7",
-                "is-actual-promise": "^1.0.0",
-                "tcompare": "6.4.5",
+                "@tapjs/stack": "2.0.1",
+                "is-actual-promise": "^1.0.1",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0"
             },
             "engines": {
@@ -612,28 +783,41 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
+            }
+        },
+        "node_modules/@tapjs/asserts/node_modules/tcompare": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+            "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^5.2.0",
+                "react-element-to-jsx-string": "^15.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             }
         },
         "node_modules/@tapjs/before": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/before/-/before-1.1.17.tgz",
-            "integrity": "sha512-pAmEAIMIqF9MPNUgEsnuWCM00iD/FJOX0P5eXSsWexWHjuZAkv5tIT/4qpXO9KYj+9c51Lh+7YSY2Xvk1Jjolw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/before/-/before-2.0.1.tgz",
+            "integrity": "sha512-GgnlWPm2PbuyYuG4gkkO2KAvT/BbGnpKs60U4XzPSJ2w73Qc/IYWP0Kz6qfCWongpiLteoco67M89ujUQApYJw==",
             "dev": true,
             "dependencies": {
-                "is-actual-promise": "^1.0.0"
+                "is-actual-promise": "^1.0.1"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/before-each": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/before-each/-/before-each-1.1.17.tgz",
-            "integrity": "sha512-d2Um3Y2j0m563QNsSxczh+QeSg5sBngnBFGOelUtQVqmq91oNWU/7mY1pwN6ip8mMIQYD75CIhq5/Z57DGomWQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/before-each/-/before-each-2.0.1.tgz",
+            "integrity": "sha512-gG1nYkvCHtWwhkueulO475KczdQZ3vBRgdkta/Qi42ZjZo6SNhYVjNc/+LRGV5vZoESrvgSd+JrDRGufd+j43w==",
             "dev": true,
             "dependencies": {
                 "function-loop": "^4.0.0"
@@ -642,21 +826,21 @@
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/config": {
-            "version": "2.4.14",
-            "resolved": "https://registry.npmjs.org/@tapjs/config/-/config-2.4.14.tgz",
-            "integrity": "sha512-dkjPVJGbLJC9BxCAxudAGiijnKc6XcQbpBSMAGJ/+VoRSqXlPkMWz0d8Ad3rNt7s+g2GBEWBx1kV7wcKtLlxmw==",
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/config/-/config-3.0.1.tgz",
+            "integrity": "sha512-gAYFzErdSuPQ3afW6iRR99hiJmRLU+x9T+NE89z9UM45iPxglWLrRv1PFfh3tmtX6rpzwD5RY4/FVPcP2+/1LQ==",
             "dev": true,
             "dependencies": {
-                "@tapjs/core": "1.4.6",
-                "@tapjs/test": "1.3.17",
+                "@tapjs/core": "2.0.1",
+                "@tapjs/test": "2.0.1",
                 "chalk": "^5.2.0",
-                "jackspeak": "^2.3.6",
+                "jackspeak": "^3.1.2",
                 "polite-json": "^4.0.1",
-                "tap-yaml": "2.2.1",
+                "tap-yaml": "2.2.2",
                 "walk-up-path": "^3.0.1"
             },
             "engines": {
@@ -666,8 +850,8 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6",
-                "@tapjs/test": "1.3.17"
+                "@tapjs/core": "2.0.1",
+                "@tapjs/test": "2.0.1"
             }
         },
         "node_modules/@tapjs/config/node_modules/chalk": {
@@ -683,35 +867,48 @@
             }
         },
         "node_modules/@tapjs/core": {
-            "version": "1.4.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/core/-/core-1.4.6.tgz",
-            "integrity": "sha512-cAKtdGJslrziwi/RJBU7jF930P/eSsemv295t6yLekNVP0XUCNtLFYirxuS1Xwob0nt0g/k+94xXB7o1wdTQvA==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/core/-/core-2.0.1.tgz",
+            "integrity": "sha512-q+8d+ohw5kudktIqgP5ETBcPWAPip+kMIxs2eL2G3dV+7Gc8WrH43cCPrbSGPRITIOSIDPrtpQZEcZwQNqDdQw==",
             "dev": true,
             "dependencies": {
-                "@tapjs/processinfo": "^3.1.6",
-                "@tapjs/stack": "1.2.7",
-                "@tapjs/test": "1.3.17",
+                "@tapjs/processinfo": "^3.1.7",
+                "@tapjs/stack": "2.0.1",
+                "@tapjs/test": "2.0.1",
                 "async-hook-domain": "^4.0.1",
-                "diff": "^5.1.0",
-                "is-actual-promise": "^1.0.0",
-                "minipass": "^7.0.3",
+                "diff": "^5.2.0",
+                "is-actual-promise": "^1.0.1",
+                "minipass": "^7.0.4",
                 "signal-exit": "4.1",
-                "tap-parser": "15.3.1",
-                "tap-yaml": "2.2.1",
-                "tcompare": "6.4.5",
+                "tap-parser": "16.0.1",
+                "tap-yaml": "2.2.2",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             }
         },
+        "node_modules/@tapjs/core/node_modules/tcompare": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+            "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^5.2.0",
+                "react-element-to-jsx-string": "^15.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            }
+        },
         "node_modules/@tapjs/error-serdes": {
-            "version": "1.2.1",
-            "resolved": "https://registry.npmjs.org/@tapjs/error-serdes/-/error-serdes-1.2.1.tgz",
-            "integrity": "sha512-/7eLEcrGo+Qz3eWrjkhDC+VSEOjabkkzr9eRADeU+OLFeZaik8L/GRk0SGhnp4YsQkv0jcNV00A42bEx2HIZcw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/error-serdes/-/error-serdes-2.0.1.tgz",
+            "integrity": "sha512-P+M4rtcfkDsUveKKmoRNF+07xpbPnRY5KrstIUOnyn483clQ7BJhsnWr162yYNCsyOj4zEfZmAJI1f8Bi7h/ZA==",
             "dev": true,
             "dependencies": {
-                "minipass": "^7.0.3"
+                "minipass": "^7.0.4"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
@@ -721,9 +918,9 @@
             }
         },
         "node_modules/@tapjs/filter": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/filter/-/filter-1.2.17.tgz",
-            "integrity": "sha512-ytsqoPThV92ML1+M+cHlhAS7nOQpDNRBJiPqw20/GmNeoQXsDzVUlWR89DP3WNNUPrr/c1pCVr9XHVhCIeYk0w==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/filter/-/filter-2.0.1.tgz",
+            "integrity": "sha512-muKEeXK7Tz6VR4hjXfT2qXPvjYES575mtiRerjHf+8qP8D7MvmC8qDZJjzFdo1nZHKhF8snvFosIVuI1BAhvsw==",
             "dev": true,
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
@@ -732,13 +929,13 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/fixture": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/fixture/-/fixture-1.2.17.tgz",
-            "integrity": "sha512-eOOQxtsEcQ/sBxaZhpqdF9DCNxXAvLuiE5HgyL6d1eB4eceu57uIUKK7NDtFVv+vlbQH/NoiSTxmN/IBRbKT8w==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/fixture/-/fixture-2.0.1.tgz",
+            "integrity": "sha512-MLgEwsBlCD69iUbZcnKBehP2js5cV4p5GrFoOKSudMuH2DQJInaF/g2bkijue61cVZwPj/MRPCqAlkwA94epjg==",
             "dev": true,
             "dependencies": {
                 "mkdirp": "^3.0.0",
@@ -751,97 +948,33 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
-            }
-        },
-        "node_modules/@tapjs/fixture/node_modules/brace-expansion": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
-            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
-            "dev": true,
-            "dependencies": {
-                "balanced-match": "^1.0.0"
-            }
-        },
-        "node_modules/@tapjs/fixture/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
-            "dev": true,
-            "dependencies": {
-                "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
-            },
-            "bin": {
-                "glob": "dist/esm/bin.mjs"
-            },
-            "engines": {
-                "node": ">=16 || 14 >=14.17"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
-        "node_modules/@tapjs/fixture/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
-            "dev": true,
-            "dependencies": {
-                "brace-expansion": "^2.0.1"
-            },
-            "engines": {
-                "node": ">=16 || 14 >=14.17"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
-        "node_modules/@tapjs/fixture/node_modules/rimraf": {
-            "version": "5.0.5",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
-            "dev": true,
-            "dependencies": {
-                "glob": "^10.3.7"
-            },
-            "bin": {
-                "rimraf": "dist/esm/bin.mjs"
-            },
-            "engines": {
-                "node": ">=14"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/intercept": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/intercept/-/intercept-1.2.17.tgz",
-            "integrity": "sha512-CNuYBxiFBMNALS1PxH3yGI10H8ObxOoD67C2xGWyzXeYrPJ/R4x31Sda9bqaoK3uf/vj28bC9kSECCFjRsNAEg==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/intercept/-/intercept-2.0.1.tgz",
+            "integrity": "sha512-BZgXE3zCAbv4lfbph1r85gihtI3kXltHlFQ8Bf3Yy9fx27DKQlBvXnD7T69ke8kQLRzhz+wTMcR/mcQjo1fa7w==",
             "dev": true,
             "dependencies": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/stack": "1.2.7"
+                "@tapjs/after": "1.1.24",
+                "@tapjs/stack": "2.0.1"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/mock": {
-            "version": "1.2.15",
-            "resolved": "https://registry.npmjs.org/@tapjs/mock/-/mock-1.2.15.tgz",
-            "integrity": "sha512-uXfVNDAMAbCGOu46B9jbryTau2pLSQjCdWnkAm/OUgZh/OtO0i7OORz9HdEPfEF2tuy1tLo9+vsCZm3lPU5F7w==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/mock/-/mock-2.0.1.tgz",
+            "integrity": "sha512-i1vkwNgO7uEuQW3+hTuE2L64aC9xk0cC3PtC6DZKqyApk2IstNgoIS38nfsI6v2kvEgZNuWlsNcRAYNDOIEhzA==",
             "dev": true,
             "dependencies": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/stack": "1.2.7",
+                "@tapjs/after": "1.1.24",
+                "@tapjs/stack": "2.0.1",
                 "resolve-import": "^1.4.5",
                 "walk-up-path": "^3.0.1"
             },
@@ -852,18 +985,18 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/node-serialize": {
-            "version": "1.2.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/node-serialize/-/node-serialize-1.2.6.tgz",
-            "integrity": "sha512-xj1OJEsdTr0pQFlirfe/apN0dHUCMCx2Nm5H3SoiSOW4D1/FUKS65VZpWgo3mXMPxRyb/2T1DH3xON1eSGq4ww==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/node-serialize/-/node-serialize-2.0.1.tgz",
+            "integrity": "sha512-1GtHDa7AXpk8y08llIPfUKRTDNsq+BhXxz7wiIfVEAOEB09kGyfpWteOg+cmvb+aHU1Ays3z+medXTIBm0D5Kg==",
             "dev": true,
             "dependencies": {
-                "@tapjs/error-serdes": "1.2.1",
-                "@tapjs/stack": "1.2.7",
-                "tap-parser": "15.3.1"
+                "@tapjs/error-serdes": "2.0.1",
+                "@tapjs/stack": "2.0.1",
+                "tap-parser": "16.0.1"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
@@ -872,13 +1005,13 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/processinfo": {
-            "version": "3.1.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/processinfo/-/processinfo-3.1.6.tgz",
-            "integrity": "sha512-ktDsaf79wJsLaoG1Pp+stHSRf6a1k/JydoRAaYVG5iJnd3DooL6yewZsciUi2yiN/WQc5tAXCIFTXL4uXGB8LA==",
+            "version": "3.1.7",
+            "resolved": "https://registry.npmjs.org/@tapjs/processinfo/-/processinfo-3.1.7.tgz",
+            "integrity": "sha512-SI5RJQ5HnUKEWnHSAF6hOm6XPdnjZ+CJzIaVHdFebed8iDAPTqb+IwMVu9yq9+VQ7FRsMMlgLL2SW4rss2iJbQ==",
             "dev": true,
             "dependencies": {
                 "pirates": "^4.0.5",
@@ -891,24 +1024,24 @@
             }
         },
         "node_modules/@tapjs/reporter": {
-            "version": "1.3.15",
-            "resolved": "https://registry.npmjs.org/@tapjs/reporter/-/reporter-1.3.15.tgz",
-            "integrity": "sha512-us1vXd6TW1V8wJxxnP2a8DNSP1WFTpODyYukqWg7ym5nCalREYnz2MFsn65rRNu/xJlmqsmv+9P63rupud7Zlg==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/reporter/-/reporter-2.0.1.tgz",
+            "integrity": "sha512-fCdl4vg8vnlqIYtTQ9dc3zOqeXrA5QbATbT4dsPIiPuCM3gvKTbntaNBeaWWZkPx697Dj+b8TIxT/xhNMNv7jQ==",
             "dev": true,
             "dependencies": {
-                "@tapjs/config": "2.4.14",
-                "@tapjs/stack": "1.2.7",
+                "@tapjs/config": "3.0.1",
+                "@tapjs/stack": "2.0.1",
                 "chalk": "^5.2.0",
                 "ink": "^4.4.1",
-                "minipass": "^7.0.3",
+                "minipass": "^7.0.4",
                 "ms": "^2.1.3",
                 "patch-console": "^2.0.0",
                 "prismjs-terminal": "^1.2.3",
                 "react": "^18.2.0",
                 "string-length": "^6.0.0",
-                "tap-parser": "15.3.1",
-                "tap-yaml": "2.2.1",
-                "tcompare": "6.4.5"
+                "tap-parser": "16.0.1",
+                "tap-yaml": "2.2.2",
+                "tcompare": "7.0.1"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
@@ -917,7 +1050,7 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/reporter/node_modules/chalk": {
@@ -938,36 +1071,49 @@
             "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
             "dev": true
         },
+        "node_modules/@tapjs/reporter/node_modules/tcompare": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+            "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^5.2.0",
+                "react-element-to-jsx-string": "^15.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            }
+        },
         "node_modules/@tapjs/run": {
-            "version": "1.4.16",
-            "resolved": "https://registry.npmjs.org/@tapjs/run/-/run-1.4.16.tgz",
-            "integrity": "sha512-ZTESjBDj5SitZgWz2hQdzfBoxgaFs89jQjWzqobcdfro0iF7TVRpSrvpz9GTMdo2Tu9aeFfMNfmaAtwNWnDabw==",
-            "dev": true,
-            "dependencies": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/before": "1.1.17",
-                "@tapjs/config": "2.4.14",
-                "@tapjs/processinfo": "^3.1.6",
-                "@tapjs/reporter": "1.3.15",
-                "@tapjs/spawn": "1.1.17",
-                "@tapjs/stdin": "1.1.17",
-                "@tapjs/test": "1.3.17",
-                "c8": "^8.0.1",
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/@tapjs/run/-/run-2.0.2.tgz",
+            "integrity": "sha512-2hPGlabqbLb3hh4BHHvwE8R9a9OiWumkCkHw5QQUZurDsVOpB94FfteqW9mktTVjZJnN0go+sN3GN2jZUaPWGQ==",
+            "dev": true,
+            "dependencies": {
+                "@tapjs/after": "1.1.24",
+                "@tapjs/before": "2.0.1",
+                "@tapjs/config": "3.0.1",
+                "@tapjs/processinfo": "^3.1.7",
+                "@tapjs/reporter": "2.0.1",
+                "@tapjs/spawn": "2.0.1",
+                "@tapjs/stdin": "2.0.1",
+                "@tapjs/test": "2.0.1",
+                "c8": "^9.1.0",
                 "chalk": "^5.3.0",
-                "chokidar": "^3.5.3",
+                "chokidar": "^3.6.0",
                 "foreground-child": "^3.1.1",
-                "glob": "^10.3.10",
-                "minipass": "^7.0.3",
+                "glob": "^10.3.16",
+                "minipass": "^7.0.4",
                 "mkdirp": "^3.0.1",
                 "opener": "^1.5.2",
-                "pacote": "^17.0.3",
+                "pacote": "^17.0.6",
                 "resolve-import": "^1.4.5",
                 "rimraf": "^5.0.5",
-                "semver": "^7.5.4",
+                "semver": "^7.6.0",
                 "signal-exit": "^4.1.0",
-                "tap-parser": "15.3.1",
-                "tap-yaml": "2.2.1",
-                "tcompare": "6.4.5",
+                "tap-parser": "16.0.1",
+                "tap-yaml": "2.2.2",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0",
                 "which": "^4.0.0"
             },
@@ -981,7 +1127,7 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/run/node_modules/brace-expansion": {
@@ -1006,22 +1152,22 @@
             }
         },
         "node_modules/@tapjs/run/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
@@ -1037,9 +1183,9 @@
             }
         },
         "node_modules/@tapjs/run/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -1051,22 +1197,17 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/@tapjs/run/node_modules/rimraf": {
-            "version": "5.0.5",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+        "node_modules/@tapjs/run/node_modules/tcompare": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+            "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
             "dev": true,
             "dependencies": {
-                "glob": "^10.3.7"
-            },
-            "bin": {
-                "rimraf": "dist/esm/bin.mjs"
+                "diff": "^5.2.0",
+                "react-element-to-jsx-string": "^15.0.0"
             },
             "engines": {
-                "node": ">=14"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             }
         },
         "node_modules/@tapjs/run/node_modules/which": {
@@ -1085,13 +1226,13 @@
             }
         },
         "node_modules/@tapjs/snapshot": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/snapshot/-/snapshot-1.2.17.tgz",
-            "integrity": "sha512-xDHys854ZA8s/1uCkE5PgBz4H1vYKChD6a4xjLVkaoRxpBHVp/IJZCD+8d69DRGnyuA4x2MGh0JLClTA9bLGrA==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/snapshot/-/snapshot-2.0.1.tgz",
+            "integrity": "sha512-ZnbCxL+9fiJ38tec6wvRtRBZz9ChRUq0Bov7dltdZMNkXqudKyB+Zzbg25bqDEIgcczyp6A9hOwTX6VybDGqpg==",
             "dev": true,
             "dependencies": {
-                "is-actual-promise": "^1.0.0",
-                "tcompare": "6.4.5",
+                "is-actual-promise": "^1.0.1",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0"
             },
             "engines": {
@@ -1101,25 +1242,38 @@
                 "url": "https://github.com/sponsors/isaacs"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
+            }
+        },
+        "node_modules/@tapjs/snapshot/node_modules/tcompare": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+            "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+            "dev": true,
+            "dependencies": {
+                "diff": "^5.2.0",
+                "react-element-to-jsx-string": "^15.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             }
         },
         "node_modules/@tapjs/spawn": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/spawn/-/spawn-1.1.17.tgz",
-            "integrity": "sha512-Bbyxd91bgXEcglvXYKrRl2MaNHk00RajTZJ1kKe3Scr1ivaYv0maE6ZInAl4UE0a4SJl4Dskec+uKoZY3qGUYQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/spawn/-/spawn-2.0.1.tgz",
+            "integrity": "sha512-3VaQKJjHV5frMZj3Ef+QlJyB6b7VsGMil223zAEz8Ttgy2hDYtcb29nvsLPUcowFyOUrsydnXEnHgpR79wEPOA==",
             "dev": true,
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/stack": {
-            "version": "1.2.7",
-            "resolved": "https://registry.npmjs.org/@tapjs/stack/-/stack-1.2.7.tgz",
-            "integrity": "sha512-7qUDWDmd+y7ZQ0vTrDTvFlWnJ+ND32NemS5HVuT1ZggHtBwJ62PQHIyCx/B5RopETBb6NvFPfUE21yTiex9Jkw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/stack/-/stack-2.0.1.tgz",
+            "integrity": "sha512-3rKbZkRkLeJl9ilV/6b80YfI4C4+OYf7iEz5/d0MIVhmVvxv0ttIy5JnZutAc4Gy9eRp5Ne5UTAIFOVY5k36cg==",
             "dev": true,
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
@@ -1129,48 +1283,49 @@
             }
         },
         "node_modules/@tapjs/stdin": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/stdin/-/stdin-1.1.17.tgz",
-            "integrity": "sha512-mDutFFPDnlVM2oYDAfyYKA+fC+aEiyz5n08D8x6YAbwZNbTIVp+h6ucyp7ygJ04fshd4l3s1HUmCZLSmHb2xEw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/stdin/-/stdin-2.0.1.tgz",
+            "integrity": "sha512-5Oe13Fzpnt9seAi8h3bsMxtJp8S+DQI6ncBD9JBcS91XKLbqyKrb1bNzeXQN2PrHBs6Atw8cOzFZh0TjL+bIaA==",
             "dev": true,
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/test": {
-            "version": "1.3.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/test/-/test-1.3.17.tgz",
-            "integrity": "sha512-yQ4uHC2GaDS+Gr5qwx9uMGxqvpYgnlVY+QexBReSeYZthWIN0KD8HDvnVt4An5Sx/Qhd7UlnNpNMBd6AkvPEew==",
-            "dev": true,
-            "dependencies": {
-                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.5",
-                "@tapjs/after": "1.1.17",
-                "@tapjs/after-each": "1.1.17",
-                "@tapjs/asserts": "1.1.17",
-                "@tapjs/before": "1.1.17",
-                "@tapjs/before-each": "1.1.17",
-                "@tapjs/filter": "1.2.17",
-                "@tapjs/fixture": "1.2.17",
-                "@tapjs/intercept": "1.2.17",
-                "@tapjs/mock": "1.2.15",
-                "@tapjs/node-serialize": "1.2.6",
-                "@tapjs/snapshot": "1.2.17",
-                "@tapjs/spawn": "1.1.17",
-                "@tapjs/stdin": "1.1.17",
-                "@tapjs/typescript": "1.3.6",
-                "@tapjs/worker": "1.1.17",
-                "glob": "^10.3.10",
-                "jackspeak": "^2.3.6",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/test/-/test-2.0.1.tgz",
+            "integrity": "sha512-PKazf7r4+bLFATML2f/h8glGcSirXmzXUYlhFuxb4xHoOhHojyKgo1p8kSj+Ksxb3hVSCQlvyXgM8QYYaoMwog==",
+            "dev": true,
+            "dependencies": {
+                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.7",
+                "@tapjs/after": "1.1.24",
+                "@tapjs/after-each": "2.0.1",
+                "@tapjs/asserts": "2.0.1",
+                "@tapjs/before": "2.0.1",
+                "@tapjs/before-each": "2.0.1",
+                "@tapjs/filter": "2.0.1",
+                "@tapjs/fixture": "2.0.1",
+                "@tapjs/intercept": "2.0.1",
+                "@tapjs/mock": "2.0.1",
+                "@tapjs/node-serialize": "2.0.1",
+                "@tapjs/snapshot": "2.0.1",
+                "@tapjs/spawn": "2.0.1",
+                "@tapjs/stdin": "2.0.1",
+                "@tapjs/typescript": "1.4.6",
+                "@tapjs/worker": "2.0.1",
+                "glob": "^10.3.16",
+                "jackspeak": "^3.1.2",
                 "mkdirp": "^3.0.0",
                 "resolve-import": "^1.4.5",
                 "rimraf": "^5.0.5",
                 "sync-content": "^1.0.1",
-                "tap-parser": "15.3.1",
-                "tshy": "^1.2.2",
-                "typescript": "5.2"
+                "tap-parser": "16.0.1",
+                "tshy": "^1.14.0",
+                "typescript": "5.4",
+                "walk-up-path": "^3.0.1"
             },
             "bin": {
                 "generate-tap-test-class": "scripts/build.mjs"
@@ -1179,7 +1334,7 @@
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/test/node_modules/brace-expansion": {
@@ -1192,31 +1347,31 @@
             }
         },
         "node_modules/@tapjs/test/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/@tapjs/test/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -1228,73 +1383,55 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/@tapjs/test/node_modules/rimraf": {
-            "version": "5.0.5",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
-            "dev": true,
-            "dependencies": {
-                "glob": "^10.3.7"
-            },
-            "bin": {
-                "rimraf": "dist/esm/bin.mjs"
-            },
-            "engines": {
-                "node": ">=14"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
         "node_modules/@tapjs/typescript": {
-            "version": "1.3.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/typescript/-/typescript-1.3.6.tgz",
-            "integrity": "sha512-bHqQb06HcD1vFvSwElH0WK4cnCNthvA5OX/KBs5w1TNFHIeRHemp/hsSnGSNDwYwDETuOxD68rDZNTpNbzysBg==",
+            "version": "1.4.6",
+            "resolved": "https://registry.npmjs.org/@tapjs/typescript/-/typescript-1.4.6.tgz",
+            "integrity": "sha512-6jxUQ7Mdb+Y2q8RJcwgZZ6dCR+X2u3hCL+xb1GDAtO7k1+B6z2b+z+I+FdhuO4YgrP0SLRjocL5rJM/xi9K7qw==",
             "dev": true,
             "dependencies": {
-                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.5"
+                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.7"
             },
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tapjs/worker": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/worker/-/worker-1.1.17.tgz",
-            "integrity": "sha512-DCRzEBT+OgP518rQqzlX6KawvGTegkeEjPVa/TB6Iifj8WOHJ+XtunkR7riIRGEoCEOMD49DCJXj70c+XP0jNw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/worker/-/worker-2.0.1.tgz",
+            "integrity": "sha512-wegz8IxNEPIIAA+R76/avZgNmZ4iC7QGFbtXKGBU962/1lXTITxshRV6e21r0IBa7YLkSVgDuVSVB3+Qzve0Yg==",
             "dev": true,
             "engines": {
                 "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "peerDependencies": {
-                "@tapjs/core": "1.4.6"
+                "@tapjs/core": "2.0.1"
             }
         },
         "node_modules/@tsconfig/node14": {
-            "version": "14.1.0",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-14.1.0.tgz",
-            "integrity": "sha512-VmsCG04YR58ciHBeJKBDNMWWfYbyP8FekWVuTlpstaUPlat1D0x/tXzkWP7yCMU0eSz9V4OZU0LBWTFJ3xZf6w==",
+            "version": "14.1.2",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-14.1.2.tgz",
+            "integrity": "sha512-1vncsbfCZ3TBLPxesRYz02Rn7SNJfbLoDVkcZ7F/ixOV6nwxwgdhD1mdPcc5YQ413qBJ8CvMxXMFfJ7oawjo7Q==",
             "dev": true
         },
         "node_modules/@tsconfig/node16": {
-            "version": "16.1.1",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-16.1.1.tgz",
-            "integrity": "sha512-+pio93ejHN4nINX4pXqfnR/fPLRtJBaT4ORaa5RH0Oc1zoYmo2B2koG+M328CQhHKn1Wj6FcOxCDFXAot9NhvA==",
+            "version": "16.1.3",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-16.1.3.tgz",
+            "integrity": "sha512-9nTOUBn+EMKO6rtSZJk+DcqsfgtlERGT9XPJ5PRj/HNENPCBY1yu/JEj5wT6GLtbCLBO2k46SeXDaY0pjMqypw==",
             "dev": true
         },
         "node_modules/@tsconfig/node18": {
-            "version": "18.2.2",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.2.tgz",
-            "integrity": "sha512-d6McJeGsuoRlwWZmVIeE8CUA27lu6jLjvv1JzqmpsytOYYbVi1tHZEnwCNVOXnj4pyLvneZlFlpXUK+X9wBWyw==",
+            "version": "18.2.4",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.4.tgz",
+            "integrity": "sha512-5xxU8vVs9/FNcvm3gE07fPbn9tl6tqGGWA9tSlwsUEkBxtRnTsNmwrV8gasZ9F/EobaSv9+nu8AxUKccw77JpQ==",
             "dev": true
         },
         "node_modules/@tsconfig/node20": {
-            "version": "20.1.2",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.2.tgz",
-            "integrity": "sha512-madaWq2k+LYMEhmcp0fs+OGaLFk0OenpHa4gmI4VEmCKX4PJntQ6fnnGADVFrVkBj0wIdAlQnK/MrlYTHsa1gQ==",
+            "version": "20.1.4",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz",
+            "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==",
             "dev": true
         },
         "node_modules/@tufjs/canonical-json": {
@@ -1307,13 +1444,13 @@
             }
         },
         "node_modules/@tufjs/models": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.0.tgz",
-            "integrity": "sha512-c8nj8BaOExmZKO2DXhDfegyhSGcG9E/mPN3U13L+/PsoWm1uaGiHHjxqSHQiasDBQwDA3aHuw9+9spYAP1qvvg==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.1.tgz",
+            "integrity": "sha512-92F7/SFyufn4DXsha9+QfKnN03JGqtMFMXgSHbZOo8JG59WkTni7UzAouNQDf7AuP9OAMxVOPQcqG3sB7w+kkg==",
             "dev": true,
             "dependencies": {
                 "@tufjs/canonical-json": "2.0.0",
-                "minimatch": "^9.0.3"
+                "minimatch": "^9.0.4"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
@@ -1329,9 +1466,9 @@
             }
         },
         "node_modules/@tufjs/models/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -1350,9 +1487,9 @@
             "dev": true
         },
         "node_modules/@types/node": {
-            "version": "20.10.6",
-            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz",
-            "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==",
+            "version": "20.13.0",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.13.0.tgz",
+            "integrity": "sha512-FM6AOb3khNkNIXPnHFDYaHerSv8uN22C91z098AnGccVu+Pcdhi+pNUFDi0iLmPIsVE0JBD0KVS7mzUYt4nRzQ==",
             "dev": true,
             "peer": true,
             "dependencies": {
@@ -1388,18 +1525,18 @@
             }
         },
         "node_modules/acorn-walk": {
-            "version": "8.3.1",
-            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz",
-            "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==",
+            "version": "8.3.2",
+            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
+            "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
             "dev": true,
             "engines": {
                 "node": ">=0.4.0"
             }
         },
         "node_modules/agent-base": {
-            "version": "7.1.0",
-            "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
-            "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==",
+            "version": "7.1.1",
+            "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
+            "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
             "dev": true,
             "dependencies": {
                 "debug": "^4.3.4"
@@ -1446,24 +1583,9 @@
             }
         },
         "node_modules/ansi-escapes": {
-            "version": "6.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz",
-            "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==",
-            "dev": true,
-            "dependencies": {
-                "type-fest": "^3.0.0"
-            },
-            "engines": {
-                "node": ">=14.16"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
-        "node_modules/ansi-escapes/node_modules/type-fest": {
-            "version": "3.13.1",
-            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
-            "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
+            "version": "6.2.1",
+            "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz",
+            "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==",
             "dev": true,
             "engines": {
                 "node": ">=14.16"
@@ -1563,40 +1685,31 @@
             }
         },
         "node_modules/braces": {
-            "version": "3.0.2",
-            "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
-            "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+            "version": "3.0.3",
+            "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+            "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "fill-range": "^7.0.1"
+                "fill-range": "^7.1.1"
             },
             "engines": {
                 "node": ">=8"
             }
         },
-        "node_modules/builtins": {
-            "version": "5.0.1",
-            "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz",
-            "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==",
-            "dev": true,
-            "dependencies": {
-                "semver": "^7.0.0"
-            }
-        },
         "node_modules/c8": {
-            "version": "8.0.1",
-            "resolved": "https://registry.npmjs.org/c8/-/c8-8.0.1.tgz",
-            "integrity": "sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w==",
+            "version": "9.1.0",
+            "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz",
+            "integrity": "sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg==",
             "dev": true,
             "dependencies": {
                 "@bcoe/v8-coverage": "^0.2.3",
                 "@istanbuljs/schema": "^0.1.3",
                 "find-up": "^5.0.0",
-                "foreground-child": "^2.0.0",
+                "foreground-child": "^3.1.1",
                 "istanbul-lib-coverage": "^3.2.0",
                 "istanbul-lib-report": "^3.0.1",
                 "istanbul-reports": "^3.1.6",
-                "rimraf": "^3.0.2",
                 "test-exclude": "^6.0.0",
                 "v8-to-istanbul": "^9.0.0",
                 "yargs": "^17.7.2",
@@ -1606,32 +1719,13 @@
                 "c8": "bin/c8.js"
             },
             "engines": {
-                "node": ">=12"
+                "node": ">=14.14.0"
             }
         },
-        "node_modules/c8/node_modules/foreground-child": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz",
-            "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==",
-            "dev": true,
-            "dependencies": {
-                "cross-spawn": "^7.0.0",
-                "signal-exit": "^3.0.2"
-            },
-            "engines": {
-                "node": ">=8.0.0"
-            }
-        },
-        "node_modules/c8/node_modules/signal-exit": {
-            "version": "3.0.7",
-            "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
-            "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
-            "dev": true
-        },
         "node_modules/cacache": {
-            "version": "18.0.2",
-            "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.2.tgz",
-            "integrity": "sha512-r3NU8h/P+4lVUHfeRw1dtgQYar3DZMm4/cm2bZgOvrFC/su7budSOeqh52VJIC4U4iG1WWwV6vRW0znqBvxNuw==",
+            "version": "18.0.3",
+            "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.3.tgz",
+            "integrity": "sha512-qXCd4rh6I07cnDqh8V48/94Tc/WSfj+o3Gn6NZ0aZovS255bUx8O13uKxRFd2eWG0xgsco7+YItQNPaa5E85hg==",
             "dev": true,
             "dependencies": {
                 "@npmcli/fs": "^3.1.0",
@@ -1661,31 +1755,31 @@
             }
         },
         "node_modules/cacache/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/cacache/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -1721,16 +1815,10 @@
             }
         },
         "node_modules/chokidar": {
-            "version": "3.5.3",
-            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
-            "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+            "version": "3.6.0",
+            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+            "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
             "dev": true,
-            "funding": [
-                {
-                    "type": "individual",
-                    "url": "https://paulmillr.com/funding/"
-                }
-            ],
             "dependencies": {
                 "anymatch": "~3.1.2",
                 "braces": "~3.0.2",
@@ -1743,6 +1831,9 @@
             "engines": {
                 "node": ">= 8.10.0"
             },
+            "funding": {
+                "url": "https://paulmillr.com/funding/"
+            },
             "optionalDependencies": {
                 "fsevents": "~2.3.2"
             }
@@ -1961,6 +2052,11 @@
             "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
             "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="
         },
+        "node_modules/compress-json": {
+            "version": "3.0.5",
+            "resolved": "https://registry.npmjs.org/compress-json/-/compress-json-3.0.5.tgz",
+            "integrity": "sha512-HYiJvE0cTIygI9zXqY5fkRr7H3NV3UAME0enzwN5M0JkzMOtUcjSyaH7HxVRzXsn7IIXD0STA9M5jyWkxERSLg=="
+        },
         "node_modules/concat-map": {
             "version": "0.0.1",
             "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2016,9 +2112,9 @@
             "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
         },
         "node_modules/diff": {
-            "version": "5.1.0",
-            "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
-            "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==",
+            "version": "5.2.0",
+            "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
+            "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
             "dev": true,
             "engines": {
                 "node": ">=0.3.1"
@@ -2038,14 +2134,12 @@
         "node_modules/eastasianwidth": {
             "version": "0.2.0",
             "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
-            "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
-            "dev": true
+            "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
         },
         "node_modules/emoji-regex": {
             "version": "9.2.2",
             "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
-            "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
-            "dev": true
+            "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
         },
         "node_modules/encoding": {
             "version": "0.1.13",
@@ -2073,9 +2167,9 @@
             "dev": true
         },
         "node_modules/escalade": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
-            "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
+            "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
             "dev": true,
             "engines": {
                 "node": ">=6"
@@ -2275,10 +2369,11 @@
             }
         },
         "node_modules/fill-range": {
-            "version": "7.0.1",
-            "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
-            "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+            "version": "7.1.1",
+            "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+            "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "to-regex-range": "^5.0.1"
             },
@@ -2313,16 +2408,36 @@
                 "node": "^10.12.0 || >=12.0.0"
             }
         },
+        "node_modules/flat-cache/node_modules/rimraf": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+            "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+            "deprecated": "Rimraf versions prior to v4 are no longer supported",
+            "license": "ISC",
+            "dependencies": {
+                "glob": "^7.1.3"
+            },
+            "bin": {
+                "rimraf": "bin.js"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
         "node_modules/flatted": {
             "version": "3.2.5",
             "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
             "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
         },
+        "node_modules/flexsearch": {
+            "version": "0.7.43",
+            "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.7.43.tgz",
+            "integrity": "sha512-c5o/+Um8aqCSOXGcZoqZOm+NqtVwNsvVpWv6lfmSclU954O3wvQKxxK8zj74fPaSJbXpSLTs4PRhh+wnoCXnKg=="
+        },
         "node_modules/foreground-child": {
             "version": "3.1.1",
             "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
             "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
-            "dev": true,
             "dependencies": {
                 "cross-spawn": "^7.0.0",
                 "signal-exit": "^4.0.1"
@@ -2473,9 +2588,9 @@
             }
         },
         "node_modules/hasown": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
-            "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+            "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
             "dev": true,
             "dependencies": {
                 "function-bind": "^1.1.2"
@@ -2493,9 +2608,9 @@
             }
         },
         "node_modules/hosted-git-info": {
-            "version": "7.0.1",
-            "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz",
-            "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==",
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
+            "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==",
             "dev": true,
             "dependencies": {
                 "lru-cache": "^10.0.1"
@@ -2517,9 +2632,9 @@
             "dev": true
         },
         "node_modules/http-proxy-agent": {
-            "version": "7.0.0",
-            "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz",
-            "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==",
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+            "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
             "dev": true,
             "dependencies": {
                 "agent-base": "^7.1.0",
@@ -2530,9 +2645,9 @@
             }
         },
         "node_modules/https-proxy-agent": {
-            "version": "7.0.2",
-            "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz",
-            "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==",
+            "version": "7.0.4",
+            "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz",
+            "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==",
             "dev": true,
             "dependencies": {
                 "agent-base": "^7.0.2",
@@ -2564,9 +2679,9 @@
             }
         },
         "node_modules/ignore-walk": {
-            "version": "6.0.4",
-            "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.4.tgz",
-            "integrity": "sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw==",
+            "version": "6.0.5",
+            "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.5.tgz",
+            "integrity": "sha512-VuuG0wCnjhnylG1ABXT3dAuIpTNDs/G8jlpmwXY03fXoXy/8ZK8/T+hMzt8L4WnrLCJgdybqgPagnF/f97cg3A==",
             "dev": true,
             "dependencies": {
                 "minimatch": "^9.0.0"
@@ -2585,9 +2700,9 @@
             }
         },
         "node_modules/ignore-walk/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -2741,21 +2856,25 @@
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/ip": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
-            "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
-            "dev": true
-        },
-        "node_modules/is-actual-promise": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/is-actual-promise/-/is-actual-promise-1.0.1.tgz",
-            "integrity": "sha512-PlsL4tNv62lx5yN2HSqaRSTgIpUAPW7U6+crVB8HfWm5161rZpeqWbl0ZSqH2MAfRKXWSZVPRNbE/r8qPcb13g==",
+        "node_modules/ip-address": {
+            "version": "9.0.5",
+            "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
+            "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
             "dev": true,
             "dependencies": {
-                "tshy": "^1.7.0"
+                "jsbn": "1.1.0",
+                "sprintf-js": "^1.1.3"
+            },
+            "engines": {
+                "node": ">= 12"
             }
         },
+        "node_modules/is-actual-promise": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/is-actual-promise/-/is-actual-promise-1.0.2.tgz",
+            "integrity": "sha512-xsFiO1of0CLsQnPZ1iXHNTyR9YszOeWKYv+q6n8oSFW3ipooFJ1j1lbRMgiMCr+pp2gLruESI4zb5Ak6eK5OnQ==",
+            "dev": true
+        },
         "node_modules/is-binary-path": {
             "version": "2.1.0",
             "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2843,6 +2962,7 @@
             "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
             "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">=0.12.0"
             }
@@ -2902,9 +3022,9 @@
             }
         },
         "node_modules/istanbul-reports": {
-            "version": "3.1.6",
-            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz",
-            "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==",
+            "version": "3.1.7",
+            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
+            "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
             "dev": true,
             "dependencies": {
                 "html-escaper": "^2.0.0",
@@ -2915,10 +3035,9 @@
             }
         },
         "node_modules/jackspeak": {
-            "version": "2.3.6",
-            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
-            "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
-            "dev": true,
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.1.2.tgz",
+            "integrity": "sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==",
             "dependencies": {
                 "@isaacs/cliui": "^8.0.2"
             },
@@ -2963,10 +3082,16 @@
             "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
             "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
         },
+        "node_modules/jsbn": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
+            "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
+            "dev": true
+        },
         "node_modules/json-parse-even-better-errors": {
-            "version": "3.0.1",
-            "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz",
-            "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==",
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz",
+            "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==",
             "dev": true,
             "engines": {
                 "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
@@ -3041,10 +3166,9 @@
             }
         },
         "node_modules/lru-cache": {
-            "version": "10.1.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz",
-            "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==",
-            "dev": true,
+            "version": "10.2.2",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
+            "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
             "engines": {
                 "node": "14 || >=16.14"
             }
@@ -3071,9 +3195,9 @@
             "dev": true
         },
         "node_modules/make-fetch-happen": {
-            "version": "13.0.0",
-            "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.0.tgz",
-            "integrity": "sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==",
+            "version": "13.0.1",
+            "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz",
+            "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==",
             "dev": true,
             "dependencies": {
                 "@npmcli/agent": "^2.0.0",
@@ -3085,6 +3209,7 @@
                 "minipass-flush": "^1.0.5",
                 "minipass-pipeline": "^1.2.4",
                 "negotiator": "^0.6.3",
+                "proc-log": "^4.2.0",
                 "promise-retry": "^2.0.1",
                 "ssri": "^10.0.0"
             },
@@ -3124,10 +3249,9 @@
             }
         },
         "node_modules/minipass": {
-            "version": "7.0.4",
-            "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
-            "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
-            "dev": true,
+            "version": "7.1.2",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+            "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
             "engines": {
                 "node": ">=16 || 14 >=14.17"
             }
@@ -3145,9 +3269,9 @@
             }
         },
         "node_modules/minipass-fetch": {
-            "version": "3.0.4",
-            "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz",
-            "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==",
+            "version": "3.0.5",
+            "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz",
+            "integrity": "sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==",
             "dev": true,
             "dependencies": {
                 "minipass": "^7.0.3",
@@ -3300,6 +3424,35 @@
             "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
             "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
         },
+        "node_modules/msgpackr": {
+            "version": "1.10.2",
+            "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.10.2.tgz",
+            "integrity": "sha512-L60rsPynBvNE+8BWipKKZ9jHcSGbtyJYIwjRq0VrIvQ08cRjntGXJYW/tmciZ2IHWIY8WEW32Qa2xbh5+SKBZA==",
+            "optionalDependencies": {
+                "msgpackr-extract": "^3.0.2"
+            }
+        },
+        "node_modules/msgpackr-extract": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.2.tgz",
+            "integrity": "sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==",
+            "hasInstallScript": true,
+            "optional": true,
+            "dependencies": {
+                "node-gyp-build-optional-packages": "5.0.7"
+            },
+            "bin": {
+                "download-msgpackr-prebuilds": "bin/download-prebuilds.js"
+            },
+            "optionalDependencies": {
+                "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.2"
+            }
+        },
         "node_modules/natural-compare": {
             "version": "1.4.0",
             "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -3315,9 +3468,9 @@
             }
         },
         "node_modules/node-gyp": {
-            "version": "10.0.1",
-            "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.0.1.tgz",
-            "integrity": "sha512-gg3/bHehQfZivQVfqIyy8wTdSymF9yTyP4CJifK73imyNMU8AIGQE2pUa7dNWfmMeG9cDVF2eehiRMv0LC1iAg==",
+            "version": "10.1.0",
+            "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.1.0.tgz",
+            "integrity": "sha512-B4J5M1cABxPc5PwfjhbV5hoy2DP9p8lFXASnEN6hugXOa61416tnTZ29x9sSwAd0o99XNIcpvDDy1swAExsVKA==",
             "dev": true,
             "dependencies": {
                 "env-paths": "^2.2.0",
@@ -3338,6 +3491,17 @@
                 "node": "^16.14.0 || >=18.0.0"
             }
         },
+        "node_modules/node-gyp-build-optional-packages": {
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz",
+            "integrity": "sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==",
+            "optional": true,
+            "bin": {
+                "node-gyp-build-optional-packages": "bin.js",
+                "node-gyp-build-optional-packages-optional": "optional.js",
+                "node-gyp-build-optional-packages-test": "build-test.js"
+            }
+        },
         "node_modules/node-gyp/node_modules/brace-expansion": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -3348,22 +3512,22 @@
             }
         },
         "node_modules/node-gyp/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
@@ -3379,9 +3543,9 @@
             }
         },
         "node_modules/node-gyp/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -3393,6 +3557,15 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
+        "node_modules/node-gyp/node_modules/proc-log": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz",
+            "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==",
+            "dev": true,
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
         "node_modules/node-gyp/node_modules/which": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
@@ -3409,9 +3582,9 @@
             }
         },
         "node_modules/nopt": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.0.tgz",
-            "integrity": "sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==",
+            "version": "7.2.1",
+            "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
+            "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
             "dev": true,
             "dependencies": {
                 "abbrev": "^2.0.0"
@@ -3424,9 +3597,9 @@
             }
         },
         "node_modules/normalize-package-data": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.0.tgz",
-            "integrity": "sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==",
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.1.tgz",
+            "integrity": "sha512-6rvCfeRW+OEZagAB4lMLSNuTNYZWLVtKccK79VSTf//yTY5VOCgcpH80O+bZK8Neps7pUnd5G+QlMg1yV/2iZQ==",
             "dev": true,
             "dependencies": {
                 "hosted-git-info": "^7.0.0",
@@ -3448,9 +3621,9 @@
             }
         },
         "node_modules/npm-bundled": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz",
-            "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==",
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.1.tgz",
+            "integrity": "sha512-+AvaheE/ww1JEwRHOrn4WHNzOxGtVp+adrg2AeZS/7KuxGUYFuBta98wYpfHBbJp6Tg6j1NKSEVHNcfZzJHQwQ==",
             "dev": true,
             "dependencies": {
                 "npm-normalize-package-bin": "^3.0.0"
@@ -3481,13 +3654,13 @@
             }
         },
         "node_modules/npm-package-arg": {
-            "version": "11.0.1",
-            "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.1.tgz",
-            "integrity": "sha512-M7s1BD4NxdAvBKUPqqRW957Xwcl/4Zvo8Aj+ANrzvIPzGJZElrH7Z//rSaec2ORcND6FHHLnZeY8qgTpXDMFQQ==",
+            "version": "11.0.2",
+            "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.2.tgz",
+            "integrity": "sha512-IGN0IAwmhDJwy13Wc8k+4PEbTPhpJnMtfR53ZbOyjkvmEcLS4nCwp6mvMWjS5sUjeiW3mpx6cHmuhKEu9XmcQw==",
             "dev": true,
             "dependencies": {
                 "hosted-git-info": "^7.0.0",
-                "proc-log": "^3.0.0",
+                "proc-log": "^4.0.0",
                 "semver": "^7.3.5",
                 "validate-npm-package-name": "^5.0.0"
             },
@@ -3496,9 +3669,9 @@
             }
         },
         "node_modules/npm-packlist": {
-            "version": "8.0.1",
-            "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.1.tgz",
-            "integrity": "sha512-MQpL27ZrsJQ2kiAuQPpZb5LtJwydNRnI15QWXsf3WHERu4rzjRj6Zju/My2fov7tLuu3Gle/uoIX/DDZ3u4O4Q==",
+            "version": "8.0.2",
+            "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.2.tgz",
+            "integrity": "sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA==",
             "dev": true,
             "dependencies": {
                 "ignore-walk": "^6.0.4"
@@ -3508,9 +3681,9 @@
             }
         },
         "node_modules/npm-pick-manifest": {
-            "version": "9.0.0",
-            "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.0.tgz",
-            "integrity": "sha512-VfvRSs/b6n9ol4Qb+bDwNGUXutpy76x6MARw/XssevE0TnctIKcmklJZM5Z7nqs5z5aW+0S63pgCNbpkUNNXBg==",
+            "version": "9.0.1",
+            "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.1.tgz",
+            "integrity": "sha512-Udm1f0l2nXb3wxDpKjfohwgdFUSV50UVwzEIpDXVsbDMXVIEF81a/i0UhuQbhrPMMmdiq3+YMFLFIRVLs3hxQw==",
             "dev": true,
             "dependencies": {
                 "npm-install-checks": "^6.0.0",
@@ -3523,18 +3696,19 @@
             }
         },
         "node_modules/npm-registry-fetch": {
-            "version": "16.1.0",
-            "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.1.0.tgz",
-            "integrity": "sha512-PQCELXKt8Azvxnt5Y85GseQDJJlglTFM9L9U9gkv2y4e9s0k3GVDdOx3YoB6gm2Do0hlkzC39iCGXby+Wve1Bw==",
+            "version": "16.2.1",
+            "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.2.1.tgz",
+            "integrity": "sha512-8l+7jxhim55S85fjiDGJ1rZXBWGtRLi1OSb4Z3BPLObPuIaeKRlPRiYMSHU4/81ck3t71Z+UwDDl47gcpmfQQA==",
             "dev": true,
             "dependencies": {
+                "@npmcli/redact": "^1.1.0",
                 "make-fetch-happen": "^13.0.0",
                 "minipass": "^7.0.2",
                 "minipass-fetch": "^3.0.0",
                 "minipass-json-stream": "^1.0.1",
                 "minizlib": "^2.1.2",
                 "npm-package-arg": "^11.0.0",
-                "proc-log": "^3.0.0"
+                "proc-log": "^4.0.0"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
@@ -3632,9 +3806,9 @@
             }
         },
         "node_modules/pacote": {
-            "version": "17.0.5",
-            "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.5.tgz",
-            "integrity": "sha512-TAE0m20zSDMnchPja9vtQjri19X3pZIyRpm2TJVeI+yU42leJBBDTRYhOcWFsPhaMxf+3iwQkFiKz16G9AEeeA==",
+            "version": "17.0.7",
+            "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.7.tgz",
+            "integrity": "sha512-sgvnoUMlkv9xHwDUKjKQFXVyUi8dtJGKp3vg6sYy+TxbDic5RjZCHF3ygv0EJgNRZ2GfRONjlKPUfokJ9lDpwQ==",
             "dev": true,
             "dependencies": {
                 "@npmcli/git": "^5.0.0",
@@ -3648,11 +3822,11 @@
                 "npm-packlist": "^8.0.0",
                 "npm-pick-manifest": "^9.0.0",
                 "npm-registry-fetch": "^16.0.0",
-                "proc-log": "^3.0.0",
+                "proc-log": "^4.0.0",
                 "promise-retry": "^2.0.1",
                 "read-package-json": "^7.0.0",
                 "read-package-json-fast": "^3.0.0",
-                "sigstore": "^2.0.0",
+                "sigstore": "^2.2.0",
                 "ssri": "^10.0.0",
                 "tar": "^6.1.11"
             },
@@ -3708,16 +3882,15 @@
             }
         },
         "node_modules/path-scurry": {
-            "version": "1.10.1",
-            "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
-            "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
-            "dev": true,
+            "version": "1.11.1",
+            "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+            "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
             "dependencies": {
-                "lru-cache": "^9.1.1 || ^10.0.0",
+                "lru-cache": "^10.2.0",
                 "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
@@ -3803,9 +3976,9 @@
             }
         },
         "node_modules/proc-log": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz",
-            "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==",
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz",
+            "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==",
             "dev": true,
             "engines": {
                 "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
@@ -3878,9 +4051,9 @@
             ]
         },
         "node_modules/react": {
-            "version": "18.2.0",
-            "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
-            "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
+            "version": "18.3.1",
+            "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+            "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
             "dev": true,
             "dependencies": {
                 "loose-envify": "^1.1.0"
@@ -3925,25 +4098,26 @@
             "dev": true
         },
         "node_modules/react-reconciler": {
-            "version": "0.29.0",
-            "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.0.tgz",
-            "integrity": "sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==",
+            "version": "0.29.2",
+            "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
+            "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
             "dev": true,
             "dependencies": {
                 "loose-envify": "^1.1.0",
-                "scheduler": "^0.23.0"
+                "scheduler": "^0.23.2"
             },
             "engines": {
                 "node": ">=0.10.0"
             },
             "peerDependencies": {
-                "react": "^18.2.0"
+                "react": "^18.3.1"
             }
         },
         "node_modules/read-package-json": {
-            "version": "7.0.0",
-            "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.0.tgz",
-            "integrity": "sha512-uL4Z10OKV4p6vbdvIXB+OzhInYtIozl/VxUBPgNkBuUi2DeRonnuspmaVAMcrkmfjKGNmRndyQAbE7/AmzGwFg==",
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.1.tgz",
+            "integrity": "sha512-8PcDiZ8DXUjLf687Ol4BR8Bpm2umR7vhoZOzNRt+uxD9GpBh/K+CAAALVIiYFknmvlmyg7hM7BSNUXPaCCqd0Q==",
+            "deprecated": "This package is no longer supported. Please use @npmcli/package-json instead.",
             "dev": true,
             "dependencies": {
                 "glob": "^10.2.2",
@@ -3978,31 +4152,31 @@
             }
         },
         "node_modules/read-package-json/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/read-package-json/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -4069,31 +4243,31 @@
             }
         },
         "node_modules/resolve-import/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/resolve-import/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -4146,14 +4320,64 @@
             }
         },
         "node_modules/rimraf": {
-            "version": "3.0.2",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
-            "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
+            "license": "ISC",
             "dependencies": {
-                "glob": "^7.1.3"
+                "glob": "^10.3.7"
             },
             "bin": {
-                "rimraf": "bin.js"
+                "rimraf": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=14.18"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/rimraf/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "license": "MIT",
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/rimraf/node_modules/glob": {
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
+            "license": "ISC",
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.18"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/rimraf/node_modules/minimatch": {
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
+            "license": "ISC",
+            "dependencies": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
@@ -4189,22 +4413,19 @@
             "optional": true
         },
         "node_modules/scheduler": {
-            "version": "0.23.0",
-            "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
-            "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+            "version": "0.23.2",
+            "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+            "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
             "dev": true,
             "dependencies": {
                 "loose-envify": "^1.1.0"
             }
         },
         "node_modules/semver": {
-            "version": "7.5.4",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
-            "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+            "version": "7.6.2",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
+            "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
             "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
             "bin": {
                 "semver": "bin/semver.js"
             },
@@ -4212,18 +4433,6 @@
                 "node": ">=10"
             }
         },
-        "node_modules/semver/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/shebang-command": {
             "version": "2.0.0",
             "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4247,7 +4456,6 @@
             "version": "4.1.0",
             "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
             "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
-            "dev": true,
             "engines": {
                 "node": ">=14"
             },
@@ -4256,15 +4464,17 @@
             }
         },
         "node_modules/sigstore": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.1.0.tgz",
-            "integrity": "sha512-kPIj+ZLkyI3QaM0qX8V/nSsweYND3W448pwkDgS6CQ74MfhEkIR8ToK5Iyx46KJYRjseVcD3Rp9zAmUAj6ZjPw==",
+            "version": "2.3.1",
+            "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.3.1.tgz",
+            "integrity": "sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==",
             "dev": true,
             "dependencies": {
-                "@sigstore/bundle": "^2.1.0",
-                "@sigstore/protobuf-specs": "^0.2.1",
-                "@sigstore/sign": "^2.1.0",
-                "@sigstore/tuf": "^2.1.0"
+                "@sigstore/bundle": "^2.3.2",
+                "@sigstore/core": "^1.0.0",
+                "@sigstore/protobuf-specs": "^0.3.2",
+                "@sigstore/sign": "^2.3.2",
+                "@sigstore/tuf": "^2.3.4",
+                "@sigstore/verify": "^1.2.1"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
@@ -4309,26 +4519,26 @@
             }
         },
         "node_modules/socks": {
-            "version": "2.7.1",
-            "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz",
-            "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==",
+            "version": "2.8.3",
+            "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz",
+            "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==",
             "dev": true,
             "dependencies": {
-                "ip": "^2.0.0",
+                "ip-address": "^9.0.5",
                 "smart-buffer": "^4.2.0"
             },
             "engines": {
-                "node": ">= 10.13.0",
+                "node": ">= 10.0.0",
                 "npm": ">= 3.0.0"
             }
         },
         "node_modules/socks-proxy-agent": {
-            "version": "8.0.2",
-            "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz",
-            "integrity": "sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==",
+            "version": "8.0.3",
+            "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.3.tgz",
+            "integrity": "sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A==",
             "dev": true,
             "dependencies": {
-                "agent-base": "^7.0.2",
+                "agent-base": "^7.1.1",
                 "debug": "^4.3.4",
                 "socks": "^2.7.1"
             },
@@ -4347,9 +4557,9 @@
             }
         },
         "node_modules/spdx-exceptions": {
-            "version": "2.3.0",
-            "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
-            "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+            "version": "2.5.0",
+            "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz",
+            "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==",
             "dev": true
         },
         "node_modules/spdx-expression-parse": {
@@ -4363,15 +4573,21 @@
             }
         },
         "node_modules/spdx-license-ids": {
-            "version": "3.0.16",
-            "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz",
-            "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==",
+            "version": "3.0.18",
+            "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz",
+            "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==",
+            "dev": true
+        },
+        "node_modules/sprintf-js": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+            "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
             "dev": true
         },
         "node_modules/ssri": {
-            "version": "10.0.5",
-            "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz",
-            "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==",
+            "version": "10.0.6",
+            "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz",
+            "integrity": "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==",
             "dev": true,
             "dependencies": {
                 "minipass": "^7.0.3"
@@ -4447,7 +4663,6 @@
             "version": "5.1.2",
             "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
             "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
-            "dev": true,
             "dependencies": {
                 "eastasianwidth": "^0.2.0",
                 "emoji-regex": "^9.2.2",
@@ -4465,7 +4680,6 @@
             "version": "4.2.3",
             "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
             "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-            "dev": true,
             "dependencies": {
                 "emoji-regex": "^8.0.0",
                 "is-fullwidth-code-point": "^3.0.0",
@@ -4478,14 +4692,12 @@
         "node_modules/string-width-cjs/node_modules/emoji-regex": {
             "version": "8.0.0",
             "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
-            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-            "dev": true
+            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
         },
         "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
             "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-            "dev": true,
             "engines": {
                 "node": ">=8"
             }
@@ -4494,7 +4706,6 @@
             "version": "6.0.1",
             "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
             "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
-            "dev": true,
             "engines": {
                 "node": ">=12"
             },
@@ -4506,7 +4717,6 @@
             "version": "7.1.0",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
             "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-            "dev": true,
             "dependencies": {
                 "ansi-regex": "^6.0.1"
             },
@@ -4533,7 +4743,6 @@
             "version": "6.0.1",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
             "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-            "dev": true,
             "dependencies": {
                 "ansi-regex": "^5.0.1"
             },
@@ -4599,31 +4808,31 @@
             }
         },
         "node_modules/sync-content/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "version": "10.4.1",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+            "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
             "dev": true,
             "dependencies": {
                 "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
+                "jackspeak": "^3.1.2",
+                "minimatch": "^9.0.4",
+                "minipass": "^7.1.2",
+                "path-scurry": "^1.11.1"
             },
             "bin": {
                 "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">=16 || 14 >=14.17"
+                "node": ">=16 || 14 >=14.18"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
         "node_modules/sync-content/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -4635,48 +4844,30 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/sync-content/node_modules/rimraf": {
-            "version": "5.0.5",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
-            "dev": true,
-            "dependencies": {
-                "glob": "^10.3.7"
-            },
-            "bin": {
-                "rimraf": "dist/esm/bin.mjs"
-            },
-            "engines": {
-                "node": ">=14"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
         "node_modules/tap": {
-            "version": "18.6.1",
-            "resolved": "https://registry.npmjs.org/tap/-/tap-18.6.1.tgz",
-            "integrity": "sha512-5cBQhJ1gdbsrTR3tA5kZZTts0HyOML6bcM7pEF7GF8d6y1ajfRMjbInS1Ty7/x2Ip0ko3cY1dYjPJ9JFNPsm7w==",
-            "dev": true,
-            "dependencies": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/after-each": "1.1.17",
-                "@tapjs/asserts": "1.1.17",
-                "@tapjs/before": "1.1.17",
-                "@tapjs/before-each": "1.1.17",
-                "@tapjs/core": "1.4.6",
-                "@tapjs/filter": "1.2.17",
-                "@tapjs/fixture": "1.2.17",
-                "@tapjs/intercept": "1.2.17",
-                "@tapjs/mock": "1.2.15",
-                "@tapjs/node-serialize": "1.2.6",
-                "@tapjs/run": "1.4.16",
-                "@tapjs/snapshot": "1.2.17",
-                "@tapjs/spawn": "1.1.17",
-                "@tapjs/stdin": "1.1.17",
-                "@tapjs/test": "1.3.17",
-                "@tapjs/typescript": "1.3.6",
-                "@tapjs/worker": "1.1.17",
+            "version": "19.0.2",
+            "resolved": "https://registry.npmjs.org/tap/-/tap-19.0.2.tgz",
+            "integrity": "sha512-SRGulk1RKlVuYtnPeephj+xyE0sG9CvGlKYP4lymBZykLtkwBPnEBjQ2iQmLX5z0BFEMfKh8G4bvZkhoSJb3kg==",
+            "dev": true,
+            "dependencies": {
+                "@tapjs/after": "1.1.24",
+                "@tapjs/after-each": "2.0.1",
+                "@tapjs/asserts": "2.0.1",
+                "@tapjs/before": "2.0.1",
+                "@tapjs/before-each": "2.0.1",
+                "@tapjs/core": "2.0.1",
+                "@tapjs/filter": "2.0.1",
+                "@tapjs/fixture": "2.0.1",
+                "@tapjs/intercept": "2.0.1",
+                "@tapjs/mock": "2.0.1",
+                "@tapjs/node-serialize": "2.0.1",
+                "@tapjs/run": "2.0.2",
+                "@tapjs/snapshot": "2.0.1",
+                "@tapjs/spawn": "2.0.1",
+                "@tapjs/stdin": "2.0.1",
+                "@tapjs/test": "2.0.1",
+                "@tapjs/typescript": "1.4.6",
+                "@tapjs/worker": "2.0.1",
                 "resolve-import": "^1.4.5"
             },
             "bin": {
@@ -4690,13 +4881,13 @@
             }
         },
         "node_modules/tap-parser": {
-            "version": "15.3.1",
-            "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-15.3.1.tgz",
-            "integrity": "sha512-hwAtXX5TBGt2MJeYvASc7DjP48PUzA7P8RTbLxQcgKCEH7ICD5IsRco7l5YvkzjHlZbUbeI9wzO8B4hw2sKgnQ==",
+            "version": "16.0.1",
+            "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-16.0.1.tgz",
+            "integrity": "sha512-vKianJzSSzLkJ3bHBwzvZDDRi9yGMwkRANJxwPAjAue50owB8rlluYySmTN4tZVH0nsh6stvrQbg9kuCL5svdg==",
             "dev": true,
             "dependencies": {
                 "events-to-array": "^2.0.3",
-                "tap-yaml": "2.2.1"
+                "tap-yaml": "2.2.2"
             },
             "bin": {
                 "tap-parser": "bin/cmd.cjs"
@@ -4706,12 +4897,12 @@
             }
         },
         "node_modules/tap-yaml": {
-            "version": "2.2.1",
-            "resolved": "https://registry.npmjs.org/tap-yaml/-/tap-yaml-2.2.1.tgz",
-            "integrity": "sha512-ovZuUMLAIH59jnFHXKEGJ+WyDYl6Cuduwg9qpvnqkZOUA1nU84q02Sry1HT0KXcdv2uB91bEKKxnIybBgrb6oA==",
+            "version": "2.2.2",
+            "resolved": "https://registry.npmjs.org/tap-yaml/-/tap-yaml-2.2.2.tgz",
+            "integrity": "sha512-MWG4OpAKtNoNVjCz/BqlDJiwTM99tiHRhHPS4iGOe1ZS0CgM4jSFH92lthSFvvy4EdDjQZDV7uYqUFlU9JuNhw==",
             "dev": true,
             "dependencies": {
-                "yaml": "^2.3.0",
+                "yaml": "^2.4.1",
                 "yaml-types": "^0.3.0"
             },
             "engines": {
@@ -4719,9 +4910,9 @@
             }
         },
         "node_modules/tar": {
-            "version": "6.2.0",
-            "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
-            "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==",
+            "version": "6.2.1",
+            "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+            "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
             "dev": true,
             "dependencies": {
                 "chownr": "^2.0.0",
@@ -4817,6 +5008,7 @@
             "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
             "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "is-number": "^7.0.0"
             },
@@ -4834,19 +5026,21 @@
             }
         },
         "node_modules/tshy": {
-            "version": "1.8.2",
-            "resolved": "https://registry.npmjs.org/tshy/-/tshy-1.8.2.tgz",
-            "integrity": "sha512-aGlSY+jkZYAv0YDgtdv1U2vvbGTUdlXmhVP4uegujlJ/wuznmJqSu5cUV/6IW7N7a3HFRhofWvIS/FquYN9zgA==",
+            "version": "1.14.0",
+            "resolved": "https://registry.npmjs.org/tshy/-/tshy-1.14.0.tgz",
+            "integrity": "sha512-YiUujgi4Jb+t2I48LwSRzHkBpniH9WjjktNozn+nlsGmVemKSjDNY7EwBRPvPCr5zAC/3ITAYWH9Z7kUinGSrw==",
             "dev": true,
             "dependencies": {
                 "chalk": "^5.3.0",
-                "chokidar": "^3.5.3",
+                "chokidar": "^3.6.0",
                 "foreground-child": "^3.1.1",
+                "minimatch": "^9.0.4",
                 "mkdirp": "^3.0.1",
-                "resolve-import": "^1.4.4",
+                "polite-json": "^4.0.1",
+                "resolve-import": "^1.4.5",
                 "rimraf": "^5.0.1",
                 "sync-content": "^1.0.2",
-                "typescript": "5.2",
+                "typescript": "^5.4.5",
                 "walk-up-path": "^3.0.1"
             },
             "bin": {
@@ -4877,32 +5071,10 @@
                 "url": "https://github.com/chalk/chalk?sponsor=1"
             }
         },
-        "node_modules/tshy/node_modules/glob": {
-            "version": "10.3.10",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
-            "dev": true,
-            "dependencies": {
-                "foreground-child": "^3.1.0",
-                "jackspeak": "^2.3.5",
-                "minimatch": "^9.0.1",
-                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                "path-scurry": "^1.10.1"
-            },
-            "bin": {
-                "glob": "dist/esm/bin.mjs"
-            },
-            "engines": {
-                "node": ">=16 || 14 >=14.17"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
         "node_modules/tshy/node_modules/minimatch": {
-            "version": "9.0.3",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "version": "9.0.4",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+            "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
             "dev": true,
             "dependencies": {
                 "brace-expansion": "^2.0.1"
@@ -4914,38 +5086,20 @@
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/tshy/node_modules/rimraf": {
-            "version": "5.0.5",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
-            "dev": true,
-            "dependencies": {
-                "glob": "^10.3.7"
-            },
-            "bin": {
-                "rimraf": "dist/esm/bin.mjs"
-            },
-            "engines": {
-                "node": ">=14"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
         "node_modules/tslib": {
             "version": "2.6.2",
             "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
             "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
         },
         "node_modules/tuf-js": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.1.0.tgz",
-            "integrity": "sha512-eD7YPPjVlMzdggrOeE8zwoegUaG/rt6Bt3jwoQPunRiNVzgcCE009UDFJKJjG+Gk9wFu6W/Vi+P5d/5QpdD9jA==",
+            "version": "2.2.1",
+            "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.1.tgz",
+            "integrity": "sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==",
             "dev": true,
             "dependencies": {
-                "@tufjs/models": "2.0.0",
+                "@tufjs/models": "2.0.1",
                 "debug": "^4.3.4",
-                "make-fetch-happen": "^13.0.0"
+                "make-fetch-happen": "^13.0.1"
             },
             "engines": {
                 "node": "^16.14.0 || >=18.0.0"
@@ -4974,9 +5128,9 @@
             }
         },
         "node_modules/typescript": {
-            "version": "5.2.2",
-            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
-            "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
+            "version": "5.4.5",
+            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
+            "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
             "dev": true,
             "bin": {
                 "tsc": "bin/tsc",
@@ -5055,9 +5209,9 @@
             }
         },
         "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": {
-            "version": "0.3.20",
-            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
-            "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
+            "version": "0.3.25",
+            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+            "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
             "dev": true,
             "dependencies": {
                 "@jridgewell/resolve-uri": "^3.1.0",
@@ -5075,13 +5229,10 @@
             }
         },
         "node_modules/validate-npm-package-name": {
-            "version": "5.0.0",
-            "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz",
-            "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==",
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz",
+            "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==",
             "dev": true,
-            "dependencies": {
-                "builtins": "^5.0.0"
-            },
             "engines": {
                 "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
             }
@@ -5133,7 +5284,6 @@
             "version": "8.1.0",
             "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
             "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
-            "dev": true,
             "dependencies": {
                 "ansi-styles": "^6.1.0",
                 "string-width": "^5.0.1",
@@ -5151,7 +5301,6 @@
             "version": "7.0.0",
             "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
             "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
-            "dev": true,
             "dependencies": {
                 "ansi-styles": "^4.0.0",
                 "string-width": "^4.1.0",
@@ -5167,14 +5316,12 @@
         "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
             "version": "8.0.0",
             "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
-            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-            "dev": true
+            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
         },
         "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
             "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-            "dev": true,
             "engines": {
                 "node": ">=8"
             }
@@ -5183,7 +5330,6 @@
             "version": "4.2.3",
             "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
             "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-            "dev": true,
             "dependencies": {
                 "emoji-regex": "^8.0.0",
                 "is-fullwidth-code-point": "^3.0.0",
@@ -5197,7 +5343,6 @@
             "version": "6.0.1",
             "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
             "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
-            "dev": true,
             "engines": {
                 "node": ">=12"
             },
@@ -5209,7 +5354,6 @@
             "version": "6.2.1",
             "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
             "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
-            "dev": true,
             "engines": {
                 "node": ">=12"
             },
@@ -5221,7 +5365,6 @@
             "version": "7.1.0",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
             "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-            "dev": true,
             "dependencies": {
                 "ansi-regex": "^6.0.1"
             },
@@ -5238,10 +5381,11 @@
             "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
         },
         "node_modules/ws": {
-            "version": "8.16.0",
-            "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
-            "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
+            "version": "8.17.1",
+            "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+            "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">=10.0.0"
             },
@@ -5274,10 +5418,13 @@
             "dev": true
         },
         "node_modules/yaml": {
-            "version": "2.3.4",
-            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",
-            "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==",
+            "version": "2.4.2",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",
+            "integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==",
             "dev": true,
+            "bin": {
+                "yaml": "bin.mjs"
+            },
             "engines": {
                 "node": ">= 14"
             }
@@ -5467,7 +5614,6 @@
             "version": "8.0.2",
             "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
             "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
-            "dev": true,
             "requires": {
                 "string-width": "^5.1.2",
                 "string-width-cjs": "npm:string-width@^4.2.0",
@@ -5480,14 +5626,12 @@
                 "ansi-regex": {
                     "version": "6.0.1",
                     "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
-                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
-                    "dev": true
+                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="
                 },
                 "strip-ansi": {
                     "version": "7.1.0",
                     "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
                     "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-                    "dev": true,
                     "requires": {
                         "ansi-regex": "^6.0.1"
                     }
@@ -5495,9 +5639,9 @@
             }
         },
         "@isaacs/ts-node-temp-fork-for-pr-2009": {
-            "version": "10.9.5",
-            "resolved": "https://registry.npmjs.org/@isaacs/ts-node-temp-fork-for-pr-2009/-/ts-node-temp-fork-for-pr-2009-10.9.5.tgz",
-            "integrity": "sha512-hEDlwpHhIabtB+Urku8muNMEkGui0LVGlYLS3KoB9QBDf0Pw3r7q0RrfoQmFuk8CvRpGzErO3/vLQd9Ys+/g4g==",
+            "version": "10.9.7",
+            "resolved": "https://registry.npmjs.org/@isaacs/ts-node-temp-fork-for-pr-2009/-/ts-node-temp-fork-for-pr-2009-10.9.7.tgz",
+            "integrity": "sha512-9f0bhUr9TnwwpgUhEpr3FjxSaH/OHaARkE2F9fM0lS4nIs2GNerrvGwQz493dk0JKlTaGYVrKbq36vA/whZ34g==",
             "dev": true,
             "requires": {
                 "@cspotcode/source-map-support": "^0.8.0",
@@ -5528,9 +5672,9 @@
             "dev": true
         },
         "@jridgewell/resolve-uri": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
-            "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+            "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
             "dev": true
         },
         "@jridgewell/sourcemap-codec": {
@@ -5558,6 +5702,42 @@
                 "tslib": "^2.4.1"
             }
         },
+        "@msgpackr-extract/msgpackr-extract-darwin-arm64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.2.tgz",
+            "integrity": "sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==",
+            "optional": true
+        },
+        "@msgpackr-extract/msgpackr-extract-darwin-x64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.2.tgz",
+            "integrity": "sha512-lwriRAHm1Yg4iDf23Oxm9n/t5Zpw1lVnxYU3HnJPTi2lJRkKTrps1KVgvL6m7WvmhYVt/FIsssWay+k45QHeuw==",
+            "optional": true
+        },
+        "@msgpackr-extract/msgpackr-extract-linux-arm": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.2.tgz",
+            "integrity": "sha512-MOI9Dlfrpi2Cuc7i5dXdxPbFIgbDBGgKR5F2yWEa6FVEtSWncfVNKW5AKjImAQ6CZlBK9tympdsZJ2xThBiWWA==",
+            "optional": true
+        },
+        "@msgpackr-extract/msgpackr-extract-linux-arm64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.2.tgz",
+            "integrity": "sha512-FU20Bo66/f7He9Fp9sP2zaJ1Q8L9uLPZQDub/WlUip78JlPeMbVL8546HbZfcW9LNciEXc8d+tThSJjSC+tmsg==",
+            "optional": true
+        },
+        "@msgpackr-extract/msgpackr-extract-linux-x64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.2.tgz",
+            "integrity": "sha512-gsWNDCklNy7Ajk0vBBf9jEx04RUxuDQfBse918Ww+Qb9HCPoGzS+XJTLe96iN3BVK7grnLiYghP/M4L8VsaHeA==",
+            "optional": true
+        },
+        "@msgpackr-extract/msgpackr-extract-win32-x64": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.2.tgz",
+            "integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==",
+            "optional": true
+        },
         "@nodelib/fs.scandir": {
             "version": "2.1.5",
             "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -5582,37 +5762,37 @@
             }
         },
         "@npmcli/agent": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.0.tgz",
-            "integrity": "sha512-2yThA1Es98orMkpSLVqlDZAMPK3jHJhifP2gnNUdk1754uZ8yI5c+ulCoVG+WlntQA6MzhrURMXjSd9Z7dJ2/Q==",
+            "version": "2.2.2",
+            "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz",
+            "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==",
             "dev": true,
             "requires": {
                 "agent-base": "^7.1.0",
                 "http-proxy-agent": "^7.0.0",
                 "https-proxy-agent": "^7.0.1",
                 "lru-cache": "^10.0.1",
-                "socks-proxy-agent": "^8.0.1"
+                "socks-proxy-agent": "^8.0.3"
             }
         },
         "@npmcli/fs": {
-            "version": "3.1.0",
-            "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz",
-            "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==",
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz",
+            "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==",
             "dev": true,
             "requires": {
                 "semver": "^7.3.5"
             }
         },
         "@npmcli/git": {
-            "version": "5.0.4",
-            "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.4.tgz",
-            "integrity": "sha512-nr6/WezNzuYUppzXRaYu/W4aT5rLxdXqEFupbh6e/ovlYFQ8hpu1UUPV3Ir/YTl+74iXl2ZOMlGzudh9ZPUchQ==",
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.7.tgz",
+            "integrity": "sha512-WaOVvto604d5IpdCRV2KjQu8PzkfE96d50CQGKgywXh2GxXmDeUO5EWcBC4V57uFyrNqx83+MewuJh3WTR3xPA==",
             "dev": true,
             "requires": {
                 "@npmcli/promise-spawn": "^7.0.0",
                 "lru-cache": "^10.0.1",
                 "npm-pick-manifest": "^9.0.0",
-                "proc-log": "^3.0.0",
+                "proc-log": "^4.0.0",
                 "promise-inflight": "^1.0.1",
                 "promise-retry": "^2.0.1",
                 "semver": "^7.3.5",
@@ -5637,9 +5817,9 @@
             }
         },
         "@npmcli/installed-package-contents": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz",
-            "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==",
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.1.0.tgz",
+            "integrity": "sha512-c8UuGLeZpm69BryRykLuKRyKFZYJsZSCT4aVY5ds4omyZqJ172ApzgfKJ5eV/r3HgLdUYgFVe54KSFVjKoe27w==",
             "dev": true,
             "requires": {
                 "npm-bundled": "^3.0.0",
@@ -5652,10 +5832,58 @@
             "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==",
             "dev": true
         },
+        "@npmcli/package-json": {
+            "version": "5.1.1",
+            "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.1.1.tgz",
+            "integrity": "sha512-uTq5j/UqUzbOaOxVy+osfOhpqOiLfUZ0Ut33UbcyyAPJbZcJsf4Mrsyb8r58FoIFlofw0iOFsuCA/oDK14VDJQ==",
+            "dev": true,
+            "requires": {
+                "@npmcli/git": "^5.0.0",
+                "glob": "^10.2.2",
+                "hosted-git-info": "^7.0.0",
+                "json-parse-even-better-errors": "^3.0.0",
+                "normalize-package-data": "^6.0.0",
+                "proc-log": "^4.0.0",
+                "semver": "^7.5.3"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "glob": {
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
+                    "dev": true,
+                    "requires": {
+                        "foreground-child": "^3.1.0",
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                }
+            }
+        },
         "@npmcli/promise-spawn": {
-            "version": "7.0.1",
-            "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz",
-            "integrity": "sha512-P4KkF9jX3y+7yFUxgcUdDtLy+t4OlDGuEBLNs57AZsfSfg+uV6MLndqGpnl4831ggaEdXwR50XFoZP4VFtHolg==",
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz",
+            "integrity": "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==",
             "dev": true,
             "requires": {
                 "which": "^4.0.0"
@@ -5678,16 +5906,22 @@
                 }
             }
         },
+        "@npmcli/redact": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-1.1.0.tgz",
+            "integrity": "sha512-PfnWuOkQgu7gCbnSsAisaX7hKOdZ4wSAhAzH3/ph5dSGau52kCRrMMGbiSQLwyTZpgldkZ49b0brkOr1AzGBHQ==",
+            "dev": true
+        },
         "@npmcli/run-script": {
-            "version": "7.0.3",
-            "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.3.tgz",
-            "integrity": "sha512-ZMWGLHpzMq3rBGIwPyeaoaleaLMvrBrH8nugHxTi5ACkJZXTxXPtVuEH91ifgtss5hUwJQ2VDnzDBWPmz78rvg==",
+            "version": "7.0.4",
+            "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.4.tgz",
+            "integrity": "sha512-9ApYM/3+rBt9V80aYg6tZfzj3UWdiYyCt7gJUD1VJKvWF5nwKDSICXbYIQbspFTq6TOpbsEtIC0LArB8d9PFmg==",
             "dev": true,
             "requires": {
                 "@npmcli/node-gyp": "^3.0.0",
+                "@npmcli/package-json": "^5.0.0",
                 "@npmcli/promise-spawn": "^7.0.0",
                 "node-gyp": "^10.0.0",
-                "read-package-json-fast": "^3.0.0",
                 "which": "^4.0.0"
             },
             "dependencies": {
@@ -5712,105 +5946,136 @@
             "version": "0.11.0",
             "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
             "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
-            "dev": true,
             "optional": true
         },
         "@sigstore/bundle": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz",
-            "integrity": "sha512-89uOo6yh/oxaU8AeOUnVrTdVMcGk9Q1hJa7Hkvalc6G3Z3CupWk4Xe9djSgJm9fMkH69s0P0cVHUoKSOemLdng==",
+            "version": "2.3.2",
+            "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.3.2.tgz",
+            "integrity": "sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA==",
             "dev": true,
             "requires": {
-                "@sigstore/protobuf-specs": "^0.2.1"
+                "@sigstore/protobuf-specs": "^0.3.2"
             }
         },
+        "@sigstore/core": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-1.1.0.tgz",
+            "integrity": "sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg==",
+            "dev": true
+        },
         "@sigstore/protobuf-specs": {
-            "version": "0.2.1",
-            "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.2.1.tgz",
-            "integrity": "sha512-XTWVxnWJu+c1oCshMLwnKvz8ZQJJDVOlciMfgpJBQbThVjKTCG8dwyhgLngBD2KN0ap9F/gOV8rFDEx8uh7R2A==",
+            "version": "0.3.2",
+            "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.2.tgz",
+            "integrity": "sha512-c6B0ehIWxMI8wiS/bj6rHMPqeFvngFV7cDU/MY+B16P9Z3Mp9k8L93eYZ7BYzSickzuqAQqAq0V956b3Ju6mLw==",
             "dev": true
         },
         "@sigstore/sign": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.2.0.tgz",
-            "integrity": "sha512-AAbmnEHDQv6CSfrWA5wXslGtzLPtAtHZleKOgxdQYvx/s76Fk6T6ZVt7w2IGV9j1UrFeBocTTQxaXG2oRrDhYA==",
+            "version": "2.3.2",
+            "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.3.2.tgz",
+            "integrity": "sha512-5Vz5dPVuunIIvC5vBb0APwo7qKA4G9yM48kPWJT+OEERs40md5GoUR1yedwpekWZ4m0Hhw44m6zU+ObsON+iDA==",
             "dev": true,
             "requires": {
-                "@sigstore/bundle": "^2.1.0",
-                "@sigstore/protobuf-specs": "^0.2.1",
-                "make-fetch-happen": "^13.0.0"
+                "@sigstore/bundle": "^2.3.2",
+                "@sigstore/core": "^1.0.0",
+                "@sigstore/protobuf-specs": "^0.3.2",
+                "make-fetch-happen": "^13.0.1",
+                "proc-log": "^4.2.0",
+                "promise-retry": "^2.0.1"
             }
         },
         "@sigstore/tuf": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.2.0.tgz",
-            "integrity": "sha512-KKATZ5orWfqd9ZG6MN8PtCIx4eevWSuGRKQvofnWXRpyMyUEpmrzg5M5BrCpjM+NfZ0RbNGOh5tCz/P2uoRqOA==",
+            "version": "2.3.4",
+            "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.4.tgz",
+            "integrity": "sha512-44vtsveTPUpqhm9NCrbU8CWLe3Vck2HO1PNLw7RIajbB7xhtn5RBPm1VNSCMwqGYHhDsBJG8gDF0q4lgydsJvw==",
             "dev": true,
             "requires": {
-                "@sigstore/protobuf-specs": "^0.2.1",
-                "tuf-js": "^2.1.0"
+                "@sigstore/protobuf-specs": "^0.3.2",
+                "tuf-js": "^2.2.1"
+            }
+        },
+        "@sigstore/verify": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-1.2.1.tgz",
+            "integrity": "sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g==",
+            "dev": true,
+            "requires": {
+                "@sigstore/bundle": "^2.3.2",
+                "@sigstore/core": "^1.1.0",
+                "@sigstore/protobuf-specs": "^0.3.2"
             }
         },
         "@tapjs/after": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/after/-/after-1.1.17.tgz",
-            "integrity": "sha512-14qeP+mHZ8nIMDGtdCwTgvKclLlHxfARMTasb9fw//tmF/8ZDZhTemtCDxAP75wihxy5P7nzVZo/6TpVeOZrwg==",
+            "version": "1.1.24",
+            "resolved": "https://registry.npmjs.org/@tapjs/after/-/after-1.1.24.tgz",
+            "integrity": "sha512-Qys3CtftkfHGC7thDGm9TBzRCBLAoJKrXufF1zQxI1oNUjclWZP/s8CtHH0mwUTISOTehmBLV3wPPHSslD67Ng==",
             "dev": true,
             "requires": {
-                "is-actual-promise": "^1.0.0"
+                "is-actual-promise": "^1.0.1"
             }
         },
         "@tapjs/after-each": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/after-each/-/after-each-1.1.17.tgz",
-            "integrity": "sha512-ia8sr00Wilni+2+wO4MKYCYikeRwUC41HamV8EPN63R2UmiBEOe/cMSf+KYADIh56JvxAiH7Xa0+GSFU+N2FQQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/after-each/-/after-each-2.0.1.tgz",
+            "integrity": "sha512-3JXIJ4g9LPjyXmn/1VuIMC0vh7uBgUpQPksjffxv0rL8wq4C8lvmqt8Qu/fVImJucqzA+WrRqVG1b2Ab0ocDOw==",
             "dev": true,
             "requires": {
                 "function-loop": "^4.0.0"
             }
         },
         "@tapjs/asserts": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/asserts/-/asserts-1.1.17.tgz",
-            "integrity": "sha512-eKmbWBORDXu9bUHtPTu7qFrXNj5UeeH2nABJeP9BGHIn2ydmTgMEWCO3E+ljf7tisHchY5/x672lr99+O/mbTQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/asserts/-/asserts-2.0.1.tgz",
+            "integrity": "sha512-v2xYDLUwMGt8pzoY5LIjDCaw2NM+G01NW4pC3RcpsZLZbzQv1x/phi2RAX0ixI0nCmZZybqRygFKuMcJamS+gg==",
             "dev": true,
             "requires": {
-                "@tapjs/stack": "1.2.7",
-                "is-actual-promise": "^1.0.0",
-                "tcompare": "6.4.5",
+                "@tapjs/stack": "2.0.1",
+                "is-actual-promise": "^1.0.1",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0"
+            },
+            "dependencies": {
+                "tcompare": {
+                    "version": "7.0.1",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+                    "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+                    "dev": true,
+                    "requires": {
+                        "diff": "^5.2.0",
+                        "react-element-to-jsx-string": "^15.0.0"
+                    }
+                }
             }
         },
         "@tapjs/before": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/before/-/before-1.1.17.tgz",
-            "integrity": "sha512-pAmEAIMIqF9MPNUgEsnuWCM00iD/FJOX0P5eXSsWexWHjuZAkv5tIT/4qpXO9KYj+9c51Lh+7YSY2Xvk1Jjolw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/before/-/before-2.0.1.tgz",
+            "integrity": "sha512-GgnlWPm2PbuyYuG4gkkO2KAvT/BbGnpKs60U4XzPSJ2w73Qc/IYWP0Kz6qfCWongpiLteoco67M89ujUQApYJw==",
             "dev": true,
             "requires": {
-                "is-actual-promise": "^1.0.0"
+                "is-actual-promise": "^1.0.1"
             }
         },
         "@tapjs/before-each": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/before-each/-/before-each-1.1.17.tgz",
-            "integrity": "sha512-d2Um3Y2j0m563QNsSxczh+QeSg5sBngnBFGOelUtQVqmq91oNWU/7mY1pwN6ip8mMIQYD75CIhq5/Z57DGomWQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/before-each/-/before-each-2.0.1.tgz",
+            "integrity": "sha512-gG1nYkvCHtWwhkueulO475KczdQZ3vBRgdkta/Qi42ZjZo6SNhYVjNc/+LRGV5vZoESrvgSd+JrDRGufd+j43w==",
             "dev": true,
             "requires": {
                 "function-loop": "^4.0.0"
             }
         },
         "@tapjs/config": {
-            "version": "2.4.14",
-            "resolved": "https://registry.npmjs.org/@tapjs/config/-/config-2.4.14.tgz",
-            "integrity": "sha512-dkjPVJGbLJC9BxCAxudAGiijnKc6XcQbpBSMAGJ/+VoRSqXlPkMWz0d8Ad3rNt7s+g2GBEWBx1kV7wcKtLlxmw==",
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/config/-/config-3.0.1.tgz",
+            "integrity": "sha512-gAYFzErdSuPQ3afW6iRR99hiJmRLU+x9T+NE89z9UM45iPxglWLrRv1PFfh3tmtX6rpzwD5RY4/FVPcP2+/1LQ==",
             "dev": true,
             "requires": {
-                "@tapjs/core": "1.4.6",
-                "@tapjs/test": "1.3.17",
+                "@tapjs/core": "2.0.1",
+                "@tapjs/test": "2.0.1",
                 "chalk": "^5.2.0",
-                "jackspeak": "^2.3.6",
+                "jackspeak": "^3.1.2",
                 "polite-json": "^4.0.1",
-                "tap-yaml": "2.2.1",
+                "tap-yaml": "2.2.2",
                 "walk-up-path": "^3.0.1"
             },
             "dependencies": {
@@ -5823,130 +6088,100 @@
             }
         },
         "@tapjs/core": {
-            "version": "1.4.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/core/-/core-1.4.6.tgz",
-            "integrity": "sha512-cAKtdGJslrziwi/RJBU7jF930P/eSsemv295t6yLekNVP0XUCNtLFYirxuS1Xwob0nt0g/k+94xXB7o1wdTQvA==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/core/-/core-2.0.1.tgz",
+            "integrity": "sha512-q+8d+ohw5kudktIqgP5ETBcPWAPip+kMIxs2eL2G3dV+7Gc8WrH43cCPrbSGPRITIOSIDPrtpQZEcZwQNqDdQw==",
             "dev": true,
             "requires": {
-                "@tapjs/processinfo": "^3.1.6",
-                "@tapjs/stack": "1.2.7",
-                "@tapjs/test": "1.3.17",
+                "@tapjs/processinfo": "^3.1.7",
+                "@tapjs/stack": "2.0.1",
+                "@tapjs/test": "2.0.1",
                 "async-hook-domain": "^4.0.1",
-                "diff": "^5.1.0",
-                "is-actual-promise": "^1.0.0",
-                "minipass": "^7.0.3",
+                "diff": "^5.2.0",
+                "is-actual-promise": "^1.0.1",
+                "minipass": "^7.0.4",
                 "signal-exit": "4.1",
-                "tap-parser": "15.3.1",
-                "tap-yaml": "2.2.1",
-                "tcompare": "6.4.5",
+                "tap-parser": "16.0.1",
+                "tap-yaml": "2.2.2",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0"
+            },
+            "dependencies": {
+                "tcompare": {
+                    "version": "7.0.1",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+                    "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+                    "dev": true,
+                    "requires": {
+                        "diff": "^5.2.0",
+                        "react-element-to-jsx-string": "^15.0.0"
+                    }
+                }
             }
         },
         "@tapjs/error-serdes": {
-            "version": "1.2.1",
-            "resolved": "https://registry.npmjs.org/@tapjs/error-serdes/-/error-serdes-1.2.1.tgz",
-            "integrity": "sha512-/7eLEcrGo+Qz3eWrjkhDC+VSEOjabkkzr9eRADeU+OLFeZaik8L/GRk0SGhnp4YsQkv0jcNV00A42bEx2HIZcw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/error-serdes/-/error-serdes-2.0.1.tgz",
+            "integrity": "sha512-P+M4rtcfkDsUveKKmoRNF+07xpbPnRY5KrstIUOnyn483clQ7BJhsnWr162yYNCsyOj4zEfZmAJI1f8Bi7h/ZA==",
             "dev": true,
             "requires": {
-                "minipass": "^7.0.3"
+                "minipass": "^7.0.4"
             }
         },
         "@tapjs/filter": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/filter/-/filter-1.2.17.tgz",
-            "integrity": "sha512-ytsqoPThV92ML1+M+cHlhAS7nOQpDNRBJiPqw20/GmNeoQXsDzVUlWR89DP3WNNUPrr/c1pCVr9XHVhCIeYk0w==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/filter/-/filter-2.0.1.tgz",
+            "integrity": "sha512-muKEeXK7Tz6VR4hjXfT2qXPvjYES575mtiRerjHf+8qP8D7MvmC8qDZJjzFdo1nZHKhF8snvFosIVuI1BAhvsw==",
             "dev": true,
             "requires": {}
         },
         "@tapjs/fixture": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/fixture/-/fixture-1.2.17.tgz",
-            "integrity": "sha512-eOOQxtsEcQ/sBxaZhpqdF9DCNxXAvLuiE5HgyL6d1eB4eceu57uIUKK7NDtFVv+vlbQH/NoiSTxmN/IBRbKT8w==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/fixture/-/fixture-2.0.1.tgz",
+            "integrity": "sha512-MLgEwsBlCD69iUbZcnKBehP2js5cV4p5GrFoOKSudMuH2DQJInaF/g2bkijue61cVZwPj/MRPCqAlkwA94epjg==",
             "dev": true,
             "requires": {
                 "mkdirp": "^3.0.0",
                 "rimraf": "^5.0.5"
-            },
-            "dependencies": {
-                "brace-expansion": {
-                    "version": "2.0.1",
-                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
-                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
-                    "dev": true,
-                    "requires": {
-                        "balanced-match": "^1.0.0"
-                    }
-                },
-                "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
-                    "dev": true,
-                    "requires": {
-                        "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
-                    }
-                },
-                "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
-                    "dev": true,
-                    "requires": {
-                        "brace-expansion": "^2.0.1"
-                    }
-                },
-                "rimraf": {
-                    "version": "5.0.5",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
-                    "dev": true,
-                    "requires": {
-                        "glob": "^10.3.7"
-                    }
-                }
             }
         },
         "@tapjs/intercept": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/intercept/-/intercept-1.2.17.tgz",
-            "integrity": "sha512-CNuYBxiFBMNALS1PxH3yGI10H8ObxOoD67C2xGWyzXeYrPJ/R4x31Sda9bqaoK3uf/vj28bC9kSECCFjRsNAEg==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/intercept/-/intercept-2.0.1.tgz",
+            "integrity": "sha512-BZgXE3zCAbv4lfbph1r85gihtI3kXltHlFQ8Bf3Yy9fx27DKQlBvXnD7T69ke8kQLRzhz+wTMcR/mcQjo1fa7w==",
             "dev": true,
             "requires": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/stack": "1.2.7"
+                "@tapjs/after": "1.1.24",
+                "@tapjs/stack": "2.0.1"
             }
         },
         "@tapjs/mock": {
-            "version": "1.2.15",
-            "resolved": "https://registry.npmjs.org/@tapjs/mock/-/mock-1.2.15.tgz",
-            "integrity": "sha512-uXfVNDAMAbCGOu46B9jbryTau2pLSQjCdWnkAm/OUgZh/OtO0i7OORz9HdEPfEF2tuy1tLo9+vsCZm3lPU5F7w==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/mock/-/mock-2.0.1.tgz",
+            "integrity": "sha512-i1vkwNgO7uEuQW3+hTuE2L64aC9xk0cC3PtC6DZKqyApk2IstNgoIS38nfsI6v2kvEgZNuWlsNcRAYNDOIEhzA==",
             "dev": true,
             "requires": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/stack": "1.2.7",
+                "@tapjs/after": "1.1.24",
+                "@tapjs/stack": "2.0.1",
                 "resolve-import": "^1.4.5",
                 "walk-up-path": "^3.0.1"
             }
         },
         "@tapjs/node-serialize": {
-            "version": "1.2.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/node-serialize/-/node-serialize-1.2.6.tgz",
-            "integrity": "sha512-xj1OJEsdTr0pQFlirfe/apN0dHUCMCx2Nm5H3SoiSOW4D1/FUKS65VZpWgo3mXMPxRyb/2T1DH3xON1eSGq4ww==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/node-serialize/-/node-serialize-2.0.1.tgz",
+            "integrity": "sha512-1GtHDa7AXpk8y08llIPfUKRTDNsq+BhXxz7wiIfVEAOEB09kGyfpWteOg+cmvb+aHU1Ays3z+medXTIBm0D5Kg==",
             "dev": true,
             "requires": {
-                "@tapjs/error-serdes": "1.2.1",
-                "@tapjs/stack": "1.2.7",
-                "tap-parser": "15.3.1"
+                "@tapjs/error-serdes": "2.0.1",
+                "@tapjs/stack": "2.0.1",
+                "tap-parser": "16.0.1"
             }
         },
         "@tapjs/processinfo": {
-            "version": "3.1.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/processinfo/-/processinfo-3.1.6.tgz",
-            "integrity": "sha512-ktDsaf79wJsLaoG1Pp+stHSRf6a1k/JydoRAaYVG5iJnd3DooL6yewZsciUi2yiN/WQc5tAXCIFTXL4uXGB8LA==",
+            "version": "3.1.7",
+            "resolved": "https://registry.npmjs.org/@tapjs/processinfo/-/processinfo-3.1.7.tgz",
+            "integrity": "sha512-SI5RJQ5HnUKEWnHSAF6hOm6XPdnjZ+CJzIaVHdFebed8iDAPTqb+IwMVu9yq9+VQ7FRsMMlgLL2SW4rss2iJbQ==",
             "dev": true,
             "requires": {
                 "pirates": "^4.0.5",
@@ -5956,24 +6191,24 @@
             }
         },
         "@tapjs/reporter": {
-            "version": "1.3.15",
-            "resolved": "https://registry.npmjs.org/@tapjs/reporter/-/reporter-1.3.15.tgz",
-            "integrity": "sha512-us1vXd6TW1V8wJxxnP2a8DNSP1WFTpODyYukqWg7ym5nCalREYnz2MFsn65rRNu/xJlmqsmv+9P63rupud7Zlg==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/reporter/-/reporter-2.0.1.tgz",
+            "integrity": "sha512-fCdl4vg8vnlqIYtTQ9dc3zOqeXrA5QbATbT4dsPIiPuCM3gvKTbntaNBeaWWZkPx697Dj+b8TIxT/xhNMNv7jQ==",
             "dev": true,
             "requires": {
-                "@tapjs/config": "2.4.14",
-                "@tapjs/stack": "1.2.7",
+                "@tapjs/config": "3.0.1",
+                "@tapjs/stack": "2.0.1",
                 "chalk": "^5.2.0",
                 "ink": "^4.4.1",
-                "minipass": "^7.0.3",
+                "minipass": "^7.0.4",
                 "ms": "^2.1.3",
                 "patch-console": "^2.0.0",
                 "prismjs-terminal": "^1.2.3",
                 "react": "^18.2.0",
                 "string-length": "^6.0.0",
-                "tap-parser": "15.3.1",
-                "tap-yaml": "2.2.1",
-                "tcompare": "6.4.5"
+                "tap-parser": "16.0.1",
+                "tap-yaml": "2.2.2",
+                "tcompare": "7.0.1"
             },
             "dependencies": {
                 "chalk": {
@@ -5987,39 +6222,49 @@
                     "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
                     "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
                     "dev": true
+                },
+                "tcompare": {
+                    "version": "7.0.1",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+                    "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+                    "dev": true,
+                    "requires": {
+                        "diff": "^5.2.0",
+                        "react-element-to-jsx-string": "^15.0.0"
+                    }
                 }
             }
         },
         "@tapjs/run": {
-            "version": "1.4.16",
-            "resolved": "https://registry.npmjs.org/@tapjs/run/-/run-1.4.16.tgz",
-            "integrity": "sha512-ZTESjBDj5SitZgWz2hQdzfBoxgaFs89jQjWzqobcdfro0iF7TVRpSrvpz9GTMdo2Tu9aeFfMNfmaAtwNWnDabw==",
-            "dev": true,
-            "requires": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/before": "1.1.17",
-                "@tapjs/config": "2.4.14",
-                "@tapjs/processinfo": "^3.1.6",
-                "@tapjs/reporter": "1.3.15",
-                "@tapjs/spawn": "1.1.17",
-                "@tapjs/stdin": "1.1.17",
-                "@tapjs/test": "1.3.17",
-                "c8": "^8.0.1",
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/@tapjs/run/-/run-2.0.2.tgz",
+            "integrity": "sha512-2hPGlabqbLb3hh4BHHvwE8R9a9OiWumkCkHw5QQUZurDsVOpB94FfteqW9mktTVjZJnN0go+sN3GN2jZUaPWGQ==",
+            "dev": true,
+            "requires": {
+                "@tapjs/after": "1.1.24",
+                "@tapjs/before": "2.0.1",
+                "@tapjs/config": "3.0.1",
+                "@tapjs/processinfo": "^3.1.7",
+                "@tapjs/reporter": "2.0.1",
+                "@tapjs/spawn": "2.0.1",
+                "@tapjs/stdin": "2.0.1",
+                "@tapjs/test": "2.0.1",
+                "c8": "^9.1.0",
                 "chalk": "^5.3.0",
-                "chokidar": "^3.5.3",
+                "chokidar": "^3.6.0",
                 "foreground-child": "^3.1.1",
-                "glob": "^10.3.10",
-                "minipass": "^7.0.3",
+                "glob": "^10.3.16",
+                "minipass": "^7.0.4",
                 "mkdirp": "^3.0.1",
                 "opener": "^1.5.2",
-                "pacote": "^17.0.3",
+                "pacote": "^17.0.6",
                 "resolve-import": "^1.4.5",
                 "rimraf": "^5.0.5",
-                "semver": "^7.5.4",
+                "semver": "^7.6.0",
                 "signal-exit": "^4.1.0",
-                "tap-parser": "15.3.1",
-                "tap-yaml": "2.2.1",
-                "tcompare": "6.4.5",
+                "tap-parser": "16.0.1",
+                "tap-yaml": "2.2.2",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0",
                 "which": "^4.0.0"
             },
@@ -6040,16 +6285,16 @@
                     "dev": true
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "isexe": {
@@ -6059,21 +6304,22 @@
                     "dev": true
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
                 },
-                "rimraf": {
-                    "version": "5.0.5",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+                "tcompare": {
+                    "version": "7.0.1",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+                    "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
                     "dev": true,
                     "requires": {
-                        "glob": "^10.3.7"
+                        "diff": "^5.2.0",
+                        "react-element-to-jsx-string": "^15.0.0"
                     }
                 },
                 "which": {
@@ -6088,67 +6334,80 @@
             }
         },
         "@tapjs/snapshot": {
-            "version": "1.2.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/snapshot/-/snapshot-1.2.17.tgz",
-            "integrity": "sha512-xDHys854ZA8s/1uCkE5PgBz4H1vYKChD6a4xjLVkaoRxpBHVp/IJZCD+8d69DRGnyuA4x2MGh0JLClTA9bLGrA==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/snapshot/-/snapshot-2.0.1.tgz",
+            "integrity": "sha512-ZnbCxL+9fiJ38tec6wvRtRBZz9ChRUq0Bov7dltdZMNkXqudKyB+Zzbg25bqDEIgcczyp6A9hOwTX6VybDGqpg==",
             "dev": true,
             "requires": {
-                "is-actual-promise": "^1.0.0",
-                "tcompare": "6.4.5",
+                "is-actual-promise": "^1.0.1",
+                "tcompare": "7.0.1",
                 "trivial-deferred": "^2.0.0"
+            },
+            "dependencies": {
+                "tcompare": {
+                    "version": "7.0.1",
+                    "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-7.0.1.tgz",
+                    "integrity": "sha512-JN5s7hgmg/Ya5HxZqCnywT+XiOGRFcJRgYhtMyt/1m+h0yWpWwApO7HIM8Bpwyno9hI151ljjp5eAPCHhIGbpQ==",
+                    "dev": true,
+                    "requires": {
+                        "diff": "^5.2.0",
+                        "react-element-to-jsx-string": "^15.0.0"
+                    }
+                }
             }
         },
         "@tapjs/spawn": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/spawn/-/spawn-1.1.17.tgz",
-            "integrity": "sha512-Bbyxd91bgXEcglvXYKrRl2MaNHk00RajTZJ1kKe3Scr1ivaYv0maE6ZInAl4UE0a4SJl4Dskec+uKoZY3qGUYQ==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/spawn/-/spawn-2.0.1.tgz",
+            "integrity": "sha512-3VaQKJjHV5frMZj3Ef+QlJyB6b7VsGMil223zAEz8Ttgy2hDYtcb29nvsLPUcowFyOUrsydnXEnHgpR79wEPOA==",
             "dev": true,
             "requires": {}
         },
         "@tapjs/stack": {
-            "version": "1.2.7",
-            "resolved": "https://registry.npmjs.org/@tapjs/stack/-/stack-1.2.7.tgz",
-            "integrity": "sha512-7qUDWDmd+y7ZQ0vTrDTvFlWnJ+ND32NemS5HVuT1ZggHtBwJ62PQHIyCx/B5RopETBb6NvFPfUE21yTiex9Jkw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/stack/-/stack-2.0.1.tgz",
+            "integrity": "sha512-3rKbZkRkLeJl9ilV/6b80YfI4C4+OYf7iEz5/d0MIVhmVvxv0ttIy5JnZutAc4Gy9eRp5Ne5UTAIFOVY5k36cg==",
             "dev": true
         },
         "@tapjs/stdin": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/stdin/-/stdin-1.1.17.tgz",
-            "integrity": "sha512-mDutFFPDnlVM2oYDAfyYKA+fC+aEiyz5n08D8x6YAbwZNbTIVp+h6ucyp7ygJ04fshd4l3s1HUmCZLSmHb2xEw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/stdin/-/stdin-2.0.1.tgz",
+            "integrity": "sha512-5Oe13Fzpnt9seAi8h3bsMxtJp8S+DQI6ncBD9JBcS91XKLbqyKrb1bNzeXQN2PrHBs6Atw8cOzFZh0TjL+bIaA==",
             "dev": true,
             "requires": {}
         },
         "@tapjs/test": {
-            "version": "1.3.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/test/-/test-1.3.17.tgz",
-            "integrity": "sha512-yQ4uHC2GaDS+Gr5qwx9uMGxqvpYgnlVY+QexBReSeYZthWIN0KD8HDvnVt4An5Sx/Qhd7UlnNpNMBd6AkvPEew==",
-            "dev": true,
-            "requires": {
-                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.5",
-                "@tapjs/after": "1.1.17",
-                "@tapjs/after-each": "1.1.17",
-                "@tapjs/asserts": "1.1.17",
-                "@tapjs/before": "1.1.17",
-                "@tapjs/before-each": "1.1.17",
-                "@tapjs/filter": "1.2.17",
-                "@tapjs/fixture": "1.2.17",
-                "@tapjs/intercept": "1.2.17",
-                "@tapjs/mock": "1.2.15",
-                "@tapjs/node-serialize": "1.2.6",
-                "@tapjs/snapshot": "1.2.17",
-                "@tapjs/spawn": "1.1.17",
-                "@tapjs/stdin": "1.1.17",
-                "@tapjs/typescript": "1.3.6",
-                "@tapjs/worker": "1.1.17",
-                "glob": "^10.3.10",
-                "jackspeak": "^2.3.6",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/test/-/test-2.0.1.tgz",
+            "integrity": "sha512-PKazf7r4+bLFATML2f/h8glGcSirXmzXUYlhFuxb4xHoOhHojyKgo1p8kSj+Ksxb3hVSCQlvyXgM8QYYaoMwog==",
+            "dev": true,
+            "requires": {
+                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.7",
+                "@tapjs/after": "1.1.24",
+                "@tapjs/after-each": "2.0.1",
+                "@tapjs/asserts": "2.0.1",
+                "@tapjs/before": "2.0.1",
+                "@tapjs/before-each": "2.0.1",
+                "@tapjs/filter": "2.0.1",
+                "@tapjs/fixture": "2.0.1",
+                "@tapjs/intercept": "2.0.1",
+                "@tapjs/mock": "2.0.1",
+                "@tapjs/node-serialize": "2.0.1",
+                "@tapjs/snapshot": "2.0.1",
+                "@tapjs/spawn": "2.0.1",
+                "@tapjs/stdin": "2.0.1",
+                "@tapjs/typescript": "1.4.6",
+                "@tapjs/worker": "2.0.1",
+                "glob": "^10.3.16",
+                "jackspeak": "^3.1.2",
                 "mkdirp": "^3.0.0",
                 "resolve-import": "^1.4.5",
                 "rimraf": "^5.0.5",
                 "sync-content": "^1.0.1",
-                "tap-parser": "15.3.1",
-                "tshy": "^1.2.2",
-                "typescript": "5.2"
+                "tap-parser": "16.0.1",
+                "tshy": "^1.14.0",
+                "typescript": "5.4",
+                "walk-up-path": "^3.0.1"
             },
             "dependencies": {
                 "brace-expansion": {
@@ -6161,76 +6420,67 @@
                     }
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
-                },
-                "rimraf": {
-                    "version": "5.0.5",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
-                    "dev": true,
-                    "requires": {
-                        "glob": "^10.3.7"
-                    }
                 }
             }
         },
         "@tapjs/typescript": {
-            "version": "1.3.6",
-            "resolved": "https://registry.npmjs.org/@tapjs/typescript/-/typescript-1.3.6.tgz",
-            "integrity": "sha512-bHqQb06HcD1vFvSwElH0WK4cnCNthvA5OX/KBs5w1TNFHIeRHemp/hsSnGSNDwYwDETuOxD68rDZNTpNbzysBg==",
+            "version": "1.4.6",
+            "resolved": "https://registry.npmjs.org/@tapjs/typescript/-/typescript-1.4.6.tgz",
+            "integrity": "sha512-6jxUQ7Mdb+Y2q8RJcwgZZ6dCR+X2u3hCL+xb1GDAtO7k1+B6z2b+z+I+FdhuO4YgrP0SLRjocL5rJM/xi9K7qw==",
             "dev": true,
             "requires": {
-                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.5"
+                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.7"
             }
         },
         "@tapjs/worker": {
-            "version": "1.1.17",
-            "resolved": "https://registry.npmjs.org/@tapjs/worker/-/worker-1.1.17.tgz",
-            "integrity": "sha512-DCRzEBT+OgP518rQqzlX6KawvGTegkeEjPVa/TB6Iifj8WOHJ+XtunkR7riIRGEoCEOMD49DCJXj70c+XP0jNw==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/worker/-/worker-2.0.1.tgz",
+            "integrity": "sha512-wegz8IxNEPIIAA+R76/avZgNmZ4iC7QGFbtXKGBU962/1lXTITxshRV6e21r0IBa7YLkSVgDuVSVB3+Qzve0Yg==",
             "dev": true,
             "requires": {}
         },
         "@tsconfig/node14": {
-            "version": "14.1.0",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-14.1.0.tgz",
-            "integrity": "sha512-VmsCG04YR58ciHBeJKBDNMWWfYbyP8FekWVuTlpstaUPlat1D0x/tXzkWP7yCMU0eSz9V4OZU0LBWTFJ3xZf6w==",
+            "version": "14.1.2",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-14.1.2.tgz",
+            "integrity": "sha512-1vncsbfCZ3TBLPxesRYz02Rn7SNJfbLoDVkcZ7F/ixOV6nwxwgdhD1mdPcc5YQ413qBJ8CvMxXMFfJ7oawjo7Q==",
             "dev": true
         },
         "@tsconfig/node16": {
-            "version": "16.1.1",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-16.1.1.tgz",
-            "integrity": "sha512-+pio93ejHN4nINX4pXqfnR/fPLRtJBaT4ORaa5RH0Oc1zoYmo2B2koG+M328CQhHKn1Wj6FcOxCDFXAot9NhvA==",
+            "version": "16.1.3",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-16.1.3.tgz",
+            "integrity": "sha512-9nTOUBn+EMKO6rtSZJk+DcqsfgtlERGT9XPJ5PRj/HNENPCBY1yu/JEj5wT6GLtbCLBO2k46SeXDaY0pjMqypw==",
             "dev": true
         },
         "@tsconfig/node18": {
-            "version": "18.2.2",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.2.tgz",
-            "integrity": "sha512-d6McJeGsuoRlwWZmVIeE8CUA27lu6jLjvv1JzqmpsytOYYbVi1tHZEnwCNVOXnj4pyLvneZlFlpXUK+X9wBWyw==",
+            "version": "18.2.4",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.4.tgz",
+            "integrity": "sha512-5xxU8vVs9/FNcvm3gE07fPbn9tl6tqGGWA9tSlwsUEkBxtRnTsNmwrV8gasZ9F/EobaSv9+nu8AxUKccw77JpQ==",
             "dev": true
         },
         "@tsconfig/node20": {
-            "version": "20.1.2",
-            "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.2.tgz",
-            "integrity": "sha512-madaWq2k+LYMEhmcp0fs+OGaLFk0OenpHa4gmI4VEmCKX4PJntQ6fnnGADVFrVkBj0wIdAlQnK/MrlYTHsa1gQ==",
+            "version": "20.1.4",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz",
+            "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==",
             "dev": true
         },
         "@tufjs/canonical-json": {
@@ -6240,13 +6490,13 @@
             "dev": true
         },
         "@tufjs/models": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.0.tgz",
-            "integrity": "sha512-c8nj8BaOExmZKO2DXhDfegyhSGcG9E/mPN3U13L+/PsoWm1uaGiHHjxqSHQiasDBQwDA3aHuw9+9spYAP1qvvg==",
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.1.tgz",
+            "integrity": "sha512-92F7/SFyufn4DXsha9+QfKnN03JGqtMFMXgSHbZOo8JG59WkTni7UzAouNQDf7AuP9OAMxVOPQcqG3sB7w+kkg==",
             "dev": true,
             "requires": {
                 "@tufjs/canonical-json": "2.0.0",
-                "minimatch": "^9.0.3"
+                "minimatch": "^9.0.4"
             },
             "dependencies": {
                 "brace-expansion": {
@@ -6259,9 +6509,9 @@
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
@@ -6276,9 +6526,9 @@
             "dev": true
         },
         "@types/node": {
-            "version": "20.10.6",
-            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz",
-            "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==",
+            "version": "20.13.0",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.13.0.tgz",
+            "integrity": "sha512-FM6AOb3khNkNIXPnHFDYaHerSv8uN22C91z098AnGccVu+Pcdhi+pNUFDi0iLmPIsVE0JBD0KVS7mzUYt4nRzQ==",
             "dev": true,
             "peer": true,
             "requires": {
@@ -6303,15 +6553,15 @@
             "requires": {}
         },
         "acorn-walk": {
-            "version": "8.3.1",
-            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz",
-            "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==",
+            "version": "8.3.2",
+            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
+            "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
             "dev": true
         },
         "agent-base": {
-            "version": "7.1.0",
-            "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
-            "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==",
+            "version": "7.1.1",
+            "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
+            "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
             "dev": true,
             "requires": {
                 "debug": "^4.3.4"
@@ -6347,21 +6597,10 @@
             }
         },
         "ansi-escapes": {
-            "version": "6.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz",
-            "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==",
-            "dev": true,
-            "requires": {
-                "type-fest": "^3.0.0"
-            },
-            "dependencies": {
-                "type-fest": {
-                    "version": "3.13.1",
-                    "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
-                    "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
-                    "dev": true
-                }
-            }
+            "version": "6.2.1",
+            "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz",
+            "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==",
+            "dev": true
         },
         "ansi-regex": {
             "version": "5.0.1",
@@ -6430,65 +6669,37 @@
             }
         },
         "braces": {
-            "version": "3.0.2",
-            "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
-            "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
-            "dev": true,
-            "requires": {
-                "fill-range": "^7.0.1"
-            }
-        },
-        "builtins": {
-            "version": "5.0.1",
-            "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz",
-            "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==",
+            "version": "3.0.3",
+            "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+            "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
             "dev": true,
             "requires": {
-                "semver": "^7.0.0"
+                "fill-range": "^7.1.1"
             }
         },
         "c8": {
-            "version": "8.0.1",
-            "resolved": "https://registry.npmjs.org/c8/-/c8-8.0.1.tgz",
-            "integrity": "sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w==",
+            "version": "9.1.0",
+            "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz",
+            "integrity": "sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg==",
             "dev": true,
             "requires": {
                 "@bcoe/v8-coverage": "^0.2.3",
                 "@istanbuljs/schema": "^0.1.3",
                 "find-up": "^5.0.0",
-                "foreground-child": "^2.0.0",
+                "foreground-child": "^3.1.1",
                 "istanbul-lib-coverage": "^3.2.0",
                 "istanbul-lib-report": "^3.0.1",
                 "istanbul-reports": "^3.1.6",
-                "rimraf": "^3.0.2",
                 "test-exclude": "^6.0.0",
                 "v8-to-istanbul": "^9.0.0",
                 "yargs": "^17.7.2",
                 "yargs-parser": "^21.1.1"
-            },
-            "dependencies": {
-                "foreground-child": {
-                    "version": "2.0.0",
-                    "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz",
-                    "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==",
-                    "dev": true,
-                    "requires": {
-                        "cross-spawn": "^7.0.0",
-                        "signal-exit": "^3.0.2"
-                    }
-                },
-                "signal-exit": {
-                    "version": "3.0.7",
-                    "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
-                    "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
-                    "dev": true
-                }
             }
         },
         "cacache": {
-            "version": "18.0.2",
-            "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.2.tgz",
-            "integrity": "sha512-r3NU8h/P+4lVUHfeRw1dtgQYar3DZMm4/cm2bZgOvrFC/su7budSOeqh52VJIC4U4iG1WWwV6vRW0znqBvxNuw==",
+            "version": "18.0.3",
+            "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.3.tgz",
+            "integrity": "sha512-qXCd4rh6I07cnDqh8V48/94Tc/WSfj+o3Gn6NZ0aZovS255bUx8O13uKxRFd2eWG0xgsco7+YItQNPaa5E85hg==",
             "dev": true,
             "requires": {
                 "@npmcli/fs": "^3.1.0",
@@ -6515,22 +6726,22 @@
                     }
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
@@ -6553,9 +6764,9 @@
             }
         },
         "chokidar": {
-            "version": "3.5.3",
-            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
-            "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+            "version": "3.6.0",
+            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+            "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
             "dev": true,
             "requires": {
                 "anymatch": "~3.1.2",
@@ -6719,6 +6930,11 @@
             "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
             "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="
         },
+        "compress-json": {
+            "version": "3.0.5",
+            "resolved": "https://registry.npmjs.org/compress-json/-/compress-json-3.0.5.tgz",
+            "integrity": "sha512-HYiJvE0cTIygI9zXqY5fkRr7H3NV3UAME0enzwN5M0JkzMOtUcjSyaH7HxVRzXsn7IIXD0STA9M5jyWkxERSLg=="
+        },
         "concat-map": {
             "version": "0.0.1",
             "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -6760,9 +6976,9 @@
             "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
         },
         "diff": {
-            "version": "5.1.0",
-            "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
-            "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==",
+            "version": "5.2.0",
+            "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
+            "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
             "dev": true
         },
         "doctrine": {
@@ -6776,14 +6992,12 @@
         "eastasianwidth": {
             "version": "0.2.0",
             "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
-            "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
-            "dev": true
+            "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
         },
         "emoji-regex": {
             "version": "9.2.2",
             "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
-            "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
-            "dev": true
+            "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
         },
         "encoding": {
             "version": "0.1.13",
@@ -6808,9 +7022,9 @@
             "dev": true
         },
         "escalade": {
-            "version": "3.1.1",
-            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
-            "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
+            "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
             "dev": true
         },
         "escape-string-regexp": {
@@ -6959,9 +7173,9 @@
             }
         },
         "fill-range": {
-            "version": "7.0.1",
-            "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
-            "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+            "version": "7.1.1",
+            "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+            "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
             "dev": true,
             "requires": {
                 "to-regex-range": "^5.0.1"
@@ -6983,6 +7197,16 @@
             "requires": {
                 "flatted": "^3.1.0",
                 "rimraf": "^3.0.2"
+            },
+            "dependencies": {
+                "rimraf": {
+                    "version": "3.0.2",
+                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+                    "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+                    "requires": {
+                        "glob": "^7.1.3"
+                    }
+                }
             }
         },
         "flatted": {
@@ -6990,11 +7214,15 @@
             "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
             "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
         },
+        "flexsearch": {
+            "version": "0.7.43",
+            "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.7.43.tgz",
+            "integrity": "sha512-c5o/+Um8aqCSOXGcZoqZOm+NqtVwNsvVpWv6lfmSclU954O3wvQKxxK8zj74fPaSJbXpSLTs4PRhh+wnoCXnKg=="
+        },
         "foreground-child": {
             "version": "3.1.1",
             "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
             "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
-            "dev": true,
             "requires": {
                 "cross-spawn": "^7.0.0",
                 "signal-exit": "^4.0.1"
@@ -7091,9 +7319,9 @@
             "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
         },
         "hasown": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
-            "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+            "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
             "dev": true,
             "requires": {
                 "function-bind": "^1.1.2"
@@ -7105,9 +7333,9 @@
             "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
         },
         "hosted-git-info": {
-            "version": "7.0.1",
-            "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz",
-            "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==",
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
+            "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==",
             "dev": true,
             "requires": {
                 "lru-cache": "^10.0.1"
@@ -7126,9 +7354,9 @@
             "dev": true
         },
         "http-proxy-agent": {
-            "version": "7.0.0",
-            "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz",
-            "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==",
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+            "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
             "dev": true,
             "requires": {
                 "agent-base": "^7.1.0",
@@ -7136,9 +7364,9 @@
             }
         },
         "https-proxy-agent": {
-            "version": "7.0.2",
-            "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz",
-            "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==",
+            "version": "7.0.4",
+            "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz",
+            "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==",
             "dev": true,
             "requires": {
                 "agent-base": "^7.0.2",
@@ -7161,9 +7389,9 @@
             "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ=="
         },
         "ignore-walk": {
-            "version": "6.0.4",
-            "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.4.tgz",
-            "integrity": "sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw==",
+            "version": "6.0.5",
+            "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.5.tgz",
+            "integrity": "sha512-VuuG0wCnjhnylG1ABXT3dAuIpTNDs/G8jlpmwXY03fXoXy/8ZK8/T+hMzt8L4WnrLCJgdybqgPagnF/f97cg3A==",
             "dev": true,
             "requires": {
                 "minimatch": "^9.0.0"
@@ -7179,9 +7407,9 @@
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
@@ -7284,21 +7512,22 @@
                 }
             }
         },
-        "ip": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
-            "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
-            "dev": true
-        },
-        "is-actual-promise": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/is-actual-promise/-/is-actual-promise-1.0.1.tgz",
-            "integrity": "sha512-PlsL4tNv62lx5yN2HSqaRSTgIpUAPW7U6+crVB8HfWm5161rZpeqWbl0ZSqH2MAfRKXWSZVPRNbE/r8qPcb13g==",
+        "ip-address": {
+            "version": "9.0.5",
+            "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
+            "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
             "dev": true,
             "requires": {
-                "tshy": "^1.7.0"
+                "jsbn": "1.1.0",
+                "sprintf-js": "^1.1.3"
             }
         },
+        "is-actual-promise": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/is-actual-promise/-/is-actual-promise-1.0.2.tgz",
+            "integrity": "sha512-xsFiO1of0CLsQnPZ1iXHNTyR9YszOeWKYv+q6n8oSFW3ipooFJ1j1lbRMgiMCr+pp2gLruESI4zb5Ak6eK5OnQ==",
+            "dev": true
+        },
         "is-binary-path": {
             "version": "2.1.0",
             "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -7409,9 +7638,9 @@
             }
         },
         "istanbul-reports": {
-            "version": "3.1.6",
-            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz",
-            "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==",
+            "version": "3.1.7",
+            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
+            "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
             "dev": true,
             "requires": {
                 "html-escaper": "^2.0.0",
@@ -7419,10 +7648,9 @@
             }
         },
         "jackspeak": {
-            "version": "2.3.6",
-            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
-            "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
-            "dev": true,
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.1.2.tgz",
+            "integrity": "sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==",
             "requires": {
                 "@isaacs/cliui": "^8.0.2",
                 "@pkgjs/parseargs": "^0.11.0"
@@ -7452,10 +7680,16 @@
             "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
             "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
         },
+        "jsbn": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
+            "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
+            "dev": true
+        },
         "json-parse-even-better-errors": {
-            "version": "3.0.1",
-            "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz",
-            "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==",
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz",
+            "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==",
             "dev": true
         },
         "json-schema-traverse": {
@@ -7512,10 +7746,9 @@
             }
         },
         "lru-cache": {
-            "version": "10.1.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz",
-            "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==",
-            "dev": true
+            "version": "10.2.2",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
+            "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ=="
         },
         "make-dir": {
             "version": "4.0.0",
@@ -7533,9 +7766,9 @@
             "dev": true
         },
         "make-fetch-happen": {
-            "version": "13.0.0",
-            "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.0.tgz",
-            "integrity": "sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==",
+            "version": "13.0.1",
+            "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz",
+            "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==",
             "dev": true,
             "requires": {
                 "@npmcli/agent": "^2.0.0",
@@ -7547,6 +7780,7 @@
                 "minipass-flush": "^1.0.5",
                 "minipass-pipeline": "^1.2.4",
                 "negotiator": "^0.6.3",
+                "proc-log": "^4.2.0",
                 "promise-retry": "^2.0.1",
                 "ssri": "^10.0.0"
             }
@@ -7571,10 +7805,9 @@
             }
         },
         "minipass": {
-            "version": "7.0.4",
-            "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
-            "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
-            "dev": true
+            "version": "7.1.2",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+            "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="
         },
         "minipass-collect": {
             "version": "2.0.1",
@@ -7586,9 +7819,9 @@
             }
         },
         "minipass-fetch": {
-            "version": "3.0.4",
-            "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz",
-            "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==",
+            "version": "3.0.5",
+            "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz",
+            "integrity": "sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==",
             "dev": true,
             "requires": {
                 "encoding": "^0.1.13",
@@ -7710,6 +7943,29 @@
             "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
             "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
         },
+        "msgpackr": {
+            "version": "1.10.2",
+            "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.10.2.tgz",
+            "integrity": "sha512-L60rsPynBvNE+8BWipKKZ9jHcSGbtyJYIwjRq0VrIvQ08cRjntGXJYW/tmciZ2IHWIY8WEW32Qa2xbh5+SKBZA==",
+            "requires": {
+                "msgpackr-extract": "^3.0.2"
+            }
+        },
+        "msgpackr-extract": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.2.tgz",
+            "integrity": "sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==",
+            "optional": true,
+            "requires": {
+                "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.2",
+                "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.2",
+                "node-gyp-build-optional-packages": "5.0.7"
+            }
+        },
         "natural-compare": {
             "version": "1.4.0",
             "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -7722,9 +7978,9 @@
             "dev": true
         },
         "node-gyp": {
-            "version": "10.0.1",
-            "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.0.1.tgz",
-            "integrity": "sha512-gg3/bHehQfZivQVfqIyy8wTdSymF9yTyP4CJifK73imyNMU8AIGQE2pUa7dNWfmMeG9cDVF2eehiRMv0LC1iAg==",
+            "version": "10.1.0",
+            "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.1.0.tgz",
+            "integrity": "sha512-B4J5M1cABxPc5PwfjhbV5hoy2DP9p8lFXASnEN6hugXOa61416tnTZ29x9sSwAd0o99XNIcpvDDy1swAExsVKA==",
             "dev": true,
             "requires": {
                 "env-paths": "^2.2.0",
@@ -7749,16 +8005,16 @@
                     }
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "isexe": {
@@ -7768,14 +8024,20 @@
                     "dev": true
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
                 },
+                "proc-log": {
+                    "version": "3.0.0",
+                    "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz",
+                    "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==",
+                    "dev": true
+                },
                 "which": {
                     "version": "4.0.0",
                     "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
@@ -7787,19 +8049,25 @@
                 }
             }
         },
+        "node-gyp-build-optional-packages": {
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz",
+            "integrity": "sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==",
+            "optional": true
+        },
         "nopt": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.0.tgz",
-            "integrity": "sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==",
+            "version": "7.2.1",
+            "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
+            "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
             "dev": true,
             "requires": {
                 "abbrev": "^2.0.0"
             }
         },
         "normalize-package-data": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.0.tgz",
-            "integrity": "sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==",
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.1.tgz",
+            "integrity": "sha512-6rvCfeRW+OEZagAB4lMLSNuTNYZWLVtKccK79VSTf//yTY5VOCgcpH80O+bZK8Neps7pUnd5G+QlMg1yV/2iZQ==",
             "dev": true,
             "requires": {
                 "hosted-git-info": "^7.0.0",
@@ -7815,9 +8083,9 @@
             "dev": true
         },
         "npm-bundled": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz",
-            "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==",
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.1.tgz",
+            "integrity": "sha512-+AvaheE/ww1JEwRHOrn4WHNzOxGtVp+adrg2AeZS/7KuxGUYFuBta98wYpfHBbJp6Tg6j1NKSEVHNcfZzJHQwQ==",
             "dev": true,
             "requires": {
                 "npm-normalize-package-bin": "^3.0.0"
@@ -7839,30 +8107,30 @@
             "dev": true
         },
         "npm-package-arg": {
-            "version": "11.0.1",
-            "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.1.tgz",
-            "integrity": "sha512-M7s1BD4NxdAvBKUPqqRW957Xwcl/4Zvo8Aj+ANrzvIPzGJZElrH7Z//rSaec2ORcND6FHHLnZeY8qgTpXDMFQQ==",
+            "version": "11.0.2",
+            "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.2.tgz",
+            "integrity": "sha512-IGN0IAwmhDJwy13Wc8k+4PEbTPhpJnMtfR53ZbOyjkvmEcLS4nCwp6mvMWjS5sUjeiW3mpx6cHmuhKEu9XmcQw==",
             "dev": true,
             "requires": {
                 "hosted-git-info": "^7.0.0",
-                "proc-log": "^3.0.0",
+                "proc-log": "^4.0.0",
                 "semver": "^7.3.5",
                 "validate-npm-package-name": "^5.0.0"
             }
         },
         "npm-packlist": {
-            "version": "8.0.1",
-            "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.1.tgz",
-            "integrity": "sha512-MQpL27ZrsJQ2kiAuQPpZb5LtJwydNRnI15QWXsf3WHERu4rzjRj6Zju/My2fov7tLuu3Gle/uoIX/DDZ3u4O4Q==",
+            "version": "8.0.2",
+            "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.2.tgz",
+            "integrity": "sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA==",
             "dev": true,
             "requires": {
                 "ignore-walk": "^6.0.4"
             }
         },
         "npm-pick-manifest": {
-            "version": "9.0.0",
-            "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.0.tgz",
-            "integrity": "sha512-VfvRSs/b6n9ol4Qb+bDwNGUXutpy76x6MARw/XssevE0TnctIKcmklJZM5Z7nqs5z5aW+0S63pgCNbpkUNNXBg==",
+            "version": "9.0.1",
+            "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.1.tgz",
+            "integrity": "sha512-Udm1f0l2nXb3wxDpKjfohwgdFUSV50UVwzEIpDXVsbDMXVIEF81a/i0UhuQbhrPMMmdiq3+YMFLFIRVLs3hxQw==",
             "dev": true,
             "requires": {
                 "npm-install-checks": "^6.0.0",
@@ -7872,18 +8140,19 @@
             }
         },
         "npm-registry-fetch": {
-            "version": "16.1.0",
-            "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.1.0.tgz",
-            "integrity": "sha512-PQCELXKt8Azvxnt5Y85GseQDJJlglTFM9L9U9gkv2y4e9s0k3GVDdOx3YoB6gm2Do0hlkzC39iCGXby+Wve1Bw==",
+            "version": "16.2.1",
+            "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.2.1.tgz",
+            "integrity": "sha512-8l+7jxhim55S85fjiDGJ1rZXBWGtRLi1OSb4Z3BPLObPuIaeKRlPRiYMSHU4/81ck3t71Z+UwDDl47gcpmfQQA==",
             "dev": true,
             "requires": {
+                "@npmcli/redact": "^1.1.0",
                 "make-fetch-happen": "^13.0.0",
                 "minipass": "^7.0.2",
                 "minipass-fetch": "^3.0.0",
                 "minipass-json-stream": "^1.0.1",
                 "minizlib": "^2.1.2",
                 "npm-package-arg": "^11.0.0",
-                "proc-log": "^3.0.0"
+                "proc-log": "^4.0.0"
             }
         },
         "once": {
@@ -7948,9 +8217,9 @@
             }
         },
         "pacote": {
-            "version": "17.0.5",
-            "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.5.tgz",
-            "integrity": "sha512-TAE0m20zSDMnchPja9vtQjri19X3pZIyRpm2TJVeI+yU42leJBBDTRYhOcWFsPhaMxf+3iwQkFiKz16G9AEeeA==",
+            "version": "17.0.7",
+            "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.7.tgz",
+            "integrity": "sha512-sgvnoUMlkv9xHwDUKjKQFXVyUi8dtJGKp3vg6sYy+TxbDic5RjZCHF3ygv0EJgNRZ2GfRONjlKPUfokJ9lDpwQ==",
             "dev": true,
             "requires": {
                 "@npmcli/git": "^5.0.0",
@@ -7964,11 +8233,11 @@
                 "npm-packlist": "^8.0.0",
                 "npm-pick-manifest": "^9.0.0",
                 "npm-registry-fetch": "^16.0.0",
-                "proc-log": "^3.0.0",
+                "proc-log": "^4.0.0",
                 "promise-retry": "^2.0.1",
                 "read-package-json": "^7.0.0",
                 "read-package-json-fast": "^3.0.0",
-                "sigstore": "^2.0.0",
+                "sigstore": "^2.2.0",
                 "ssri": "^10.0.0",
                 "tar": "^6.1.11"
             }
@@ -8003,12 +8272,11 @@
             "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
         },
         "path-scurry": {
-            "version": "1.10.1",
-            "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
-            "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
-            "dev": true,
+            "version": "1.11.1",
+            "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+            "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
             "requires": {
-                "lru-cache": "^9.1.1 || ^10.0.0",
+                "lru-cache": "^10.2.0",
                 "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
             }
         },
@@ -8061,9 +8329,9 @@
             }
         },
         "proc-log": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz",
-            "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==",
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz",
+            "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==",
             "dev": true
         },
         "process-on-spawn": {
@@ -8110,9 +8378,9 @@
             "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
         },
         "react": {
-            "version": "18.2.0",
-            "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
-            "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
+            "version": "18.3.1",
+            "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+            "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
             "dev": true,
             "requires": {
                 "loose-envify": "^1.1.0"
@@ -8147,19 +8415,19 @@
             "dev": true
         },
         "react-reconciler": {
-            "version": "0.29.0",
-            "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.0.tgz",
-            "integrity": "sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==",
+            "version": "0.29.2",
+            "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
+            "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
             "dev": true,
             "requires": {
                 "loose-envify": "^1.1.0",
-                "scheduler": "^0.23.0"
+                "scheduler": "^0.23.2"
             }
         },
         "read-package-json": {
-            "version": "7.0.0",
-            "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.0.tgz",
-            "integrity": "sha512-uL4Z10OKV4p6vbdvIXB+OzhInYtIozl/VxUBPgNkBuUi2DeRonnuspmaVAMcrkmfjKGNmRndyQAbE7/AmzGwFg==",
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.1.tgz",
+            "integrity": "sha512-8PcDiZ8DXUjLf687Ol4BR8Bpm2umR7vhoZOzNRt+uxD9GpBh/K+CAAALVIiYFknmvlmyg7hM7BSNUXPaCCqd0Q==",
             "dev": true,
             "requires": {
                 "glob": "^10.2.2",
@@ -8178,22 +8446,22 @@
                     }
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
@@ -8251,22 +8519,22 @@
                     }
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
@@ -8304,11 +8572,41 @@
             "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="
         },
         "rimraf": {
-            "version": "3.0.2",
-            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
-            "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+            "version": "5.0.7",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz",
+            "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==",
             "requires": {
-                "glob": "^7.1.3"
+                "glob": "^10.3.7"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "glob": {
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
+                    "requires": {
+                        "foreground-child": "^3.1.0",
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                }
             }
         },
         "run-parallel": {
@@ -8327,33 +8625,19 @@
             "optional": true
         },
         "scheduler": {
-            "version": "0.23.0",
-            "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
-            "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+            "version": "0.23.2",
+            "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+            "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
             "dev": true,
             "requires": {
                 "loose-envify": "^1.1.0"
             }
         },
         "semver": {
-            "version": "7.5.4",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
-            "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
-            "dev": true,
-            "requires": {
-                "lru-cache": "^6.0.0"
-            },
-            "dependencies": {
-                "lru-cache": {
-                    "version": "6.0.0",
-                    "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-                    "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-                    "dev": true,
-                    "requires": {
-                        "yallist": "^4.0.0"
-                    }
-                }
-            }
+            "version": "7.6.2",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
+            "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
+            "dev": true
         },
         "shebang-command": {
             "version": "2.0.0",
@@ -8371,19 +8655,20 @@
         "signal-exit": {
             "version": "4.1.0",
             "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
-            "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
-            "dev": true
+            "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="
         },
         "sigstore": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.1.0.tgz",
-            "integrity": "sha512-kPIj+ZLkyI3QaM0qX8V/nSsweYND3W448pwkDgS6CQ74MfhEkIR8ToK5Iyx46KJYRjseVcD3Rp9zAmUAj6ZjPw==",
+            "version": "2.3.1",
+            "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.3.1.tgz",
+            "integrity": "sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==",
             "dev": true,
             "requires": {
-                "@sigstore/bundle": "^2.1.0",
-                "@sigstore/protobuf-specs": "^0.2.1",
-                "@sigstore/sign": "^2.1.0",
-                "@sigstore/tuf": "^2.1.0"
+                "@sigstore/bundle": "^2.3.2",
+                "@sigstore/core": "^1.0.0",
+                "@sigstore/protobuf-specs": "^0.3.2",
+                "@sigstore/sign": "^2.3.2",
+                "@sigstore/tuf": "^2.3.4",
+                "@sigstore/verify": "^1.2.1"
             }
         },
         "slice-ansi": {
@@ -8411,22 +8696,22 @@
             "dev": true
         },
         "socks": {
-            "version": "2.7.1",
-            "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz",
-            "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==",
+            "version": "2.8.3",
+            "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz",
+            "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==",
             "dev": true,
             "requires": {
-                "ip": "^2.0.0",
+                "ip-address": "^9.0.5",
                 "smart-buffer": "^4.2.0"
             }
         },
         "socks-proxy-agent": {
-            "version": "8.0.2",
-            "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz",
-            "integrity": "sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==",
+            "version": "8.0.3",
+            "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.3.tgz",
+            "integrity": "sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A==",
             "dev": true,
             "requires": {
-                "agent-base": "^7.0.2",
+                "agent-base": "^7.1.1",
                 "debug": "^4.3.4",
                 "socks": "^2.7.1"
             }
@@ -8442,9 +8727,9 @@
             }
         },
         "spdx-exceptions": {
-            "version": "2.3.0",
-            "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
-            "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+            "version": "2.5.0",
+            "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz",
+            "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==",
             "dev": true
         },
         "spdx-expression-parse": {
@@ -8458,15 +8743,21 @@
             }
         },
         "spdx-license-ids": {
-            "version": "3.0.16",
-            "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz",
-            "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==",
+            "version": "3.0.18",
+            "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz",
+            "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==",
+            "dev": true
+        },
+        "sprintf-js": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+            "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
             "dev": true
         },
         "ssri": {
-            "version": "10.0.5",
-            "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz",
-            "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==",
+            "version": "10.0.6",
+            "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz",
+            "integrity": "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==",
             "dev": true,
             "requires": {
                 "minipass": "^7.0.3"
@@ -8519,7 +8810,6 @@
             "version": "5.1.2",
             "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
             "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
-            "dev": true,
             "requires": {
                 "eastasianwidth": "^0.2.0",
                 "emoji-regex": "^9.2.2",
@@ -8529,14 +8819,12 @@
                 "ansi-regex": {
                     "version": "6.0.1",
                     "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
-                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
-                    "dev": true
+                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="
                 },
                 "strip-ansi": {
                     "version": "7.1.0",
                     "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
                     "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-                    "dev": true,
                     "requires": {
                         "ansi-regex": "^6.0.1"
                     }
@@ -8547,7 +8835,6 @@
             "version": "npm:string-width@4.2.3",
             "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
             "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-            "dev": true,
             "requires": {
                 "emoji-regex": "^8.0.0",
                 "is-fullwidth-code-point": "^3.0.0",
@@ -8557,14 +8844,12 @@
                 "emoji-regex": {
                     "version": "8.0.0",
                     "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
-                    "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-                    "dev": true
+                    "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
                 },
                 "is-fullwidth-code-point": {
                     "version": "3.0.0",
                     "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
-                    "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-                    "dev": true
+                    "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
                 }
             }
         },
@@ -8580,7 +8865,6 @@
             "version": "npm:strip-ansi@6.0.1",
             "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
             "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-            "dev": true,
             "requires": {
                 "ansi-regex": "^5.0.1"
             }
@@ -8625,89 +8909,80 @@
                     }
                 },
                 "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "version": "10.4.1",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
+                    "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
                     "dev": true,
                     "requires": {
                         "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
+                        "jackspeak": "^3.1.2",
+                        "minimatch": "^9.0.4",
+                        "minipass": "^7.1.2",
+                        "path-scurry": "^1.11.1"
                     }
                 },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
-                },
-                "rimraf": {
-                    "version": "5.0.5",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
-                    "dev": true,
-                    "requires": {
-                        "glob": "^10.3.7"
-                    }
                 }
             }
         },
         "tap": {
-            "version": "18.6.1",
-            "resolved": "https://registry.npmjs.org/tap/-/tap-18.6.1.tgz",
-            "integrity": "sha512-5cBQhJ1gdbsrTR3tA5kZZTts0HyOML6bcM7pEF7GF8d6y1ajfRMjbInS1Ty7/x2Ip0ko3cY1dYjPJ9JFNPsm7w==",
-            "dev": true,
-            "requires": {
-                "@tapjs/after": "1.1.17",
-                "@tapjs/after-each": "1.1.17",
-                "@tapjs/asserts": "1.1.17",
-                "@tapjs/before": "1.1.17",
-                "@tapjs/before-each": "1.1.17",
-                "@tapjs/core": "1.4.6",
-                "@tapjs/filter": "1.2.17",
-                "@tapjs/fixture": "1.2.17",
-                "@tapjs/intercept": "1.2.17",
-                "@tapjs/mock": "1.2.15",
-                "@tapjs/node-serialize": "1.2.6",
-                "@tapjs/run": "1.4.16",
-                "@tapjs/snapshot": "1.2.17",
-                "@tapjs/spawn": "1.1.17",
-                "@tapjs/stdin": "1.1.17",
-                "@tapjs/test": "1.3.17",
-                "@tapjs/typescript": "1.3.6",
-                "@tapjs/worker": "1.1.17",
+            "version": "19.0.2",
+            "resolved": "https://registry.npmjs.org/tap/-/tap-19.0.2.tgz",
+            "integrity": "sha512-SRGulk1RKlVuYtnPeephj+xyE0sG9CvGlKYP4lymBZykLtkwBPnEBjQ2iQmLX5z0BFEMfKh8G4bvZkhoSJb3kg==",
+            "dev": true,
+            "requires": {
+                "@tapjs/after": "1.1.24",
+                "@tapjs/after-each": "2.0.1",
+                "@tapjs/asserts": "2.0.1",
+                "@tapjs/before": "2.0.1",
+                "@tapjs/before-each": "2.0.1",
+                "@tapjs/core": "2.0.1",
+                "@tapjs/filter": "2.0.1",
+                "@tapjs/fixture": "2.0.1",
+                "@tapjs/intercept": "2.0.1",
+                "@tapjs/mock": "2.0.1",
+                "@tapjs/node-serialize": "2.0.1",
+                "@tapjs/run": "2.0.2",
+                "@tapjs/snapshot": "2.0.1",
+                "@tapjs/spawn": "2.0.1",
+                "@tapjs/stdin": "2.0.1",
+                "@tapjs/test": "2.0.1",
+                "@tapjs/typescript": "1.4.6",
+                "@tapjs/worker": "2.0.1",
                 "resolve-import": "^1.4.5"
             }
         },
         "tap-parser": {
-            "version": "15.3.1",
-            "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-15.3.1.tgz",
-            "integrity": "sha512-hwAtXX5TBGt2MJeYvASc7DjP48PUzA7P8RTbLxQcgKCEH7ICD5IsRco7l5YvkzjHlZbUbeI9wzO8B4hw2sKgnQ==",
+            "version": "16.0.1",
+            "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-16.0.1.tgz",
+            "integrity": "sha512-vKianJzSSzLkJ3bHBwzvZDDRi9yGMwkRANJxwPAjAue50owB8rlluYySmTN4tZVH0nsh6stvrQbg9kuCL5svdg==",
             "dev": true,
             "requires": {
                 "events-to-array": "^2.0.3",
-                "tap-yaml": "2.2.1"
+                "tap-yaml": "2.2.2"
             }
         },
         "tap-yaml": {
-            "version": "2.2.1",
-            "resolved": "https://registry.npmjs.org/tap-yaml/-/tap-yaml-2.2.1.tgz",
-            "integrity": "sha512-ovZuUMLAIH59jnFHXKEGJ+WyDYl6Cuduwg9qpvnqkZOUA1nU84q02Sry1HT0KXcdv2uB91bEKKxnIybBgrb6oA==",
+            "version": "2.2.2",
+            "resolved": "https://registry.npmjs.org/tap-yaml/-/tap-yaml-2.2.2.tgz",
+            "integrity": "sha512-MWG4OpAKtNoNVjCz/BqlDJiwTM99tiHRhHPS4iGOe1ZS0CgM4jSFH92lthSFvvy4EdDjQZDV7uYqUFlU9JuNhw==",
             "dev": true,
             "requires": {
-                "yaml": "^2.3.0",
+                "yaml": "^2.4.1",
                 "yaml-types": "^0.3.0"
             }
         },
         "tar": {
-            "version": "6.2.0",
-            "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
-            "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==",
+            "version": "6.2.1",
+            "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+            "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
             "dev": true,
             "requires": {
                 "chownr": "^2.0.0",
@@ -8794,19 +9069,21 @@
             "dev": true
         },
         "tshy": {
-            "version": "1.8.2",
-            "resolved": "https://registry.npmjs.org/tshy/-/tshy-1.8.2.tgz",
-            "integrity": "sha512-aGlSY+jkZYAv0YDgtdv1U2vvbGTUdlXmhVP4uegujlJ/wuznmJqSu5cUV/6IW7N7a3HFRhofWvIS/FquYN9zgA==",
+            "version": "1.14.0",
+            "resolved": "https://registry.npmjs.org/tshy/-/tshy-1.14.0.tgz",
+            "integrity": "sha512-YiUujgi4Jb+t2I48LwSRzHkBpniH9WjjktNozn+nlsGmVemKSjDNY7EwBRPvPCr5zAC/3ITAYWH9Z7kUinGSrw==",
             "dev": true,
             "requires": {
                 "chalk": "^5.3.0",
-                "chokidar": "^3.5.3",
+                "chokidar": "^3.6.0",
                 "foreground-child": "^3.1.1",
+                "minimatch": "^9.0.4",
                 "mkdirp": "^3.0.1",
-                "resolve-import": "^1.4.4",
+                "polite-json": "^4.0.1",
+                "resolve-import": "^1.4.5",
                 "rimraf": "^5.0.1",
                 "sync-content": "^1.0.2",
-                "typescript": "5.2",
+                "typescript": "^5.4.5",
                 "walk-up-path": "^3.0.1"
             },
             "dependencies": {
@@ -8825,36 +9102,14 @@
                     "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
                     "dev": true
                 },
-                "glob": {
-                    "version": "10.3.10",
-                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
-                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
-                    "dev": true,
-                    "requires": {
-                        "foreground-child": "^3.1.0",
-                        "jackspeak": "^2.3.5",
-                        "minimatch": "^9.0.1",
-                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
-                        "path-scurry": "^1.10.1"
-                    }
-                },
                 "minimatch": {
-                    "version": "9.0.3",
-                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "version": "9.0.4",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+                    "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
                     "dev": true,
                     "requires": {
                         "brace-expansion": "^2.0.1"
                     }
-                },
-                "rimraf": {
-                    "version": "5.0.5",
-                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
-                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
-                    "dev": true,
-                    "requires": {
-                        "glob": "^10.3.7"
-                    }
                 }
             }
         },
@@ -8864,14 +9119,14 @@
             "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
         },
         "tuf-js": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.1.0.tgz",
-            "integrity": "sha512-eD7YPPjVlMzdggrOeE8zwoegUaG/rt6Bt3jwoQPunRiNVzgcCE009UDFJKJjG+Gk9wFu6W/Vi+P5d/5QpdD9jA==",
+            "version": "2.2.1",
+            "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.1.tgz",
+            "integrity": "sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==",
             "dev": true,
             "requires": {
-                "@tufjs/models": "2.0.0",
+                "@tufjs/models": "2.0.1",
                 "debug": "^4.3.4",
-                "make-fetch-happen": "^13.0.0"
+                "make-fetch-happen": "^13.0.1"
             }
         },
         "type-check": {
@@ -8888,9 +9143,9 @@
             "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="
         },
         "typescript": {
-            "version": "5.2.2",
-            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
-            "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
+            "version": "5.4.5",
+            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
+            "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
             "dev": true
         },
         "undici-types": {
@@ -8950,9 +9205,9 @@
             },
             "dependencies": {
                 "@jridgewell/trace-mapping": {
-                    "version": "0.3.20",
-                    "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
-                    "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
+                    "version": "0.3.25",
+                    "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+                    "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
                     "dev": true,
                     "requires": {
                         "@jridgewell/resolve-uri": "^3.1.0",
@@ -8972,13 +9227,10 @@
             }
         },
         "validate-npm-package-name": {
-            "version": "5.0.0",
-            "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz",
-            "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==",
-            "dev": true,
-            "requires": {
-                "builtins": "^5.0.0"
-            }
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz",
+            "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==",
+            "dev": true
         },
         "walk-up-path": {
             "version": "3.0.1",
@@ -9012,7 +9264,6 @@
             "version": "8.1.0",
             "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
             "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
-            "dev": true,
             "requires": {
                 "ansi-styles": "^6.1.0",
                 "string-width": "^5.0.1",
@@ -9022,20 +9273,17 @@
                 "ansi-regex": {
                     "version": "6.0.1",
                     "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
-                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
-                    "dev": true
+                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="
                 },
                 "ansi-styles": {
                     "version": "6.2.1",
                     "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
-                    "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
-                    "dev": true
+                    "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="
                 },
                 "strip-ansi": {
                     "version": "7.1.0",
                     "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
                     "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
-                    "dev": true,
                     "requires": {
                         "ansi-regex": "^6.0.1"
                     }
@@ -9046,7 +9294,6 @@
             "version": "npm:wrap-ansi@7.0.0",
             "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
             "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
-            "dev": true,
             "requires": {
                 "ansi-styles": "^4.0.0",
                 "string-width": "^4.1.0",
@@ -9056,20 +9303,17 @@
                 "emoji-regex": {
                     "version": "8.0.0",
                     "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
-                    "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
-                    "dev": true
+                    "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
                 },
                 "is-fullwidth-code-point": {
                     "version": "3.0.0",
                     "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
-                    "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-                    "dev": true
+                    "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
                 },
                 "string-width": {
                     "version": "4.2.3",
                     "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
                     "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
-                    "dev": true,
                     "requires": {
                         "emoji-regex": "^8.0.0",
                         "is-fullwidth-code-point": "^3.0.0",
@@ -9084,9 +9328,9 @@
             "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
         },
         "ws": {
-            "version": "8.16.0",
-            "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
-            "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
+            "version": "8.17.1",
+            "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+            "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
             "dev": true,
             "requires": {}
         },
@@ -9103,9 +9347,9 @@
             "dev": true
         },
         "yaml": {
-            "version": "2.3.4",
-            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",
-            "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==",
+            "version": "2.4.2",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",
+            "integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==",
             "dev": true
         },
         "yaml-types": {
diff --git a/package.json b/package.json
index fc755f41..ed18fdba 100644
--- a/package.json
+++ b/package.json
@@ -12,61 +12,75 @@
         "dev": "eslint src && node src/upd8.js"
     },
     "imports": {
-        "#aggregate": "./src/util/aggregate.js",
+        "#aggregate": "./src/aggregate.js",
         "#cacheable-object": "./src/data/cacheable-object.js",
-        "#colors": "./src/util/colors.js",
+        "#colors": "./src/common-util/colors.js",
         "#composite": "./src/data/composite.js",
         "#composite/control-flow": "./src/data/composite/control-flow/index.js",
         "#composite/data": "./src/data/composite/data/index.js",
         "#composite/wiki-data": "./src/data/composite/wiki-data/index.js",
         "#composite/wiki-properties": "./src/data/composite/wiki-properties/index.js",
         "#composite/things/album": "./src/data/composite/things/album/index.js",
+        "#composite/things/art-tag": "./src/data/composite/things/art-tag/index.js",
+        "#composite/things/artist": "./src/data/composite/things/artist/index.js",
+        "#composite/things/artwork": "./src/data/composite/things/artwork/index.js",
+        "#composite/things/contribution": "./src/data/composite/things/contribution/index.js",
         "#composite/things/flash": "./src/data/composite/things/flash/index.js",
         "#composite/things/flash-act": "./src/data/composite/things/flash-act/index.js",
         "#composite/things/track": "./src/data/composite/things/track/index.js",
+        "#composite/things/track-section": "./src/data/composite/things/track-section/index.js",
         "#content-dependencies": "./src/content/dependencies/index.js",
         "#content-function": "./src/content-function.js",
-        "#cli": "./src/util/cli.js",
+        "#cli": "./src/cli.js",
         "#data-checks": "./src/data/checks.js",
-        "#external-links": "./src/util/external-links.js",
+        "#external-links": "./src/external-links.js",
         "#find": "./src/find.js",
-        "#html": "./src/util/html.js",
+        "#html": "./src/html.js",
+        "#import-heck": "./src/import-heck.js",
         "#language": "./src/data/language.js",
         "#page-specs": "./src/page/index.js",
-        "#node-utils": "./src/util/node-utils.js",
+        "#node-utils": "./src/node-utils.js",
         "#repl": "./src/write/build-modes/repl.js",
-        "#replacer": "./src/util/replacer.js",
+        "#replacer": "./src/replacer.js",
+        "#reverse": "./src/reverse.js",
+        "#search": "./src/search.js",
+        "#search-spec": "./src/common-util/search-spec.js",
         "#serialize": "./src/data/serialize.js",
-        "#sugar": "./src/util/sugar.js",
-        "#sort": "./src/util/sort.js",
+        "#sort": "./src/common-util/sort.js",
+        "#sugar": "./src/common-util/sugar.js",
         "#test-lib": "./test/lib/index.js",
         "#thing": "./src/data/thing.js",
         "#things": "./src/data/things/index.js",
         "#thumbs": "./src/gen-thumbs.js",
-        "#urls": "./src/util/urls.js",
-        "#validators": "./src/data/validators.js",
-        "#wiki-data": "./src/util/wiki-data.js",
+        "#urls": "./src/urls.js",
+        "#validators": "./src/validators.js",
+        "#web-routes": "./src/web-routes.js",
+        "#wiki-data": "./src/common-util/wiki-data.js",
         "#yaml": "./src/data/yaml.js"
     },
     "engines": {
-        "node": ">= 20.9.0"
+        "node": ">= 22.13.0"
     },
     "dependencies": {
         "@js-temporal/polyfill": "^0.4.4",
         "chroma-js": "^2.4.2",
         "command-exists": "^1.2.9",
+        "compress-json": "^3.0.5",
         "eslint": "^8.37.0",
+        "flexsearch": "^0.7.43",
         "he": "^1.2.0",
         "image-size": "^1.0.2",
         "js-yaml": "^4.1.0",
         "marked": "^10.0.0",
+        "msgpackr": "^1.10.2",
+        "rimraf": "^5.0.7",
         "striptags": "^4.0.0-alpha.4",
         "word-wrap": "^1.2.3"
     },
-    "license": "GPL-3.0",
+    "license": "MIT",
     "devDependencies": {
         "chokidar": "^3.5.3",
-        "tap": "^18.4.0",
+        "tap": "^19.0.2",
         "tcompare": "^6.0.0"
     },
     "tap": {
diff --git a/src/util/aggregate.js b/src/aggregate.js
index f0023359..cb806e89 100644
--- a/src/util/aggregate.js
+++ b/src/aggregate.js
@@ -1,5 +1,5 @@
-import {colors} from './cli.js';
-import {empty, typeAppearance} from './sugar.js';
+import {colors} from '#cli';
+import {empty, typeAppearance} from '#sugar';
 
 // Utility function for providing useful interfaces to the JS AggregateError
 // class.
@@ -91,6 +91,45 @@ export function openAggregate({
     return aggregate.callAsync(() => withAggregateAsync(...args));
   };
 
+  aggregate.receive = (results) => {
+    if (!Array.isArray(results)) {
+      if (typeof results === 'object' && results.aggregate) {
+        const {aggregate, result} = results;
+
+        try {
+          aggregate.close();
+        } catch (error) {
+          errors.push(error);
+        }
+
+        return result;
+      }
+
+      throw new Error(`Expected an array or {aggregate, result} object`);
+    }
+
+    return results.map(({aggregate, result}) => {
+      if (!aggregate) {
+        throw new Error(`Expected an array of {aggregate, result} objects`);
+      }
+
+      try {
+        aggregate.close();
+      } catch (error) {
+        errors.push(error);
+      }
+
+      return result;
+    });
+  };
+
+  aggregate.contain = (results) => {
+    return {
+      aggregate,
+      result: aggregate.receive(results),
+    };
+  };
+
   aggregate.map = (...args) => {
     const parent = aggregate;
     const {result, aggregate: child} = mapAggregate(...args);
@@ -136,18 +175,33 @@ export function aggregateThrows(errorClass) {
   return {[openAggregate.errorClassSymbol]: errorClass};
 }
 
-// Helper function for allowing both (fn, aggregateOpts) and (aggregateOpts, fn)
-// in aggregate utilities.
-function _reorganizeAggregateArguments(arg1, arg2) {
-  if (typeof arg1 === 'function') {
-    return {fn: arg1, opts: arg2 ?? {}};
-  } else if (typeof arg2 === 'function') {
-    return {fn: arg2, opts: arg1 ?? {}};
+// Helper function for allowing both (fn, opts) and (opts, fn) in aggregate
+// utilities (or other shapes besides functions).
+function _reorganizeAggregateArguments(arg1, arg2, desire = v => typeof v === 'function') {
+  if (desire(arg1)) {
+    return [arg1, arg2 ?? {}];
+  } else if (desire(arg2)) {
+    return [arg2, arg1];
   } else {
-    throw new Error(`Expected a function`);
+    return [undefined, undefined];
   }
 }
 
+// Takes a list of {aggregate, result} objects, puts all the aggregates into
+// a new aggregate, and puts all the results into an array, returning both on
+// a new {aggregate, result} object. This is essentailly the generalized
+// composable version of functions like mapAggregate or filterAggregate.
+export function receiveAggregate(arg1, arg2) {
+  const [array, opts] = _reorganizeAggregateArguments(arg1, arg2, Array.isArray);
+  if (!array) {
+    throw new Error(`Expected an array`);
+  }
+
+  const aggregate = openAggregate(opts);
+  const result = aggregate.receive(array);
+  return {aggregate, result};
+}
+
 // Performs an ordinary array map with the given function, collating into a
 // results array (with errored inputs filtered out) and an error aggregate.
 //
@@ -158,12 +212,20 @@ function _reorganizeAggregateArguments(arg1, arg2) {
 // use aggregate.close() to throw the error. (This aggregate may be passed to a
 // parent aggregate: `parent.call(aggregate.close)`!)
 export function mapAggregate(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _mapAggregate('sync', null, array, fn, opts);
 }
 
 export function mapAggregateAsync(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
   return _mapAggregate('async', promiseAll, array, fn, remainingOpts);
 }
@@ -200,12 +262,20 @@ export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) {
 //
 // As with mapAggregate, the returned aggregate property is not yet closed.
 export function filterAggregate(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _filterAggregate('sync', null, array, fn, opts);
 }
 
 export async function filterAggregateAsync(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
   return _filterAggregate('async', promiseAll, array, fn, remainingOpts);
 }
@@ -268,12 +338,20 @@ function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) {
 // function with it, then closing the function and returning the result (if
 // there's no throw).
 export function withAggregate(arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _withAggregate('sync', opts, fn);
 }
 
 export function withAggregateAsync(arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _withAggregate('async', opts, fn);
 }
 
@@ -294,7 +372,10 @@ export function _withAggregate(mode, aggregateOpts, fn) {
 
 export const unhelpfulTraceLines = [
   /sugar/,
+  /sort/,
   /aggregate/,
+  /composite/,
+  /cacheable-object/,
   /node:/,
   /<anonymous>/,
 ];
@@ -359,7 +440,15 @@ export function showAggregate(topError, {
       }
     }
 
-    return determineCauseHelper(cause.cause);
+    if (cause.cause) {
+      return determineCauseHelper(cause.cause);
+    }
+
+    if (cause.errors) {
+      return determineErrorsHelper(cause);
+    }
+
+    return cause;
   };
 
   const determineCause = error =>
@@ -397,7 +486,7 @@ export function showAggregate(topError, {
       : error.errors?.flatMap(determineErrorsHelper) ?? null);
 
   const flattenErrorStructure = (error, level = 0) => {
-    const cause = determineCause(error);
+    const cause = determineCause(error); // may be an array!
     const errors = determineErrors(error);
 
     return {
@@ -412,7 +501,9 @@ export function showAggregate(topError, {
           : error.stack),
 
       cause:
-        (cause
+        (Array.isArray(cause)
+          ? cause.map(cause => flattenErrorStructure(cause, level + 1))
+       : cause
           ? flattenErrorStructure(cause, level + 1)
           : null),
 
@@ -447,15 +538,29 @@ export function showAggregate(topError, {
       unhelpfulTraceLines: ownUnhelpfulTraceLines,
     },
   }, index, apparentSiblings) => {
+    const causeSingle = Array.isArray(cause) ? null : cause;
+    const causeArray = Array.isArray(cause) ? cause : null;
+
     const subApparentSiblings =
-      (cause && errors
-        ? [cause, ...errors]
-     : cause
-        ? [cause]
+      (causeSingle && errors
+        ? [causeSingle, ...errors]
+     : causeSingle
+        ? [causeSingle]
+     : causeArray && errors
+        ? [...causeArray, ...errors]
+     : causeArray
+        ? causeArray
      : errors
         ? errors
         : []);
 
+    const presentedAsErrors =
+      (causeArray && errors
+        ? [...causeArray, ...errors]
+     : causeArray
+        ? causeArray
+        : errors);
+
     const anythingHasErrorsThisLayer =
       apparentSiblings.some(({errors}) => !empty(errors));
 
@@ -503,8 +608,8 @@ export function showAggregate(topError, {
     const bar1 = ' ';
 
     const causePart =
-      (cause
-        ? recursive(cause, 0, subApparentSiblings)
+      (causeSingle
+        ? recursive(causeSingle, 0, subApparentSiblings)
             .split('\n')
             .map((line, i) => i === 0 ? ` ${head1} ${line}` : ` ${bar1} ${line}`)
             .join('\n')
@@ -514,8 +619,8 @@ export function showAggregate(topError, {
     const bar2 = level % 2 === 0 ? '\u2502' : colors.dim('\u254e');
 
     const errorsPart =
-      (errors
-        ? errors
+      (presentedAsErrors
+        ? presentedAsErrors
             .map((error, index) => recursive(error, index + 1, subApparentSiblings))
             .flatMap(str => str.split('\n'))
             .map((line, i) => i === 0 ? ` ${head2} ${line}` : ` ${bar2} ${line}`)
diff --git a/src/util/cli.js b/src/cli.js
index ce513f08..bd4ec685 100644
--- a/src/util/cli.js
+++ b/src/cli.js
@@ -1,12 +1,8 @@
 // Utility functions for CLI- and de8ugging-rel8ted stuff.
-//
-// A 8unch of these depend on process.stdout 8eing availa8le, so they won't
-// work within the 8rowser.
 
-const {process} = globalThis;
+import {sortByName} from '#sort';
 
 export const ENABLE_COLOR =
-  process &&
   ((process.env.CLICOLOR_FORCE && process.env.CLICOLOR_FORCE === '1') ??
     (process.env.CLICOLOR &&
       process.env.CLICOLOR === '1' &&
@@ -95,8 +91,12 @@ export async function parseOptions(options, optionDescriptorMap) {
   // }
   //
   // ['--directory', 'apple'] -> {'directory': 'apple'}
+  // ['--directory=banana'] -> {'directory': 'banana'}
   // ['--directory', 'artichoke'] -> (error)
+  //
   // ['--files', 'a', 'b', 'c', ';'] -> {'files': ['a', 'b', 'c']}
+  // ['--files=a,b,c'] -> {'files': ['a', 'b', 'c']}
+  // ['--files', 'a,b,c'] -> {'files': ['a', 'b', 'c']}
 
   const handleDashless = optionDescriptorMap[parseOptions.handleDashless];
   const handleUnknown = optionDescriptorMap[parseOptions.handleUnknown];
@@ -149,9 +149,27 @@ export async function parseOptions(options, optionDescriptorMap) {
         }
 
         case 'series': {
+          if (option.includes('=')) {
+            result[name] = option.split('=')[1].split(',');
+            break;
+          }
+
+          // without a semicolon to conclude the series,
+          // assume the next option expresses the whole series
           if (!options.slice(i).includes(';')) {
-            console.error(`Expected a series of values concluding with ; (\\;) for --${name}`);
-            process.exit(1);
+            let value = options[++i];
+
+            if (!value || value.startsWith('-')) {
+              value = null;
+            }
+
+            if (!value) {
+              console.error(`Expected values for --${name}`);
+              process.exit(1);
+            }
+
+            result[name] = value.split('=')[1].split(',');
+            break;
           }
 
           const endIndex = i + options.slice(i).indexOf(';');
@@ -201,6 +219,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();
 
@@ -398,3 +489,27 @@ export async function logicalPathTo(target) {
   const cwd = await logicalCWD();
   return relative(cwd, target);
 }
+
+export function stringifyCache(cache) {
+  cache ??= {};
+
+  if (Object.keys(cache).length === 0) {
+    return `{}`;
+  }
+
+  const entries = Object.entries(cache);
+  sortByName(entries, {getName: entry => entry[0]});
+
+  return [
+    `{`,
+    entries
+      .map(([key, value]) => [JSON.stringify(key), JSON.stringify(value)])
+      .map(([key, value]) => `${key}: ${value}`)
+      .map((line, index, array) =>
+        (index < array.length - 1
+          ? `${line},`
+          : line))
+      .map(line => `  ${line}`),
+    `}`,
+  ].flat().join('\n');
+}
diff --git a/src/util/colors.js b/src/common-util/colors.js
index 50339cd3..7298c46a 100644
--- a/src/util/colors.js
+++ b/src/common-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/common-util/search-spec.js b/src/common-util/search-spec.js
new file mode 100644
index 00000000..75de0d16
--- /dev/null
+++ b/src/common-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.mainReleaseTrack),
+    ].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 =
+        (thing.constructor.hasPropertyDescriptor('artTags')
+          ? thing.artTags.map(artTag => artTag.nameShort)
+          : []);
+
+      fields.additionalNames =
+        (thing.constructor.hasPropertyDescriptor('additionalNames')
+          ? thing.additionalNames.map(entry => entry.name)
+       : thing.constructor.hasPropertyDescriptor('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/serialize.js b/src/common-util/serialize.js
index 4992e2bf..eb18a759 100644
--- a/src/util/serialize.js
+++ b/src/common-util/serialize.js
@@ -14,10 +14,10 @@ export function serializeLink(thing) {
 }
 
 export function serializeContribs(contribs) {
-  return contribs.map(({who, what}) => {
+  return contribs.map(({artist, annotation}) => {
     const ret = {};
-    ret.artist = serializeLink(who);
-    if (what) ret.contribution = what;
+    ret.artist = serializeLink(artist);
+    if (annotation) ret.contribution = annotation;
     return ret;
   });
 }
diff --git a/src/util/sort.js b/src/common-util/sort.js
index b3a90812..d93d94c1 100644
--- a/src/util/sort.js
+++ b/src/common-util/sort.js
@@ -3,6 +3,12 @@
 // initial sort matters! (Spoilers: If what you're doing involves any kind of
 // parallelization, it definitely matters.)
 
+// TODO: This is obviously limiting. It does describe the behavior
+// we've been *assuming* for the entire time the wiki is around,
+// but it would be nice to support sorting in different locales
+// somehow.
+export const SORTING_LOCALE = 'en';
+
 import {empty, sortMultipleArrays, unique}
   from './sugar.js';
 
@@ -17,8 +23,8 @@ export function compareCaseLessSensitive(a, b) {
   const bl = b.toLowerCase();
 
   return al === bl
-    ? a.localeCompare(b, undefined, {numeric: true})
-    : al.localeCompare(bl, undefined, {numeric: true});
+    ? a.localeCompare(b, SORTING_LOCALE, {numeric: true})
+    : al.localeCompare(bl, SORTING_LOCALE, {numeric: true});
 }
 
 // Subtract common prefixes and other characters which some people don't like
@@ -383,12 +389,29 @@ export function sortAlbumsTracksChronologically(data, {
   return data;
 }
 
+export function sortArtworksChronologically(data, {
+  latestFirst = false,
+} = {}) {
+  // Artworks conveniently describe their things as artwork.thing, so they
+  // work in sortEntryThingPairs. (Yes, this is just assuming the artworks
+  // are only for albums and tracks... sorry... TODO...)
+  sortEntryThingPairs(data, things =>
+    sortAlbumsTracksChronologically(things, {latestFirst}));
+
+  // Artworks' own dates always matter before however the thing places itself,
+  // and accommodate per-thing properties like coverArtDate anyway.
+  sortByDate(data, {latestFirst});
+
+  return data;
+}
+
 export function sortFlashesChronologically(data, {
   latestFirst = false,
   getDate,
 } = {}) {
   // Group flashes by act...
-  sortByDirectory(data, {
+  sortAlphabetically(data, {
+    getName: flash => flash.act.name,
     getDirectory: flash => flash.act.directory,
   });
 
@@ -403,3 +426,36 @@ export function sortFlashesChronologically(data, {
 
   return data;
 }
+
+export function sortContributionsChronologically(data, sortThings, {
+  latestFirst = false,
+  getThing = contrib => contrib.thing,
+} = {}) {
+  // 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: getThing(contrib),
+    }));
+
+  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/common-util/sugar.js
index e060f458..66e160aa 100644
--- a/src/util/sugar.js
+++ b/src/common-util/sugar.js
@@ -6,8 +6,6 @@
 // It will likely only do exactly what I want it to, and only in the cases I
 // decided were relevant enough to 8other handling.
 
-import {colors} from './cli.js';
-
 // Apparently JavaScript doesn't come with a function to split an array into
 // chunks! Weird. Anyway, this is an awesome place to use a generator, even
 // though we don't really make use of the 8enefits of generators any time we
@@ -48,18 +46,30 @@ export function empty(value) {
 
 // Repeats all the items of an array a number of times.
 export function repeat(times, array) {
-  if (typeof array === 'string') return repeat(times, [array]);
-  if (empty(array)) return [];
   if (times === 0) return [];
-  if (times === 1) return array.slice();
+  if (array === null || array === undefined) return [];
+  if (Array.isArray(array) && empty(array)) return [];
 
   const out = [];
+
   for (let n = 1; n <= times; n++) {
-    out.push(...array);
+    const value =
+      (typeof array === 'function'
+        ? array()
+        : array);
+
+    if (Array.isArray(value)) out.push(...value);
+    else out.push(value);
   }
+
   return out;
 }
 
+// Gets a random item from an array.
+export function pick(array) {
+  return array[Math.floor(Math.random() * array.length)];
+}
+
 // Gets the item at an index relative to another index.
 export function atOffset(array, index, offset, {
   wrap = false,
@@ -88,6 +98,18 @@ export function atOffset(array, index, offset, {
   return array[index + offset];
 }
 
+// Gets the index of the first item that satisfies the provided function,
+// or, if none does, returns the length of the array (the index just past the
+// final item).
+export function findIndexOrEnd(array, fn) {
+  const index = array.findIndex(fn);
+  if (index >= 0) {
+    return index;
+  } else {
+    return array.length;
+  }
+}
+
 // Sums the values in an array, optionally taking a function which maps each
 // item to a number (handy for accessing a certain property on an array of like
 // objects). This also coalesces null values to zero, so if the mapping function
@@ -136,6 +158,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:
 //
 //   [
@@ -182,9 +221,43 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
     ? arr1.every((x, i) => arr2[i] === x)
     : arr1.every((x) => arr2.includes(x)));
 
+export function compareObjects(obj1, obj2, {
+  checkOrder = false,
+  checkSymbols = true,
+} = {}) {
+  const keys1 = Object.keys(obj1);
+  const keys2 = Object.keys(obj2);
+  if (!compareArrays(keys1, keys2, {checkOrder})) return false;
+
+  let syms1, syms2;
+  if (checkSymbols) {
+    syms1 = Object.getOwnPropertySymbols(obj1);
+    syms2 = Object.getOwnPropertySymbols(obj2);
+    if (!compareArrays(syms1, syms2, {checkOrder})) return false;
+  }
+
+  for (const key of keys1) {
+    if (obj2[key] !== obj1[key]) return false;
+  }
+
+  if (checkSymbols) {
+    for (const sym of syms1) {
+      if (obj2[sym] !== obj1[sym]) return false;
+    }
+  }
+
+  return true;
+}
+
 // 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 +333,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
@@ -273,11 +356,19 @@ export function splitKeys(key) {
 }
 
 // Follows a key path like 'foo.bar.baz' to get an item nested deeply inside
-// an object.
+// an object. If a value partway through the chain is an array, the values
+// down the rest of the chain are gotten for each item in the array.
+//
+// obj: {x: [{y: ['a']}, {y: ['b', 'c']}]}
+// key: 'x.y'
+//   -> [['a'], ['b', 'c']]
+//
 export function getNestedProp(obj, key) {
   const recursive = (o, k) =>
     (k.length === 1
       ? o[k[0]]
+   : Array.isArray(o[k[0]])
+      ? o[k[0]].map(v => recursive(v, k.slice(1)))
       : recursive(o[k[0]], k.slice(1)));
 
   return recursive(obj, splitKeys(key));
@@ -315,6 +406,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:
 //
@@ -355,14 +467,12 @@ export function* iterateMultiline(content, iterator, {
 
     const columnNumber = index - startOfLine;
 
-    let where = null;
-    if (formatWhere) {
-      where =
-        colors.yellow(
-          (isMultiline
-            ? `line: ${lineNumber + 1}, col: ${columnNumber + 1}`
-            : `pos: ${index + 1}`));
-    }
+    const where =
+      (formatWhere && isMultiline
+        ? `line: ${lineNumber + 1}, col: ${columnNumber + 1}`
+     : formatWhere
+        ? `pos: ${index + 1}`
+        : null);
 
     countLineBreaks(index, length);
 
@@ -671,6 +781,38 @@ export function chunkMultipleArrays(...args) {
   return results;
 }
 
+// This (or its helper function) should probably be a generator, but generators
+// are scary... Note that the root node is never considered a leaf, even if it
+// doesn't have any branches. It does NOT pay attention to the *values* of the
+// leaf nodes - it's suited to handle this kind of form:
+//
+//   {
+//     foo: {
+//       bar: {},
+//       baz: {},
+//       qux: {
+//         woz: {},
+//       },
+//     },
+//   }
+//
+// for which it outputs ['bar', 'baz', 'woz'].
+//
+export function collectTreeLeaves(tree) {
+  const recursive = ([key, value]) =>
+    (value instanceof Map
+      ? (value.size === 0
+          ? [key]
+          : Array.from(value.entries()).flatMap(recursive))
+      : (empty(Object.keys(value))
+          ? [key]
+          : Object.entries(value).flatMap(recursive)));
+
+  const root = Symbol();
+  const leaves = recursive([root, tree]);
+  return (leaves[0] === root ? [] : leaves);
+}
+
 // Delicious function annotations, such as:
 //
 //   (*bound) soWeAreBackInTheMine
diff --git a/src/util/wiki-data.js b/src/common-util/wiki-data.js
index f8ab3ef3..0aa18ddb 100644
--- a/src/util/wiki-data.js
+++ b/src/common-util/wiki-data.js
@@ -1,6 +1,6 @@
 // Utility functions for interacting with wiki data.
 
-import {accumulateSum, empty} from './sugar.js';
+import {accumulateSum, chunkByConditions, empty, unique} from './sugar.js';
 import {sortByDate} from './sort.js';
 
 // This is a duplicate binding of filterMultipleArrays that's included purely
@@ -89,8 +89,12 @@ export function getKebabCase(name) {
 // This regular expression *doesn't* match bodies, which will need to be parsed
 // out of the original string based on the indices matched using this.
 //
+
+const dateRegex = groupName =>
+  String.raw`(?<${groupName}>[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4})`;
+
 const commentaryRegexRaw =
-  String.raw`^<i>(?<artistReferences>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?<date>[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4}))?\))?`;
+  String.raw`^<i>(?<artistReferences>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?:(?<dateKind>sometime|throughout|around) )?${dateRegex('date')}(?: ?- ?${dateRegex('secondDate')})?(?: (?<accessKind>captured|accessed) ${dateRegex('accessDate')})?)?\))?`;
 export const commentaryRegexCaseInsensitive =
   new RegExp(commentaryRegexRaw, 'gmi');
 export const commentaryRegexCaseSensitive =
@@ -98,6 +102,11 @@ export const commentaryRegexCaseSensitive =
 export const commentaryRegexCaseSensitiveOneShot =
   new RegExp(commentaryRegexRaw);
 
+// The #validators function isOldStyleLyrics() describes
+// what this regular expression detects.
+export const oldStyleLyricsDetectionRegex =
+  /^<i>.*:<\/i>/m;
+
 export function filterAlbumsByCommentary(albums) {
   return albums
     .filter((album) => [album, ...album.tracks].some((x) => x.commentary));
@@ -138,11 +147,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}) {
@@ -154,10 +172,10 @@ export function getFlashLink(flash) {
 }
 
 export function getTotalDuration(tracks, {
-  originalReleasesOnly = false,
+  mainReleasesOnly = false,
 } = {}) {
-  if (originalReleasesOnly) {
-    tracks = tracks.filter(t => !t.originalReleaseTrack);
+  if (mainReleasesOnly) {
+    tracks = tracks.filter(t => !t.mainReleaseTrack);
   }
 
   return accumulateSum(tracks, track => track.duration);
@@ -179,6 +197,25 @@ export function getArtistAvatar(artist, {to}) {
   return to('media.artistAvatar', artist.directory, artist.avatarFileExtension);
 }
 
+// Used in multiple content functions for the artist info page,
+// because shared logic is torture oooooooooooooooo.
+export function chunkArtistTrackContributions(contributions) {
+  return (
+    // First chunk by (contribution) date and album.
+    chunkByConditions(contributions, [
+      ({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,
+        ])));
+}
+
 // Big-ass homepage row functions
 
 export function getNewAdditions(numAlbums, {albumData}) {
@@ -329,7 +366,7 @@ export function filterItemsForCarousel(items) {
 
   return items
     .filter(item => item.hasCoverArt)
-    .filter(item => item.artTags.every(tag => !tag.isContentWarning))
+    .filter(item => item.artTags.every(artTag => !artTag.isContentWarning))
     .slice(0, maxCarouselLayoutItems + 1);
 }
 
@@ -400,3 +437,63 @@ export class TupleMap {
     return value;
   }
 }
+
+export class TupleMapForBabies {
+  #here = new WeakMap();
+  #next = new WeakMap();
+
+  set(...args) {
+    const first = args.at(0);
+    const last = args.at(-1);
+    const rest = args.slice(1, -1);
+
+    if (empty(rest)) {
+      this.#here.set(first, last);
+    } else if (this.#next.has(first)) {
+      this.#next.get(first).set(...rest, last);
+    } else {
+      const tupleMap = new TupleMapForBabies();
+      this.#next.set(first, tupleMap);
+      tupleMap.set(...rest, last);
+    }
+  }
+
+  get(...args) {
+    const first = args.at(0);
+    const rest = args.slice(1);
+
+    if (empty(rest)) {
+      return this.#here.get(first);
+    } else if (this.#next.has(first)) {
+      return this.#next.get(first).get(...rest);
+    } else {
+      return undefined;
+    }
+  }
+
+  has(...args) {
+    const first = args.at(0);
+    const rest = args.slice(1);
+
+    if (empty(rest)) {
+      return this.#here.has(first);
+    } else if (this.#next.has(first)) {
+      return this.#next.get(first).has(...rest);
+    } else {
+      return false;
+    }
+  }
+}
+
+const combinedWikiDataTupleMap = new TupleMapForBabies();
+
+export function combineWikiDataArrays(arrays) {
+  const map = combinedWikiDataTupleMap;
+  if (map.has(...arrays)) {
+    return map.get(...arrays);
+  } else {
+    const combined = arrays.flat();
+    map.set(...arrays, combined);
+    return combined;
+  }
+}
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..507b2329 100644
--- a/src/content/dependencies/generateAdditionalFilesListChunk.js
+++ b/src/content/dependencies/generateAdditionalFilesListChunk.js
@@ -17,37 +17,30 @@ 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('b', slots.title),
+                }))),
+
+            html.tag('ul', [
+              html.tag('li', {class: 'entry-description'},
+                {[html.onlyIfContent]: true},
+                slots.description),
+
+              (html.isBlank(slots.items)
+                ? html.tag('li',
+                    language.$(capsule, 'noFilesAvailable'))
+                : slots.items),
+            ]),
+          ]))),
 };
diff --git a/src/content/dependencies/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js
deleted file mode 100644
index 9e119bce..00000000
--- a/src/content/dependencies/generateAdditionalFilesShortcut.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import {empty} from '#sugar';
-
-export default {
-  extraDependencies: ['html', 'language'],
-
-  data(additionalFiles) {
-    return {
-      titles: additionalFiles.map(fileGroup => fileGroup.title),
-    };
-  },
-
-  generate(data, {html, language}) {
-    if (empty(data.titles)) {
-      return html.blank();
-    }
-
-    return language.$('releaseInfo.additionalFiles.shortcut', {
-      anchorLink:
-        html.tag('a',
-          {href: '#additional-files'},
-          language.$('releaseInfo.additionalFiles.shortcut.anchorLink')),
-
-      titles:
-        language.formatUnitList(data.titles),
-    });
-  },
-}
diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js
index 63427c58..b7392dfd 100644
--- a/src/content/dependencies/generateAdditionalNamesBox.js
+++ b/src/content/dependencies/generateAdditionalNamesBox.js
@@ -9,12 +9,20 @@ export default {
   }),
 
   generate: (relations, {html, language}) =>
-    html.tag('div', {id: 'additional-names-box'}, [
-      html.tag('p',
-        language.$('misc.additionalNames.title')),
+    html.tag('div', {id: 'additional-names-box'},
+      {class: 'drop'},
+      {[html.onlyIfContent]: true},
 
-      html.tag('ul',
-        relations.items
-          .map(item => html.tag('li', item))),
-    ]),
+      [
+        html.tag('p',
+          {[html.onlyIfSiblings]: true},
+
+          language.$('misc.additionalNames.title')),
+
+        html.tag('ul',
+          {[html.onlyIfContent]: true},
+
+          relations.items
+            .map(item => html.tag('li', item))),
+      ]),
 };
diff --git a/src/content/dependencies/generateAdditionalNamesBoxItem.js b/src/content/dependencies/generateAdditionalNamesBoxItem.js
index 7515b5b0..e3e59a34 100644
--- a/src/content/dependencies/generateAdditionalNamesBoxItem.js
+++ b/src/content/dependencies/generateAdditionalNamesBoxItem.js
@@ -1,7 +1,5 @@
-import {stitchArrays} from '#sugar';
-
 export default {
-  contentDependencies: ['linkTrack', 'transformContent'],
+  contentDependencies: ['transformContent'],
   extraDependencies: ['html', 'language'],
 
   relations: (relation, entry) => ({
@@ -12,21 +10,9 @@ export default {
       (entry.annotation
         ? relation('transformContent', entry.annotation)
         : null),
-
-    trackLinks:
-      (entry.from
-        ? entry.from.map(track => relation('linkTrack', track))
-        : null),
-  }),
-
-  data: (entry) => ({
-    albumNames:
-      (entry.from
-        ? entry.from.map(track => track.album.name)
-        : null),
   }),
 
-  generate: (data, relations, {html, language}) => {
+  generate: (relations, {html, language}) => {
     const prefix = 'misc.additionalNames.item';
 
     const itemParts = [prefix];
@@ -42,19 +28,10 @@ export default {
     if (relations.annotationContent) {
       accentParts.push('withAnnotation');
       accentOptions.annotation =
-        relations.annotationContent.slot('mode', 'inline');
-    }
-
-    if (relations.trackLinks) {
-      accentParts.push('withAlbums');
-      accentOptions.albums =
-        language.formatConjunctionList(
-          stitchArrays({
-            trackLink: relations.trackLinks,
-            albumName: data.albumNames,
-          }).map(({trackLink, albumName}) =>
-              trackLink.slot('content',
-                language.sanitize(albumName))));
+        relations.annotationContent.slots({
+          mode: 'inline',
+          absorbPunctuationFollowingExternalLinks: false,
+        });
     }
 
     if (accentParts.length > 2) {
diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js
index 9818a43c..ad17206f 100644
--- a/src/content/dependencies/generateAlbumAdditionalFilesList.js
+++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js
@@ -9,7 +9,7 @@ export default {
     'transformContent',
   ],
 
-  extraDependencies: ['getSizeOfAdditionalFile', 'html', 'urls'],
+  extraDependencies: ['getSizeOfMediaFile', 'html', 'urls'],
 
   relations: (relation, album, additionalFiles) => ({
     list:
@@ -55,7 +55,7 @@ export default {
     showFileSizes: {type: 'boolean', default: true},
   },
 
-  generate: (data, relations, slots, {getSizeOfAdditionalFile, urls}) =>
+  generate: (data, relations, slots, {getSizeOfMediaFile, urls}) =>
     relations.list.slots({
       chunks:
         stitchArrays({
@@ -86,7 +86,7 @@ export default {
                   fileLink: fileLink,
                   fileSize:
                     (slots.showFileSizes
-                      ? getSizeOfAdditionalFile(
+                      ? getSizeOfMediaFile(
                           urls
                             .from('media.root')
                             .to('media.albumAdditionalFile', data.albumDirectory, location))
diff --git a/src/content/dependencies/generateAlbumArtInfoBox.js b/src/content/dependencies/generateAlbumArtInfoBox.js
new file mode 100644
index 00000000..8c44c930
--- /dev/null
+++ b/src/content/dependencies/generateAlbumArtInfoBox.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: ['generateReleaseInfoContributionsLine'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    wallpaperArtistContributionsLine:
+      (album.wallpaperArtwork
+        ? relation('generateReleaseInfoContributionsLine',
+            album.wallpaperArtwork.artistContribs)
+        : null),
+
+    bannerArtistContributionsLine:
+      (album.bannerArtwork
+        ? relation('generateReleaseInfoContributionsLine',
+            album.bannerArtwork.artistContribs)
+        : null),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule =>
+      html.tag('div', {class: 'album-art-info'},
+        {[html.onlyIfContent]: true},
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.wallpaperArtistContributionsLine?.slots({
+              stringKey: capsule + '.wallpaperArtBy',
+              chronologyKind: 'wallpaperArt',
+            }),
+
+            relations.bannerArtistContributionsLine?.slots({
+              stringKey: capsule + '.bannerArtBy',
+              chronologyKind: 'bannerArt',
+            }),
+          ]))),
+};
diff --git a/src/content/dependencies/generateAlbumArtworkColumn.js b/src/content/dependencies/generateAlbumArtworkColumn.js
new file mode 100644
index 00000000..e6762463
--- /dev/null
+++ b/src/content/dependencies/generateAlbumArtworkColumn.js
@@ -0,0 +1,38 @@
+export default {
+  contentDependencies: ['generateAlbumArtInfoBox', 'generateCoverArtwork'],
+  extraDependencies: ['html'],
+
+  relations: (relation, album) => ({
+    firstCover:
+      (album.hasCoverArt
+        ? relation('generateCoverArtwork', album.coverArtworks[0])
+        : null),
+
+    restCovers:
+      (album.hasCoverArt
+        ? album.coverArtworks.slice(1).map(artwork =>
+            relation('generateCoverArtwork', artwork))
+        : []),
+
+    albumArtInfoBox:
+      relation('generateAlbumArtInfoBox', album),
+  }),
+
+  generate: (relations, {html}) =>
+    html.tags([
+      relations.firstCover?.slots({
+        showOriginDetails: true,
+        showArtTagDetails: true,
+        showReferenceDetails: true,
+      }),
+
+      relations.albumArtInfoBox,
+
+      relations.restCovers.map(cover =>
+        cover.slots({
+          showOriginDetails: true,
+          showArtTagDetails: true,
+          showReferenceDetails: true,
+        })),
+    ]),
+};
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 751a0c91..1e39b47d 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -2,15 +2,14 @@ import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateAlbumCoverArtwork',
+    'generateAlbumCommentarySidebar',
     'generateAlbumNavAccent',
-    'generateAlbumSidebarTrackSection',
+    'generateAlbumSecondaryNav',
     'generateAlbumStyleRules',
     'generateCommentaryEntry',
     'generateContentHeading',
-    'generateTrackCoverArtwork',
+    'generateCoverArtwork',
     'generatePageLayout',
-    'generatePageSidebar',
     'linkAlbum',
     'linkExternal',
     'linkTrack',
@@ -18,14 +17,32 @@ export default {
 
   extraDependencies: ['html', 'language'],
 
-  relations(relation, album) {
+  query(album) {
+    const query = {};
+
+    query.tracksWithCommentary =
+      album.tracks
+        .filter(({commentary}) => !empty(commentary));
+
+    query.thingsWithCommentary =
+      (empty(album.commentary)
+        ? query.tracksWithCommentary
+        : [album, ...query.tracksWithCommentary]);
+
+    return query;
+  },
+
+  relations(relation, query, album) {
     const relations = {};
 
     relations.layout =
       relation('generatePageLayout');
 
+    relations.secondaryNav =
+      relation('generateAlbumSecondaryNav', album);
+
     relations.sidebar =
-      relation('generatePageSidebar');
+      relation('generateAlbumCommentarySidebar', album);
 
     relations.albumStyleRules =
       relation('generateAlbumStyleRules', album, null);
@@ -36,7 +53,7 @@ export default {
     relations.albumNavAccent =
       relation('generateAlbumNavAccent', album, null);
 
-    if (album.commentary) {
+    if (!empty(album.commentary)) {
       relations.albumCommentaryHeading =
         relation('generateContentHeading');
 
@@ -48,7 +65,7 @@ export default {
 
       if (album.hasCoverArt) {
         relations.albumCommentaryCover =
-          relation('generateAlbumCoverArtwork', album);
+          relation('generateCoverArtwork', album.coverArtworks[0]);
       }
 
       relations.albumCommentaryEntries =
@@ -56,80 +73,65 @@ export default {
           .map(entry => relation('generateCommentaryEntry', entry));
     }
 
-    const tracksWithCommentary =
-      album.tracks
-        .filter(({commentary}) => commentary);
-
     relations.trackCommentaryHeadings =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(() => relation('generateContentHeading'));
 
     relations.trackCommentaryLinks =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track => relation('linkTrack', track));
 
     relations.trackCommentaryListeningLinks =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track =>
           track.urls.map(url => relation('linkExternal', url)));
 
     relations.trackCommentaryCovers =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track =>
           (track.hasUniqueCoverArt
-            ? relation('generateTrackCoverArtwork', track)
+            ? relation('generateCoverArtwork', track.trackArtworks[0])
             : null));
 
     relations.trackCommentaryEntries =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track =>
           track.commentary
             .map(entry => relation('generateCommentaryEntry', entry)));
 
-    relations.sidebarAlbumLink =
-      relation('linkAlbum', album);
-
-    relations.sidebarTrackSections =
-      album.trackSections.map(trackSection =>
-        relation('generateAlbumSidebarTrackSection', album, null, trackSection));
-
     return relations;
   },
 
-  data(album) {
+  data(query, album) {
     const data = {};
 
     data.name = album.name;
     data.color = album.color;
-
-    const tracksWithCommentary =
-      album.tracks
-        .filter(({commentary}) => commentary);
-
-    const thingsWithCommentary =
-      (album.commentary
-        ? [album, ...tracksWithCommentary]
-        : tracksWithCommentary);
+    data.date = album.date;
 
     data.entryCount =
-      thingsWithCommentary
+      query.thingsWithCommentary
         .flatMap(({commentary}) => commentary)
         .length;
 
     data.wordCount =
-      thingsWithCommentary
+      query.thingsWithCommentary
         .flatMap(({commentary}) => commentary)
         .map(({body}) => body)
         .join(' ')
         .split(' ')
         .length;
 
+    data.trackCommentaryTrackDates =
+      query.tracksWithCommentary
+        .map(track => track.dateFirstReleased);
+
     data.trackCommentaryDirectories =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track => track.directory);
 
     data.trackCommentaryColors =
-      tracksWithCommentary
+      query.tracksWithCommentary
         .map(track =>
           (track.color === album.color
             ? null
@@ -138,11 +140,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,
           }),
 
@@ -154,44 +156,74 @@ export default {
         mainClasses: ['long-content'],
         mainContent: [
           html.tag('p',
-            language.$('albumCommentaryPage.infoLine', {
-              words:
-                html.tag('b',
-                  language.formatWordCount(data.wordCount, {unit: true})),
-
-              entries:
-                html.tag('b',
-                  language.countCommentaryEntries(data.entryCount, {unit: true})),
-            })),
-
-          relations.albumCommentaryEntries && [
-            relations.albumCommentaryHeading.slots({
-              tag: 'h3',
-              color: data.color,
-
-              title:
-                language.$('albumCommentaryPage.entry.title.albumCommentary', {
-                  album: relations.albumCommentaryLink,
+            {[html.joinChildren]: html.tag('br')},
+
+            [
+              data.date &&
+              data.entryCount >= 1 &&
+                language.$('releaseInfo.albumReleased', {
+                  date:
+                    html.tag('b',
+                      language.formatDate(data.date)),
                 }),
 
-              accent:
-                !empty(relations.albumCommentaryListeningLinks) &&
-                  language.$('albumCommentaryPage.entry.title.albumCommentary.accent', {
-                    listeningLinks:
-                      language.formatUnitList(
-                        relations.albumCommentaryListeningLinks
-                          .map(link => link.slots({
-                            context: 'album',
-                            tab: 'separate',
-                          }))),
-                  }),
-            }),
-
-            relations.albumCommentaryCover
-              ?.slots({mode: 'commentary'}),
-
-            relations.albumCommentaryEntries,
-          ],
+              language.encapsulate(pageCapsule, 'infoLine', workingCapsule => {
+                const workingOptions = {};
+
+                if (data.entryCount >= 1) {
+                  workingOptions.words =
+                    html.tag('b',
+                      language.formatWordCount(data.wordCount, {unit: true}));
+
+                  workingOptions.entries =
+                    html.tag('b',
+                      language.countCommentaryEntries(data.entryCount, {unit: true}));
+                }
+
+                if (data.entryCount === 0) {
+                  workingCapsule += '.withoutCommentary';
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })
+            ]),
+
+          relations.albumCommentaryEntries &&
+            language.encapsulate(pageCapsule, 'entry', entryCapsule => [
+              language.encapsulate(entryCapsule, 'title.albumCommentary', titleCapsule =>
+                relations.albumCommentaryHeading.slots({
+                  tag: 'h3',
+                  attributes: {id: 'album-commentary'},
+                  color: data.color,
+
+                  title:
+                    language.$(titleCapsule, {
+                      album: relations.albumCommentaryLink,
+                    }),
+
+                  stickyTitle:
+                    language.$(titleCapsule, 'sticky', {
+                      album: data.name,
+                    }),
+
+                  accent:
+                    language.$(titleCapsule, 'accent', {
+                      [language.onlyIfOptions]: ['listeningLinks'],
+                      listeningLinks:
+                        language.formatUnitList(
+                          relations.albumCommentaryListeningLinks
+                            .map(link => link.slots({
+                              context: 'album',
+                              tab: 'separate',
+                            }))),
+                    }),
+                })),
+
+              relations.albumCommentaryCover
+                ?.slots({mode: 'commentary'}),
+
+              relations.albumCommentaryEntries,
+            ]),
 
           stitchArrays({
             heading: relations.trackCommentaryHeadings,
@@ -201,6 +233,7 @@ export default {
             cover: relations.trackCommentaryCovers,
             entries: relations.trackCommentaryEntries,
             color: data.trackCommentaryColors,
+            trackDate: data.trackCommentaryTrackDates,
           }).map(({
               heading,
               link,
@@ -209,31 +242,41 @@ export default {
               cover,
               entries,
               color,
-            }) => [
-              heading.slots({
-                tag: 'h3',
-                id: directory,
-                color,
-
-                title:
-                  language.$('albumCommentaryPage.entry.title.trackCommentary', {
-                    track: link,
-                  }),
-
-                accent:
-                  !empty(listeningLinks) &&
-                    language.$('albumCommentaryPage.entry.title.trackCommentary.accent', {
-                      listeningLinks:
-                        language.formatUnitList(
-                          listeningLinks.map(link =>
-                            link.slot('tab', 'separate'))),
-                    }),
-              }),
+              trackDate,
+            }) =>
+              language.encapsulate(pageCapsule, 'entry', entryCapsule => [
+                language.encapsulate(entryCapsule, 'title.trackCommentary', titleCapsule =>
+                  heading.slots({
+                    tag: 'h3',
+                    attributes: {id: directory},
+                    color,
+
+                    title:
+                      language.$(titleCapsule, {
+                        track: link,
+                      }),
+
+                    accent:
+                      language.$(titleCapsule, 'accent', {
+                        [language.onlyIfOptions]: ['listeningLinks'],
+                        listeningLinks:
+                          language.formatUnitList(
+                            listeningLinks.map(link =>
+                              link.slot('tab', 'separate'))),
+                      }),
+                  })),
 
               cover?.slots({mode: 'commentary'}),
 
+              trackDate &&
+              trackDate !== data.date &&
+                html.tag('p', {class: 'track-info'},
+                  language.$('releaseInfo.trackReleased', {
+                    date: language.formatDate(trackDate),
+                  })),
+
               entries.map(entry => entry.slot('color', color)),
-            ]),
+            ])),
         ],
 
         navLinkStyle: 'hierarchical',
@@ -253,22 +296,11 @@ export default {
           },
         ],
 
-        leftSidebar:
-          relations.sidebar.slots({
-            attributes: {class: 'commentary-track-list-sidebar-box'},
-
-            stickyMode: 'column',
-
-            content: [
-              html.tag('h1', relations.sidebarAlbumLink),
-              relations.sidebarTrackSections.map(section =>
-                section.slots({
-                  anchor: true,
-                  open: true,
-                  mode: 'commentary',
-                })),
-            ],
+        secondaryNav:
+          relations.secondaryNav.slots({
+            alwaysVisible: true,
           }),
-      });
-  },
+
+        leftSidebar: relations.sidebar,
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumCommentarySidebar.js b/src/content/dependencies/generateAlbumCommentarySidebar.js
new file mode 100644
index 00000000..9ecec66d
--- /dev/null
+++ b/src/content/dependencies/generateAlbumCommentarySidebar.js
@@ -0,0 +1,73 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarTrackSection',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackSections:
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection',
+          album,
+          null,
+          trackSection)),
+  }),
+
+  data: (album) => ({
+    albumHasCommentary:
+      !empty(album.commentary),
+
+    anyTrackHasCommentary:
+      album.tracks.some(track => !empty(track.commentary)),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumCommentaryPage', pageCapsule =>
+      relations.sidebar.slots({
+        stickyMode: 'column',
+        boxes: [
+          relations.sidebarBox.slots({
+            attributes: {class: 'commentary-track-list-sidebar-box'},
+            content: [
+              html.tag('h1', relations.albumLink),
+
+              html.tag('p', {[html.onlyIfContent]: true},
+                language.encapsulate(pageCapsule, 'sidebar', workingCapsule => {
+                  if (data.anyTrackHasCommentary) return html.blank();
+
+                  if (data.albumHasCommentary) {
+                    workingCapsule += '.noTrackCommentary';
+                  } else {
+                    workingCapsule += '.noCommentary';
+                  }
+
+                  return language.$(workingCapsule);
+                })),
+
+              data.anyTrackHasCommentary &&
+                relations.trackSections.map(section =>
+                  section.slots({
+                    anchor: true,
+                    open: true,
+                    mode: 'commentary',
+                  })),
+            ],
+          }),
+        ]
+      })),
+}
diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js
deleted file mode 100644
index dbb22fe7..00000000
--- a/src/content/dependencies/generateAlbumCoverArtwork.js
+++ /dev/null
@@ -1,26 +0,0 @@
-export default {
-  contentDependencies: ['generateCoverArtwork'],
-
-  relations: (relation, album) => ({
-    coverArtwork:
-      relation('generateCoverArtwork', album.artTags),
-  }),
-
-  data: (album) => ({
-    path:
-      ['media.albumCover', album.directory, album.coverArtFileExtension],
-
-    color:
-      album.color,
-
-    dimensions:
-      album.coverArtDimensions,
-  }),
-
-  generate: (data, relations) =>
-    relations.coverArtwork.slots({
-      path: data.path,
-      color: data.color,
-      dimensions: data.dimensions,
-    }),
-};
diff --git a/src/content/dependencies/generateAlbumGalleryAlbumGrid.js b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js
new file mode 100644
index 00000000..7f152871
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js
@@ -0,0 +1,90 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCoverGrid',
+    'image',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (album) => ({
+    artworks:
+      (album.hasCoverArt
+        ? album.coverArtworks
+        : []),
+  }),
+
+  relations: (relation, query, album) => ({
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    albumLinks:
+      query.artworks.map(_artwork =>
+        relation('linkAlbum', album)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, album) => ({
+    albumName:
+      album.name,
+
+    artworkLabels:
+      query.artworks
+        .map(artwork => artwork.label),
+
+    artworkArtists:
+      query.artworks
+        .map(artwork => artwork.artistContribs
+          .map(contrib => contrib.artist.name)),
+  }),
+
+  slots: {
+    attributes: {type: 'attributes', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+
+      slots.attributes,
+
+      [
+        relations.coverArtistsLine,
+
+        relations.coverGrid.slots({
+          links:
+            relations.albumLinks,
+
+          names:
+            data.artworkLabels
+              .map(label => label ?? data.albumName),
+
+          images:
+            stitchArrays({
+              image: relations.images,
+              label: data.artworkLabels,
+            }).map(({image, label}) =>
+                image.slots({
+                  missingSourceContent:
+                    language.$('misc.albumGalleryGrid.noCoverArt', {
+                      name:
+                        label ?? data.albumName,
+                    }),
+                })),
+
+          info:
+            data.artworkArtists.map(artists =>
+              language.$('misc.coverGrid.details.coverArtists', {
+                [language.onlyIfOptions]: ['artists'],
+
+                artists:
+                  language.formatUnitList(artists),
+              })),
+        }),
+      ]),
+};
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
index b4f9268c..2ba3b272 100644
--- a/src/content/dependencies/generateAlbumGalleryPage.js
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -1,18 +1,18 @@
-import {compareArrays, stitchArrays} from '#sugar';
+import {stitchArrays, unique} from '#sugar';
+import {getKebabCase} from '#wiki-data';
 
 export default {
   contentDependencies: [
-    'generateAlbumGalleryCoverArtistsLine',
+    'generateAlbumGalleryAlbumGrid',
     'generateAlbumGalleryNoTrackArtworksLine',
     'generateAlbumGalleryStatsLine',
+    'generateAlbumGalleryTrackGrid',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
     'generateAlbumStyleRules',
-    'generateCoverGrid',
+    'generateIntrapageDotSwitcher',
     'generatePageLayout',
-    'image',
     'linkAlbum',
-    'linkTrack',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -20,151 +20,86 @@ export default {
   query(album) {
     const query = {};
 
-    const tracksWithUniqueCoverArt =
+    const trackArtworkLabels =
       album.tracks
-        .filter(track => track.hasUniqueCoverArt);
-
-    // Don't display "all artwork by..." for albums where there's
-    // only one unique artwork in the first place.
-    if (tracksWithUniqueCoverArt.length > 1) {
-      const allCoverArtistArrays =
-        tracksWithUniqueCoverArt
-          .map(track => track.coverArtistContribs)
-          .map(contribs => contribs.map(contrib => contrib.who));
-
-      const allSameCoverArtists =
-        allCoverArtistArrays
-          .slice(1)
-          .every(artists => compareArrays(artists, allCoverArtistArrays[0]));
-
-      if (allSameCoverArtists) {
-        query.coverArtistsForAllTracks =
-          allCoverArtistArrays[0];
-      }
-    }
+        .map(track => track.trackArtworks
+          .map(artwork => artwork.label));
+
+    const recurranceThreshold = 2;
+
+    // This list may include null, if some artworks are not labelled!
+    // That's expected.
+    query.recurringTrackArtworkLabels =
+      unique(trackArtworkLabels.flat())
+        .filter(label =>
+          trackArtworkLabels
+            .filter(labels => labels.includes(label))
+            .length >=
+          (label === null
+            ? 1
+            : recurranceThreshold));
 
     return query;
   },
 
-  relations(relation, query, album) {
-    const relations = {};
+  relations: (relation, query, album) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    relations.layout =
-      relation('generatePageLayout');
+    albumStyleRules:
+      relation('generateAlbumStyleRules', album, null),
 
-    relations.albumStyleRules =
-      relation('generateAlbumStyleRules', album, null);
-
-    relations.albumLink =
-      relation('linkAlbum', album);
-
-    relations.albumNavAccent =
-      relation('generateAlbumNavAccent', album, null);
-
-    relations.secondaryNav =
-      relation('generateAlbumSecondaryNav', album);
-
-    relations.statsLine =
-      relation('generateAlbumGalleryStatsLine', album);
-
-    if (album.tracks.every(track => !track.hasUniqueCoverArt)) {
-      relations.noTrackArtworksLine =
-        relation('generateAlbumGalleryNoTrackArtworksLine');
-    }
-
-    if (query.coverArtistsForAllTracks) {
-      relations.coverArtistsLine =
-        relation('generateAlbumGalleryCoverArtistsLine', query.coverArtistsForAllTracks);
-    }
-
-    relations.coverGrid =
-      relation('generateCoverGrid');
-
-    relations.links = [
+    albumLink:
       relation('linkAlbum', album),
 
-      ...
-        album.tracks
-          .map(track => relation('linkTrack', track)),
-    ];
-
-    relations.images = [
-      (album.hasCoverArt
-        ? relation('image', album.artTags)
-        : relation('image')),
-
-      ...
-        album.tracks.map(track =>
-          (track.hasUniqueCoverArt
-            ? relation('image', track.artTags)
-            : relation('image'))),
-    ];
-
-    return relations;
-  },
-
-  data(query, album) {
-    const data = {};
+    albumNavAccent:
+      relation('generateAlbumNavAccent', album, null),
 
-    data.name = album.name;
-    data.color = album.color;
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', album),
 
-    data.names = [
-      album.name,
-      ...album.tracks.map(track => track.name),
-    ];
+    statsLine:
+      relation('generateAlbumGalleryStatsLine', album),
 
-    data.coverArtists = [
-      (album.hasCoverArt
-        ? album.coverArtistContribs.map(({who: artist}) => artist.name)
+    noTrackArtworksLine:
+      (album.tracks.every(track => !track.hasUniqueCoverArt)
+        ? relation('generateAlbumGalleryNoTrackArtworksLine')
         : null),
 
-      ...
-        album.tracks.map(track => {
-          if (query.coverArtistsForAllTracks) {
-            return null;
-          }
+    setSwitcher:
+      relation('generateIntrapageDotSwitcher'),
 
-          if (track.hasUniqueCoverArt) {
-            return track.coverArtistContribs.map(({who: artist}) => artist.name);
-          }
+    albumGrid:
+      relation('generateAlbumGalleryAlbumGrid', album),
 
-          return null;
-        }),
-    ];
+    trackGrids:
+      query.recurringTrackArtworkLabels.map(label =>
+        relation('generateAlbumGalleryTrackGrid', album, label)),
+  }),
 
-    data.paths = [
-      (album.hasCoverArt
-        ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-        : null),
-
-      ...
-        album.tracks.map(track =>
-          (track.hasUniqueCoverArt
-            ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
-            : null)),
-    ];
+  data: (query, album) => ({
+    trackGridLabels:
+      query.recurringTrackArtworkLabels,
 
-    data.dimensions = [
-      (album.hasCoverArt
-        ? album.coverArtDimensions
-        : null),
+    trackGridIDs:
+      query.recurringTrackArtworkLabels.map(label =>
+        'track-grid-' +
+          (label
+            ? getKebabCase(label)
+            : 'no-label')),
 
-      ...
-        album.tracks.map(track =>
-          (track.hasUniqueCoverArt
-            ? track.coverArtDimensions
-            : null)),
-    ];
+    name:
+      album.name,
 
-    return data;
-  },
+    color:
+      album.color,
+  }),
 
-  generate(data, relations, {language}) {
-    return relations.layout
-      .slots({
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumGalleryPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('albumGalleryPage.title', {
+          language.$(pageCapsule, 'title', {
             album: data.name,
           }),
 
@@ -176,34 +111,39 @@ export default {
         mainClasses: ['top-index'],
         mainContent: [
           relations.statsLine,
-          relations.coverArtistsLine,
+
+          relations.albumGrid,
+
           relations.noTrackArtworksLine,
 
-          relations.coverGrid
-            .slots({
-              links: relations.links,
-              names: data.names,
-              images:
-                stitchArrays({
-                  image: relations.images,
-                  path: data.paths,
-                  dimensions: data.dimensions,
-                  name: data.names,
-                }).map(({image, path, dimensions, name}) =>
-                    image.slots({
-                      path,
-                      dimensions,
-                      missingSourceContent:
-                        language.$('misc.albumGalleryGrid.noCoverArt', {name}),
-                    })),
-              info:
-                data.coverArtists.map(names =>
-                  (names === null
-                    ? null
-                    : language.$('misc.albumGrid.details.coverArtists', {
-                        artists: language.formatUnitList(names),
-                      }))),
-            }),
+          data.trackGridLabels.some(value => value !== null) &&
+            html.tag('p', {class: 'gallery-set-switcher'},
+              language.encapsulate(pageCapsule, 'setSwitcher', switcherCapsule =>
+                language.$(switcherCapsule, {
+                  sets:
+                    relations.setSwitcher.slots({
+                      initialOptionIndex: 0,
+
+                      titles:
+                        data.trackGridLabels.map(label =>
+                          label ??
+                          language.$(switcherCapsule, 'unlabeledSet')),
+
+                      targetIDs:
+                        data.trackGridIDs,
+                    }),
+                }))),
+
+          stitchArrays({
+            grid: relations.trackGrids,
+            id: data.trackGridIDs,
+          }).map(({grid, id}, index) =>
+              grid.slots({
+                attributes: [
+                  {id},
+                  index >= 1 && {style: 'display: none'},
+                ],
+              })),
         ],
 
         navLinkStyle: 'hierarchical',
@@ -223,6 +163,5 @@ export default {
         ],
 
         secondaryNav: relations.secondaryNav,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumGalleryTrackGrid.js b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
new file mode 100644
index 00000000..85e7576c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
@@ -0,0 +1,122 @@
+import {compareArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumGalleryCoverArtistsLine',
+    'generateCoverGrid',
+    'image',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album, label) {
+    const query = {};
+
+    query.artworks =
+      album.tracks.map(track =>
+        track.trackArtworks.find(artwork => artwork.label === label) ??
+        null);
+
+    const presentArtworks =
+      query.artworks.filter(Boolean);
+
+    if (presentArtworks.length > 1) {
+      const allArtistArrays =
+        presentArtworks
+          .map(artwork => artwork.artistContribs
+            .map(contrib => contrib.artist));
+
+      const allSameArtists =
+        allArtistArrays
+          .slice(1)
+          .every(artists => compareArrays(artists, allArtistArrays[0]));
+
+      if (allSameArtists) {
+        query.artistsForAllTrackArtworks =
+          allArtistArrays[0];
+      }
+    }
+
+    return query;
+  },
+
+  relations: (relation, query, album, _label) => ({
+    coverArtistsLine:
+      (query.artistsForAllTrackArtworks
+        ? relation('generateAlbumGalleryCoverArtistsLine',
+            query.artistsForAllTrackArtworks)
+        : null),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackLinks:
+      album.tracks
+        .map(track => relation('linkTrack', track)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, album, _label) => ({
+    trackNames:
+      album.tracks
+        .map(track => track.name),
+
+    trackArtworkArtists:
+      query.artworks.map(artwork =>
+        (query.artistsForAllTrackArtworks
+          ? null
+       : artwork
+          ? artwork.artistContribs
+              .map(contrib => contrib.artist.name)
+          : null)),
+  }),
+
+  slots: {
+    attributes: {type: 'attributes', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+
+      slots.attributes,
+
+      [
+        relations.coverArtistsLine,
+
+        relations.coverGrid.slots({
+          links:
+            relations.trackLinks,
+
+          names:
+            data.trackNames,
+
+          images:
+            stitchArrays({
+              image: relations.images,
+              name: data.trackNames,
+            }).map(({image, name}) =>
+                image.slots({
+                  missingSourceContent:
+                    language.$('misc.albumGalleryGrid.noCoverArt', {name}),
+                })),
+
+          info:
+            data.trackArtworkArtists.map(artists =>
+              language.$('misc.coverGrid.details.coverArtists', {
+                [language.onlyIfOptions]: ['artists'],
+
+                artists:
+                  language.formatUnitList(artists),
+              })),
+        }),
+      ]),
+};
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index e0f23bd0..d0788523 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -1,14 +1,11 @@
-import {sortAlbumsTracksChronologically} from '#sort';
 import {empty} from '#sugar';
 
-import getChronologyRelations from '../util/getChronologyRelations.js';
-
 export default {
   contentDependencies: [
-    'generateAdditionalFilesShortcut',
+    'generateAdditionalNamesBox',
     'generateAlbumAdditionalFilesList',
+    'generateAlbumArtworkColumn',
     'generateAlbumBanner',
-    'generateAlbumCoverArtwork',
     'generateAlbumNavAccent',
     'generateAlbumReleaseInfo',
     'generateAlbumSecondaryNav',
@@ -16,161 +13,105 @@ export default {
     'generateAlbumSocialEmbed',
     'generateAlbumStyleRules',
     'generateAlbumTrackList',
-    'generateChronologyLinks',
-    'generateCommentarySection',
+    'generateCommentaryEntry',
     '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);
-    }
-
-    // Section: Release info
+  relations: (relation, album) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    relations.releaseInfo =
-      relation('generateAlbumReleaseInfo', album);
+    albumStyleRules:
+      relation('generateAlbumStyleRules', album, null),
 
-    // Section: Extra links
+    socialEmbed:
+      relation('generateAlbumSocialEmbed', album),
 
-    const extra = sections.extra = {};
+    albumNavAccent:
+      relation('generateAlbumNavAccent', album, null),
 
-    if (album.tracks.some(t => t.hasUniqueCoverArt)) {
-      extra.galleryLink =
-        relation('linkAlbumGallery', album);
-    }
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', album),
 
-    if (album.commentary || album.tracks.some(t => t.commentary)) {
-      extra.commentaryLink =
-        relation('linkAlbumCommentary', album);
-    }
+    sidebar:
+      relation('generateAlbumSidebar', album, null),
 
-    if (!empty(album.additionalFiles)) {
-      extra.additionalFilesShortcut =
-        relation('generateAdditionalFilesShortcut', album.additionalFiles);
-    }
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', album.additionalNames),
 
-    // Section: Track list
+    artworkColumn:
+      relation('generateAlbumArtworkColumn', album),
 
-    relations.trackList =
-      relation('generateAlbumTrackList', album);
+    banner:
+      (album.hasBannerArt
+        ? relation('generateAlbumBanner', album)
+        : null),
 
-    // Section: Additional files
+    contentHeading:
+      relation('generateContentHeading'),
 
-    if (!empty(album.additionalFiles)) {
-      const additionalFiles = sections.additionalFiles = {};
+    releaseInfo:
+      relation('generateAlbumReleaseInfo', album),
 
-      additionalFiles.heading =
-        relation('generateContentHeading');
+    galleryLink:
+      (album.tracks.some(t => t.hasUniqueCoverArt)
+        ? relation('linkAlbumGallery', album)
+        : null),
 
-      additionalFiles.additionalFilesList =
-        relation('generateAlbumAdditionalFilesList', album, album.additionalFiles);
-    }
+    commentaryLink:
+      ([album, ...album.tracks].some(({commentary}) => !empty(commentary))
+        ? relation('linkAlbumCommentary', album)
+        : null),
 
-    // Section: Artist commentary
+    trackList:
+      relation('generateAlbumTrackList', album),
 
-    if (album.commentary) {
-      sections.artistCommentary =
-        relation('generateCommentarySection', album.commentary);
-    }
+    additionalFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        album,
+        album.additionalFiles),
 
-    return relations;
-  },
+    artistCommentaryEntries:
+      album.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
 
-  data(album) {
-    const data = {};
+    creditSourceEntries:
+      album.creditSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
+  }),
 
-    data.name = album.name;
-    data.color = album.color;
+  data: (album) => ({
+    name:
+      album.name,
 
-    if (!empty(album.additionalFiles)) {
-      data.numAdditionalFiles = album.additionalFiles.length;
-    }
+    color:
+      album.color,
 
-    data.dateAddedToWiki = album.dateAddedToWiki;
+    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:
-          relations.cover
-            ?.slots({
-              alt: language.$('misc.alt.albumCover'),
-            })
-            ?? null,
+        additionalNames: relations.additionalNamesBox,
+
+        artworkColumnContent:
+          relations.artworkColumn,
 
         mainContent: [
           relations.releaseInfo,
@@ -179,33 +120,53 @@ export default {
             {[html.onlyIfContent]: true},
             {[html.joinChildren]: html.tag('br')},
 
-            [
-              sec.extra.additionalFilesShortcut,
-
-              sec.extra.galleryLink && sec.extra.commentaryLink &&
-                language.$('releaseInfo.viewGalleryOrCommentary', {
-                  gallery:
-                    sec.extra.galleryLink
-                      .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.gallery')),
-                  commentary:
-                    sec.extra.commentaryLink
-                      .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.commentary')),
-                }),
-
-              sec.extra.galleryLink && !sec.extra.commentaryLink &&
-                language.$('releaseInfo.viewGallery', {
-                  link:
-                    sec.extra.galleryLink
-                      .slot('content', language.$('releaseInfo.viewGallery.link')),
+            language.encapsulate('releaseInfo', capsule => [
+              !html.isBlank(relations.additionalFilesList) &&
+                language.$(capsule, 'additionalFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#additional-files'},
+                    language.$(capsule, 'additionalFiles.shortcut.link')),
                 }),
 
-              !sec.extra.galleryLink && sec.extra.commentaryLink &&
-                language.$('releaseInfo.viewCommentary', {
-                  link:
-                    sec.extra.commentaryLink
-                      .slot('content', language.$('releaseInfo.viewCommentary.link')),
-                }),
-            ]),
+              (relations.galleryLink && relations.commentaryLink
+                ? language.encapsulate(capsule, 'viewGalleryOrCommentary', capsule =>
+                    language.$(capsule, {
+                      gallery:
+                        relations.galleryLink
+                          .slot('content', language.$(capsule, 'gallery')),
+
+                      commentary:
+                        relations.commentaryLink
+                          .slot('content', language.$(capsule, 'commentary')),
+                    }))
+
+             : relations.galleryLink
+                ? language.encapsulate(capsule, 'viewGallery', capsule =>
+                    language.$(capsule, {
+                      link:
+                        relations.galleryLink
+                          .slot('content', language.$(capsule, 'link')),
+                    }))
+
+             : relations.commentaryLink
+                ? language.encapsulate(capsule, 'viewCommentary', capsule =>
+                    language.$(capsule, {
+                      link:
+                        relations.commentaryLink
+                          .slot('content', language.$(capsule, 'link')),
+                    }))
+
+                : html.blank()),
+
+              !html.isBlank(relations.creditSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#credit-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
 
           relations.trackList,
 
@@ -213,28 +174,43 @@ export default {
             {[html.onlyIfContent]: true},
             {[html.joinChildren]: html.tag('br')},
 
-            [
-              data.dateAddedToWiki &&
-                language.$('releaseInfo.addedToWiki', {
-                  date: language.formatDate(data.dateAddedToWiki),
+            language.encapsulate('releaseInfo', capsule => [
+              language.$(capsule, 'addedToWiki', {
+                [language.onlyIfOptions]: ['date'],
+                date: language.formatDate(data.dateAddedToWiki),
+              }),
+            ])),
+
+          language.encapsulate('releaseInfo.additionalFiles', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'additional-files'},
+                  title: language.$(capsule, 'heading'),
                 }),
-            ]),
 
-          sec.additionalFiles && [
-            sec.additionalFiles.heading
+              relations.additionalFilesList,
+            ])),
+
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
-                id: 'additional-files',
-                title:
-                  language.$('releaseInfo.additionalFiles.heading', {
-                    additionalFiles:
-                      language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
-                  }),
+                attributes: {id: 'artist-commentary'},
+                title: language.$('misc.artistCommentary'),
               }),
 
-            sec.additionalFiles.additionalFilesList,
-          ],
+            relations.artistCommentaryEntries,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'credit-sources'},
+                title: language.$('misc.creditSources'),
+              }),
 
-          sec.artistCommentary,
+            relations.creditSourceEntries,
+          ]),
         ],
 
         navLinkStyle: 'hierarchical',
@@ -250,16 +226,6 @@ export default {
           },
         ],
 
-        navContent:
-          relations.chronologyLinks.slots({
-            chronologyInfoSets: [
-              {
-                headingString: 'misc.chronology.heading.coverArt',
-                contributions: relations.coverArtistChronologyContributions,
-              },
-            ],
-          }),
-
         banner: relations.banner ?? null,
         bannerPosition: 'top',
 
@@ -268,6 +234,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..432c5f3d 100644
--- a/src/content/dependencies/generateAlbumNavAccent.js
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -1,8 +1,10 @@
-import {empty} from '#sugar';
+import {atOffset, empty} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generatePreviousNextLinks',
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
     'linkTrack',
     'linkAlbumCommentary',
     'linkAlbumGallery',
@@ -10,47 +12,68 @@ export default {
 
   extraDependencies: ['html', 'language'],
 
-  relations(relation, album, track) {
-    const relations = {};
+  query(album, track) {
+    const query = {};
 
-    relations.previousNextLinks =
-      relation('generatePreviousNextLinks');
+    const index =
+      (track
+        ? album.tracks.indexOf(track)
+        : null);
 
-    relations.previousTrackLink = null;
-    relations.nextTrackLink = null;
+    query.previousTrack =
+      (track
+        ? atOffset(album.tracks, index, -1)
+        : null);
 
-    if (track) {
-      const index = album.tracks.indexOf(track);
+    query.nextTrack =
+      (track
+        ? atOffset(album.tracks, index, +1)
+        : null);
 
-      if (index > 0) {
-        relations.previousTrackLink =
-          relation('linkTrack', album.tracks[index - 1]);
-      }
+    return query;
+  },
 
-      if (index < album.tracks.length - 1) {
-        relations.nextTrackLink =
-          relation('linkTrack', album.tracks[index + 1]);
-      }
-    }
+  relations: (relation, query, album, _track) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
 
-    relations.albumGalleryLink =
-      relation('linkAlbumGallery', album);
+    previousLink:
+      relation('generatePreviousLink'),
 
-    if (album.commentary || album.tracks.some(t => t.commentary)) {
-      relations.albumCommentaryLink =
-        relation('linkAlbumCommentary', album);
-    }
+    nextLink:
+      relation('generateNextLink'),
 
-    return relations;
-  },
+    previousTrackLink:
+      (query.previousTrack
+        ? relation('linkTrack', query.previousTrack)
+        : null),
 
-  data(album, track) {
-    return {
-      hasMultipleTracks: album.tracks.length > 1,
-      galleryIsStub: album.tracks.every(t => !t.hasUniqueCoverArt),
-      isTrackPage: !!track,
-    };
-  },
+    nextTrackLink:
+      (query.nextTrack
+        ? relation('linkTrack', query.nextTrack)
+        : null),
+
+    albumGalleryLink:
+      relation('linkAlbumGallery', album),
+
+    albumCommentaryLink:
+      relation('linkAlbumCommentary', album),
+  }),
+
+  data: (query, album, track) => ({
+    hasMultipleTracks:
+      album.tracks.length > 1,
+
+    commentaryPageIsStub:
+      [album, ...album.tracks]
+        .every(({commentary}) => empty(commentary)),
+
+    galleryIsStub:
+      album.tracks.every(t => !t.hasUniqueCoverArt),
+
+    isTrackPage:
+      !!track,
+  }),
 
   slots: {
     showTrackNavigation: {type: 'boolean', default: false},
@@ -62,51 +85,58 @@ export default {
   },
 
   generate(data, relations, slots, {html, language}) {
-    const {content: extraLinks = []} =
-      slots.showExtraLinks &&
-        {content: [
-          (!data.galleryIsStub || slots.currentExtra === 'gallery') &&
-            relations.albumGalleryLink?.slots({
-              attributes: {class: slots.currentExtra === 'gallery' && 'current'},
-              content: language.$('albumPage.nav.gallery'),
-            }),
-
-          relations.albumCommentaryLink?.slots({
-            attributes: {class: slots.currentExtra === 'commentary' && 'current'},
-            content: language.$('albumPage.nav.commentary'),
-          }),
-        ]};
-
-    const {content: previousNextLinks = []} =
-      slots.showTrackNavigation &&
+    const albumNavCapsule = language.encapsulate('albumPage.nav');
+    const trackNavCapsule = language.encapsulate('trackPage.nav');
+
+    const previousLink =
       data.isTrackPage &&
-      data.hasMultipleTracks &&
-        relations.previousNextLinks.slots({
-          previousLink: relations.previousTrackLink,
-          nextLink: relations.nextTrackLink,
+        relations.previousLink.slot('link', relations.previousTrackLink);
+
+    const nextLink =
+      data.isTrackPage &&
+        relations.nextLink.slot('link', relations.nextTrackLink);
+
+    const galleryLink =
+      (!data.galleryIsStub || slots.currentExtra === 'gallery') &&
+        relations.albumGalleryLink.slots({
+          attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+          content: language.$(albumNavCapsule, 'gallery'),
+        });
+
+    const commentaryLink =
+      (!data.commentaryPageIsStub || slots.currentExtra === 'commentary') &&
+        relations.albumCommentaryLink.slots({
+          attributes: {class: slots.currentExtra === 'commentary' && 'current'},
+          content: language.$(albumNavCapsule, 'commentary'),
         });
 
     const randomLink =
-      slots.showTrackNavigation &&
       data.hasMultipleTracks &&
         html.tag('a',
           {id: 'random-button'},
           {href: '#', 'data-random': 'track-in-sidebar'},
 
           (data.isTrackPage
-            ? language.$('trackPage.nav.random')
-            : language.$('albumPage.nav.randomTrack')));
+            ? language.$(trackNavCapsule, 'random')
+            : language.$(albumNavCapsule, 'randomTrack')));
+
+    return relations.switcher.slots({
+      links: [
+        slots.showTrackNavigation &&
+          previousLink,
+
+        slots.showTrackNavigation &&
+          nextLink,
 
-    const allLinks = [
-      ...previousNextLinks,
-      ...extraLinks,
-      randomLink,
-    ].filter(Boolean);
+        slots.showExtraLinks &&
+          galleryLink,
 
-    if (empty(allLinks)) {
-      return html.blank();
-    }
+        slots.showExtraLinks &&
+          commentaryLink,
 
-    return `(${language.formatUnitList(allLinks)})`;
+        slots.showTrackNavigation &&
+          randomLink,
+      ],
+    });
   },
 };
diff --git a/src/content/dependencies/generateAlbumReferencedArtworksPage.js b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
new file mode 100644
index 00000000..7586393c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
@@ -0,0 +1,58 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleRules',
+    'generateBackToAlbumLink',
+    'generateReferencedArtworksPage',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    page:
+      relation('generateReferencedArtworksPage', album.coverArtworks[0]),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', album, null),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    backToAlbumLink:
+      relation('generateBackToAlbumLink', album),
+  }),
+
+  data: (album) => ({
+    name:
+      album.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.page.slots({
+      title:
+        language.$('albumPage.title', {
+          album:
+            data.name,
+        }),
+
+      styleRules: [relations.albumStyleRules],
+
+      navLinks: [
+        {auto: 'home'},
+
+        {
+          html:
+            relations.albumLink
+              .slot('attributes', {class: 'current'}),
+
+          accent:
+            html.tag('a', {href: ''},
+              {class: 'current'},
+
+              language.$('referencedArtworksPage.subtitle')),
+        },
+      ],
+
+      navBottomRowContent: relations.backToAlbumLink,
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumReferencingArtworksPage.js b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
new file mode 100644
index 00000000..d072d2f6
--- /dev/null
+++ b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
@@ -0,0 +1,58 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleRules',
+    'generateBackToAlbumLink',
+    'generateReferencingArtworksPage',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    page:
+      relation('generateReferencingArtworksPage', album.coverArtworks[0]),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', album, null),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    backToAlbumLink:
+      relation('generateBackToAlbumLink', album),
+  }),
+
+  data: (album) => ({
+    name:
+      album.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.page.slots({
+      title:
+        language.$('albumPage.title', {
+          album:
+            data.name,
+        }),
+
+      styleRules: [relations.albumStyleRules],
+
+      navLinks: [
+        {auto: 'home'},
+
+        {
+          html:
+            relations.albumLink
+              .slot('attributes', {class: 'current'}),
+
+          accent:
+            html.tag('a', {href: ''},
+              {class: 'current'},
+
+              language.$('referencingArtworksPage.subtitle')),
+        },
+      ],
+
+      navBottomRowContent: relations.backToAlbumLink,
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index 6fc1375b..0abb412c 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -14,20 +14,15 @@ export default {
     relations.artistContributionsLine =
       relation('generateReleaseInfoContributionsLine', album.artistContribs);
 
-    relations.coverArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.coverArtistContribs);
-
     relations.wallpaperArtistContributionsLine =
       relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs);
 
     relations.bannerArtistContributionsLine =
       relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
 
-    if (!empty(album.urls)) {
-      relations.externalLinks =
-        album.urls.map(url =>
-          relation('linkExternal', url));
-    }
+    relations.externalLinks =
+      album.urls.map(url =>
+        relation('linkExternal', url));
 
     return relations;
   },
@@ -43,55 +38,58 @@ export default {
       data.coverArtDate = album.coverArtDate;
     }
 
-    data.duration = accumulateSum(album.tracks, track => track.duration);
-    data.durationApproximate = album.tracks.length > 1;
+    const durationTerms =
+      album.tracks
+        .map(track => track.duration)
+        .filter(value => value > 0);
+
+    if (empty(durationTerms)) {
+      data.duration = null;
+      data.durationApproximate = null;
+    } else {
+      data.duration = accumulateSum(durationTerms);
+      data.durationApproximate = album.tracks.length > 1;
+    }
 
     data.numTracks = album.tracks.length;
 
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return html.tags([
-      html.tag('p',
-        {[html.onlyIfContent]: true},
-        {[html.joinChildren]: html.tag('br')},
-
-        [
-          relations.artistContributionsLine
-            .slots({stringKey: 'releaseInfo.by'}),
-
-          relations.coverArtistContributionsLine
-            .slots({stringKey: 'releaseInfo.coverArtBy'}),
-
-          relations.wallpaperArtistContributionsLine
-            .slots({stringKey: 'releaseInfo.wallpaperArtBy'}),
-
-          relations.bannerArtistContributionsLine
-            .slots({stringKey: 'releaseInfo.bannerArtBy'}),
-
-          data.date &&
-            language.$('releaseInfo.released', {
-              date: language.formatDate(data.date),
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule =>
+      html.tags([
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.artistContributionsLine.slots({
+              stringKey: capsule + '.by',
+              featuringStringKey: capsule + '.by.featuring',
+              chronologyKind: 'album',
             }),
 
-          data.coverArtDate &&
-            language.$('releaseInfo.artReleased', {
-              date: language.formatDate(data.coverArtDate),
+            language.$(capsule, 'released', {
+              [language.onlyIfOptions]: ['date'],
+              date: language.formatDate(data.date),
             }),
 
-          data.duration &&
-            language.$('releaseInfo.duration', {
+            language.$(capsule, 'duration', {
+              [language.onlyIfOptions]: ['duration'],
               duration:
                 language.formatDuration(data.duration, {
                   approximate: data.durationApproximate,
                 }),
             }),
-        ]),
+          ]),
 
-      relations.externalLinks &&
         html.tag('p',
-          language.$('releaseInfo.listenOn', {
+          {[html.onlyIfContent]: true},
+
+          language.$(capsule, 'listenOn', {
+            [language.onlyIfOptions]: ['links'],
+
             links:
               language.formatDisjunctionList(
                 relations.externalLinks
@@ -105,6 +103,5 @@ export default {
                         : 'albumMultipleTracks'),
                     ]))),
           })),
-    ]);
-  },
+      ])),
 };
diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js
index 400420ba..bfa48f03 100644
--- a/src/content/dependencies/generateAlbumSecondaryNav.js
+++ b/src/content/dependencies/generateAlbumSecondaryNav.js
@@ -1,168 +1,127 @@
-import {sortChronologically} from '#sort';
-import {atOffset, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateColorStyleAttribute',
-    'generatePreviousNextLinks',
+    'generateAlbumSecondaryNavGroupPart',
+    'generateAlbumSecondaryNavSeriesPart',
+    'generateDotSwitcherTemplate',
     'generateSecondaryNav',
-    'linkAlbumDynamically',
-    'linkGroup',
-    'linkTrack',
   ],
 
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['html', 'wikiData'],
 
-  query(album) {
+  sprawl: ({groupData}) => ({
+    // TODO: Series aren't their own things, so we access them weirdly.
+    seriesData:
+      groupData.flatMap(group => group.serieses),
+  }),
+
+  query(sprawl, album) {
     const query = {};
 
     query.groups =
       album.groups;
 
-    if (album.date) {
-      // Sort by latest first. This matches the sorting order used on group
-      // gallery pages, ensuring that previous/next matches moving up/down
-      // the gallery. Note that this makes the index offsets "backwards"
-      // compared to how latest-last chronological lists are accessed.
-      const groupAlbums =
-        query.groups.map(group =>
-          sortChronologically(
-            group.albums.filter(album => album.date),
-            {latestFirst: true}));
-
-      const groupCurrentIndex =
-        groupAlbums.map(albums =>
-          albums.indexOf(album));
-
-      query.groupPreviousAlbum =
-        stitchArrays({
-          albums: groupAlbums,
-          index: groupCurrentIndex,
-        }).map(({albums, index}) =>
-            atOffset(albums, index, +1));
-
-      query.groupNextAlbum =
-        stitchArrays({
-          albums: groupAlbums,
-          index: groupCurrentIndex,
-        }).map(({albums, index}) =>
-            atOffset(albums, index, -1));
-    }
+    query.groupSerieses =
+      query.groups
+        .map(group =>
+          group.serieses
+            .filter(series => series.albums.includes(album)));
+
+    query.disconnectedSerieses =
+      sprawl.seriesData
+        .filter(series =>
+          series.albums.includes(album) &&
+          !query.groups.includes(series.group));
 
     return query;
   },
 
-  relations(relation, query, album) {
-    const relations = {};
-
-    relations.secondaryNav =
-      relation('generateSecondaryNav');
-
-    relations.groupLinks =
-      album.groups
-        .map(group => relation('linkGroup', group));
-
-    relations.colorStyles =
-      album.groups
-        .map(group => relation('generateColorStyleAttribute', group.color));
-
-    if (album.date) {
-      relations.previousNextLinks =
-        stitchArrays({
-          previousAlbum: query.groupPreviousAlbum,
-          nextAlbum: query.groupNextAlbum
-        }).map(({previousAlbum, nextAlbum}) =>
-            (previousAlbum || nextAlbum
-              ? relation('generatePreviousNextLinks')
-              : null));
-
-      relations.previousAlbumLinks =
-        query.groupPreviousAlbum.map(previousAlbum =>
-          (previousAlbum
-            ? relation('linkAlbumDynamically', previousAlbum)
-            : null));
-
-      relations.nextAlbumLinks =
-        query.groupNextAlbum.map(nextAlbum =>
-          (nextAlbum
-            ? relation('linkAlbumDynamically', nextAlbum)
-            : null));
-    }
-
-    return relations;
-  },
+  relations: (relation, query, _sprawl, album) => ({
+    secondaryNav:
+      relation('generateSecondaryNav'),
+
+    // Just use a generic dot switcher here. We want the common behavior,
+    // but the "options" may each contain multiple links (group + series),
+    // so this is a different use than typical interpage dot switchers.
+    switcher:
+      relation('generateDotSwitcherTemplate'),
+
+    groupParts:
+      query.groups
+        .map(group =>
+          relation('generateAlbumSecondaryNavGroupPart',
+            group,
+            album)),
+
+    seriesParts:
+      query.groupSerieses
+        .map(serieses => serieses
+          .map(series =>
+            relation('generateAlbumSecondaryNavSeriesPart',
+              series,
+              album))),
+
+    disconnectedSeriesParts:
+      query.disconnectedSerieses
+        .map(series =>
+          relation('generateAlbumSecondaryNavSeriesPart',
+            series,
+            album)),
+  }),
 
   slots: {
     mode: {
       validate: v => v.is('album', 'track'),
       default: 'album',
     },
+
+    alwaysVisible: {
+      type: 'boolean',
+      default: false,
+    },
   },
 
-  generate(relations, slots, {html, language}) {
-    const navLinksShouldShowPreviousNext =
-      (slots.mode === 'track'
-        ? Array.from(relations.previousNextLinks, () => false)
-        : stitchArrays({
-            previousAlbumLink: relations.previousAlbumLinks ?? null,
-            nextAlbumLink: relations.nextAlbumLinks ?? null,
-          }).map(({previousAlbumLink, nextAlbumLink}) =>
-              previousAlbumLink ||
-              nextAlbumLink));
-
-    const navLinkPreviousNextLinks =
+  generate(relations, slots, {html}) {
+    const groupConnectedParts =
       stitchArrays({
-        showPreviousNext: navLinksShouldShowPreviousNext,
-        previousNextLinks: relations.previousNextLinks ?? null,
-        previousAlbumLink: relations.previousAlbumLinks ?? null,
-        nextAlbumLink: relations.nextAlbumLinks ?? null,
-      }).map(({
-          showPreviousNext,
-          previousNextLinks,
-          previousAlbumLink,
-          nextAlbumLink,
-        }) =>
-          (showPreviousNext
-            ? previousNextLinks.slots({
-                previousLink: previousAlbumLink,
-                nextLink: nextAlbumLink,
-                id: false,
-              })
-            : null));
-
-    for (const groupLink of relations.groupLinks) {
-      groupLink.setSlot('color', false);
-    }
-
-    const navLinkContents =
-      stitchArrays({
-        groupLink: relations.groupLinks,
-        previousNextLinks: navLinkPreviousNextLinks,
-      }).map(({groupLink, previousNextLinks}) => [
-          language.$('albumSidebar.groupBox.title', {
-            group: groupLink,
-          }),
-
-          previousNextLinks &&
-            `(${language.formatUnitList(previousNextLinks.content)})`,
-        ]);
-
-    const navLinks =
-      stitchArrays({
-        content: navLinkContents,
-        colorStyle: relations.colorStyles,
-      }).map(({content, colorStyle}, index) =>
-          html.tag('span', {class: 'nav-link'},
-            index > 0 &&
-              {class: 'has-divider'},
+        groupPart: relations.groupParts,
+        seriesParts: relations.seriesParts,
+      }).map(({groupPart, seriesParts}) => {
+          for (const part of [groupPart, ...seriesParts]) {
+            part.setSlot('mode', slots.mode);
+          }
+
+          if (html.isBlank(seriesParts)) {
+            return groupPart;
+          } else {
+            return (
+              html.tag('span', {class: 'group-with-series'},
+                {[html.joinChildren]: ''},
+
+                [groupPart, ...seriesParts]));
+          }
+        });
+
+    const allParts = [
+      ...relations.disconnectedSeriesParts,
+      ...groupConnectedParts,
+    ];
 
-            colorStyle.slot('context', 'primary-only'),
+    return relations.secondaryNav.slots({
+      alwaysVisible: slots.alwaysVisible,
 
-            content));
+      attributes: [
+        {class: 'album-secondary-nav'},
 
-    return relations.secondaryNav.slots({
-      class: 'nav-links-groups',
-      content: navLinks,
+        slots.mode === 'album' &&
+          {class: 'with-previous-next'},
+      ],
+
+      content:
+        (slots.mode === 'album'
+          ? allParts
+          : relations.switcher.slot('options', allParts)),
     });
   },
 };
diff --git a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js
new file mode 100644
index 00000000..22dfa51c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js
@@ -0,0 +1,94 @@
+import {sortChronologically} from '#sort';
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateSecondaryNavParentSiblingsPart',
+    'linkAlbumDynamically',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html'],
+
+  query(group, album) {
+    const query = {};
+
+    if (album.date) {
+      // Sort by latest first. This matches the sorting order used on group
+      // gallery pages, ensuring that previous/next matches moving up/down
+      // the gallery. Note that this makes the index offsets "backwards"
+      // compared to how latest-last chronological lists are accessed.
+      const albums =
+        sortChronologically(
+          group.albums.filter(album => album.date),
+          {latestFirst: true});
+
+      const currentIndex =
+        albums.indexOf(album);
+
+      query.previousAlbum =
+        atOffset(albums, currentIndex, +1);
+
+      query.nextAlbum =
+        atOffset(albums, currentIndex, -1);
+    }
+
+    return query;
+  },
+
+  relations: (relation, query, group, _album) => ({
+    parentSiblingsPart:
+      relation('generateSecondaryNavParentSiblingsPart'),
+
+    groupLink:
+      relation('linkGroup', group),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', group.color),
+
+    previousAlbumLink:
+      (query.previousAlbum
+        ? relation('linkAlbumDynamically', query.previousAlbum)
+        : null),
+
+    nextAlbumLink:
+      (query.nextAlbum
+        ? relation('linkAlbumDynamically', query.nextAlbum)
+        : null),
+  }),
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'album',
+    },
+  },
+
+  generate: (relations, slots) =>
+    relations.parentSiblingsPart.slots({
+      attributes: {class: 'group-nav-links'},
+
+      showPreviousNext: slots.mode === 'album',
+
+      colorStyle: relations.colorStyle,
+      mainLink: relations.groupLink,
+
+      previousLink:
+        (relations.previousAlbumLink
+          ? relations.previousAlbumLink.slots({
+              linkCommentaryPages: true,
+            })
+          : null),
+
+      nextLink:
+        (relations.nextAlbumLink
+          ? relations.nextAlbumLink.slots({
+              linkCommentaryPages: true,
+            })
+          : null),
+
+      stringsKey: 'albumSecondaryNav.group',
+      mainLinkOption: 'group',
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js
new file mode 100644
index 00000000..16f205e3
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js
@@ -0,0 +1,94 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateSecondaryNavParentSiblingsPart',
+    'linkAlbumDynamically',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(series, album) {
+    const query = {};
+
+    const albums =
+      series.albums;
+
+    const currentIndex =
+      albums.indexOf(album);
+
+    query.previousAlbum =
+      atOffset(albums, currentIndex, -1);
+
+    query.nextAlbum =
+      atOffset(albums, currentIndex, +1);
+
+    return query;
+  },
+
+  relations: (relation, query, series, _album) => ({
+    parentSiblingsPart:
+      relation('generateSecondaryNavParentSiblingsPart'),
+
+    groupLink:
+      relation('linkGroup', series.group),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', series.group.color),
+
+    previousAlbumLink:
+      (query.previousAlbum
+        ? relation('linkAlbumDynamically', query.previousAlbum)
+        : null),
+
+    nextAlbumLink:
+      (query.nextAlbum
+        ? relation('linkAlbumDynamically', query.nextAlbum)
+        : null),
+  }),
+
+  data: (_query, series) => ({
+    name: series.name,
+  }),
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'album',
+    },
+  },
+
+  generate: (data, relations, slots, {language}) =>
+    relations.parentSiblingsPart.slots({
+      attributes: {class: 'series-nav-links'},
+
+      showPreviousNext: slots.mode === 'album',
+
+      colorStyle: relations.colorStyle,
+
+      mainLink:
+        relations.groupLink.slots({
+          attributes: {class: 'series'},
+          content: language.sanitize(data.name),
+        }),
+
+      previousLink:
+        (relations.previousAlbumLink
+          ? relations.previousAlbumLink.slots({
+              linkCommentaryPages: true,
+            })
+          : null),
+
+      nextLink:
+        (relations.nextAlbumLink
+          ? relations.nextAlbumLink.slots({
+              linkCommentaryPages: true,
+            })
+          : null),
+
+      stringsKey: 'albumSecondaryNav.series',
+      mainLinkOption: 'series',
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
index 355a9a9a..7cf689cc 100644
--- a/src/content/dependencies/generateAlbumSidebar.js
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -1,12 +1,73 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import {stitchArrays, transposeArrays} from '#sugar';
+
 export default {
   contentDependencies: [
     'generateAlbumSidebarGroupBox',
+    'generateAlbumSidebarSeriesBox',
     'generateAlbumSidebarTrackListBox',
     'generatePageSidebar',
     'generatePageSidebarConjoinedBox',
+    'generateTrackReleaseBox',
   ],
 
-  relations: (relation, album, track) => ({
+  extraDependencies: ['html', 'wikiData'],
+
+  sprawl: ({groupData}) => ({
+    // TODO: Series aren't their own things, so we access them weirdly.
+    seriesData:
+      groupData.flatMap(group => group.serieses),
+  }),
+
+  query(sprawl, album, track) {
+    const query = {};
+
+    query.groups =
+      album.groups;
+
+    query.groupSerieses =
+      query.groups
+        .map(group =>
+          group.serieses
+            .filter(series => series.albums.includes(album)));
+
+    query.disconnectedSerieses =
+      sprawl.seriesData
+        .filter(series =>
+          series.albums.includes(album) &&
+          !query.groups.includes(series.group));
+
+    if (track) {
+      const albumTrackMap =
+        new Map(transposeArrays([
+          track.allReleases.map(t => t.album),
+          track.allReleases,
+        ]));
+
+      const allReleaseAlbums =
+        sortAlbumsTracksChronologically(
+          Array.from(albumTrackMap.keys()));
+
+      const currentReleaseIndex =
+        allReleaseAlbums.indexOf(track.album);
+
+      const earlierReleaseAlbums =
+        allReleaseAlbums.slice(0, currentReleaseIndex);
+
+      const laterReleaseAlbums =
+        allReleaseAlbums.slice(currentReleaseIndex + 1);
+
+      query.earlierReleaseTracks =
+        earlierReleaseAlbums.map(album => albumTrackMap.get(album));
+
+      query.laterReleaseTracks =
+        laterReleaseAlbums.map(album => albumTrackMap.get(album));
+    }
+
+    return query;
+  },
+
+  relations: (relation, query, _sprawl, album, track) => ({
     sidebar:
       relation('generatePageSidebar'),
 
@@ -17,31 +78,94 @@ export default {
       relation('generateAlbumSidebarTrackListBox', album, track),
 
     groupBoxes:
-      album.groups.map(group =>
-        relation('generateAlbumSidebarGroupBox', album, group)),
+      query.groups
+        .map(group =>
+          relation('generateAlbumSidebarGroupBox', album, group)),
+
+    seriesBoxes:
+      query.groupSerieses
+        .map(serieses => serieses
+          .map(series =>
+            relation('generateAlbumSidebarSeriesBox', album, series))),
+
+    disconnectedSeriesBoxes:
+      query.disconnectedSerieses
+        .map(series =>
+          relation('generateAlbumSidebarSeriesBox', album, series)),
+
+    earlierTrackReleaseBoxes:
+      (track
+        ? query.earlierReleaseTracks
+            .map(track =>
+              relation('generateTrackReleaseBox', track))
+        : null),
+
+    laterTrackReleaseBoxes:
+      (track
+        ? query.laterReleaseTracks
+            .map(track =>
+              relation('generateTrackReleaseBox', track))
+        : null),
   }),
 
-  data: (album, track) => ({
+  data: (_query, _sprawl, _album, track) => ({
     isAlbumPage: !track,
+    isTrackPage: !!track,
   }),
 
-  generate: (data, relations) =>
-    relations.sidebar.slots({
+  generate(data, relations, {html}) {
+    for (const box of [
+      ...relations.groupBoxes,
+      ...relations.seriesBoxes.flat(),
+      ...relations.disconnectedSeriesBoxes,
+    ]) {
+      box.setSlot('mode',
+        data.isAlbumPage ? 'album' : 'track');
+    }
+
+    return relations.sidebar.slots({
       boxes: [
-        data.isAlbumPage &&
-          relations.groupBoxes
-            .map(box => box.slot('mode', 'album')),
+        data.isAlbumPage && [
+          relations.disconnectedSeriesBoxes,
+
+          stitchArrays({
+            groupBox: relations.groupBoxes,
+            seriesBoxes: relations.seriesBoxes,
+          }).map(({groupBox, seriesBoxes}) => [
+              groupBox,
+              seriesBoxes.map(seriesBox => [
+                html.tag('div',
+                  {class: 'sidebar-box-joiner'},
+                  {class: 'collapsible'}),
+                seriesBox,
+              ]),
+            ]),
+        ],
+
+        data.isTrackPage &&
+          relations.earlierTrackReleaseBoxes,
 
         relations.trackListBox,
 
-        !data.isAlbumPage &&
+        data.isTrackPage &&
+          relations.laterTrackReleaseBoxes,
+
+        data.isTrackPage &&
           relations.conjoinedBox.slots({
             attributes: {class: 'conjoined-group-sidebar-box'},
             boxes:
-              relations.groupBoxes
-                .map(box => box.slot('mode', 'track'))
+              ([relations.disconnectedSeriesBoxes,
+                stitchArrays({
+                  groupBox: relations.groupBoxes,
+                  seriesBoxes: relations.seriesBoxes,
+                }).flatMap(({groupBox, seriesBoxes}) => [
+                    groupBox,
+                    ...seriesBoxes,
+                  ]),
+              ]).flat()
                 .map(box => box.content), /* TODO: Kludge. */
           }),
       ],
-    }),
+    });
+  },
 };
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
index 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/generateAlbumSidebarSeriesBox.js b/src/content/dependencies/generateAlbumSidebarSeriesBox.js
new file mode 100644
index 00000000..37616cb2
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarSeriesBox.js
@@ -0,0 +1,102 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkAlbum',
+    'linkGroup',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album, series) {
+    const query = {};
+
+    const albums =
+      series.albums;
+
+    const index =
+      albums.indexOf(album);
+
+    query.previousAlbum =
+      atOffset(albums, index, -1);
+
+    query.nextAlbum =
+      atOffset(albums, index, +1);
+
+    return query;
+  },
+
+  relations: (relation, query, _album, series) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    groupLink:
+      relation('linkGroup', series.group),
+
+    description:
+      relation('transformContent', series.description),
+
+    previousAlbumLink:
+      (query.previousAlbum
+        ? relation('linkAlbum', query.previousAlbum)
+        : null),
+
+    nextAlbumLink:
+      (query.nextAlbum
+        ? relation('linkAlbum', query.nextAlbum)
+        : null),
+  }),
+
+  data: (_query, _album, series) => ({
+    name: series.name,
+  }),
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'track',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('albumSidebar.groupBox', boxCapsule =>
+      relations.box.slots({
+        attributes: {class: 'individual-series-sidebar-box'},
+        content: [
+          html.tag('h1',
+            language.$(boxCapsule, 'title', {
+              group:
+                relations.groupLink.slots({
+                  attributes: {class: 'series'},
+                  content: language.sanitize(data.name),
+                }),
+            })),
+
+          slots.mode === 'album' &&
+            relations.description
+              ?.slot('mode', 'multiline'),
+
+          slots.mode === 'album' &&
+            html.tag('p', {class: 'series-chronology-link'},
+              {[html.onlyIfContent]: true},
+
+              language.$(boxCapsule, 'next', {
+                [language.onlyIfOptions]: ['album'],
+
+                album: relations.nextAlbumLink,
+              })),
+
+          slots.mode === 'album' &&
+            html.tag('p', {class: 'series-chronology-link'},
+              {[html.onlyIfContent]: true},
+
+              language.$(boxCapsule, 'previous', {
+                [language.onlyIfOptions]: ['album'],
+
+                album: relations.previousAlbumLink,
+              })),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
index aa5c723d..dae5fa03 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackSection.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -1,3 +1,5 @@
+import {empty, stitchArrays} from '#sugar';
+
 export default {
   contentDependencies: ['linkTrack'],
   extraDependencies: ['getColors', 'html', 'language'],
@@ -15,23 +17,25 @@ export default {
   data(album, track, trackSection) {
     const data = {};
 
-    data.hasTrackNumbers = album.hasTrackNumbers;
+    data.hasTrackNumbers =
+      album.hasTrackNumbers &&
+      !empty(trackSection.tracks);
+
     data.isTrackPage = !!track;
 
     data.name = trackSection.name;
     data.color = trackSection.color;
     data.isDefaultTrackSection = trackSection.isDefaultTrackSection;
 
-    data.firstTrackNumber = trackSection.startIndex + 1;
-    data.lastTrackNumber = trackSection.startIndex + trackSection.tracks.length;
+    data.firstTrackNumber =
+      (data.hasTrackNumbers
+        ? trackSection.tracks.at(0).trackNumber
+        : null);
 
-    if (track) {
-      const index = trackSection.tracks.indexOf(track);
-      if (index !== -1) {
-        data.includesCurrentTrack = true;
-        data.currentTrackIndex = index;
-      }
-    }
+    data.lastTrackNumber =
+      (data.hasTrackNumbers
+        ? trackSection.tracks.at(-1).trackNumber
+        : null);
 
     data.trackDirectories =
       trackSection.tracks
@@ -39,7 +43,14 @@ export default {
 
     data.tracksAreMissingCommentary =
       trackSection.tracks
-        .map(track => !track.commentary);
+        .map(track => empty(track.commentary));
+
+    data.tracksAreCurrentTrack =
+      trackSection.tracks
+        .map(traaaaaaaack => traaaaaaaack === track);
+
+    data.includesCurrentTrack =
+      data.tracksAreCurrentTrack.includes(true);
 
     return data;
   },
@@ -55,10 +66,12 @@ export default {
   },
 
   generate(data, relations, slots, {getColors, html, language}) {
+    const capsule = language.encapsulate('albumSidebar.trackList');
+
     const sectionName =
-      html.tag('span', {class: 'group-name'},
+      html.tag('b',
         (data.isDefaultTrackSection
-          ? language.$('albumSidebar.trackList.fallbackSectionName')
+          ? language.$(capsule, 'fallbackSectionName')
           : data.name));
 
     let colorStyle;
@@ -68,29 +81,39 @@ export default {
     }
 
     const trackListItems =
-      relations.trackLinks.map((trackLink, index) =>
-        html.tag('li',
-          data.includesCurrentTrack &&
-          index === data.currentTrackIndex &&
-            {class: 'current'},
-
-          slots.mode === 'commentary' &&
-          data.tracksAreMissingCommentary[index] &&
-            {class: 'no-commentary'},
-
-          language.$('albumSidebar.trackList.item', {
-            track:
-              (slots.mode === 'commentary' && data.tracksAreMissingCommentary[index]
-                ? trackLink.slots({
-                    linkless: true,
-                  })
-             : slots.anchor
-                ? trackLink.slots({
-                    anchor: true,
-                    hash: data.trackDirectories[index],
-                  })
-                : trackLink),
-          })));
+      stitchArrays({
+        trackLink: relations.trackLinks,
+        directory: data.trackDirectories,
+        isCurrentTrack: data.tracksAreCurrentTrack,
+        missingCommentary: data.tracksAreMissingCommentary,
+      }).map(({
+          trackLink,
+          directory,
+          isCurrentTrack,
+          missingCommentary,
+        }) =>
+          html.tag('li',
+            data.includesCurrentTrack &&
+            isCurrentTrack &&
+              {class: 'current'},
+
+            slots.mode === 'commentary' &&
+            missingCommentary &&
+              {class: 'no-commentary'},
+
+            language.$(capsule, 'item', {
+              track:
+                (slots.mode === 'commentary' && missingCommentary
+                  ? trackLink.slots({
+                      linkless: true,
+                    })
+               : slots.anchor
+                  ? trackLink.slots({
+                      anchor: true,
+                      hash: directory,
+                    })
+                  : trackLink),
+            })));
 
     return html.tag('details',
       data.includesCurrentTrack &&
@@ -117,14 +140,22 @@ export default {
           colorStyle,
 
           html.tag('span',
-            (data.hasTrackNumbers
-              ? language.$('albumSidebar.trackList.group.withRange', {
-                  group: sectionName,
-                  range: `${data.firstTrackNumber}–${data.lastTrackNumber}`
-                })
-              : language.$('albumSidebar.trackList.group', {
-                  group: sectionName,
-                })))),
+            language.encapsulate(capsule, 'group', groupCapsule =>
+              language.encapsulate(groupCapsule, workingCapsule => {
+                const workingOptions = {group: sectionName};
+
+                if (data.hasTrackNumbers) {
+                  workingCapsule += '.withRange';
+                  workingOptions.rangePart =
+                    html.tag('span', {class: 'track-section-range'},
+                      language.$(groupCapsule, 'withRange.rangePart', {
+                        range:
+                          `${data.firstTrackNumber}–${data.lastTrackNumber}`,
+                      }));
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })))),
 
         (data.hasTrackNumbers
           ? html.tag('ol',
diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js
index c8b123fe..e28a3fd0 100644
--- a/src/content/dependencies/generateAlbumSocialEmbed.js
+++ b/src/content/dependencies/generateAlbumSocialEmbed.js
@@ -6,7 +6,7 @@ export default {
     'generateAlbumSocialEmbedDescription',
   ],
 
-  extraDependencies: ['absoluteTo', 'language', 'urls'],
+  extraDependencies: ['absoluteTo', 'language'],
 
   relations(relation, album) {
     return {
@@ -25,15 +25,14 @@ export default {
 
     if (data.hasHeading) {
       const firstGroup = album.groups[0];
-      data.headingGroupName = firstGroup.directory;
+      data.headingGroupName = firstGroup.name;
       data.headingGroupDirectory = firstGroup.directory;
     }
 
     data.hasImage = album.hasCoverArt;
 
     if (data.hasImage) {
-      data.coverArtDirectory = album.directory;
-      data.coverArtFileExtension = album.coverArtFileExtension;
+      data.imagePath = album.coverArtworks[0].path;
     }
 
     data.albumName = album.name;
@@ -41,34 +40,31 @@ export default {
     return data;
   },
 
-  generate(data, relations, {absoluteTo, language, urls}) {
-    return relations.socialEmbed.slots({
-      title:
-        language.$('albumPage.socialEmbed.title', {
-          album: data.albumName,
-        }),
-
-      description: relations.description,
-
-      headingContent:
-        (data.hasHeading
-          ? language.$('albumPage.socialEmbed.heading', {
-              group: data.headingGroupName,
-            })
-          : null),
-
-      headingLink:
-        (data.hasHeading
-          ? absoluteTo('localized.groupGallery', data.headingGroupDirectory)
-          : null),
-
-      imagePath:
-        (data.hasImage
-          ? '/' +
-            urls
-              .from('shared.root')
-              .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension)
-          : null),
-    });
-  },
+  generate: (data, relations, {absoluteTo, language}) =>
+    language.encapsulate('albumPage.socialEmbed', embedCapsule =>
+      relations.socialEmbed.slots({
+        title:
+          language.$(embedCapsule, 'title', {
+            album: data.albumName,
+          }),
+
+        description: relations.description,
+
+        headingContent:
+          (data.hasHeading
+            ? language.$(embedCapsule, 'heading', {
+                group: data.headingGroupName,
+              })
+            : null),
+
+        headingLink:
+          (data.hasHeading
+            ? absoluteTo('localized.groupGallery', data.headingGroupDirectory)
+            : null),
+
+        imagePath:
+          (data.hasImage
+            ? data.imagePath
+            : null),
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumSocialEmbedDescription.js b/src/content/dependencies/generateAlbumSocialEmbedDescription.js
index 7099616a..69c39c3a 100644
--- a/src/content/dependencies/generateAlbumSocialEmbedDescription.js
+++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js
@@ -3,46 +3,39 @@ import {accumulateSum} from '#sugar';
 export default {
   extraDependencies: ['language'],
 
-  data(album) {
-    const data = {};
-
-    const duration = accumulateSum(album.tracks, track => track.duration);
-
-    data.hasDuration = duration > 0;
-    data.hasTracks = album.tracks.length > 0;
-    data.hasDate = !!album.date;
-    data.hasAny = (data.hasDuration || data.hasTracks || data.hasDuration);
-
-    if (!data.hasAny)
-      return data;
-
-    if (data.hasDuration)
-      data.duration = duration;
-
-    if (data.hasTracks)
-      data.tracks = album.tracks.length;
-
-    if (data.hasDate)
-      data.date = album.date;
-
-    return data;
-  },
-
-  generate(data, {language}) {
-    return language.formatString(
-      'albumPage.socialEmbed.body' + [
-        data.hasDuration && '.withDuration',
-        data.hasTracks && '.withTracks',
-        data.hasDate && '.withReleaseDate',
-      ].filter(Boolean).join(''),
-
-      Object.fromEntries([
-        data.hasDuration &&
-          ['duration', language.formatDuration(data.duration)],
-        data.hasTracks &&
-          ['tracks', language.countTracks(data.tracks, {unit: true})],
-        data.hasDate &&
-          ['date', language.formatDate(data.date)],
-      ].filter(Boolean)));
-  },
+  data: (album) => ({
+    duration:
+      accumulateSum(album.tracks, track => track.duration),
+
+    tracks:
+      album.tracks.length,
+
+    date:
+      album.date,
+  }),
+
+  generate: (data, {language}) =>
+    language.encapsulate('albumPage.socialEmbed.body', workingCapsule => {
+      const workingOptions = {};
+
+      if (data.duration > 0) {
+        workingCapsule += '.withDuration';
+        workingOptions.duration =
+          language.formatDuration(data.duration);
+      }
+
+      if (data.tracks > 0) {
+        workingCapsule += '.withTracks';
+        workingOptions.tracks =
+          language.countTracks(data.tracks, {unit: true});
+      }
+
+      if (data.date) {
+        workingCapsule += '.withReleaseDate';
+        workingOptions.date =
+          language.formatDate(data.date);
+      }
+
+      return language.$(workingCapsule, workingOptions);
+    }),
 };
diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js
index c5acf374..6bfcc62e 100644
--- a/src/content/dependencies/generateAlbumStyleRules.js
+++ b/src/content/dependencies/generateAlbumStyleRules.js
@@ -1,4 +1,4 @@
-import {empty} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   extraDependencies: ['to'],
@@ -10,8 +10,22 @@ export default {
     data.hasBanner = !empty(album.bannerArtistContribs);
 
     if (data.hasWallpaper) {
-      data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension];
-      data.wallpaperStyle = album.wallpaperStyle;
+      if (!empty(album.wallpaperParts)) {
+        data.wallpaperMode = 'parts';
+
+        data.wallpaperPaths =
+          album.wallpaperParts.map(part =>
+            (part.asset
+              ? ['media.albumWallpaperPart', album.directory, part.asset]
+              : null));
+
+        data.wallpaperStyles =
+          album.wallpaperParts.map(part => part.style);
+      } else {
+        data.wallpaperMode = 'one';
+        data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension];
+        data.wallpaperStyle = album.wallpaperStyle;
+      }
     }
 
     if (data.hasBanner) {
@@ -42,13 +56,34 @@ export default {
         ? [`${selector} {`, indent(parts), `}`]
         : []);
 
-    const wallpaperRule =
-      data.hasWallpaper &&
+    const oneWallpaperRule =
+      data.wallpaperMode === 'one' &&
         rule(`body::before`, [
           `background-image: url("${to(...data.wallpaperPath)}");`,
           data.wallpaperStyle,
         ]);
 
+    const wallpaperPartRules =
+      data.wallpaperMode === 'parts' &&
+        stitchArrays({
+          path: data.wallpaperPaths,
+          style: data.wallpaperStyles,
+        }).map(({path, style}, index) =>
+            rule(`.wallpaper-part:nth-child(${index + 1})`, [
+              path && `background-image: url("${to(...path)}");`,
+              style,
+            ]));
+
+    const nukeBasicWallpaperRule =
+      data.wallpaperMode === 'parts' &&
+        rule(`body::before`, ['display: none']);
+
+    const wallpaperRules = [
+      oneWallpaperRule,
+      ...wallpaperPartRules || [],
+      nukeBasicWallpaperRule,
+    ];
+
     const bannerRule =
       data.hasBanner &&
         rule(`#banner img`, [
@@ -64,7 +99,7 @@ export default {
       ]);
 
     return (
-      [wallpaperRule, bannerRule, dataRule]
+      [...wallpaperRules, bannerRule, dataRule]
         .filter(Boolean)
         .flat()
         .join('\n'));
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
index ee06b9e6..0a949ded 100644
--- a/src/content/dependencies/generateAlbumTrackList.js
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -35,7 +35,12 @@ function getDisplayMode(album) {
 }
 
 export default {
-  contentDependencies: ['generateAlbumTrackListItem', 'generateContentHeading'],
+  contentDependencies: [
+    'generateAlbumTrackListItem',
+    'generateContentHeading',
+    'transformContent',
+  ],
+
   extraDependencies: ['html', 'language'],
 
   query(album) {
@@ -53,6 +58,10 @@ export default {
           album.trackSections.map(() =>
             relation('generateContentHeading'));
 
+        relations.trackSectionDescriptions =
+          album.trackSections.map(section =>
+            relation('transformContent', section.description));
+
         relations.trackSectionItems =
           album.trackSections.map(section =>
             section.tracks.map(track =>
@@ -93,11 +102,11 @@ export default {
             .map(section => section.tracks.length > 1);
 
         if (album.hasTrackNumbers) {
-          data.trackSectionStartIndices =
+          data.trackSectionsStartCountingFrom =
             album.trackSections
-              .map(section => section.startIndex);
+              .map(section => section.startCountingFrom);
         } else {
-          data.trackSectionStartIndices =
+          data.trackSectionsStartCountingFrom =
             album.trackSections
               .map(() => null);
         }
@@ -132,43 +141,59 @@ export default {
         return html.tag('dl', {class: 'album-group-list'},
           stitchArrays({
             heading: relations.trackSectionHeadings,
+            description: relations.trackSectionDescriptions,
             items: relations.trackSectionItems,
 
             name: data.trackSectionNames,
             duration: data.trackSectionDurations,
             durationApproximate: data.trackSectionDurationsApproximate,
-            startIndex: data.trackSectionStartIndices,
+            startCountingFrom: data.trackSectionsStartCountingFrom,
           }).map(({
               heading,
+              description,
               items,
 
               name,
               duration,
               durationApproximate,
-              startIndex,
+              startCountingFrom,
             }) => [
-              heading.slots({
-                tag: 'dt',
-                title:
-                  (duration === 0
-                    ? language.$('trackList.section', {
-                        section: name,
-                      })
-                    : language.$('trackList.section.withDuration', {
-                        section: name,
-                        duration:
+              language.encapsulate('trackList.section', capsule =>
+                heading.slots({
+                  tag: 'dt',
+
+                  title:
+                    language.encapsulate(capsule, capsule => {
+                      const options = {section: name};
+
+                      if (duration !== 0) {
+                        capsule += '.withDuration';
+                        options.duration =
                           language.formatDuration(duration, {
                             approximate: durationApproximate,
-                          }),
-                      })),
-              }),
+                          });
+                      }
+
+                      return language.$(capsule, options);
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky', {
+                      section: name,
+                    }),
+                })),
+
+              html.tag('dd', [
+                html.tag('blockquote',
+                  {[html.onlyIfContent]: true},
+                  description),
 
-              html.tag('dd',
                 html.tag(listTag,
                   data.hasTrackNumbers &&
-                    {start: startIndex + 1},
+                    {start: startCountingFrom},
 
-                  slotItems(items))),
+                  slotItems(items)),
+              ]),
             ]));
 
       case 'tracks':
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
index 18980740..44297c15 100644
--- a/src/content/dependencies/generateAlbumTrackListItem.js
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -1,75 +1,36 @@
-import {compareArrays, empty} from '#sugar';
-
 export default {
-  contentDependencies: [
-    'generateAlbumTrackListMissingDuration',
-    'linkContribution',
-    'linkTrack',
-  ],
-
-  extraDependencies: ['getColors', 'html', 'language'],
-
-  query(track, album) {
-    const query = {};
+  contentDependencies: ['generateTrackListItem'],
+  extraDependencies: ['html'],
 
-    query.duration = track.duration ?? 0;
+  query: (track, album) => ({
+    trackHasDuration:
+      !!track.duration,
 
-    query.trackHasDuration = !!track.duration;
-
-    query.sectionHasDuration =
+    sectionHasDuration:
       !album.trackSections
         .some(section =>
           section.tracks.every(track => !track.duration) &&
-          section.tracks.includes(track));
-
-    query.albumHasDuration =
-      album.tracks.some(track => track.duration);
-
-    return query;
-  },
-
-  relations(relation, query, track) {
-    const relations = {};
-
-    if (!empty(track.artistContribs)) {
-      relations.contributionLinks =
-        track.artistContribs
-          .map(contrib => relation('linkContribution', contrib));
-    }
+          section.tracks.includes(track)),
 
-    relations.trackLink =
-      relation('linkTrack', track);
-
-    if (!query.trackHasDuration) {
-      relations.missingDuration =
-        relation('generateAlbumTrackListMissingDuration');
-    }
-
-    return relations;
-  },
+    albumHasDuration:
+      album.tracks.some(track => track.duration),
+  }),
 
-  data(query, track, album) {
-    const data = {};
+  relations: (relation, query, track) => ({
+    item:
+      relation('generateTrackListItem',
+        track,
+        track.album.artistContribs),
+  }),
 
-    data.duration = query.duration;
-    data.trackHasDuration = query.trackHasDuration;
-    data.sectionHasDuration = query.sectionHasDuration;
-    data.albumHasDuration = query.albumHasDuration;
+  data: (query, track, album) => ({
+    trackHasDuration: query.trackHasDuration,
+    sectionHasDuration: query.sectionHasDuration,
+    albumHasDuration: query.albumHasDuration,
 
-    if (track.color !== album.color) {
-      data.color = track.color;
-    }
-
-    data.showArtists =
-      !empty(track.artistContribs) &&
-       (empty(album.artistContribs) ||
-        !compareArrays(
-          track.artistContribs.map(c => c.who),
-          album.artistContribs.map(c => c.who),
-          {checkOrder: false}));
-
-    return data;
-  },
+    colorize:
+      track.color !== album.color,
+  }),
 
   slots: {
     collapseDurationScope: {
@@ -80,54 +41,22 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {getColors, html, language}) {
-    let colorStyle;
-    if (data.color) {
-      const {primary} = getColors(data.color);
-      colorStyle = {style: `--primary-color: ${primary}`};
-    }
-
-    const parts = ['trackList.item'];
-    const options = {};
-
-    options.track =
-      relations.trackLink
-        .slot('color', false);
-
-    const collapseDuration =
-      (slots.collapseDurationScope === 'track'
-        ? !data.trackHasDuration
-     : slots.collapseDurationScope === 'section'
-        ? !data.sectionHasDuration
-     : slots.collapseDurationScope === 'album'
-        ? !data.albumHasDuration
-        : false);
-
-    if (!collapseDuration) {
-      parts.push('withDuration');
-
-      options.duration =
-        (data.trackHasDuration
-          ? language.$('trackList.item.withDuration.duration', {
-              duration:
-                language.formatDuration(data.duration),
-            })
-          : relations.missingDuration);
-    }
-
-    if (data.showArtists) {
-      parts.push('withArtists');
-      options.by =
-        html.tag('span', {class: 'by'},
-          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) =>
+    relations.item.slots({
+      showArtists: true,
+
+      showDuration:
+        (slots.collapseDurationScope === 'track'
+          ? data.trackHasDuration
+       : slots.collapseDurationScope === 'section'
+          ? data.sectionHasDuration
+       : slots.collapseDurationScope === 'album'
+          ? data.albumHasDuration
+          : true),
+
+      colorMode:
+        (data.colorize
+          ? 'line'
+          : 'none'),
+    }),
 };
diff --git a/src/content/dependencies/generateAlbumTrackListMissingDuration.js b/src/content/dependencies/generateAlbumTrackListMissingDuration.js
deleted file mode 100644
index 6d4a6ec8..00000000
--- a/src/content/dependencies/generateAlbumTrackListMissingDuration.js
+++ /dev/null
@@ -1,33 +0,0 @@
-export default {
-  contentDependencies: ['generateTextWithTooltip', 'generateTooltip'],
-  extraDependencies: ['html', 'language'],
-
-  relations: (relation) => ({
-    textWithTooltip:
-      relation('generateTextWithTooltip'),
-
-    tooltip:
-      relation('generateTooltip'),
-  }),
-
-  generate: (relations, {html, language}) =>
-    relations.textWithTooltip.slots({
-      attributes: {class: 'missing-duration'},
-      customInteractionCue: true,
-
-      text:
-        language.$('trackList.item.withDuration.duration', {
-          duration:
-            html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
-              language.$('trackList.item.withDuration.duration.missing')),
-        }),
-
-      tooltip:
-        relations.tooltip.slots({
-          attributes: {class: 'missing-duration-tooltip'},
-
-          content:
-            language.$('trackList.item.withDuration.duration.missing.info'),
-        }),
-    }),
-};
diff --git a/src/content/dependencies/generateArtTagAncestorDescendantMapList.js b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
new file mode 100644
index 00000000..80d19b5a
--- /dev/null
+++ b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
@@ -0,0 +1,153 @@
+import {
+  filterMultipleArrays,
+  sortMultipleArrays,
+  stitchArrays,
+  unique,
+} from '#sugar';
+
+export default {
+  contentDependencies: ['linkArtTagDynamically'],
+  extraDependencies: ['html', 'language'],
+
+  // Recursion ain't too pretty!
+
+  query(ancestorArtTag, targetArtTag) {
+    const recursive = artTag => {
+      const artTags =
+        artTag.directDescendantArtTags.slice();
+
+      const displayBriefly =
+        !artTags.includes(targetArtTag) &&
+        artTags.length > 3;
+
+      const artTagsIncludeTargetArtTag =
+        artTags.map(artTag => artTag.allDescendantArtTags.includes(targetArtTag));
+
+      const numExemptArtTags =
+        (displayBriefly
+          ? artTagsIncludeTargetArtTag
+              .filter(includesTargetArtTag => !includesTargetArtTag)
+              .length
+          : null);
+
+      const artTagsTimesFeaturedTotal =
+        artTags.map(artTag =>
+          unique([
+            ...artTag.directlyFeaturedInArtworks,
+            ...artTag.indirectlyFeaturedInArtworks,
+          ]).length);
+
+      const sublists =
+        stitchArrays({
+          artTag: artTags,
+          includesTargetArtTag: artTagsIncludeTargetArtTag,
+        }).map(({artTag, includesTargetArtTag}) =>
+            (includesTargetArtTag
+              ? recursive(artTag)
+              : null));
+
+      if (displayBriefly) {
+        filterMultipleArrays(artTags, sublists, artTagsTimesFeaturedTotal,
+          (artTag, sublist) =>
+            artTag === targetArtTag ||
+            sublist !== null);
+      } else {
+        sortMultipleArrays(artTags, sublists, artTagsTimesFeaturedTotal,
+          (artTagA, artTagB, sublistA, sublistB) =>
+            (sublistA && sublistB
+              ? 0
+           : !sublistA && !sublistB
+              ? 0
+           : sublistA
+              ? 1
+              : -1));
+      }
+
+      return {
+        displayBriefly,
+        numExemptArtTags,
+        artTags,
+        artTagsTimesFeaturedTotal,
+        sublists,
+      };
+    };
+
+    return {root: recursive(ancestorArtTag)};
+  },
+
+  relations(relation, query, _ancestorArtTag, _targetArtTag) {
+    const recursive = ({artTags, sublists}) => ({
+      artTagLinks:
+        artTags
+          .map(artTag => relation('linkArtTagDynamically', artTag)),
+
+      sublists:
+        sublists
+          .map(sublist => (sublist ? recursive(sublist) : null)),
+    });
+
+    return {root: recursive(query.root)};
+  },
+
+  data(query, _ancestorArtTag, targetArtTag) {
+    const recursive = ({
+      displayBriefly,
+      numExemptArtTags,
+      artTags,
+      artTagsTimesFeaturedTotal,
+      sublists,
+    }) => ({
+      displayBriefly,
+      numExemptArtTags,
+      artTagsTimesFeaturedTotal,
+
+      artTagsAreTargetTag:
+        artTags
+          .map(artTag => artTag === targetArtTag),
+
+      sublists:
+        sublists
+          .map(sublist => (sublist ? recursive(sublist) : null)),
+    });
+
+    return {root: recursive(query.root)};
+  },
+
+  generate(data, relations, {html, language}) {
+    const recursive = (dataNode, relationsNode) =>
+      html.tag('dl', {class: dataNode === data.root && 'tree-list'}, [
+        dataNode.displayBriefly &&
+          html.tag('dt',
+            language.$('artTagPage.sidebar.otherTagsExempt', {
+              tags:
+                language.countArtTags(dataNode.numExemptArtTags, {unit: true}),
+            })),
+
+        stitchArrays({
+          isTargetTag: dataNode.artTagsAreTargetTag,
+          timesFeaturedTotal: dataNode.artTagsTimesFeaturedTotal,
+          dataSublist: dataNode.sublists,
+
+          artTagLink: relationsNode.artTagLinks,
+          relationsSublist: relationsNode.sublists,
+        }).map(({
+            isTargetTag, timesFeaturedTotal, dataSublist,
+            artTagLink, relationsSublist,
+          }) => [
+            html.tag('dt',
+              {class: (dataSublist || isTargetTag) && 'current'},
+              [
+                artTagLink,
+                html.tag('span', {class: 'times-used'},
+                  language.countTimesFeatured(timesFeaturedTotal)),
+              ]),
+
+            dataSublist &&
+              html.tag('dd',
+                recursive(dataSublist, relationsSublist)),
+          ]),
+      ]);
+
+    return recursive(data.root, relations.root);
+  },
+};
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
index 338d18fe..344e7bda 100644
--- a/src/content/dependencies/generateArtTagGalleryPage.js
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -1,14 +1,19 @@
-import {sortAlbumsTracksChronologically} from '#sort';
-import {stitchArrays} from '#sugar';
+import {sortArtworksChronologically} from '#sort';
+import {empty, unique} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateArtTagGalleryPageFeaturedLine',
+    'generateArtTagGalleryPageShowingLine',
+    'generateArtTagNavLinks',
     'generateCoverGrid',
     'generatePageLayout',
+    'generateQuickDescription',
     'image',
-    'linkAlbum',
-    'linkArtTag',
-    'linkTrack',
+    'linkAnythingMan',
+    'linkArtTagGallery',
+    'linkExternal',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
@@ -19,135 +24,199 @@ export default {
     };
   },
 
-  query(sprawl, tag) {
-    const things = tag.taggedInThings.slice();
+  query(sprawl, artTag) {
+    const directArtworks = artTag.directlyFeaturedInArtworks;
+    const indirectArtworks = artTag.indirectlyFeaturedInArtworks;
+    const allArtworks = unique([...directArtworks, ...indirectArtworks]);
 
-    sortAlbumsTracksChronologically(things, {
-      getDate: thing => thing.coverArtDate ?? thing.date,
-      latestFirst: true,
-    });
+    sortArtworksChronologically(allArtworks, {latestFirst: true});
 
-    return {things};
+    return {directArtworks, indirectArtworks, allArtworks};
   },
 
-  relations(relation, query, sprawl, tag) {
+  relations(relation, query, sprawl, artTag) {
     const relations = {};
 
     relations.layout =
       relation('generatePageLayout');
 
-    relations.artTagMainLink =
-      relation('linkArtTag', tag);
+    relations.navLinks =
+      relation('generateArtTagNavLinks', artTag);
+
+    relations.additionalNamesBox =
+      relation('generateAdditionalNamesBox', artTag.additionalNames);
+
+    relations.quickDescription =
+      relation('generateQuickDescription', artTag);
+
+    relations.featuredLine =
+      relation('generateArtTagGalleryPageFeaturedLine');
+
+    relations.showingLine =
+      relation('generateArtTagGalleryPageShowingLine');
+
+    if (!empty(artTag.extraReadingURLs)) {
+      relations.extraReadingLinks =
+        artTag.extraReadingURLs
+          .map(url => relation('linkExternal', url));
+    }
+
+    if (!empty(artTag.directAncestorArtTags)) {
+      relations.ancestorLinks =
+        artTag.directAncestorArtTags
+          .map(artTag => relation('linkArtTagGallery', artTag));
+    }
+
+    if (!empty(artTag.directDescendantArtTags)) {
+      relations.descendantLinks =
+        artTag.directDescendantArtTags
+          .map(artTag => relation('linkArtTagGallery', artTag));
+    }
 
     relations.coverGrid =
       relation('generateCoverGrid');
 
     relations.links =
-      query.things.map(thing =>
-        (thing.album
-          ? relation('linkTrack', thing)
-          : relation('linkAlbum', thing)));
+      query.allArtworks
+        .map(artwork => relation('linkAnythingMan', artwork.thing));
 
     relations.images =
-      query.things.map(thing =>
-        relation('image', thing.artTags));
+      query.allArtworks
+        .map(artwork => relation('image', artwork));
 
     return relations;
   },
 
-  data(query, sprawl, tag) {
+  data(query, sprawl, artTag) {
     const data = {};
 
     data.enableListings = sprawl.enableListings;
 
-    data.name = tag.name;
-    data.color = tag.color;
+    data.name = artTag.name;
+    data.color = artTag.color;
 
-    data.numArtworks = query.things.length;
+    data.numArtworksIndirectly = query.indirectArtworks.length;
+    data.numArtworksDirectly = query.directArtworks.length;
+    data.numArtworksTotal = query.allArtworks.length;
 
     data.names =
-      query.things.map(thing => thing.name);
+      query.allArtworks
+        .map(artwork => artwork.thing.name);
 
-    data.paths =
-      query.things.map(thing =>
-        (thing.album
-          ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
-          : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
+    data.coverArtists =
+      query.allArtworks
+        .map(artwork => artwork.artistContribs
+          .map(contrib => contrib.artist.name));
 
-    data.dimensions =
-      query.things.map(thing => thing.coverArtDimensions);
+    data.onlyFeaturedIndirectly =
+      query.allArtworks.map(artwork =>
+        !query.directArtworks.includes(artwork));
 
-    data.coverArtists =
-      query.things.map(thing =>
-        thing.coverArtistContribs
-          .map(({who: artist}) => artist.name));
+    data.hasMixedDirectIndirect =
+      data.onlyFeaturedIndirectly.includes(true) &&
+      data.onlyFeaturedIndirectly.includes(false);
 
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artTagGalleryPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('tagPage.title', {
+          language.$(pageCapsule, 'title', {
             tag: data.name,
           }),
 
         headingMode: 'static',
-
         color: data.color,
 
+        additionalNames: relations.additionalNamesBox,
+
         mainClasses: ['top-index'],
         mainContent: [
-          html.tag('p', {class: 'quick-info'},
-            language.$('tagPage.infoLine', {
-              coverArts: language.countCoverArts(data.numArtworks, {
-                unit: true,
+          relations.quickDescription.slots({
+            extraReadingLinks: relations.extraReadingLinks ?? null,
+          }),
+
+          data.numArtworksTotal === 0 &&
+            html.tag('p', {class: 'quick-info'},
+              language.encapsulate(pageCapsule, 'featuredLine.notFeatured', capsule => [
+                language.$(capsule),
+                html.tag('br'),
+                language.$(capsule, 'callToAction'),
+              ])),
+
+          data.numArtworksTotal >= 1 &&
+            relations.featuredLine.clone()
+              .slots({
+                showing: 'all',
+                count: data.numArtworksTotal,
               }),
-            })),
+
+          data.hasMixedDirectIndirect && [
+            relations.featuredLine.clone()
+              .slots({
+                showing: 'direct',
+                count: data.numArtworksDirectly,
+              }),
+
+            relations.featuredLine.clone()
+              .slots({
+                showing: 'indirect',
+                count: data.numArtworksIndirectly,
+              }),
+          ],
+
+          relations.ancestorLinks &&
+            html.tag('p', {id: 'descends-from-line'},
+              {class: 'quick-info'},
+              language.$(pageCapsule, 'descendsFrom', {
+                tags: language.formatUnitList(relations.ancestorLinks),
+              })),
+
+          relations.descendantLinks &&
+            html.tag('p', {id: 'descendants-line'},
+              {class: 'quick-info'},
+              language.$(pageCapsule, 'descendants', {
+                tags: language.formatUnitList(relations.descendantLinks),
+              })),
+
+          data.hasMixedDirectIndirect && [
+            relations.showingLine.clone()
+              .slot('showing', 'all'),
+
+            relations.showingLine.clone()
+              .slot('showing', 'direct'),
+
+            relations.showingLine.clone()
+              .slot('showing', 'indirect'),
+          ],
 
           relations.coverGrid
             .slots({
               links: relations.links,
+              images: relations.images,
               names: data.names,
-              images:
-                stitchArrays({
-                  image: relations.images,
-                  path: data.paths,
-                  dimensions: data.dimensions,
-                }).map(({image, path, dimensions}) =>
-                    image.slots({
-                      path,
-                      dimensions,
-                    })),
+              lazy: 12,
+
+              classes:
+                data.onlyFeaturedIndirectly.map(onlyFeaturedIndirectly =>
+                  (onlyFeaturedIndirectly ? 'featured-indirectly' : '')),
 
               info:
                 data.coverArtists.map(names =>
                   (names === null
                     ? null
-                    : language.$('misc.albumGrid.details.coverArtists', {
+                    : language.$('misc.coverGrid.details.coverArtists', {
                         artists: language.formatUnitList(names),
                       }))),
             }),
         ],
 
         navLinkStyle: 'hierarchical',
-        navLinks: [
-          {auto: 'home'},
-
-          data.enableListings &&
-            {
-              path: ['localized.listingIndex'],
-              title: language.$('listingIndex.title'),
-            },
-
-          {
-            html:
-              language.$('tagPage.nav.tag', {
-                tag: relations.artTagMainLink,
-              }),
-          },
-        ],
-      });
-  },
+        navLinks:
+          html.resolve(
+            relations.navLinks
+              .slot('currentExtra', 'gallery')),
+      })),
 };
diff --git a/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js
new file mode 100644
index 00000000..b4620fa4
--- /dev/null
+++ b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js
@@ -0,0 +1,23 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    showing: {
+      validate: v => v.is('all', 'direct', 'indirect'),
+    },
+
+    count: {type: 'number'},
+  },
+
+  generate: (slots, {html, language}) =>
+    language.encapsulate('artTagGalleryPage', pageCapsule =>
+      html.tag('p', {class: 'quick-info'},
+        {id: `featured-${slots.showing}-line`},
+
+        language.$(pageCapsule, 'featuredLine', slots.showing, {
+          coverArts:
+            language.countArtworks(slots.count, {
+              unit: true,
+            }),
+        }))),
+};
diff --git a/src/content/dependencies/generateArtTagGalleryPageShowingLine.js b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js
new file mode 100644
index 00000000..6df4d0e5
--- /dev/null
+++ b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js
@@ -0,0 +1,22 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    showing: {
+      validate: v => v.is('all', 'direct', 'indirect'),
+    },
+
+    count: {type: 'number'},
+  },
+
+  generate: (slots, {html, language}) =>
+    language.encapsulate('artTagGalleryPage', pageCapsule =>
+      html.tag('p', {class: 'quick-info'},
+        {id: `showing-${slots.showing}-line`},
+
+        language.$(pageCapsule, 'showingLine', {
+          showing:
+            html.tag('a', {href: '#'},
+              language.$(pageCapsule, 'showingLine', slots.showing)),
+        }))),
+};
diff --git a/src/content/dependencies/generateArtTagInfoPage.js b/src/content/dependencies/generateArtTagInfoPage.js
new file mode 100644
index 00000000..9df51b77
--- /dev/null
+++ b/src/content/dependencies/generateArtTagInfoPage.js
@@ -0,0 +1,281 @@
+import {empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateArtTagNavLinks',
+    'generateArtTagSidebar',
+    'generateContentHeading',
+    'generatePageLayout',
+    'linkArtTagGallery',
+    'linkArtTagInfo',
+    'linkExternal',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    enableListings: wikiInfo.enableListings,
+  }),
+
+  query(sprawl, artTag) {
+    const query = {};
+
+    query.directThings =
+      artTag.directlyFeaturedInArtworks;
+
+    query.indirectThings =
+      artTag.indirectlyFeaturedInArtworks;
+
+    query.allThings =
+      unique([...query.directThings, ...query.indirectThings]);
+
+    query.allDescendantsHaveMoreDescendants =
+      artTag.directDescendantArtTags
+        .every(descendant => !empty(descendant.directDescendantArtTags));
+
+    return query;
+  },
+
+  relations: (relation, query, sprawl, artTag) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    navLinks:
+      relation('generateArtTagNavLinks', artTag),
+
+    sidebar:
+      relation('generateArtTagSidebar', artTag),
+
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', artTag.additionalNames),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    description:
+      relation('transformContent', artTag.description),
+
+    galleryLink:
+      (empty(query.allThings)
+        ? null
+        : relation('linkArtTagGallery', artTag)),
+
+    extraReadingLinks:
+      artTag.extraReadingURLs
+        .map(url => relation('linkExternal', url)),
+
+    relatedArtTagLinks:
+      artTag.relatedArtTags
+        .map(({artTag}) => relation('linkArtTagInfo', artTag)),
+
+    directAncestorLinks:
+      artTag.directAncestorArtTags
+        .map(artTag => relation('linkArtTagInfo', artTag)),
+
+    directDescendantInfoLinks:
+      artTag.directDescendantArtTags
+        .map(artTag => relation('linkArtTagInfo', artTag)),
+
+    directDescendantGalleryLinks:
+      artTag.directDescendantArtTags.map(artTag =>
+        (query.allDescendantsHaveMoreDescendants
+          ? null
+          : relation('linkArtTagGallery', artTag))),
+  }),
+
+  data: (query, sprawl, artTag) => ({
+    enableListings:
+      sprawl.enableListings,
+
+    name:
+      artTag.name,
+
+    color:
+      artTag.color,
+
+    numArtworksIndirectly:
+      query.indirectThings.length,
+
+    numArtworksDirectly:
+      query.directThings.length,
+
+    numArtworksTotal:
+      query.allThings.length,
+
+    relatedArtTagAnnotations:
+      artTag.relatedArtTags
+        .map(({annotation}) => annotation),
+
+    directDescendantTimesFeaturedTotal:
+      artTag.directDescendantArtTags.map(artTag =>
+        unique([
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
+        ]).length),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artTagInfoPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            tag: language.sanitize(data.name),
+          }),
+
+        headingMode: 'sticky',
+        color: data.color,
+
+        additionalNames: relations.additionalNamesBox,
+
+        mainContent: [
+          html.tag('p',
+            language.encapsulate(pageCapsule, 'featuredIn', capsule =>
+              (data.numArtworksTotal === 0
+                ? language.$(capsule, 'notFeatured')
+
+             : data.numArtworksDirectly === 0
+                ? language.$(capsule, 'indirectlyOnly', {
+                    artworks:
+                      language.countArtworks(data.numArtworksIndirectly, {unit: true}),
+                  })
+
+             : data.numArtworksIndirectly === 0
+                ? language.$(capsule, 'directlyOnly', {
+                    artworks:
+                      language.countArtworks(data.numArtworksDirectly, {unit: true}),
+                  })
+
+                : language.$(capsule, 'directlyAndIndirectly', {
+                    artworksDirectly:
+                      language.countArtworks(data.numArtworksDirectly, {unit: true}),
+
+                    artworksIndirectly:
+                      language.countArtworks(data.numArtworksIndirectly, {unit: false}),
+
+                    artworksTotal:
+                      language.countArtworks(data.numArtworksTotal, {unit: false}),
+                  })))),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$(pageCapsule, 'viewArtGallery', {
+              [language.onlyIfOptions]: ['link'],
+
+              link:
+                relations.galleryLink
+                  ?.slot('content', language.$(pageCapsule, 'viewArtGallery.link')),
+            })),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.encapsulate(pageCapsule, 'seeAlso', capsule =>
+              language.$(capsule, {
+                [language.onlyIfOptions]: ['tags'],
+
+                tags:
+                  language.formatUnitList(
+                    stitchArrays({
+                      artTagLink: relations.relatedArtTagLinks,
+                      annotation: data.relatedArtTagAnnotations,
+                    }).map(({artTagLink, annotation}) =>
+                        (html.isBlank(annotation)
+                          ? artTagLink
+                          : language.$(capsule, 'tagWithAnnotation', {
+                              tag: artTagLink,
+                              annotation,
+                            })))),
+              }))),
+
+          html.tag('blockquote',
+            {[html.onlyIfContent]: true},
+
+            relations.description
+              .slot('mode', 'multiline')),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$(pageCapsule, 'readMoreOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              tag: language.sanitize(data.name),
+              links: language.formatDisjunctionList(relations.extraReadingLinks),
+            })),
+
+          language.encapsulate(pageCapsule, 'descendsFromTags', listCapsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  title:
+                    language.$(listCapsule, {
+                      tag: language.sanitize(data.name),
+                    }),
+                }),
+
+              html.tag('ul',
+                {[html.onlyIfContent]: true},
+
+                relations.directAncestorLinks
+                  .map(link =>
+                    html.tag('li',
+                      language.$(listCapsule, 'item', {
+                        tag: link,
+                      })))),
+            ])),
+
+          language.encapsulate(pageCapsule, 'descendantTags', listCapsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  title:
+                    language.$(listCapsule, {
+                      tag: language.sanitize(data.name),
+                    }),
+                }),
+
+              html.tag('ul',
+                {[html.onlyIfContent]: true},
+
+                stitchArrays({
+                  infoLink: relations.directDescendantInfoLinks,
+                  galleryLink: relations.directDescendantGalleryLinks,
+                  timesFeaturedTotal: data.directDescendantTimesFeaturedTotal,
+                }).map(({infoLink, galleryLink, timesFeaturedTotal}) =>
+                    html.tag('li',
+                      language.encapsulate(listCapsule, 'item', itemCapsule =>
+                        language.encapsulate(itemCapsule, workingCapsule => {
+                          const workingOptions = {};
+
+                          workingOptions.tag = infoLink;
+
+                          if (!html.isBlank(galleryLink ?? html.blank())) {
+                            workingCapsule += '.withGallery';
+                            workingOptions.gallery =
+                              galleryLink.slot('content',
+                                language.$(itemCapsule, 'withGallery.gallery'));
+                          }
+
+                          if (timesFeaturedTotal >= 1) {
+                            workingCapsule += `.withTimesUsed`;
+                            workingOptions.timesUsed =
+                              language.countTimesFeatured(timesFeaturedTotal, {
+                                unit: true,
+                              });
+                          }
+
+                          return language.$(workingCapsule, workingOptions);
+                        }))))),
+            ])),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: relations.navLinks.content,
+
+        leftSidebar:
+          relations.sidebar,
+      })),
+};
diff --git a/src/content/dependencies/generateArtTagNavLinks.js b/src/content/dependencies/generateArtTagNavLinks.js
new file mode 100644
index 00000000..9061a09f
--- /dev/null
+++ b/src/content/dependencies/generateArtTagNavLinks.js
@@ -0,0 +1,81 @@
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'linkArtTagInfo',
+    'linkArtTagGallery',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) =>
+    ({enableListings: wikiInfo.enableListings}),
+
+  relations: (relation, sprawl, tag) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    mainLink:
+      relation('linkArtTagInfo', tag),
+
+    infoLink:
+      relation('linkArtTagInfo', tag),
+
+    galleryLink:
+      relation('linkArtTagGallery', tag),
+  }),
+
+  data: (sprawl) =>
+    ({enableListings: sprawl.enableListings}),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(data, relations, slots, {language}) {
+    if (!data.enableListings) {
+      return [
+        {auto: 'home'},
+        {auto: 'current'},
+      ];
+    }
+
+    const infoLink =
+      relations.infoLink.slots({
+        attributes: {class: slots.currentExtra === null && 'current'},
+        content: language.$('misc.nav.info'),
+      });
+
+    const galleryLink =
+      relations.galleryLink.slots({
+        attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+        content: language.$('misc.nav.gallery'),
+      });
+
+    return [
+      {auto: 'home'},
+
+      data.enableListings &&
+        {
+          path: ['localized.listingIndex'],
+          title: language.$('listingIndex.title'),
+        },
+
+      {
+        html:
+          language.$('artTagPage.nav.tag', {
+            tag: relations.mainLink,
+          }),
+
+        accent:
+          relations.switcher.slots({
+            links: [
+              infoLink,
+              galleryLink,
+            ],
+          }),
+      },
+    ].filter(Boolean);
+  },
+};
diff --git a/src/content/dependencies/generateArtTagSidebar.js b/src/content/dependencies/generateArtTagSidebar.js
new file mode 100644
index 00000000..9e2f813c
--- /dev/null
+++ b/src/content/dependencies/generateArtTagSidebar.js
@@ -0,0 +1,124 @@
+import {collectTreeLeaves, empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'generateArtTagAncestorDescendantMapList',
+    'linkArtTagDynamically',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({artTagData}) =>
+    ({artTagData}),
+
+  query(sprawl, artTag) {
+    const baobab = artTag.ancestorArtTagBaobabTree;
+    const uniqueLeaves = new Set(collectTreeLeaves(baobab));
+
+    // Just match the order in tag data.
+    const furthestAncestorArtTags =
+      sprawl.artTagData
+        .filter(artTag => uniqueLeaves.has(artTag));
+
+    return {furthestAncestorArtTags};
+  },
+
+  relations: (relation, query, sprawl, artTag) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    artTagLink:
+      relation('linkArtTagDynamically', artTag),
+
+    directDescendantArtTagLinks:
+      artTag.directDescendantArtTags
+        .map(descendantArtTag =>
+          relation('linkArtTagDynamically', descendantArtTag)),
+
+    furthestAncestorArtTagMapLists:
+      query.furthestAncestorArtTags
+        .map(ancestorArtTag =>
+          relation('generateArtTagAncestorDescendantMapList',
+            ancestorArtTag,
+            artTag)),
+  }),
+
+  data: (query, sprawl, artTag) => ({
+    name: artTag.name,
+
+    directDescendantTimesFeaturedTotal:
+      artTag.directDescendantArtTags.map(artTag =>
+        unique([
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
+        ]).length),
+
+    furthestAncestorArtTagNames:
+      query.furthestAncestorArtTags
+        .map(ancestorArtTag => ancestorArtTag.name),
+  }),
+
+  generate(data, relations, {html, language}) {
+    if (
+      empty(relations.directDescendantArtTagLinks) &&
+      empty(relations.furthestAncestorArtTagMapLists)
+    ) {
+      return relations.sidebar;
+    }
+
+    return relations.sidebar.slots({
+      boxes: [
+        relations.sidebarBox.slots({
+          content: [
+            html.tag('h1',
+              relations.artTagLink),
+
+            !empty(relations.directDescendantArtTagLinks) &&
+              html.tag('details', {class: 'current', open: true}, [
+                html.tag('summary',
+                  html.tag('span',
+                    html.tag('b',
+                      language.sanitize(data.name)))),
+
+                html.tag('ul',
+                  stitchArrays({
+                    link: relations.directDescendantArtTagLinks,
+                    timesFeaturedTotal: data.directDescendantTimesFeaturedTotal,
+                  }).map(({link, timesFeaturedTotal}) =>
+                      html.tag('li', [
+                        link,
+                        html.tag('span', {class: 'times-used'},
+                          language.countTimesFeatured(timesFeaturedTotal)),
+                      ]))),
+              ]),
+
+            stitchArrays({
+              name: data.furthestAncestorArtTagNames,
+              list: relations.furthestAncestorArtTagMapLists,
+            }).map(({name, list}) =>
+                html.tag('details',
+                  {
+                    class: 'has-tree-list',
+                    open:
+                      empty(relations.directDescendantArtTagLinks) &&
+                      relations.furthestAncestorArtTagMapLists.length === 1,
+                  },
+                  [
+                    html.tag('summary',
+                      html.tag('span',
+                        html.tag('b',
+                          language.sanitize(name)))),
+
+                      list,
+                    ])),
+          ],
+        }),
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateArtistArtworkColumn.js b/src/content/dependencies/generateArtistArtworkColumn.js
new file mode 100644
index 00000000..a4135489
--- /dev/null
+++ b/src/content/dependencies/generateArtistArtworkColumn.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations: (relation, artist) => ({
+    coverArtwork:
+      (artist.hasAvatar
+        ? relation('generateCoverArtwork', artist.avatarArtwork)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.coverArtwork,
+};
diff --git a/src/content/dependencies/generateArtistCredit.js b/src/content/dependencies/generateArtistCredit.js
new file mode 100644
index 00000000..6bdbeb23
--- /dev/null
+++ b/src/content/dependencies/generateArtistCredit.js
@@ -0,0 +1,180 @@
+import {compareArrays, empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistCreditWikiEditsPart',
+    'linkContribution',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (creditContributions, contextContributions) => {
+    const query = {};
+
+    const featuringFilter = contribution =>
+      contribution.annotation === 'featuring';
+
+    const wikiEditFilter = contribution =>
+      contribution.annotation?.startsWith('edits for wiki');
+
+    const normalFilter = contribution =>
+      !featuringFilter(contribution) &&
+      !wikiEditFilter(contribution);
+
+    query.normalContributions =
+      creditContributions.filter(normalFilter);
+
+    query.featuringContributions =
+      creditContributions.filter(featuringFilter);
+
+    query.wikiEditContributions =
+      creditContributions.filter(wikiEditFilter);
+
+    const contextNormalContributions =
+      contextContributions.filter(normalFilter);
+
+    // Note that the normal contributions will implicitly *always*
+    // "differ from context" if no context contributions are given,
+    // as in release info lines.
+    query.normalContributionsDifferFromContext =
+      !compareArrays(
+        query.normalContributions.map(({artist}) => artist),
+        contextNormalContributions.map(({artist}) => artist),
+        {checkOrder: false});
+
+    return query;
+  },
+
+  relations: (relation, query, _creditContributions, _contextContributions) => ({
+    normalContributionLinks:
+      query.normalContributions
+        .map(contrib => relation('linkContribution', contrib)),
+
+    featuringContributionLinks:
+      query.featuringContributions
+        .map(contrib => relation('linkContribution', contrib)),
+
+    wikiEditsPart:
+      relation('generateArtistCreditWikiEditsPart',
+        query.wikiEditContributions),
+  }),
+
+  data: (query, _creditContributions, _contextContributions) => ({
+    normalContributionsDifferFromContext:
+      query.normalContributionsDifferFromContext,
+
+    hasWikiEdits:
+      !empty(query.wikiEditContributions),
+  }),
+
+  slots: {
+    // This string is mandatory.
+    normalStringKey: {type: 'string'},
+
+    // This string is optional.
+    // Without it, there's no special behavior for "featuring" credits.
+    normalFeaturingStringKey: {type: 'string'},
+
+    // This string is optional.
+    // Without it, "featuring" credits will always be alongside main credits.
+    // It won't be used if contextContributions isn't provided.
+    featuringStringKey: {type: 'string'},
+
+    additionalStringOptions: {validate: v => v.isObject},
+
+    showAnnotation: {type: 'boolean', default: false},
+    showExternalLinks: {type: 'boolean', default: false},
+    showChronology: {type: 'boolean', default: false},
+    showWikiEdits: {type: 'boolean', default: false},
+
+    trimAnnotation: {type: 'boolean', default: false},
+
+    chronologyKind: {type: 'string'},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    if (!slots.normalStringKey) return html.blank();
+
+    for (const link of [
+      ...relations.normalContributionLinks,
+      ...relations.featuringContributionLinks,
+    ]) {
+      link.setSlots({
+        showExternalLinks: slots.showExternalLinks,
+        showChronology: slots.showChronology,
+        trimAnnotation: slots.trimAnnotation,
+        chronologyKind: slots.chronologyKind,
+      });
+    }
+
+    for (const link of relations.normalContributionLinks) {
+      link.setSlots({
+        showAnnotation: slots.showAnnotation,
+      });
+    }
+
+    for (const link of relations.featuringContributionLinks) {
+      link.setSlots({
+        showAnnotation:
+          (slots.featuringStringKey || slots.normalFeaturingStringKey
+            ? false
+            : slots.showAnnotation),
+      });
+    }
+
+    if (empty(relations.normalContributionLinks)) {
+      return html.blank();
+    }
+
+    const artistsList =
+      (data.hasWikiEdits && slots.showWikiEdits
+        ? language.$('misc.artistLink.withEditsForWiki', {
+            artists:
+              language.formatConjunctionList(relations.normalContributionLinks),
+
+            edits:
+              relations.wikiEditsPart.slots({
+                showAnnotation: slots.showAnnotation,
+              }),
+          })
+        : language.formatConjunctionList(relations.normalContributionLinks));
+
+    const featuringList =
+      language.formatConjunctionList(relations.featuringContributionLinks);
+
+    const everyoneList =
+      language.formatConjunctionList([
+        ...relations.normalContributionLinks,
+        ...relations.featuringContributionLinks,
+      ]);
+
+    if (empty(relations.featuringContributionLinks)) {
+      if (data.normalContributionsDifferFromContext) {
+        return language.$(slots.normalStringKey, {
+          ...slots.additionalStringOptions,
+          artists: artistsList,
+        });
+      } else {
+        return html.blank();
+      }
+    }
+
+    if (data.normalContributionsDifferFromContext && slots.normalFeaturingStringKey) {
+      return language.$(slots.normalFeaturingStringKey, {
+        ...slots.additionalStringOptions,
+        artists: artistsList,
+        featuring: featuringList,
+      });
+    } else if (slots.featuringStringKey) {
+      return language.$(slots.featuringStringKey, {
+        ...slots.additionalStringOptions,
+        artists: featuringList,
+      });
+    } else {
+      return language.$(slots.normalStringKey, {
+        ...slots.additionalStringOptions,
+        artists: everyoneList,
+      });
+    }
+  },
+};
diff --git a/src/content/dependencies/generateArtistCreditWikiEditsPart.js b/src/content/dependencies/generateArtistCreditWikiEditsPart.js
new file mode 100644
index 00000000..70296e39
--- /dev/null
+++ b/src/content/dependencies/generateArtistCreditWikiEditsPart.js
@@ -0,0 +1,55 @@
+export default {
+  contentDependencies: [
+    'generateTextWithTooltip',
+    'generateTooltip',
+    'linkContribution',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, contributions) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateTooltip'),
+
+    contributionLinks:
+      contributions
+        .map(contrib => relation('linkContribution', contrib)),
+  }),
+
+  slots: {
+    showAnnotation: {type: 'boolean', default: true},
+  },
+
+  generate: (relations, slots, {language}) =>
+    language.encapsulate('misc.artistLink.withEditsForWiki', capsule =>
+      relations.textWithTooltip.slots({
+        attributes:
+          {class: 'wiki-edits'},
+
+        text:
+          language.$(capsule, 'edits'),
+
+        tooltip:
+          relations.tooltip.slots({
+            attributes:
+              {class: 'wiki-edits-tooltip'},
+
+            content:
+              language.$(capsule, 'editsLine', {
+                [language.onlyIfOptions]: ['artists'],
+
+                artists:
+                  language.formatConjunctionList(
+                    relations.contributionLinks.map(link =>
+                      link.slots({
+                        showAnnotation: slots.showAnnotation,
+                        trimAnnotation: true,
+                        preventTooltip: true,
+                      }))),
+                }),
+          }),
+      })),
+};
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
index 36343c18..6a24275e 100644
--- a/src/content/dependencies/generateArtistGalleryPage.js
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -1,5 +1,4 @@
-import {sortAlbumsTracksChronologically} from '#sort';
-import {stitchArrays} from '#sugar';
+import {sortArtworksChronologically} from '#sort';
 
 export default {
   contentDependencies: [
@@ -7,86 +6,65 @@ export default {
     'generateCoverGrid',
     'generatePageLayout',
     'image',
-    'linkAlbum',
-    'linkTrack',
+    'linkAnythingMan',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  query(artist) {
-    const things = [
-      ...artist.albumsAsCoverArtist,
-      ...artist.tracksAsCoverArtist,
-    ];
-
-    sortAlbumsTracksChronologically(things, {
-      latestFirst: true,
-      getDate: thing => thing.coverArtDate ?? thing.date,
-    });
-
-    return {things};
-  },
-
-  relations(relation, query, artist) {
-    const relations = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.artistNavLinks =
-      relation('generateArtistNavLinks', artist);
-
-    relations.coverGrid =
-      relation('generateCoverGrid');
-
-    relations.links =
-      query.things.map(thing =>
-        (thing.album
-          ? relation('linkTrack', thing)
-          : relation('linkAlbum', thing)));
-
-    relations.images =
-      query.things.map(thing =>
-        relation('image', thing.artTags));
-
-    return relations;
-  },
-
-  data(query, artist) {
-    const data = {};
-
-    data.name = artist.name;
-
-    data.numArtworks = query.things.length;
-
-    data.names =
-      query.things.map(thing => thing.name);
-
-    data.paths =
-      query.things.map(thing =>
-        (thing.album
-          ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
-          : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
-
-    data.dimensions =
-      query.things.map(thing => thing.coverArtDimensions);
-
-    data.otherCoverArtists =
-      query.things.map(thing =>
-        (thing.coverArtistContribs.length > 1
-          ? thing.coverArtistContribs
-              .filter(({who}) => who !== artist)
-              .map(({who}) => who.name)
-          : null));
-
-    return data;
-  },
-
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
+  query: (artist) => ({
+    artworks:
+      sortArtworksChronologically(
+        ([
+          artist.albumCoverArtistContributions,
+          artist.trackCoverArtistContributions,
+        ]).flat()
+          .filter(contrib => !contrib.annotation?.startsWith(`edits for wiki`))
+          .map(contrib => contrib.thing),
+        {latestFirst: true}),
+  }),
+
+  relations: (relation, query, artist) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    artistNavLinks:
+      relation('generateArtistNavLinks', artist),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      query.artworks
+        .map(artwork => relation('linkAnythingMan', artwork.thing)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, artist) => ({
+    name:
+      artist.name,
+
+    numArtworks:
+      query.artworks.length,
+
+    names:
+      query.artworks
+        .map(artwork => artwork.thing.name),
+
+    otherCoverArtists:
+      query.artworks
+        .map(artwork => artwork.artistContribs
+          .filter(contrib => contrib.artist !== artist)
+          .map(contrib => contrib.artist.name)),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistGalleryPage', pageCapsule =>
+      relations.layout.slots({
         title:
-          language.$('artistGalleryPage.title', {
+          language.$(pageCapsule, 'title', {
             artist: data.name,
           }),
 
@@ -95,35 +73,26 @@ export default {
         mainClasses: ['top-index'],
         mainContent: [
           html.tag('p', {class: 'quick-info'},
-            language.$('artistGalleryPage.infoLine', {
-              coverArts: language.countCoverArts(data.numArtworks, {
-                unit: true,
-              }),
+            language.$(pageCapsule, 'infoLine', {
+              coverArts:
+                language.countArtworks(data.numArtworks, {
+                  unit: true,
+                }),
             })),
 
           relations.coverGrid
             .slots({
               links: relations.links,
+              images: relations.images,
               names: data.names,
 
-              images:
-                stitchArrays({
-                  image: relations.images,
-                  path: data.paths,
-                  dimensions: data.dimensions,
-                }).map(({image, path, dimensions}) =>
-                    image.slots({
-                      path,
-                      dimensions,
-                    })),
-
               info:
                 data.otherCoverArtists.map(names =>
-                  (names === null
-                    ? null
-                    : language.$('misc.albumGrid.details.otherCoverArtists', {
-                        artists: language.formatUnitList(names),
-                      }))),
+                  language.$('misc.coverGrid.details.otherCoverArtists', {
+                    [language.onlyIfOptions]: ['artists'],
+
+                    artists: language.formatUnitList(names),
+                  })),
             }),
         ],
 
@@ -135,6 +104,5 @@ export default {
               currentExtra: 'gallery',
             })
             .content,
-      })
-  },
+      })),
 }
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index 1725d4b9..3e0cd1d2 100644
--- a/src/content/dependencies/generateArtistGroupContributionsInfo.js
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -36,7 +36,7 @@ export default {
     for (const track of filteredTracks) {
       for (const group of track.album.groups) {
         groupToCountMap.set(group, groupToCountMap.get(group) + 1);
-        if (track.duration && track.originalReleaseTrack === null) {
+        if (track.duration && track.mainReleaseTrack === null) {
           groupToDurationMap.set(group, groupToDurationMap.get(group) + track.duration);
           groupToDurationCountMap.set(group, groupToDurationCountMap.get(group) + 1);
         }
@@ -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..3a3cf8b7 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -1,8 +1,8 @@
-import {empty, unique} from '#sugar';
-import {getTotalDuration} from '#wiki-data';
+import {empty, stitchArrays, unique} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateArtistArtworkColumn',
     'generateArtistGroupContributionsInfo',
     'generateArtistInfoPageArtworksChunkedList',
     'generateArtistInfoPageCommentaryChunkedList',
@@ -10,209 +10,264 @@ export default {
     'generateArtistInfoPageTracksChunkedList',
     'generateArtistNavLinks',
     'generateContentHeading',
-    'generateCoverArtwork',
     'generatePageLayout',
-    'linkAlbum',
     'linkArtistGallery',
     'linkExternal',
     'linkGroup',
-    'linkTrack',
     'transformContent',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
-
-  sprawl({wikiInfo}) {
-    return {
-      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
-    };
-  },
-
-  query(sprawl, artist) {
-    return {
-      // Even if an artist has served as both "artist" (compositional) and
-      // "contributor" (instruments, production, etc) on the same track, that
-      // track only counts as one unique contribution.
-      allTracks:
-        unique([...artist.tracksAsArtist, ...artist.tracksAsContributor]),
-
-      // Artworks are different, though. We intentionally duplicate album data
-      // objects when the artist has contributed some combination of cover art,
-      // wallpaper, and banner - these each count as a unique contribution.
-      allArtworks: [
-        ...artist.albumsAsCoverArtist,
-        ...artist.albumsAsWallpaperArtist,
-        ...artist.albumsAsBannerArtist,
-        ...artist.tracksAsCoverArtist,
-      ],
-
-      // Banners and wallpapers don't show up in the artist gallery page, only
-      // cover art.
-      hasGallery:
-        !empty(artist.albumsAsCoverArtist) ||
-        !empty(artist.tracksAsCoverArtist),
-    };
-  },
-
-  relations(relation, query, sprawl, artist) {
-    const relations = {};
-    const sections = relations.sections = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.artistNavLinks =
-      relation('generateArtistNavLinks', artist);
-
-    if (artist.hasAvatar) {
-      relations.cover =
-        relation('generateCoverArtwork', []);
-    }
-
-    if (artist.contextNotes) {
-      const contextNotes = sections.contextNotes = {};
-      contextNotes.content = relation('transformContent', artist.contextNotes);
-    }
-
-    if (!empty(artist.urls)) {
-      const visit = sections.visit = {};
-      visit.externalLinks =
-        artist.urls.map(url =>
-          relation('linkExternal', url));
-    }
-
-    if (!empty(query.allTracks)) {
-      const tracks = sections.tracks = {};
-      tracks.heading = relation('generateContentHeading');
-      tracks.list = relation('generateArtistInfoPageTracksChunkedList', artist);
-      tracks.groupInfo = relation('generateArtistGroupContributionsInfo', query.allTracks);
-    }
-
-    if (!empty(query.allArtworks)) {
-      const artworks = sections.artworks = {};
-      artworks.heading = relation('generateContentHeading');
-      artworks.list = relation('generateArtistInfoPageArtworksChunkedList', artist);
-      artworks.groupInfo =
-        relation('generateArtistGroupContributionsInfo', query.allArtworks);
-
-      if (query.hasGallery) {
-        artworks.artistGalleryLink =
-          relation('linkArtistGallery', artist);
-      }
-    }
-
-    if (sprawl.enableFlashesAndGames && !empty(artist.flashesAsContributor)) {
-      const flashes = sections.flashes = {};
-      flashes.heading = relation('generateContentHeading');
-      flashes.list = relation('generateArtistInfoPageFlashesChunkedList', artist);
-    }
-
-    if (!empty(artist.albumsAsCommentator) || !empty(artist.tracksAsCommentator)) {
-      const commentary = sections.commentary = {};
-      commentary.heading = relation('generateContentHeading');
-      commentary.list = relation('generateArtistInfoPageCommentaryChunkedList', artist);
-    }
-
-    return relations;
-  },
-
-  data(query, sprawl, artist) {
-    const data = {};
-
-    data.name = artist.name;
-    data.directory = artist.directory;
-
-    if (artist.hasAvatar) {
-      data.avatarFileExtension = artist.avatarFileExtension;
-    }
-
-    data.totalTrackCount = query.allTracks.length;
-    data.totalDuration = getTotalDuration(query.allTracks, {originalReleasesOnly: true});
-
-    return data;
-  },
-
-  generate(data, relations, {html, language}) {
-    const {sections: sec} = relations;
-
-    return relations.layout
-      .slots({
+  extraDependencies: ['html', 'language'],
+
+  query: (artist) => ({
+    // Even if an artist has served as both "artist" (compositional) and
+    // "contributor" (instruments, production, etc) on the same track, that
+    // track only counts as one unique contribution in the list.
+    allTracks:
+      unique(
+        ([
+          artist.trackArtistContributions,
+          artist.trackContributorContributions,
+        ]).flat()
+          .map(({thing}) => thing)),
+
+    // Artworks are different, though. We intentionally duplicate album data
+    // objects when the artist has contributed some combination of cover art,
+    // wallpaper, and banner - these each count as a unique contribution.
+    allArtworkThings:
+      ([
+        artist.albumCoverArtistContributions,
+        artist.albumWallpaperArtistContributions,
+        artist.albumBannerArtistContributions,
+        artist.trackCoverArtistContributions,
+      ]).flat()
+        .filter(({annotation}) => !annotation?.startsWith('edits for wiki'))
+        .map(({thing}) => thing.thing),
+
+    // Banners and wallpapers don't show up in the artist gallery page, only
+    // cover art.
+    hasGallery:
+      !empty(artist.albumCoverArtistContributions) ||
+      !empty(artist.trackCoverArtistContributions),
+
+    aliasLinkedGroups:
+      artist.closelyLinkedGroups
+        .filter(({annotation}) =>
+          annotation === 'alias'),
+
+    generalLinkedGroups:
+      artist.closelyLinkedGroups
+        .filter(({annotation}) =>
+          annotation !== 'alias'),
+  }),
+
+  relations: (relation, query, artist) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    artistNavLinks:
+      relation('generateArtistNavLinks', artist),
+
+    artworkColumn:
+      relation('generateArtistArtworkColumn', artist),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    contextNotes:
+      relation('transformContent', artist.contextNotes),
+
+    closeGroupLinks:
+      query.generalLinkedGroups
+        .map(({group}) => relation('linkGroup', group)),
+
+    aliasGroupLinks:
+      query.aliasLinkedGroups
+        .map(({group}) => relation('linkGroup', group)),
+
+    visitLinks:
+      artist.urls
+        .map(url => relation('linkExternal', url)),
+
+    tracksChunkedList:
+      relation('generateArtistInfoPageTracksChunkedList', artist),
+
+    tracksGroupInfo:
+      relation('generateArtistGroupContributionsInfo', query.allTracks),
+
+    artworksChunkedList:
+      relation('generateArtistInfoPageArtworksChunkedList', artist, false),
+
+    editsForWikiArtworksChunkedList:
+      relation('generateArtistInfoPageArtworksChunkedList', artist, true),
+
+    artworksGroupInfo:
+      relation('generateArtistGroupContributionsInfo', query.allArtworkThings),
+
+    artistGalleryLink:
+      (query.hasGallery
+        ? relation('linkArtistGallery', artist)
+        : null),
+
+    flashesChunkedList:
+      relation('generateArtistInfoPageFlashesChunkedList', artist),
+
+    commentaryChunkedList:
+      relation('generateArtistInfoPageCommentaryChunkedList', artist, false),
+
+    wikiEditorCommentaryChunkedList:
+      relation('generateArtistInfoPageCommentaryChunkedList', artist, true),
+  }),
+
+  data: (query, artist) => ({
+    name:
+      artist.name,
+
+    closeGroupAnnotations:
+      query.generalLinkedGroups
+        .map(({annotation}) => annotation),
+
+    totalTrackCount:
+      query.allTracks.length,
+
+    totalDuration:
+      artist.totalDuration,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistPage', pageCapsule =>
+      relations.layout.slots({
         title: data.name,
         headingMode: 'sticky',
 
-        cover:
-          (relations.cover
-            ? relations.cover.slots({
-                path: [
-                  'media.artistAvatar',
-                  data.directory,
-                  data.avatarFileExtension,
-                ],
-              })
-            : null),
+        artworkColumnContent:
+          relations.artworkColumn,
 
         mainContent: [
-          sec.contextNotes && [
-            html.tag('p', language.$('releaseInfo.note')),
+          html.tags([
+            html.tag('p',
+              {[html.onlyIfSiblings]: true},
+              language.$('releaseInfo.note')),
+
             html.tag('blockquote',
-              sec.contextNotes.content),
-          ],
+              {[html.onlyIfContent]: true},
+              relations.contextNotes),
+          ]),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate(pageCapsule, 'closelyLinkedGroups', capsule => [
+              language.encapsulate(capsule, capsule => {
+                const [workingCapsule, option] =
+                  (relations.closeGroupLinks.length === 0
+                    ? [null, null]
+                 : relations.closeGroupLinks.length === 1
+                    ? [language.encapsulate(capsule, 'one'), 'group']
+                    : [language.encapsulate(capsule, 'multiple'), 'groups']);
+
+                if (!workingCapsule) return html.blank();
+
+                return language.$(workingCapsule, {
+                  [option]:
+                    language.formatUnitList(
+                      stitchArrays({
+                        link: relations.closeGroupLinks,
+                        annotation: data.closeGroupAnnotations,
+                      }).map(({link, annotation}) =>
+                          language.encapsulate(capsule, 'group', workingCapsule => {
+                            const workingOptions = {group: link};
+
+                            if (annotation) {
+                              workingCapsule += '.withAnnotation';
+                              workingOptions.annotation = annotation;
+                            }
+
+                            return language.$(workingCapsule, workingOptions);
+                          }))),
+                });
+              }),
 
-          sec.visit &&
-            html.tag('p',
-              language.$('releaseInfo.visitOn', {
-                links:
-                  language.formatDisjunctionList(
-                    sec.visit.externalLinks
-                      .map(link => link.slot('context', 'artist'))),
-              })),
-
-          sec.artworks?.artistGalleryLink &&
-            html.tag('p',
-              language.$('artistPage.viewArtGallery', {
-                link: sec.artworks.artistGalleryLink.slots({
-                  content: language.$('artistPage.viewArtGallery.link'),
-                }),
-              })),
+              language.$(capsule, 'alias', {
+                [language.onlyIfOptions]: ['groups'],
 
-          (sec.tracks || sec.artworsk || sec.flashes || sec.commentary) &&
-            html.tag('p',
-              language.$('misc.jumpTo.withLinks', {
-                links: language.formatUnitList(
-                  [
-                    sec.tracks &&
-                      html.tag('a',
-                        {href: '#tracks'},
-                        language.$('artistPage.trackList.title')),
+                groups:
+                  language.formatConjunctionList(relations.aliasGroupLinks),
+              }),
+            ])),
 
-                    sec.artworks &&
-                      html.tag('a',
-                        {href: '#art'},
-                        language.$('artistPage.artList.title')),
+          html.tag('p',
+            {[html.onlyIfContent]: true},
 
-                    sec.flashes &&
-                      html.tag('a',
-                        {href: '#flashes'},
-                        language.$('artistPage.flashList.title')),
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
 
-                    sec.commentary &&
-                      html.tag('a',
-                        {href: '#commentary'},
-                        language.$('artistPage.commentaryList.title')),
-                  ].filter(Boolean)),
-              })),
+              links:
+                language.formatDisjunctionList(
+                  relations.visitLinks
+                    .map(link => link.slot('context', 'artist'))),
+            })),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.encapsulate(pageCapsule, 'viewArtGallery', capsule =>
+              language.$(capsule, {
+                [language.onlyIfOptions]: ['link'],
+
+                link:
+                  relations.artistGalleryLink?.slots({
+                    content:
+                      language.$(capsule, 'link'),
+                  }),
+              }))),
 
-          sec.tracks && [
-            sec.tracks.heading
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$('misc.jumpTo.withLinks', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatUnitList([
+                  !html.isBlank(relations.tracksChunkedList) &&
+                    html.tag('a',
+                      {href: '#tracks'},
+                      language.$(pageCapsule, 'trackList.title')),
+
+                  (!html.isBlank(relations.artworksChunkedList) ||
+                   !html.isBlank(relations.editsForWikiArtworksChunkedList)) &&
+                      html.tag('a',
+                        {href: '#art'},
+                        language.$(pageCapsule, 'artList.title')),
+
+                  !html.isBlank(relations.flashesChunkedList) &&
+                    html.tag('a',
+                      {href: '#flashes'},
+                      language.$(pageCapsule, 'flashList.title')),
+
+                  (!html.isBlank(relations.commentaryChunkedList) ||
+                   !html.isBlank(relations.wikiEditorCommentaryChunkedList)) &&
+                    html.tag('a',
+                      {href: '#commentary'},
+                      language.$(pageCapsule, 'commentaryList.title')),
+                ].filter(Boolean)),
+            })),
+
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
-                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 +276,118 @@ export default {
                     }),
                 })),
 
-            sec.tracks.list
-              .slots({
-                groupInfo: [
-                  sec.tracks.groupInfo
-                    .clone()
+            relations.tracksChunkedList.slots({
+              groupInfo:
+                language.encapsulate(pageCapsule, 'groupContributions', capsule => [
+                  relations.tracksGroupInfo.clone()
                     .slots({
-                      title: language.$('artistPage.groupContributions.title.music'),
+                      title: language.$(capsule, 'title.music'),
                       showSortButton: true,
                       sort: 'count',
                       countUnit: 'tracks',
                       visible: true,
                     }),
 
-                  sec.tracks.groupInfo
-                    .clone()
+                  relations.tracksGroupInfo.clone()
                     .slots({
-                      title: language.$('artistPage.groupContributions.title.music'),
+                      title: language.$(capsule, 'title.music'),
                       showSortButton: true,
                       sort: 'duration',
                       countUnit: 'tracks',
                       visible: false,
                     }),
-                ],
-              }),
-          ],
+                ]),
+            }),
+          ]),
 
-          sec.artworks && [
-            sec.artworks.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
-                id: 'art',
-                title: language.$('artistPage.artList.title'),
+                attributes: {id: 'art'},
+                title: language.$(pageCapsule, 'artList.title'),
               }),
 
-            sec.artworks.artistGalleryLink &&
-              html.tag('p',
-                language.$('artistPage.viewArtGallery.orBrowseList', {
-                  link: sec.artworks.artistGalleryLink.slots({
-                    content: language.$('artistPage.viewArtGallery.link'),
-                  }),
-                })),
+            html.tag('p',
+              {[html.onlyIfContent]: true},
+
+              language.encapsulate(pageCapsule, 'viewArtGallery', capsule =>
+                language.$(capsule, 'orBrowseList', {
+                  [language.onlyIfOptions]: ['link'],
 
-            sec.artworks.list
+                  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([
+              language.encapsulate(pageCapsule, 'wikiEditArtworks', capsule =>
+                relations.contentHeading.clone()
+                  .slots({
+                    tag: 'p',
+
+                    title:
+                      language.$(capsule, {artist: data.name}),
+
+                    stickyTitle:
+                      language.$(capsule, 'sticky'),
+                  })),
+
+              relations.editsForWikiArtworksChunkedList,
+            ]),
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
-                id: 'flashes',
-                title: language.$('artistPage.flashList.title'),
+                attributes: {id: 'flashes'},
+                title: language.$(pageCapsule, 'flashList.title'),
               }),
 
-            sec.flashes.list,
-          ],
+            relations.flashesChunkedList,
+          ]),
 
-          sec.commentary && [
-            sec.commentary.heading
+          html.tags([
+            relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
-                id: 'commentary',
-                title: language.$('artistPage.commentaryList.title'),
+                attributes: {id: 'commentary'},
+                title: language.$(pageCapsule, 'commentaryList.title'),
               }),
 
-            sec.commentary.list,
-          ],
+            relations.commentaryChunkedList,
+
+            html.tags([
+              language.encapsulate(pageCapsule, 'wikiEditorCommentary', capsule =>
+                relations.contentHeading.clone()
+                  .slots({
+                    tag: 'p',
+
+                    title:
+                      language.$(capsule, {artist: data.name}),
+
+                    stickyTitle:
+                      language.$(capsule, 'sticky'),
+                  })),
+
+              relations.wikiEditorCommentaryChunkedList,
+            ]),
+          ]),
         ],
 
         navLinkStyle: 'hierarchical',
@@ -306,6 +397,5 @@ export default {
               showExtraLinks: true,
             })
             .content,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunk.js b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js
new file mode 100644
index 00000000..66e4204a
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js
@@ -0,0 +1,50 @@
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageArtworksChunkItem',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album, contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunk'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    items:
+      contribs
+        .map(contrib =>
+          relation('generateArtistInfoPageArtworksChunkItem', contrib)),
+  }),
+
+  data: (_album, contribs) => ({
+    dates:
+      contribs
+        .map(contrib => contrib.date),
+  }),
+
+  slots: {
+    filterEditsForWiki: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots) =>
+    relations.template.slots({
+      mode: 'album',
+      albumLink: relations.albumLink,
+
+      dates:
+        (slots.filterEditsForWiki
+          ? Array.from({length: data.dates}, () => null)
+          : data.dates),
+
+      items:
+        relations.items.map(item =>
+          item.slot('filterEditsForWiki', slots.filterEditsForWiki)),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
new file mode 100644
index 00000000..2f2fe0c5
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
@@ -0,0 +1,72 @@
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageOtherArtistLinks',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (contrib) => ({
+    kind:
+      (contrib.isBannerArtistContribution
+        ? 'banner'
+     : contrib.isWallpaperArtistContribution
+        ? 'wallpaper'
+     : contrib.isForAlbum
+        ? 'album-cover'
+        : 'track-cover'),
+  }),
+
+  relations: (relation, query, contrib) => ({
+    template:
+      relation('generateArtistInfoPageChunkItem'),
+
+    trackLink:
+      (query.kind === 'track-cover'
+        ? relation('linkTrack', contrib.thing.thing)
+        : null),
+
+    otherArtistLinks:
+      relation('generateArtistInfoPageOtherArtistLinks', [contrib]),
+  }),
+
+  data: (query, contrib) => ({
+    kind:
+      query.kind,
+
+    annotation:
+      contrib.annotation,
+  }),
+
+  slots: {
+    filterEditsForWiki: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.template.slots({
+      otherArtistLinks: relations.otherArtistLinks,
+
+      annotation:
+        (slots.filterEditsForWiki
+          ? data.annotation?.replace(/^edits for wiki(: )?/, '')
+          : data.annotation),
+
+      content:
+        language.encapsulate('artistPage.creditList.entry', capsule =>
+          (data.kind === 'track-cover'
+            ? language.$(capsule, 'track', {
+                track: relations.trackLink,
+              })
+            : html.tag('i',
+                language.encapsulate(capsule, 'album', capsule =>
+                  (data.kind === 'wallpaper'
+                    ? language.$(capsule, 'wallpaperArt')
+                 : data.kind === 'banner'
+                    ? language.$(capsule, 'bannerArt')
+                    : language.$(capsule, 'coverArt')))))),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
index 0beeb271..75a4aa5a 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -1,241 +1,72 @@
-import {sortAlbumsTracksChronologically, sortEntryThingPairs} from '#sort';
-import {chunkByProperties, stitchArrays} from '#sugar';
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {chunkByConditions, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateArtistInfoPageChunk',
     'generateArtistInfoPageChunkedList',
-    'generateArtistInfoPageChunkItem',
-    'generateArtistInfoPageOtherArtistLinks',
-    'linkAlbum',
-    'linkTrack',
+    'generateArtistInfoPageArtworksChunk',
   ],
 
-  extraDependencies: ['html', 'language'],
+  query(artist, filterEditsForWiki) {
+    const query = {};
 
-  query(artist) {
-    // TODO: Add and integrate wallpaper and banner date fields (#90)
-    // This will probably only happen once all artworks follow a standard
-    // shape (#70) and get their own sorting function. Read for more info:
-    // https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607422961
-
-    const processEntry = ({thing, type, track, album, contribs}) => ({
-      thing: thing,
-      entry: {
-        type: type,
-        track: track,
-        album: album,
-        contribs: contribs,
-        date: thing.coverArtDate ?? thing.date,
-      },
-    });
-
-    const processAlbumEntry = ({type, album, contribs}) =>
-      processEntry({
-        thing: album,
-        type: type,
-        track: null,
-        album: album,
-        contribs: contribs,
-      });
-
-    const processTrackEntry = ({type, track, contribs}) =>
-      processEntry({
-        thing: track,
-        type: type,
-        track: track,
-        album: track.album,
-        contribs: contribs,
-      });
-
-    const processAlbumEntries = ({type, albums, contribs}) =>
-      stitchArrays({
-        album: albums,
-        contribs: contribs,
-      }).map(entry =>
-          processAlbumEntry({type, ...entry}));
-
-    const processTrackEntries = ({type, tracks, contribs}) =>
-      stitchArrays({
-        track: tracks,
-        contribs: contribs,
-      }).map(entry =>
-          processTrackEntry({type, ...entry}));
-
-    const {
-      albumsAsCoverArtist,
-      albumsAsWallpaperArtist,
-      albumsAsBannerArtist,
-      tracksAsCoverArtist,
-    } = artist;
-
-    const albumsAsCoverArtistContribs =
-      albumsAsCoverArtist
-        .map(album => album.coverArtistContribs);
-
-    const albumsAsWallpaperArtistContribs =
-      albumsAsWallpaperArtist
-        .map(album => album.wallpaperArtistContribs);
-
-    const albumsAsBannerArtistContribs =
-      albumsAsBannerArtist
-        .map(album => album.bannerArtistContribs);
-
-    const tracksAsCoverArtistContribs =
-      tracksAsCoverArtist
-        .map(track => track.coverArtistContribs);
-
-    const albumsAsCoverArtistEntries =
-      processAlbumEntries({
-        type: 'albumCover',
-        albums: albumsAsCoverArtist,
-        contribs: albumsAsCoverArtistContribs,
-      });
-
-    const albumsAsWallpaperArtistEntries =
-      processAlbumEntries({
-        type: 'albumWallpaper',
-        albums: albumsAsWallpaperArtist,
-        contribs: albumsAsWallpaperArtistContribs,
-      });
-
-    const albumsAsBannerArtistEntries =
-      processAlbumEntries({
-        type: 'albumBanner',
-        albums: albumsAsBannerArtist,
-        contribs: albumsAsBannerArtistContribs,
-      });
-
-    const tracksAsCoverArtistEntries =
-      processTrackEntries({
-        type: 'trackCover',
-        tracks: tracksAsCoverArtist,
-        contribs: tracksAsCoverArtistContribs,
-      });
-
-    const entries = [
-      ...albumsAsCoverArtistEntries,
-      ...albumsAsWallpaperArtistEntries,
-      ...albumsAsBannerArtistEntries,
-      ...tracksAsCoverArtistEntries,
+    const allContributions = [
+      ...artist.albumCoverArtistContributions,
+      ...artist.albumWallpaperArtistContributions,
+      ...artist.albumBannerArtistContributions,
+      ...artist.trackCoverArtistContributions,
     ];
 
-    sortEntryThingPairs(entries,
-      things => sortAlbumsTracksChronologically(things, {
-        getDate: thing => thing.coverArtDate ?? thing.date,
-      }));
-
-    const chunks =
-      chunkByProperties(
-        entries.map(({entry}) => entry),
-        ['album', 'date']);
-
-    return {chunks};
-  },
-
-  relations(relation, query, artist) {
-    return {
-      chunkedList:
-        relation('generateArtistInfoPageChunkedList'),
-
-      chunks:
-        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
-
-      albumLinks:
-        query.chunks.map(({album}) => relation('linkAlbum', album)),
-
-      items:
-        query.chunks.map(({chunk}) =>
-          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
-
-      itemTrackLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) => track ? relation('linkTrack', track) : null)),
-
-      itemOtherArtistLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))),
-    };
+    const filteredContributions =
+      allContributions
+        .filter(({annotation}) =>
+          (filterEditsForWiki
+            ? annotation?.startsWith(`edits for wiki`)
+            : !annotation?.startsWith(`edits for wiki`)));
+
+    sortContributionsChronologically(
+      filteredContributions,
+      sortAlbumsTracksChronologically,
+      {getThing: contrib => contrib.thing.thing});
+
+    query.contribs =
+      chunkByConditions(filteredContributions, [
+        ({date: date1}, {date: date2}) =>
+          +date1 !== +date2,
+        ({thing: {thing: thing1}}, {thing: {thing: thing2}}) =>
+          (thing1.album ?? thing1) !==
+          (thing2.album ?? thing2),
+      ]);
+
+    query.albums =
+      query.contribs
+        .map(contribs => contribs[0].thing.thing)
+        .map(thing => thing.album ?? thing);
+
+    return query;
   },
 
-  data(query, artist) {
-    return {
-      chunkDates:
-        query.chunks.map(({date}) => date),
+  relations: (relation, query, _artist, _filterEditsForWiki) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
 
-      itemTypes:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({type}) => type)),
-
-      itemContributions:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({contribs}) =>
-            contribs
-              .find(({who}) => who === artist)
-              .what)),
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    return relations.chunkedList.slots({
+    chunks:
+      stitchArrays({
+        album: query.albums,
+        contribs: query.contribs,
+      }).map(({album, contribs}) =>
+          relation('generateArtistInfoPageArtworksChunk', album, contribs)),
+  }),
+
+  data: (_query, _artist, filterEditsForWiki) => ({
+    filterEditsForWiki,
+  }),
+
+  generate: (data, relations) =>
+    relations.chunkedList.slots({
       chunks:
-        stitchArrays({
-          chunk: relations.chunks,
-          albumLink: relations.albumLinks,
-          date: data.chunkDates,
-
-          items: relations.items,
-          itemTrackLinks: relations.itemTrackLinks,
-          itemOtherArtistLinks: relations.itemOtherArtistLinks,
-          itemTypes: data.itemTypes,
-          itemContributions: data.itemContributions,
-        }).map(({
-            chunk,
-            albumLink,
-            date,
-
-            items,
-            itemTrackLinks,
-            itemOtherArtistLinks,
-            itemTypes,
-            itemContributions,
-          }) =>
-            chunk.slots({
-              mode: 'album',
-              albumLink,
-              date,
-
-              items:
-                stitchArrays({
-                  item: items,
-                  trackLink: itemTrackLinks,
-                  otherArtistLinks: itemOtherArtistLinks,
-                  type: itemTypes,
-                  contribution: itemContributions,
-                }).map(({
-                    item,
-                    trackLink,
-                    otherArtistLinks,
-                    type,
-                    contribution,
-                  }) =>
-                    item.slots({
-                      otherArtistLinks,
-                      annotation: contribution,
-
-                      content:
-                        (type === 'trackCover'
-                          ? language.$('artistPage.creditList.entry.track', {
-                              track: trackLink,
-                            })
-                          : html.tag('i',
-                              language.$('artistPage.creditList.entry.album.' + {
-                                albumWallpaper: 'wallpaperArt',
-                                albumBanner: 'bannerArt',
-                                albumCover: 'coverArt',
-                              }[type]))),
-                    })),
-            })),
-    });
-  },
+        relations.chunks.map(chunk =>
+          chunk.slot('filterEditsForWiki', data.filterEditsForWiki)),
+    }),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js
index 40943914..fce68a7d 100644
--- a/src/content/dependencies/generateArtistInfoPageChunk.js
+++ b/src/content/dependencies/generateArtistInfoPageChunk.js
@@ -1,3 +1,5 @@
+import {empty} from '#sugar';
+
 export default {
   extraDependencies: ['html', 'language'],
 
@@ -6,6 +8,8 @@ export default {
       validate: v => v.is('flash', 'album'),
     },
 
+    id: {type: 'string'},
+
     albumLink: {
       type: 'html',
       mutable: false,
@@ -21,15 +25,33 @@ export default {
       mutable: false,
     },
 
-    date: {validate: v => v.isDate},
-    dateRangeStart: {validate: v => v.isDate},
-    dateRangeEnd: {validate: v => v.isDate},
+    dates: {
+      validate: v => v.sparseArrayOf(v.isDate),
+    },
 
     duration: {validate: v => v.isDuration},
     durationApproximate: {type: 'boolean'},
   },
 
   generate(slots, {html, language}) {
+    let earliestDate = null;
+    let latestDate = null;
+    let onlyDate = null;
+
+    if (!empty(slots.dates)) {
+      earliestDate =
+        slots.dates
+          .reduce((a, b) => a <= b ? a : b);
+
+      latestDate =
+        slots.dates
+          .reduce((a, b) => a <= b ? b : a);
+
+      if (+earliestDate === +latestDate) {
+        onlyDate = earliestDate;
+      }
+    }
+
     let accentedLink;
 
     accent: {
@@ -40,9 +62,9 @@ export default {
           const options = {album: accentedLink};
           const parts = ['artistPage.creditList.album'];
 
-          if (slots.date) {
+          if (onlyDate) {
             parts.push('withDate');
-            options.date = language.formatDate(slots.date);
+            options.date = language.formatDate(onlyDate);
           }
 
           if (slots.duration) {
@@ -63,16 +85,13 @@ export default {
           const options = {act: accentedLink};
           const parts = ['artistPage.creditList.flashAct'];
 
-          if (
-            slots.dateRangeStart &&
-            slots.dateRangeEnd &&
-            slots.dateRangeStart !== slots.dateRangeEnd
-          ) {
-            parts.push('withDateRange');
-            options.dateRange = language.formatDateRange(slots.dateRangeStart, slots.dateRangeEnd);
-          } else if (slots.dateRangeStart || slots.date) {
+          if (onlyDate) {
             parts.push('withDate');
-            options.date = language.formatDate(slots.dateRangeStart ?? slots.date);
+            options.date = language.formatDate(onlyDate);
+          } else if (earliestDate && latestDate) {
+            parts.push('withDateRange');
+            options.dateRange =
+              language.formatDateRange(earliestDate, latestDate);
           }
 
           accentedLink = language.formatString(...parts, options);
@@ -82,9 +101,13 @@ export default {
     }
 
     return html.tags([
-      html.tag('dt', accentedLink),
+      html.tag('dt',
+        slots.id && {id: slots.id},
+        accentedLink),
+
       html.tag('dd',
         html.tag('ul',
+          {class: 'offset-tooltips'},
           slots.items)),
     ]);
   },
diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js
index b6f40727..7987b642 100644
--- a/src/content/dependencies/generateArtistInfoPageChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js
@@ -1,6 +1,14 @@
+import {empty} from '#sugar';
+
 export default {
+  contentDependencies: ['generateTextWithTooltip'],
   extraDependencies: ['html', 'language'],
 
+  relations: (relation) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+  }),
+
   slots: {
     content: {
       type: 'html',
@@ -16,45 +24,68 @@ export default {
       validate: v => v.strictArrayOf(v.isHTML),
     },
 
-    rerelease: {type: 'boolean'},
+    rereleaseTooltip: {
+      type: 'html',
+      mutable: false,
+    },
+
+    firstReleaseTooltip: {
+      type: 'html',
+      mutable: false,
+    },
   },
 
-  generate(slots, {html, language}) {
-    let accentedContent = slots.content;
+  generate: (relations, slots, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry', entryCapsule =>
+      html.tag('li',
+        slots.rerelease && {class: 'rerelease'},
 
-    accent: {
-      if (slots.rerelease) {
-        accentedContent =
-          language.$('artistPage.creditList.entry.rerelease', {
-            entry: accentedContent,
-          });
+        language.encapsulate(entryCapsule, workingCapsule => {
+          const workingOptions = {entry: slots.content};
 
-        break accent;
-      }
+          if (!html.isBlank(slots.rereleaseTooltip)) {
+            workingCapsule += '.rerelease';
+            workingOptions.rerelease =
+              relations.textWithTooltip.slots({
+                attributes: {class: 'rerelease'},
+                text: language.$(entryCapsule, 'rerelease.term'),
+                tooltip: slots.rereleaseTooltip,
+              });
 
-      const parts = ['artistPage.creditList.entry'];
-      const options = {entry: accentedContent};
+            return language.$(workingCapsule, workingOptions);
+          }
 
-      if (slots.otherArtistLinks) {
-        parts.push('withArtists');
-        options.artists = language.formatConjunctionList(slots.otherArtistLinks);
-      }
+          if (!html.isBlank(slots.firstReleaseTooltip)) {
+            workingCapsule += '.firstRelease';
+            workingOptions.firstRelease =
+              relations.textWithTooltip.slots({
+                attributes: {class: 'first-release'},
+                text: language.$(entryCapsule, 'firstRelease.term'),
+                tooltip: slots.firstReleaseTooltip,
+              });
 
-      if (!html.isBlank(slots.annotation)) {
-        parts.push('withAnnotation');
-        options.annotation = slots.annotation;
-      }
+            return language.$(workingCapsule, workingOptions);
+          }
 
-      if (parts.length === 1) {
-        break accent;
-      }
+          let anyAccent = false;
 
-      accentedContent = language.formatString(...parts, options);
-    }
+          if (!empty(slots.otherArtistLinks)) {
+            anyAccent = true;
+            workingCapsule += '.withArtists';
+            workingOptions.artists =
+              language.formatConjunctionList(slots.otherArtistLinks);
+          }
 
-    return (
-      html.tag('li',
-        slots.rerelease && {class: 'rerelease'},
-        accentedContent));
-  },
+          if (!html.isBlank(slots.annotation)) {
+            anyAccent = true;
+            workingCapsule += '.withAnnotation';
+            workingOptions.annotation = slots.annotation;
+          }
+
+          if (anyAccent) {
+            return language.$(workingCapsule, workingOptions);
+          } else {
+            return slots.content;
+          }
+        }))),
 };
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..d0c5e14e 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',
@@ -20,7 +19,7 @@ export default {
 
   extraDependencies: ['html', 'language'],
 
-  query(artist) {
+  query(artist, filterWikiEditorCommentary) {
     const processEntry = ({
       thing,
       entry,
@@ -88,6 +87,12 @@ export default {
         .flatMap(thing =>
           thing.commentary
             .filter(entry => entry.artists.includes(artist))
+
+            .filter(({annotation}) =>
+              (filterWikiEditorCommentary
+                ? annotation?.match(/^wiki editor/i)
+                : !annotation?.match(/^wiki editor/i)))
+
             .map(entry => processEntry({thing, entry})));
 
     const processAlbumEntries = ({albums}) =>
@@ -147,7 +152,7 @@ export default {
     return {chunks};
   },
 
-  relations: (relation, query) => ({
+  relations: (relation, query, _artist, filterWikiEditorCommentary) => ({
     chunks:
       query.chunks
         .map(() => relation('generateArtistInfoPageChunk')),
@@ -180,12 +185,13 @@ export default {
       query.chunks
         .map(({chunk}) => chunk
           .map(({annotation}) =>
-            (annotation
-              ? relation('transformContent', annotation)
-              : null))),
+            relation('transformContent',
+              (filterWikiEditorCommentary
+                ? annotation?.replace(/^wiki editor(, )?/i, '')
+                : annotation)))),
   }),
 
-  data: (query) => ({
+  data: (query, _artist, _filterWikiEditorCommentary) => ({
     chunkTypes:
       query.chunks
         .map(({chunkType}) => chunkType),
@@ -198,6 +204,8 @@ export default {
 
   generate: (data, relations, {html, language}) =>
     html.tag('dl',
+      {[html.onlyIfContent]: true},
+
       stitchArrays({
         chunk: relations.chunks,
         chunkLink: relations.chunkLinks,
@@ -217,53 +225,56 @@ 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.slots({
+                            mode: 'inline',
+                            absorbPunctuationFollowingExternalLinks: false,
+                          }),
+
+                        content:
+                          (type === 'album'
+                            ? html.tag('i',
+                                language.$(capsule, 'album.commentary'))
+                            : language.$(capsule, 'track', {track: link})),
+                      })),
+                })
+           : chunkType === 'flash-act'
+              ? chunk.slots({
+                  mode: 'flash',
+                  flashActLink: chunkLink,
+                  items:
+                    stitchArrays({
+                      item: items,
+                      link: itemLinks,
+                      annotation: itemAnnotations,
+                    }).map(({item, link, annotation}) =>
+                      item.slots({
+                        annotation:
+                          (annotation
+                            ? annotation.slots({
+                                mode: 'inline',
+                                absorbPunctuationFollowingExternalLinks: false,
+                              })
+                            : null),
+
+                        content:
+                          language.$(capsule, 'flash', {
+                            flash: link,
+                          }),
+                      })),
+                })
+              : null)))),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
new file mode 100644
index 00000000..f86dead7
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
@@ -0,0 +1,75 @@
+import {sortChronologically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateTooltip',
+    'linkOtherReleaseOnArtistInfoPage',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    rereleases:
+      sortChronologically(track.allReleases).slice(1),
+  }),
+
+  relations: (relation, query, track, artist) => ({
+    tooltip:
+      relation('generateTooltip'),
+
+    firstReleaseColorStyle:
+      relation('generateColorStyleAttribute', track.color),
+
+    rereleaseLinks:
+      query.rereleases
+        .map(rerelease =>
+          relation('linkOtherReleaseOnArtistInfoPage', rerelease, artist)),
+  }),
+
+  data: (query, track) => ({
+    firstReleaseDate:
+      track.dateFirstReleased ??
+      track.album.date,
+
+    rereleaseDates:
+      query.rereleases
+        .map(rerelease =>
+          rerelease.dateFirstReleased ??
+          rerelease.album.date),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry.firstRelease', capsule =>
+      relations.tooltip.slots({
+        attributes: [
+          {class: 'first-release-tooltip'},
+          relations.firstReleaseColorStyle,
+        ],
+
+        contentAttributes: [
+          {[html.joinChildren]: html.tag('hr', {class: 'cute'})},
+        ],
+
+        content:
+          stitchArrays({
+            rereleaseLink: relations.rereleaseLinks,
+            rereleaseDate: data.rereleaseDates,
+          }).map(({rereleaseLink, rereleaseDate}) =>
+              html.tags([
+                language.$(capsule, 'rerelease', {
+                  album:
+                    html.metatag('blockwrap', rereleaseLink),
+                }),
+
+                html.tag('br'),
+
+                language.formatRelativeDate(rereleaseDate, data.firstReleaseDate, {
+                  considerRoundingDays: true,
+                  approximate: true,
+                  absolute: true,
+                }),
+              ])),
+      })),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunk.js b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js
new file mode 100644
index 00000000..8aa7223a
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js
@@ -0,0 +1,34 @@
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageFlashesChunkItem',
+    'linkFlashAct',
+  ],
+
+  relations: (relation, flashAct, contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunk'),
+
+    flashActLink:
+      relation('linkFlashAct', flashAct),
+
+    items:
+      contribs
+        .map(contrib =>
+          relation('generateArtistInfoPageFlashesChunkItem', contrib)),
+  }),
+
+  data: (_flashAct, contribs) => ({
+    dates:
+      contribs
+        .map(contrib => contrib.date),
+  }),
+
+  generate: (data, relations) =>
+    relations.template.slots({
+      mode: 'flash',
+      flashActLink: relations.flashActLink,
+      dates: data.dates,
+      items: relations.items,
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js
new file mode 100644
index 00000000..e4908bf9
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js
@@ -0,0 +1,34 @@
+export default {
+  contentDependencies: ['generateArtistInfoPageChunkItem', 'linkFlash'],
+
+  extraDependencies: ['language'],
+
+  relations: (relation, contrib) => ({
+    // Flashes and games can list multiple contributors as collaborative
+    // credits, but we don't display these on the artist page, since they
+    // usually involve many artists crediting a larger team where collaboration
+    // isn't as relevant (without more particular details that aren't tracked
+    // on the wiki).
+
+    template:
+      relation('generateArtistInfoPageChunkItem'),
+
+    flashLink:
+      relation('linkFlash', contrib.thing),
+  }),
+
+  data: (contrib) => ({
+    annotation:
+      contrib.annotation,
+  }),
+
+  generate: (data, relations, {language}) =>
+    relations.template.slots({
+      annotation: data.annotation,
+
+      content:
+        language.$('artistPage.creditList.entry.flash', {
+          flash: relations.flashLink,
+        }),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
index 88a97af2..b347faf5 100644
--- a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
@@ -1,149 +1,62 @@
-import {sortEntryThingPairs, sortFlashesChronologically} from '#sort';
-import {chunkByProperties, stitchArrays} from '#sugar';
+import {sortContributionsChronologically, sortFlashesChronologically}
+  from '#sort';
+import {chunkByConditions, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateArtistInfoPageChunk',
-    'generateArtistInfoPageChunkItem',
-    'linkFlash',
+    'generateArtistInfoPageChunkedList',
+    'generateArtistInfoPageFlashesChunk',
   ],
 
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['wikiData'],
 
-  query(artist) {
-    const processFlashEntry = ({flash, contribs}) => ({
-      thing: flash,
-      entry: {
-        flash: flash,
-        act: flash.act,
-        contribs: contribs,
-      },
-    });
+  sprawl: ({wikiInfo}) => ({
+    enableFlashesAndGames:
+      wikiInfo.enableFlashesAndGames,
+  }),
 
-    const processFlashEntries = ({flashes, contribs}) =>
-      stitchArrays({
-        flash: flashes,
-        contribs: contribs,
-      }).map(processFlashEntry);
-
-    const {flashesAsContributor} = artist;
-
-    const flashesAsContributorContribs =
-      flashesAsContributor
-        .map(flash => flash.contributorContribs);
-
-    const flashesAsContributorEntries =
-      processFlashEntries({
-        flashes: flashesAsContributor,
-        contribs: flashesAsContributorContribs,
-      });
-
-    const entries = [
-      ...flashesAsContributorEntries,
-    ];
-
-    sortEntryThingPairs(entries, sortFlashesChronologically);
-
-    const chunks =
-      chunkByProperties(
-        entries.map(({entry}) => entry),
-        ['act']);
-
-    return {chunks};
-  },
+  query(sprawl, artist) {
+    const query = {};
 
-  relations(relation, query) {
-    // Flashes and games can list multiple contributors as collaborative
-    // credits, but we don't display these on the artist page, since they
-    // usually involve many artists crediting a larger team where collaboration
-    // isn't as relevant (without more particular details that aren't tracked
-    // on the wiki).
+    const allContributions =
+      (sprawl.enableFlashesAndGames
+        ? [
+            ...artist.flashContributorContributions,
+          ]
+      : []);
 
-    return {
-      chunks:
-        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
+    sortContributionsChronologically(
+      allContributions,
+      sortFlashesChronologically);
 
-      actLinks:
-        query.chunks.map(({chunk}) =>
-          relation('linkFlash', chunk[0].flash)),
+    query.contribs =
+      chunkByConditions(allContributions, [
+        ({thing: flash1}, {thing: flash2}) =>
+          flash1.act !== flash2.act,
+      ]);
 
-      items:
-        query.chunks.map(({chunk}) =>
-          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+    query.flashActs =
+      query.contribs
+        .map(contribs => contribs[0].thing)
+        .map(thing => thing.act);
 
-      itemFlashLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({flash}) => relation('linkFlash', flash))),
-    };
+    return query;
   },
 
-  data(query, artist) {
-    return {
-      actNames:
-        query.chunks.map(({act}) => act.name),
+  relations: (relation, query, _sprawl, _artist) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
 
-      firstDates:
-        query.chunks.map(({chunk}) => chunk[0].flash.date ?? null),
-
-      lastDates:
-        query.chunks.map(({chunk}) => chunk.at(-1).flash.date ?? null),
-
-      itemContributions:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({contribs}) =>
-            contribs
-              .find(({who}) => who === artist)
-              .what)),
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    return html.tag('dl',
+    chunks:
       stitchArrays({
-        chunk: relations.chunks,
-        actLink: relations.actLinks,
-        actName: data.actNames,
-        firstDate: data.firstDates,
-        lastDate: data.lastDates,
-
-        items: relations.items,
-        itemFlashLinks: relations.itemFlashLinks,
-        itemContributions: data.itemContributions,
-      }).map(({
-          chunk,
-          actLink,
-          actName,
-          firstDate,
-          lastDate,
-
-          items,
-          itemFlashLinks,
-          itemContributions,
-        }) =>
-          chunk.slots({
-            mode: 'flash',
-            flashActLink: actLink.slot('content', actName),
-            dateRangeStart: firstDate,
-            dateRangeEnd: lastDate,
-
-            items:
-              stitchArrays({
-                item: items,
-                flashLink: itemFlashLinks,
-                contribution: itemContributions,
-              }).map(({
-                  item,
-                  flashLink,
-                  contribution,
-                }) =>
-                  item.slots({
-                    annotation: contribution,
-
-                    content:
-                      language.$('artistPage.creditList.entry.flash', {
-                        flash: flashLink,
-                      }),
-                  })),
-          })));
-  },
+        flashAct: query.flashActs,
+        contribs: query.contribs,
+      }).map(({flashAct, contribs}) =>
+          relation('generateArtistInfoPageFlashesChunk', flashAct, contribs)),
+  }),
+
+  generate: (relations) =>
+    relations.chunkedList.slots({
+      chunks: relations.chunks,
+    }),
 };
diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
index dea7742a..dcee9c00 100644
--- a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
+++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
@@ -1,23 +1,30 @@
-import {empty} from '#sugar';
+import {unique} from '#sugar';
 
 export default {
   contentDependencies: ['linkArtist'],
 
-  relations(relation, contribs, artist) {
-    const otherArtistContribs = contribs.filter(({who}) => who !== artist);
+  query(contribs) {
+    const associatedContributionsByOtherArtists =
+      contribs
+        .flatMap(ownContrib =>
+          ownContrib.associatedContributions
+            .filter(associatedContrib =>
+              associatedContrib.artist !== ownContrib.artist));
 
-    if (empty(otherArtistContribs)) {
-      return {};
-    }
+    const otherArtists =
+      unique(
+        associatedContributionsByOtherArtists
+          .map(contrib => contrib.artist));
 
-    const otherArtistLinks =
-      otherArtistContribs
-        .map(({who}) => relation('linkArtist', who));
-
-    return {otherArtistLinks};
+    return {otherArtists};
   },
 
-  generate(relations) {
-    return relations.otherArtistLinks ?? null;
-  },
+  relations: (relation, query) => ({
+    artistLinks:
+      query.otherArtists
+        .map(artist => relation('linkArtist', artist)),
+  }),
+
+  generate: (relations) =>
+    relations.artistLinks,
 };
diff --git a/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
new file mode 100644
index 00000000..1d849919
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
@@ -0,0 +1,61 @@
+import {sortChronologically} from '#sort';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateTooltip',
+    'linkOtherReleaseOnArtistInfoPage'
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    firstRelease:
+      sortChronologically(track.allReleases)[0],
+  }),
+
+  relations: (relation, query, track, artist) => ({
+    tooltip:
+      relation('generateTooltip'),
+
+    rereleaseColorStyle:
+      relation('generateColorStyleAttribute', track.color),
+
+    firstReleaseLink:
+      relation('linkOtherReleaseOnArtistInfoPage', query.firstRelease, artist),
+  }),
+
+  data: (query, track) => ({
+    rereleaseDate:
+      track.dateFirstReleased ??
+      track.album.date,
+
+    firstReleaseDate:
+      query.firstRelease.dateFirstReleased ??
+      query.firstRelease.album.date,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry.rerelease', capsule =>
+      relations.tooltip.slots({
+        attributes: [
+          {class: 'rerelease-tooltip'},
+          relations.rereleaseColorStyle,
+        ],
+
+        content: [
+          language.$(capsule, 'firstRelease', {
+            album:
+              html.metatag('blockwrap', relations.firstReleaseLink),
+          }),
+
+          html.tag('br'),
+
+          language.formatRelativeDate(data.firstReleaseDate, data.rereleaseDate, {
+            considerRoundingDays: true,
+            approximate: true,
+            absolute: true,
+          }),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunk.js b/src/content/dependencies/generateArtistInfoPageTracksChunk.js
new file mode 100644
index 00000000..f6d70901
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunk.js
@@ -0,0 +1,67 @@
+import {unique} from '#sugar';
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageTracksChunkItem',
+    'linkAlbum',
+  ],
+
+  relations: (relation, artist, album, trackContribLists) => ({
+    template:
+      relation('generateArtistInfoPageChunk'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    // Intentional mapping here: each item may be associated with
+    // more than one contribution.
+    items:
+      trackContribLists.map(trackContribs =>
+        relation('generateArtistInfoPageTracksChunkItem',
+          artist,
+          trackContribs)),
+  }),
+
+  data(_artist, album, trackContribLists) {
+    const data = {};
+
+    const contribs =
+      trackContribLists.flat();
+
+    data.dates =
+      contribs
+        .map(contrib => contrib.date);
+
+    // TODO: Duration stuff should *maybe* be in proper data logic? Maaaybe?
+    const durationTerms =
+      unique(
+        contribs
+          .filter(contrib => contrib.countInDurationTotals)
+          .map(contrib => contrib.thing)
+          .filter(track => track.isMainRelease)
+          .filter(track => track.duration > 0));
+
+    data.duration =
+      getTotalDuration(durationTerms);
+
+    data.durationApproximate =
+      durationTerms.length > 1;
+
+    return data;
+  },
+
+  generate: (data, relations) =>
+    relations.template.slots({
+      mode: 'album',
+
+      albumLink: relations.albumLink,
+
+      dates: data.dates,
+      duration: data.duration,
+      durationApproximate: data.durationApproximate,
+
+      items: relations.items,
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
new file mode 100644
index 00000000..a42d6fee
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
@@ -0,0 +1,146 @@
+import {sortChronologically} from '#sort';
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageFirstReleaseTooltip',
+    'generateArtistInfoPageOtherArtistLinks',
+    'generateArtistInfoPageRereleaseTooltip',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query (_artist, contribs) {
+    const query = {};
+
+    // TODO: Very mysterious what to do if the set of contributions is,
+    // in total, associated with more than one thing. No design yet.
+    query.track =
+      contribs[0].thing;
+
+    const creditedAsArtist =
+      contribs
+        .some(contrib => contrib.isArtistContribution);
+
+    const creditedAsContributor =
+      contribs
+        .some(contrib => contrib.isContributorContribution);
+
+    const annotatedContribs =
+      contribs
+        .filter(contrib => contrib.annotation);
+
+    const annotatedArtistContribs =
+      annotatedContribs
+        .filter(contrib => contrib.isArtistContribution);
+
+    const annotatedContributorContribs =
+      annotatedContribs
+        .filter(contrib => contrib.isContributorContribution);
+
+    // Don't display annotations associated with crediting in the
+    // Contributors field if the artist is also credited as an Artist
+    // *and* the Artist-field contribution is non-annotated. This is
+    // so that we don't misrepresent the artist - the contributor
+    // annotation tends to be for "secondary" and performance roles.
+    // For example, this avoids crediting Marcy Nabors on Renewed
+    // Return seemingly only for "bass clarinet" when they're also
+    // the one who composed and arranged Renewed Return!
+    if (
+      creditedAsArtist &&
+      creditedAsContributor &&
+      empty(annotatedArtistContribs)
+    ) {
+      query.displayedContributions = null;
+    } else if (
+      !empty(annotatedArtistContribs) ||
+      !empty(annotatedContributorContribs)
+    ) {
+      query.displayedContributions = [
+        ...annotatedArtistContribs,
+        ...annotatedContributorContribs,
+      ];
+    }
+
+    // It's kinda awkward to perform this chronological sort here,
+    // per track, rather than just reusing the one that's done to
+    // sort all the items on the page altogether... but then, the
+    // sort for the page is actually *a different* sort, on purpsoe.
+    // That sort is according to the dates of the contributions;
+    // this is according to the dates of the tracks. Those can be
+    // different - and it's the latter that determines whether the
+    // track is a rerelease!
+    const allReleasesChronologically =
+      sortChronologically(query.track.allReleases);
+
+    query.isFirstRelease =
+      allReleasesChronologically[0] === query.track;
+
+    query.isRerelease =
+      allReleasesChronologically[0] !== query.track;
+
+    query.hasOtherReleases =
+      !empty(query.track.otherReleases);
+
+    return query;
+  },
+
+  relations: (relation, query, artist, contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunkItem'),
+
+    trackLink:
+      relation('linkTrack', query.track),
+
+    otherArtistLinks:
+      relation('generateArtistInfoPageOtherArtistLinks', contribs),
+
+    rereleaseTooltip:
+      (query.isRerelease
+        ? relation('generateArtistInfoPageRereleaseTooltip', query.track, artist)
+        : null),
+
+    firstReleaseTooltip:
+      (query.isFirstRelease && query.hasOtherReleases
+        ? relation('generateArtistInfoPageFirstReleaseTooltip', query.track, artist)
+        : null),
+  }),
+
+  data: (query) => ({
+    duration:
+      query.track.duration,
+
+    contribAnnotations:
+      (query.displayedContributions
+        ? query.displayedContributions
+            .map(contrib => contrib.annotation)
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.template.slots({
+      otherArtistLinks: relations.otherArtistLinks,
+      rereleaseTooltip: relations.rereleaseTooltip,
+      firstReleaseTooltip: relations.firstReleaseTooltip,
+
+      annotation:
+        (data.contribAnnotations
+          ? language.formatUnitList(data.contribAnnotations)
+          : html.blank()),
+
+      content:
+        language.encapsulate('artistPage.creditList.entry.track', workingCapsule => {
+          const workingOptions = {track: relations.trackLink};
+
+          if (data.duration) {
+            workingCapsule += '.withDuration';
+            workingOptions.duration =
+              language.formatDuration(data.duration);
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
index f003779d..84eb29ac 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -1,293 +1,81 @@
-import {sortAlbumsTracksChronologically, sortEntryThingPairs} from '#sort';
-import {accumulateSum, chunkByProperties, empty, stitchArrays} from '#sugar';
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {stitchArrays} from '#sugar';
+import {chunkArtistTrackContributions} from '#wiki-data';
 
 export default {
   contentDependencies: [
-    'generateArtistInfoPageChunk',
     'generateArtistInfoPageChunkedList',
-    'generateArtistInfoPageChunkItem',
-    'generateArtistInfoPageOtherArtistLinks',
-    'linkAlbum',
-    'linkTrack',
+    'generateArtistInfoPageTracksChunk',
   ],
 
-  extraDependencies: ['html', 'language'],
-
   query(artist) {
-    const processTrackEntry = ({track, contribs}) => ({
-      thing: track,
-      entry: {
-        track: track,
-        album: track.album,
-        date: track.date,
-        contribs: contribs,
-      },
-    });
-
-    const processTrackEntries = ({tracks, contribs}) =>
-      stitchArrays({
-        track: tracks,
-        contribs: contribs,
-      }).map(processTrackEntry);
-
-    const {tracksAsArtist, tracksAsContributor} = artist;
-
-    const tracksAsArtistAndContributor =
-      tracksAsArtist
-        .filter(track => tracksAsContributor.includes(track));
-
-    const tracksAsArtistOnly =
-      tracksAsArtist
-        .filter(track => !tracksAsContributor.includes(track));
-
-    const tracksAsContributorOnly =
-      tracksAsContributor
-        .filter(track => !tracksAsArtist.includes(track));
-
-    const tracksAsArtistAndContributorContribs =
-      tracksAsArtistAndContributor
-        .map(track => [
-          ...
-            track.artistContribs
-              .map(contrib => ({...contrib, kind: 'artist'})),
-          ...
-            track.contributorContribs
-              .map(contrib => ({...contrib, kind: 'contributor'})),
-        ]);
-
-    const tracksAsArtistOnlyContribs =
-      tracksAsArtistOnly
-        .map(track => track.artistContribs
-          .map(contrib => ({...contrib, kind: 'artist'})));
-
-    const tracksAsContributorOnlyContribs =
-      tracksAsContributorOnly
-        .map(track => track.contributorContribs
-          .map(contrib => ({...contrib, kind: 'contributor'})));
+    const query = {};
 
-    const tracksAsArtistAndContributorEntries =
-      processTrackEntries({
-        tracks: tracksAsArtistAndContributor,
-        contribs: tracksAsArtistAndContributorContribs,
-      });
-
-    const tracksAsArtistOnlyEntries =
-      processTrackEntries({
-        tracks: tracksAsArtistOnly,
-        contribs: tracksAsArtistOnlyContribs,
-      });
-
-    const tracksAsContributorOnlyEntries =
-      processTrackEntries({
-        tracks: tracksAsContributorOnly,
-        contribs: tracksAsContributorOnlyContribs,
-      });
-
-    const entries = [
-      ...tracksAsArtistAndContributorEntries,
-      ...tracksAsArtistOnlyEntries,
-      ...tracksAsContributorOnlyEntries,
+    const allContributions = [
+      ...artist.trackArtistContributions,
+      ...artist.trackContributorContributions,
     ];
 
-    sortEntryThingPairs(entries, sortAlbumsTracksChronologically);
-
-    const chunks =
-      chunkByProperties(
-        entries.map(({entry}) => entry),
-        ['album', 'date']);
-
-    return {chunks};
-  },
-
-  relations(relation, query, artist) {
-    return {
-      chunkedList:
-        relation('generateArtistInfoPageChunkedList'),
-
-      chunks:
-        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
-
-      albumLinks:
-        query.chunks.map(({album}) => relation('linkAlbum', album)),
+    sortContributionsChronologically(
+      allContributions,
+      sortAlbumsTracksChronologically);
 
-      items:
-        query.chunks.map(({chunk}) =>
-          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+    query.contribs =
+      chunkArtistTrackContributions(allContributions);
 
-      trackLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) => relation('linkTrack', track))),
+    query.albums =
+      query.contribs
+        .map(contribs =>
+          contribs[0][0].thing.album);
 
-      trackOtherArtistLinks:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))),
-    };
+    return query;
   },
 
-  data(query, artist) {
-    return {
-      chunkDates:
-        query.chunks.map(({date}) => date),
-
-      chunkDurations:
-        query.chunks.map(({chunk}) =>
-          accumulateSum(
-            chunk
-              .filter(({track}) => track.duration && track.originalReleaseTrack === null)
-              .map(({track}) => track.duration))),
-
-      chunkDurationsApproximate:
-        query.chunks.map(({chunk}) =>
-          chunk
-            .filter(({track}) => track.duration && track.originalReleaseTrack === null)
-            .length > 1),
-
-      trackDurations:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) => track.duration)),
-
-      trackContributions:
-        query.chunks.map(({chunk}) =>
-          chunk
-            .map(({contribs}) =>
-              contribs.filter(({who}) => who === artist))
-            .map(ownContribs => ({
-              creditedAsArtist:
-                ownContribs
-                  .some(({kind}) => kind === 'artist'),
-
-              creditedAsContributor:
-                ownContribs
-                  .some(({kind}) => kind === 'contributor'),
-
-              annotatedContribs:
-                ownContribs
-                  .filter(({what}) => what),
-            }))
-            .map(({annotatedContribs, ...rest}) => ({
-              ...rest,
-
-              annotatedArtistContribs:
-                annotatedContribs
-                  .filter(({kind}) => kind === 'artist'),
-
-              annotatedContributorContribs:
-                annotatedContribs
-                  .filter(({kind}) => kind === 'contributor'),
-            }))
-            .map(({
-              creditedAsArtist,
-              creditedAsContributor,
-              annotatedArtistContribs,
-              annotatedContributorContribs,
-            }) => {
-              // Don't display annotations associated with crediting in the
-              // Contributors field if the artist is also credited as an Artist
-              // *and* the Artist-field contribution is non-annotated. This is
-              // so that we don't misrepresent the artist - the contributor
-              // annotation tends to be for "secondary" and performance roles.
-              // For example, this avoids crediting Marcy Nabors on Renewed
-              // Return seemingly only for "bass clarinet" when they're also
-              // the one who composed and arranged Renewed Return!
-              if (
-                creditedAsArtist &&
-                creditedAsContributor &&
-                empty(annotatedArtistContribs)
-              ) {
-                return [];
-              }
-
-              return [
-                ...annotatedArtistContribs,
-                ...annotatedContributorContribs,
-              ];
-            })
-            .map(contribs =>
-              contribs.map(({what}) => what))
-            .map(contributions =>
-              (empty(contributions)
-                ? null
-                : contributions))),
+  relations: (relation, query, artist) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
 
-      trackRereleases:
-        query.chunks.map(({chunk}) =>
-          chunk.map(({track}) => track.originalReleaseTrack !== null)),
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    return relations.chunkedList.slots({
+    chunks:
+      stitchArrays({
+        album: query.albums,
+        contribs: query.contribs,
+      }).map(({album, contribs}) =>
+          relation('generateArtistInfoPageTracksChunk',
+            artist,
+            album,
+            contribs)),
+  }),
+
+  data: (query, _artist) => ({
+    albumDirectories:
+      query.albums
+        .map(album => album.directory),
+
+    albumChunkIndices:
+      query.albums
+        .reduce(([indices, map], album) => {
+          if (map.has(album)) {
+            const n = map.get(album);
+            indices.push(n);
+            map.set(album, n + 1);
+          } else {
+            indices.push(0);
+            map.set(album, 1);
+          }
+          return [indices, map];
+        }, [[], new Map()])
+        [0],
+  }),
+
+  generate: (data, relations) =>
+    relations.chunkedList.slots({
       chunks:
         stitchArrays({
           chunk: relations.chunks,
-          albumLink: relations.albumLinks,
-          date: data.chunkDates,
-          duration: data.chunkDurations,
-          durationApproximate: data.chunkDurationsApproximate,
-
-          items: relations.items,
-          trackLinks: relations.trackLinks,
-          trackOtherArtistLinks: relations.trackOtherArtistLinks,
-          trackDurations: data.trackDurations,
-          trackContributions: data.trackContributions,
-          trackRereleases: data.trackRereleases,
-        }).map(({
-            chunk,
-            albumLink,
-            date,
-            duration,
-            durationApproximate,
-
-            items,
-            trackLinks,
-            trackOtherArtistLinks,
-            trackDurations,
-            trackContributions,
-            trackRereleases,
-          }) =>
-            chunk.slots({
-              mode: 'album',
-              albumLink,
-              date,
-              duration,
-              durationApproximate,
-
-              items:
-                stitchArrays({
-                  item: items,
-                  trackLink: trackLinks,
-                  otherArtistLinks: trackOtherArtistLinks,
-                  duration: trackDurations,
-                  contribution: trackContributions,
-                  rerelease: trackRereleases,
-                }).map(({
-                    item,
-                    trackLink,
-                    otherArtistLinks,
-                    duration,
-                    contribution,
-                    rerelease,
-                  }) =>
-                    item.slots({
-                      otherArtistLinks,
-                      rerelease,
-
-                      annotation:
-                        (contribution
-                          ? language.formatUnitList(contribution)
-                          : html.blank()),
-
-                      content:
-                        (duration
-                          ? language.$('artistPage.creditList.entry.track.withDuration', {
-                              track: trackLink,
-                              duration: language.formatDuration(duration),
-                            })
-                          : language.$('artistPage.creditList.entry.track', {
-                              track: trackLink,
-                            })),
-                    })),
-            })),
-    });
-  },
+          albumDirectory: data.albumDirectories,
+          albumChunkIndex: data.albumChunkIndices,
+        }).map(({chunk, albumDirectory, albumChunkIndex}) =>
+            chunk.slot('id', `tracks-${albumDirectory}-${albumChunkIndex}`)),
+    }),
 };
diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js
index aa95dba2..1b4b6eca 100644
--- a/src/content/dependencies/generateArtistNavLinks.js
+++ b/src/content/dependencies/generateArtistNavLinks.js
@@ -2,43 +2,44 @@ import {empty} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateInterpageDotSwitcher',
     'linkArtist',
     'linkArtistGallery',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({wikiInfo}) {
-    return {
-      enableListings: wikiInfo.enableListings,
-    };
-  },
+  sprawl: ({wikiInfo}) => ({
+    enableListings:
+      wikiInfo.enableListings,
+  }),
 
-  relations(relation, sprawl, artist) {
-    const relations = {};
+  query: (_sprawl, artist) => ({
+    hasGallery:
+      !empty(artist.albumCoverArtistContributions) ||
+      !empty(artist.trackCoverArtistContributions),
+  }),
 
-    relations.artistMainLink =
-      relation('linkArtist', artist);
+  relations: (relation, query, _sprawl, artist) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
 
-    relations.artistInfoLink =
-      relation('linkArtist', artist);
+    artistMainLink:
+      relation('linkArtist', artist),
 
-    if (
-      !empty(artist.albumsAsCoverArtist) ||
-      !empty(artist.tracksAsCoverArtist)
-    ) {
-      relations.artistGalleryLink =
-        relation('linkArtistGallery', artist);
-    }
+    artistInfoLink:
+      relation('linkArtist', artist),
 
-    return relations;
-  },
+    artistGalleryLink:
+      (query.hasGallery
+        ? relation('linkArtistGallery', artist)
+        : null),
+  }),
 
-  data(sprawl) {
-    return {
-      enableListings: sprawl.enableListings,
-    };
-  },
+  data: (_query, sprawl) => ({
+    enableListings:
+      sprawl.enableListings,
+  }),
 
   slots: {
     showExtraLinks: {type: 'boolean', default: false},
@@ -48,53 +49,46 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {html, language}) {
-    const infoLink =
-      relations.artistInfoLink?.slots({
-        attributes: {class: slots.currentExtra === null && 'current'},
-        content: language.$('misc.nav.info'),
-      });
-
-    const {content: extraLinks = []} =
-      slots.showExtraLinks &&
-        {content: [
-          relations.artistGalleryLink?.slots({
-            attributes: {class: slots.currentExtra === 'gallery' && 'current'},
-            content: language.$('misc.nav.gallery'),
-          }),
-        ]};
-
-    const mostAccentLinks = [
-      ...extraLinks,
-    ].filter(Boolean);
-
-    // Don't show the info accent link all on its own.
-    const allAccentLinks =
-      (empty(mostAccentLinks)
-        ? []
-        : [infoLink, ...mostAccentLinks]);
-
-    const accent =
-      (empty(allAccentLinks)
-        ? html.blank()
-        : `(${language.formatUnitList(allAccentLinks)})`);
-
-    return [
-      {auto: 'home'},
-
-      data.enableListings &&
-        {
-          path: ['localized.listingIndex'],
-          title: language.$('listingIndex.title'),
-        },
+  generate: (data, relations, slots, {html, language}) => [
+    {auto: 'home'},
 
+    data.enableListings &&
       {
-        accent,
-        html:
-          language.$('artistPage.nav.artist', {
-            artist: relations.artistMainLink,
-          }),
+        path: ['localized.listingIndex'],
+        title: language.$('listingIndex.title'),
       },
-    ];
-  },
+
+    {
+      html:
+        language.$('artistPage.nav.artist', {
+          artist: relations.artistMainLink,
+        }),
+
+      accent:
+        relations.switcher.slots({
+          links: [
+            relations.artistInfoLink.slots({
+              attributes: [
+                slots.currentExtra === null &&
+                  {class: 'current'},
+
+                {[html.onlyIfSiblings]: true},
+              ],
+
+              content: language.$('misc.nav.info'),
+            }),
+
+            slots.showExtraLinks &&
+              relations.artistGalleryLink?.slots({
+                attributes: [
+                  slots.currentExtra === 'gallery' &&
+                    {class: 'current'},
+                ],
+
+                content: language.$('misc.nav.gallery'),
+              }),
+          ],
+        }),
+    },
+  ],
 };
diff --git a/src/content/dependencies/generateBackToAlbumLink.js b/src/content/dependencies/generateBackToAlbumLink.js
new file mode 100644
index 00000000..6648b463
--- /dev/null
+++ b/src/content/dependencies/generateBackToAlbumLink.js
@@ -0,0 +1,15 @@
+export default {
+  contentDependencies: ['linkAlbum'],
+  extraDependencies: ['language'],
+
+  relations: (relation, track) => ({
+    trackLink:
+      relation('linkAlbum', track),
+  }),
+
+  generate: (relations, {language}) =>
+    relations.trackLink.slots({
+      content: language.$('albumPage.nav.backToAlbum'),
+      color: false,
+    }),
+};
diff --git a/src/content/dependencies/generateBackToTrackLink.js b/src/content/dependencies/generateBackToTrackLink.js
new file mode 100644
index 00000000..8677d811
--- /dev/null
+++ b/src/content/dependencies/generateBackToTrackLink.js
@@ -0,0 +1,15 @@
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['language'],
+
+  relations: (relation, track) => ({
+    trackLink:
+      relation('linkTrack', track),
+  }),
+
+  generate: (relations, {language}) =>
+    relations.trackLink.slots({
+      content: language.$('trackPage.nav.backToTrack'),
+      color: false,
+    }),
+};
diff --git a/src/content/dependencies/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js
deleted file mode 100644
index 8ec6ee0a..00000000
--- a/src/content/dependencies/generateChronologyLinks.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import {accumulateSum, empty} from '#sugar';
-
-export default {
-  extraDependencies: ['html', 'language'],
-
-  slots: {
-    chronologyInfoSets: {
-      validate: v =>
-        v.strictArrayOf(
-          v.validateProperties({
-            headingString: v.isString,
-            contributions: v.strictArrayOf(v.validateProperties({
-              index: v.isCountingNumber,
-              artistLink: v.isHTML,
-              previousLink: v.isHTML,
-              nextLink: v.isHTML,
-            })),
-          })),
-    }
-  },
-
-  generate(slots, {html, language}) {
-    if (empty(slots.chronologyInfoSets)) {
-      return html.blank();
-    }
-
-    const totalContributionCount =
-      accumulateSum(
-        slots.chronologyInfoSets,
-        ({contributions}) => contributions.length);
-
-    if (totalContributionCount === 0) {
-      return html.blank();
-    }
-
-    if (totalContributionCount > 8) {
-      return html.tag('div', {class: 'chronology'},
-        language.$('misc.chronology.seeArtistPages'));
-    }
-
-    return html.tags(
-      slots.chronologyInfoSets.map(({
-        headingString,
-        contributions,
-      }) =>
-        contributions.map(({
-          index,
-          artistLink,
-          previousLink,
-          nextLink,
-        }) => {
-          const heading =
-            html.tag('span', {class: 'heading'},
-              language.$(headingString, {
-                index: language.formatIndex(index),
-                artist: artistLink,
-              }));
-
-          const navigation =
-            (previousLink || nextLink) &&
-              html.tag('span', {class: 'buttons'},
-                language.formatUnitList([
-                  previousLink?.slots({
-                    tooltipStyle: 'browser',
-                    color: false,
-                    content: language.$('misc.nav.previous'),
-                  }),
-
-                  nextLink?.slots({
-                    tooltipStyle: 'browser',
-                    color: false,
-                    content: language.$('misc.nav.next'),
-                  }),
-                ].filter(Boolean)));
-
-          return html.tag('div', {class: 'chronology'},
-            (navigation
-              ? language.$('misc.chronology.withNavigation', {heading, navigation})
-              : heading));
-        })));
-  },
-};
diff --git a/src/content/dependencies/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..c93020f3 100644
--- a/src/content/dependencies/generateCommentaryEntry.js
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -2,6 +2,7 @@ import {empty} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateCommentaryEntryDate',
     'generateColorStyleAttribute',
     'linkArtist',
     'transformContent',
@@ -33,66 +34,79 @@ export default {
 
     colorStyle:
       relation('generateColorStyleAttribute'),
-  }),
 
-  data: (entry) => ({
-    date: entry.date,
+    date:
+      relation('generateCommentaryEntryDate', entry),
   }),
 
   slots: {
     color: {validate: v => v.isColor},
   },
 
-  generate(data, relations, slots, {html, language}) {
-    const artistsSpan =
-      html.tag('span', {class: 'commentary-entry-artists'},
-        (relations.artistsContent
-          ? relations.artistsContent.slot('mode', 'inline')
-       : relations.artistLinks
-          ? language.formatConjunctionList(relations.artistLinks)
-          : language.$('misc.artistCommentary.entry.title.noArtists')));
-
-    const accentParts = ['misc.artistCommentary.entry.title.accent'];
-    const accentOptions = {};
-
-    if (relations.annotationContent) {
-      accentParts.push('withAnnotation');
-      accentOptions.annotation =
-        relations.annotationContent.slot('mode', 'inline');
-    }
-
-    if (data.date) {
-      accentParts.push('withDate');
-      accentOptions.date =
-        language.formatDate(data.date);
-    }
-
-    const accent =
-      (accentParts.length > 1
-        ? html.tag('span', {class: 'commentary-entry-accent'},
-            language.$(...accentParts, accentOptions))
-        : null);
-
-    const titleParts = ['misc.artistCommentary.entry.title'];
-    const titleOptions = {artists: artistsSpan};
-
-    if (accent) {
-      titleParts.push('withAccent');
-      titleOptions.accent = accent;
-    }
-
-    const style =
-      slots.color &&
-        relations.colorStyle.slot('color', slots.color);
-
-    return html.tags([
-      html.tag('p', {class: 'commentary-entry-heading'},
-        style,
-        language.$(...titleParts, titleOptions)),
-
-      html.tag('blockquote', {class: 'commentary-entry-body'},
-        style,
-        relations.bodyContent.slot('mode', 'multiline')),
-    ]);
-  },
+  generate: (relations, slots, {html, language}) =>
+    language.encapsulate('misc.artistCommentary.entry', entryCapsule =>
+      html.tags([
+        html.tag('p', {class: 'commentary-entry-heading'},
+          slots.color &&
+            relations.colorStyle.clone()
+              .slot('color', slots.color),
+
+          !html.isBlank(relations.date) &&
+            {class: 'dated'},
+
+          language.encapsulate(entryCapsule, 'title', titleCapsule => [
+            html.tag('span', {class: 'commentary-entry-heading-text'},
+              language.encapsulate(titleCapsule, workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.artists =
+                  html.tag('span', {class: 'commentary-entry-artists'},
+                    (relations.artistsContent
+                      ? relations.artistsContent.slot('mode', 'inline')
+                   : relations.artistLinks
+                      ? language.formatConjunctionList(relations.artistLinks)
+                      : language.$(titleCapsule, 'noArtists')));
+
+                const accent =
+                  html.tag('span', {class: 'commentary-entry-accent'},
+                    {[html.onlyIfContent]: true},
+
+                    language.encapsulate(titleCapsule, 'accent', accentCapsule =>
+                      language.encapsulate(accentCapsule, workingCapsule => {
+                        const workingOptions = {};
+
+                        if (relations.annotationContent) {
+                          workingCapsule += '.withAnnotation';
+                          workingOptions.annotation =
+                            relations.annotationContent.slots({
+                              mode: 'inline',
+                              absorbPunctuationFollowingExternalLinks: false,
+                            });
+                        }
+
+                        if (workingCapsule === accentCapsule) {
+                          return html.blank();
+                        } else {
+                          return language.$(workingCapsule, workingOptions);
+                        }
+                      })));
+
+                if (!html.isBlank(accent)) {
+                  workingCapsule += '.withAccent';
+                  workingOptions.accent = accent;
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })),
+
+            relations.date,
+          ])),
+
+        html.tag('blockquote', {class: 'commentary-entry-body'},
+          slots.color &&
+            relations.colorStyle.clone()
+              .slot('color', slots.color),
+
+          relations.bodyContent.slot('mode', 'multiline')),
+      ])),
 };
diff --git a/src/content/dependencies/generateCommentaryEntryDate.js b/src/content/dependencies/generateCommentaryEntryDate.js
new file mode 100644
index 00000000..f1cf5cb3
--- /dev/null
+++ b/src/content/dependencies/generateCommentaryEntryDate.js
@@ -0,0 +1,93 @@
+export default {
+  contentDependencies: ['generateTextWithTooltip', 'generateTooltip'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _entry) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateTooltip'),
+  }),
+
+  data: (entry) => ({
+    date: entry.date,
+    secondDate: entry.secondDate,
+    dateKind: entry.dateKind,
+
+    accessDate: entry.accessDate,
+    accessKind: entry.accessKind,
+  }),
+
+  generate(data, relations, {html, language}) {
+    const titleCapsule = language.encapsulate('misc.artistCommentary.entry.title');
+
+    const willDisplayTooltip =
+      !!(data.accessKind && data.accessDate);
+
+    const topAttributes =
+      {class: 'commentary-date'};
+
+    const time =
+      html.tag('time',
+        {[html.onlyIfContent]: true},
+
+        (willDisplayTooltip
+          ? {class: 'text-with-tooltip-interaction-cue'}
+          : topAttributes),
+
+        language.encapsulate(titleCapsule, 'date', workingCapsule => {
+          const workingOptions = {};
+
+          if (!data.date) {
+            return html.blank();
+          }
+
+          const rangeNeeded =
+            data.dateKind === 'sometime' ||
+            data.dateKind === 'throughout';
+
+          if (rangeNeeded && !data.secondDate) {
+            workingOptions.date = language.formatDate(data.date);
+            return language.$(workingCapsule, workingOptions);
+          }
+
+          if (data.dateKind) {
+            workingCapsule += '.' + data.dateKind;
+          }
+
+          if (data.secondDate) {
+            workingCapsule += '.range';
+            workingOptions.dateRange =
+              language.formatDateRange(data.date, data.secondDate);
+          } else {
+            workingOptions.date =
+              language.formatDate(data.date);
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }));
+
+    if (willDisplayTooltip) {
+      return relations.textWithTooltip.slots({
+        customInteractionCue: true,
+
+        attributes: topAttributes,
+        text: time,
+
+        tooltip:
+          relations.tooltip.slots({
+            attributes: {class: 'commentary-date-tooltip'},
+
+            content:
+              language.$(titleCapsule, 'date', data.accessKind, {
+                date:
+                  language.formatDate(data.accessDate),
+              }),
+          }),
+      });
+    } else {
+      return time;
+    }
+  },
+}
diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js
index 3c3504d2..d68ba42e 100644
--- a/src/content/dependencies/generateCommentaryIndexPage.js
+++ b/src/content/dependencies/generateCommentaryIndexPage.js
@@ -57,46 +57,48 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title: language.$('commentaryIndex.title'),
-
-      headingMode: 'static',
-
-      mainClasses: ['long-content'],
-      mainContent: [
-        html.tag('p', language.$('commentaryIndex.infoLine', {
-          words:
-            html.tag('b',
-              language.formatWordCount(data.totalWordCount, {unit: true})),
-
-          entries:
-            html.tag('b',
-                language.countCommentaryEntries(data.totalEntryCount, {unit: true})),
-        })),
-
-        html.tag('p',
-          language.$('commentaryIndex.albumList.title')),
-
-        html.tag('ul',
-          stitchArrays({
-            albumLink: relations.albumLinks,
-            wordCount: data.wordCounts,
-            entryCount: data.entryCounts,
-          }).map(({albumLink, wordCount, entryCount}) =>
-            html.tag('li',
-              language.$('commentaryIndex.albumList.item', {
-                album: albumLink,
-                words: language.formatWordCount(wordCount, {unit: true}),
-                entries: language.countCommentaryEntries(entryCount, {unit: true}),
-              })))),
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {auto: 'current'},
-      ],
-    });
-  },
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('commentaryIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+
+        headingMode: 'static',
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          html.tag('p', language.$(pageCapsule, 'infoLine', {
+            words:
+              html.tag('b',
+                language.formatWordCount(data.totalWordCount, {unit: true})),
+
+            entries:
+              html.tag('b',
+                  language.countCommentaryEntries(data.totalEntryCount, {unit: true})),
+          })),
+
+          language.encapsulate(pageCapsule, 'albumList', listCapsule => [
+            html.tag('p',
+              language.$(listCapsule, 'title')),
+
+            html.tag('ul',
+              stitchArrays({
+                albumLink: relations.albumLinks,
+                wordCount: data.wordCounts,
+                entryCount: data.entryCounts,
+              }).map(({albumLink, wordCount, entryCount}) =>
+                html.tag('li',
+                  language.$(listCapsule, 'item', {
+                    album: albumLink,
+                    words: language.formatWordCount(wordCount, {unit: true}),
+                    entries: language.countCommentaryEntries(entryCount, {unit: true}),
+                  })))),
+          ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js
deleted file mode 100644
index 8ae1b2d0..00000000
--- a/src/content/dependencies/generateCommentarySection.js
+++ /dev/null
@@ -1,29 +0,0 @@
-export default {
-  contentDependencies: [
-    'transformContent',
-    'generateCommentaryEntry',
-    'generateContentHeading',
-  ],
-
-  extraDependencies: ['html', 'language'],
-
-  relations: (relation, entries) => ({
-    heading:
-      relation('generateContentHeading'),
-
-    entries:
-      entries.map(entry =>
-        relation('generateCommentaryEntry', entry)),
-  }),
-
-  generate: (relations, {html, language}) =>
-    html.tags([
-      relations.heading
-        .slots({
-          id: 'artist-commentary',
-          title: language.$('misc.artistCommentary')
-        }),
-
-      relations.entries,
-    ]),
-};
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
index 469db876..f52bc043 100644
--- a/src/content/dependencies/generateContentHeading.js
+++ b/src/content/dependencies/generateContentHeading.js
@@ -12,23 +12,35 @@ export default {
       mutable: false,
     },
 
+    stickyTitle: {
+      type: 'html',
+      mutable: false,
+    },
+
     accent: {
       type: 'html',
       mutable: false,
     },
 
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
     color: {validate: v => v.isColor},
 
-    id: {type: 'string'},
-    tag: {type: 'string', default: 'p'},
+    tag: {
+      type: 'string',
+      default: 'p',
+    },
   },
 
   generate: (relations, slots, {html}) =>
     html.tag(slots.tag, {class: 'content-heading'},
       {tabindex: '0'},
+      {[html.onlyIfSiblings]: true},
 
-      slots.id &&
-        {id: slots.id},
+      slots.attributes,
 
       slots.color &&
         relations.colorStyle.slot('color', slots.color),
@@ -38,6 +50,10 @@ export default {
           {[html.onlyIfContent]: true},
           slots.title),
 
+        html.tag('template', {class: 'content-heading-sticky-title'},
+          {[html.onlyIfContent]: true},
+          slots.stickyTitle),
+
         html.tag('span', {class: 'content-heading-accent'},
           {[html.onlyIfContent]: true},
           slots.accent),
diff --git a/src/content/dependencies/generateContributionList.js b/src/content/dependencies/generateContributionList.js
index 6401e65e..d1c3de0f 100644
--- a/src/content/dependencies/generateContributionList.js
+++ b/src/content/dependencies/generateContributionList.js
@@ -2,20 +2,28 @@ export default {
   contentDependencies: ['linkContribution'],
   extraDependencies: ['html'],
 
-  relations: (relation, contributions) =>
-    ({contributionLinks:
-        contributions
-          .map(contrib => relation('linkContribution', contrib))}),
+  relations: (relation, contributions) => ({
+    contributionLinks:
+      contributions
+        .map(contrib => relation('linkContribution', contrib)),
+  }),
 
-  generate: (relations, {html}) =>
+  slots: {
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
     html.tag('ul',
-      relations.contributionLinks.map(contributionLink =>
-        html.tag('li',
-          contributionLink
-            .slots({
-              showIcons: true,
-              showContribution: true,
+      {[html.onlyIfContent]: true},
+
+      relations.contributionLinks
+        .map(contributionLink =>
+          html.tag('li',
+            contributionLink.slots({
+              showAnnotation: true,
+              showExternalLinks: true,
+              showChronology: true,
               preventWrapping: false,
-              iconMode: 'tooltip',
+              chronologyKind: slots.chronologyKind,
             })))),
 };
diff --git a/src/content/dependencies/generateContributionTooltip.js b/src/content/dependencies/generateContributionTooltip.js
new file mode 100644
index 00000000..3a31014d
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltip.js
@@ -0,0 +1,48 @@
+export default {
+  contentDependencies: [
+    'generateContributionTooltipChronologySection',
+    'generateContributionTooltipExternalLinkSection',
+    'generateTooltip',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, contribution) => ({
+    tooltip:
+      relation('generateTooltip'),
+
+    externalLinkSection:
+      relation('generateContributionTooltipExternalLinkSection', contribution),
+
+    chronologySection:
+      relation('generateContributionTooltipChronologySection', contribution),
+  }),
+
+  slots: {
+    showExternalLinks: {type: 'boolean'},
+    showChronology: {type: 'boolean'},
+
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    relations.tooltip.slots({
+      attributes:
+        {class: 'contribution-tooltip'},
+
+      contentAttributes: {
+        [html.joinChildren]:
+          html.tag('span', {class: 'tooltip-divider'}),
+      },
+
+      content: [
+        slots.showExternalLinks &&
+          relations.externalLinkSection,
+
+        slots.showChronology &&
+          relations.chronologySection.slots({
+            kind: slots.chronologyKind,
+          }),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateContributionTooltipChronologySection.js b/src/content/dependencies/generateContributionTooltipChronologySection.js
new file mode 100644
index 00000000..378c0e1c
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltipChronologySection.js
@@ -0,0 +1,129 @@
+import Thing from '#thing';
+
+function getName(thing) {
+  if (!thing) {
+    return null;
+  }
+
+  const referenceType = thing.constructor[Thing.referenceType];
+
+  if (referenceType === 'artwork') {
+    return thing.thing.name;
+  }
+
+  return thing.name;
+}
+
+export default {
+  contentDependencies: ['linkAnythingMan'],
+  extraDependencies: ['html', 'language'],
+
+  query(contribution) {
+    let previous = contribution;
+    while (previous && previous.thing === contribution.thing) {
+      previous = previous.previousBySameArtist;
+    }
+
+    let next = contribution;
+    while (next && next.thing === contribution.thing) {
+      next = next.nextBySameArtist;
+    }
+
+    return {previous, next};
+  },
+
+  relations: (relation, query, _contribution) => ({
+    previousLink:
+      (query.previous
+        ? relation('linkAnythingMan', query.previous.thing)
+        : null),
+
+    nextLink:
+      (query.next
+        ? relation('linkAnythingMan', query.next.thing)
+        : null),
+  }),
+
+  data: (query, _contribution) => ({
+    previousName:
+      getName(query.previous?.thing),
+
+    nextName:
+      getName(query.next?.thing),
+  }),
+
+  slots: {
+    kind: {
+      validate: v =>
+        v.is(
+          'album',
+          'bannerArt',
+          'coverArt',
+          'flash',
+          'track',
+          'trackArt',
+          'trackContribution',
+          'wallpaperArt'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('misc.artistLink.chronology', capsule =>
+      html.tags([
+        html.tags([
+          relations.previousLink?.slots({
+            attributes: {class: 'chronology-link'},
+            content: [
+              html.tag('span', {class: 'chronology-symbol'},
+                language.$(capsule, 'previous.symbol')),
+
+              html.tag('span', {class: 'chronology-text'},
+                language.sanitize(data.previousName)),
+            ],
+          }),
+
+          html.tag('span', {class: 'chronology-info'},
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(capsule, 'previous.info', workingCapsule => {
+              const workingOptions = {};
+
+              if (slots.kind) {
+                workingCapsule += '.withKind';
+                workingOptions.kind =
+                  language.$(capsule, 'kind', slots.kind);
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            })),
+        ]),
+
+        html.tags([
+          relations.nextLink?.slots({
+            attributes: {class: 'chronology-link'},
+            content: [
+              html.tag('span', {class: 'chronology-symbol'},
+                language.$(capsule, 'next.symbol')),
+
+              html.tag('span', {class: 'chronology-text'},
+                language.sanitize(data.nextName)),
+            ],
+          }),
+
+          html.tag('span', {class: 'chronology-info'},
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(capsule, 'next.info', workingCapsule => {
+              const workingOptions = {};
+
+              if (slots.kind) {
+                workingCapsule += '.withKind';
+                workingOptions.kind =
+                  language.$(capsule, 'kind', slots.kind);
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            }))
+        ]),
+      ])),
+};
diff --git a/src/content/dependencies/generateContributionTooltipExternalLinkSection.js b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js
new file mode 100644
index 00000000..4f9a23ed
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js
@@ -0,0 +1,70 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateExternalHandle',
+    'generateExternalIcon',
+    'generateExternalPlatform',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, contribution) => ({
+    icons:
+      contribution.artist.urls
+        .map(url => relation('generateExternalIcon', url)),
+
+    handles:
+      contribution.artist.urls
+        .map(url => relation('generateExternalHandle', url)),
+
+    platforms:
+      contribution.artist.urls
+        .map(url => relation('generateExternalPlatform', url)),
+  }),
+
+  data: (contribution) => ({
+    urls: contribution.artist.urls,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('misc.artistLink', capsule =>
+      html.tags(
+        stitchArrays({
+          icon: relations.icons,
+          handle: relations.handles,
+          platform: relations.platforms,
+          url: data.urls,
+        }).map(({icon, handle, platform, url}) => {
+            for (const template of [icon, handle, platform]) {
+              template.setSlot('context', 'artist');
+            }
+
+            return [
+              html.tag('a', {class: 'external-link'},
+                {href: url},
+
+                [
+                  icon,
+
+                  html.tag('span', {class: 'external-handle'},
+                    (html.isBlank(handle)
+                      ? platform
+                      : handle)),
+                ]),
+
+              html.tag('span', {class: 'external-platform'},
+                // This is a pretty ridiculous hack, but we currently
+                // don't have a way of telling formatExternalLink to *not*
+                // use the fallback string, which just formats the URL as
+                // its host/domain... so is technically detectable.
+                (((new URL(url))
+                    .host
+                    .endsWith(
+                      html.resolve(platform, {normalize: 'string'})))
+
+                  ? language.$(capsule, 'noExternalLinkPlatformName')
+                  : platform)),
+            ];
+          }))),
+};
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index 90c9db98..3a10ab20 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -1,55 +1,41 @@
-import {empty, stitchArrays} from '#sugar';
-
 export default {
-  contentDependencies: ['image', 'linkArtTag'],
-  extraDependencies: ['html'],
+  contentDependencies: [
+    'generateCoverArtworkArtTagDetails',
+    'generateCoverArtworkArtistDetails',
+    'generateCoverArtworkOriginDetails',
+    'generateCoverArtworkReferenceDetails',
+    'image',
+  ],
 
-  query: (artTags) => ({
-    linkableArtTags:
-      (artTags
-        ? artTags.filter(tag => !tag.isContentWarning)
-        : []),
-  }),
+  extraDependencies: ['html'],
 
-  relations: (relation, query, artTags) => ({
+  relations: (relation, artwork) => ({
     image:
-      relation('image', artTags),
+      relation('image', artwork),
 
-    tagLinks:
-      query.linkableArtTags
-        .filter(tag => !tag.isContentWarning)
-        .map(tag => relation('linkArtTag', tag)),
-  }),
+    originDetails:
+      relation('generateCoverArtworkOriginDetails', artwork),
 
-  data: (query) => {
-    const data = {};
+    artTagDetails:
+      relation('generateCoverArtworkArtTagDetails', artwork),
 
-    const seenShortNames = new Set();
-    const duplicateShortNames = new Set();
+    artistDetails:
+      relation('generateCoverArtworkArtistDetails', artwork),
 
-    for (const {nameShort: shortName} of query.linkableArtTags) {
-      if (seenShortNames.has(shortName)) {
-        duplicateShortNames.add(shortName);
-      } else {
-        seenShortNames.add(shortName);
-      }
-    }
+    referenceDetails:
+      relation('generateCoverArtworkReferenceDetails', artwork),
+  }),
 
-    data.preferShortName =
-      query.linkableArtTags
-        .map(artTag => !duplicateShortNames.has(artTag.nameShort));
+  data: (artwork) => ({
+    color:
+      artwork.thing.color ?? null,
 
-    return data;
-  },
+    dimensions:
+      artwork.dimensions,
+  }),
 
   slots: {
-    path: {
-      validate: v => v.validateArrayItems(v.isString),
-    },
-
-    alt: {
-      type: 'string',
-    },
+    alt: {type: 'string'},
 
     color: {
       validate: v => v.isColor,
@@ -60,73 +46,76 @@ export default {
       default: 'primary',
     },
 
-    dimensions: {
-      validate: v => v.isDimensions,
+    showOriginDetails: {type: 'boolean', default: false},
+    showArtTagDetails: {type: 'boolean', default: false},
+    showArtistDetails: {type: 'boolean', default: false},
+    showReferenceDetails: {type: 'boolean', default: false},
+
+    details: {
+      type: 'html',
+      mutable: false,
     },
   },
 
   generate(data, relations, slots, {html}) {
+    const {image} = relations;
+
+    image.setSlots({
+      color: slots.color ?? data.color,
+      alt: slots.alt,
+    });
+
     const square =
-      (slots.dimensions
-        ? slots.dimensions[0] === slots.dimensions[1]
+      (data.dimensions
+        ? data.dimensions[0] === data.dimensions[1]
         : true);
 
-    const sizeSlots =
-      (square
-        ? {square: true}
-        : {dimensions: slots.dimensions});
-
-    switch (slots.mode) {
-      case 'primary':
-        return html.tags([
-          relations.image.slots({
-            path: slots.path,
-            alt: slots.alt,
-            color: slots.color,
-            thumb: 'medium',
-            reveal: true,
-            link: true,
-            ...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)))),
-        ]);
-
-      case 'thumbnail':
-        return relations.image.slots({
-          path: slots.path,
-          alt: slots.alt,
-          color: slots.color,
-          thumb: 'small',
-          reveal: false,
-          link: false,
-          ...sizeSlots,
-        });
-
-      case 'commentary':
-        return relations.image.slots({
-          path: slots.path,
-          alt: slots.alt,
-          color: slots.color,
-          thumb: 'medium',
-          reveal: true,
-          link: true,
-          lazy: true,
-          ...sizeSlots,
-
-          attributes:
-            {class: 'commentary-art'},
-        });
-
-      default:
-        return html.blank();
+    if (square) {
+      image.setSlot('square', true);
+    } else {
+      image.setSlot('dimensions', data.dimensions);
     }
+
+    return (
+      html.tag('div', {class: 'cover-artwork'},
+        slots.mode === 'commentary' &&
+          {class: 'commentary-art'},
+
+        (slots.mode === 'primary'
+          ? [
+              relations.image.slots({
+                thumb: 'medium',
+                reveal: true,
+                link: true,
+              }),
+
+              slots.showOriginDetails &&
+                relations.originDetails,
+
+              slots.showArtTagDetails &&
+                relations.artTagDetails,
+
+              slots.showArtistDetails &&
+                relations.artistDetails,
+
+              slots.showReferenceDetails &&
+                relations.referenceDetails,
+
+              slots.details,
+            ]
+       : slots.mode === 'thumbnail'
+          ? relations.image.slots({
+              thumb: 'small',
+              reveal: false,
+              link: false,
+            })
+       : slots.mode === 'commentary'
+          ? relations.image.slots({
+              thumb: 'medium',
+              reveal: true,
+              link: true,
+              lazy: true,
+            })
+          : html.blank())));
   },
 };
diff --git a/src/content/dependencies/generateCoverArtworkArtTagDetails.js b/src/content/dependencies/generateCoverArtworkArtTagDetails.js
new file mode 100644
index 00000000..b20f599b
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkArtTagDetails.js
@@ -0,0 +1,50 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkArtTagGallery'],
+  extraDependencies: ['html'],
+
+  query: (artwork) => ({
+    linkableArtTags:
+      artwork.artTags
+        .filter(tag => !tag.isContentWarning),
+  }),
+
+  relations: (relation, query, _artwork) => ({
+    artTagLinks:
+      query.linkableArtTags
+        .map(tag => relation('linkArtTagGallery', tag)),
+  }),
+
+  data: (query, _artwork) => {
+    const seenShortNames = new Set();
+    const duplicateShortNames = new Set();
+
+    for (const {nameShort: shortName} of query.linkableArtTags) {
+      if (seenShortNames.has(shortName)) {
+        duplicateShortNames.add(shortName);
+      } else {
+        seenShortNames.add(shortName);
+      }
+    }
+
+    const preferShortName =
+      query.linkableArtTags
+        .map(artTag => !duplicateShortNames.has(artTag.nameShort));
+
+    return {preferShortName};
+  },
+
+  generate: (data, relations, {html}) =>
+    html.tag('ul', {class: 'image-details'},
+      {[html.onlyIfContent]: true},
+
+      {class: 'art-tag-details'},
+
+      stitchArrays({
+        artTagLink: relations.artTagLinks,
+        preferShortName: data.preferShortName,
+      }).map(({artTagLink, preferShortName}) =>
+          html.tag('li',
+            artTagLink.slot('preferShortName', preferShortName)))),
+};
diff --git a/src/content/dependencies/generateCoverArtworkArtistDetails.js b/src/content/dependencies/generateCoverArtworkArtistDetails.js
new file mode 100644
index 00000000..3ead80ab
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkArtistDetails.js
@@ -0,0 +1,25 @@
+export default {
+  contentDependencies: ['linkArtistGallery'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, artwork) => ({
+    artistLinks:
+      artwork.artistContribs
+        .map(contrib => contrib.artist)
+        .map(artist =>
+          relation('linkArtistGallery', artist)),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tag('p', {class: 'image-details'},
+      {[html.onlyIfContent]: true},
+
+      {class: 'illustrator-details'},
+
+      language.$('misc.coverGrid.details.coverArtists', {
+        [language.onlyIfOptions]: ['artists'],
+
+        artists:
+          language.formatConjunctionList(relations.artistLinks),
+      })),
+};
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
new file mode 100644
index 00000000..08a01cfe
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -0,0 +1,98 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'generateArtistCredit',
+    'generateAbsoluteDatetimestamp',
+    'linkAlbum',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'pagePath'],
+
+  query: (artwork) => ({
+    artworkThingType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    credit:
+      relation('generateArtistCredit', artwork.artistContribs, []),
+
+    source:
+      relation('transformContent', artwork.source),
+
+    albumLink:
+      (query.artworkThingType === 'album'
+        ? relation('linkAlbum', artwork.thing)
+        : null),
+
+    datetimestamp:
+      (artwork.date && artwork.date !== artwork.thing.date
+        ? relation('generateAbsoluteDatetimestamp', artwork.date)
+        : null),
+  }),
+
+
+  data: (query, artwork) => ({
+    label:
+      artwork.label,
+
+    artworkThingType:
+      query.artworkThingType,
+  }),
+
+  generate: (data, relations, {html, language, pagePath}) =>
+    language.encapsulate('misc.coverArtwork', capsule =>
+      html.tag('p', {class: 'image-details'},
+        {[html.onlyIfContent]: true},
+        {[html.joinChildren]: html.tag('br')},
+
+        {class: 'origin-details'},
+
+        [
+          language.encapsulate(capsule, 'artworkBy', workingCapsule => {
+            const workingOptions = {};
+
+            if (data.label) {
+              workingCapsule += '.customLabel';
+              workingOptions.label = data.label;
+            }
+
+            if (relations.datetimestamp) {
+              workingCapsule += '.withYear';
+              workingOptions.year =
+                relations.datetimestamp.slots({
+                  style: 'year',
+                  tooltip: true,
+                });
+            }
+
+            return relations.credit.slots({
+              showAnnotation: true,
+              showExternalLinks: true,
+              showChronology: true,
+              showWikiEdits: true,
+
+              trimAnnotation: false,
+
+              chronologyKind: 'coverArt',
+
+              normalStringKey: workingCapsule,
+              additionalStringOptions: workingOptions,
+            });
+          }),
+
+          pagePath[0] === 'track' &&
+          data.artworkThingType === 'album' &&
+            language.$(capsule, 'trackArtFromAlbum', {
+              album:
+                relations.albumLink.slot('color', false),
+            }),
+
+          language.$(capsule, 'source', {
+            [language.onlyIfOptions]: ['source'],
+            source: relations.source.slot('mode', 'inline'),
+          }),
+        ])),
+};
diff --git a/src/content/dependencies/generateCoverArtworkReferenceDetails.js b/src/content/dependencies/generateCoverArtworkReferenceDetails.js
new file mode 100644
index 00000000..035ab586
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkReferenceDetails.js
@@ -0,0 +1,60 @@
+export default {
+  contentDependencies: ['linkReferencedArtworks', 'linkReferencingArtworks'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, artwork) => ({
+    referencedArtworksLink:
+      relation('linkReferencedArtworks', artwork),
+
+    referencingArtworksLink:
+      relation('linkReferencingArtworks', artwork),
+  }),
+
+  data: (artwork) => ({
+    referenced:
+      artwork.referencedArtworks.length,
+
+    referencedBy:
+      artwork.referencedByArtworks.length,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule => {
+      const referencedText =
+        language.$(capsule, 'referencesArtworks', {
+          [language.onlyIfOptions]: ['artworks'],
+
+          artworks:
+            language.countArtworks(data.referenced, {
+              blankIfZero: true,
+              unit: true,
+            }),
+        });
+
+      const referencingText =
+        language.$(capsule, 'referencedByArtworks', {
+          [language.onlyIfOptions]: ['artworks'],
+
+          artworks:
+            language.countArtworks(data.referencedBy, {
+              blankIfZero: true,
+              unit: true,
+            }),
+        });
+
+      return (
+        html.tag('p', {class: 'image-details'},
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          {class: 'reference-details'},
+
+          [
+            !html.isBlank(referencedText) &&
+              relations.referencedArtworksLink.slot('content', referencedText),
+
+            !html.isBlank(referencingText) &&
+              relations.referencingArtworksLink.slot('content', referencingText),
+          ]));
+    }),
+}
diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js
index 69220da6..430f651e 100644
--- a/src/content/dependencies/generateCoverCarousel.js
+++ b/src/content/dependencies/generateCoverCarousel.js
@@ -2,24 +2,16 @@ import {empty, repeat, stitchArrays} from '#sugar';
 import {getCarouselLayoutForNumberOfItems} from '#wiki-data';
 
 export default {
-  contentDependencies: ['generateGridActionLinks'],
   extraDependencies: ['html'],
 
-  relations(relation) {
-    return {
-      actionLinks: relation('generateGridActionLinks'),
-    };
-  },
-
   slots: {
     images: {validate: v => v.strictArrayOf(v.isHTML)},
     links: {validate: v => v.strictArrayOf(v.isHTML)},
 
     lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)},
-    actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
   },
 
-  generate(relations, slots, {html}) {
+  generate(slots, {html}) {
     const stitched =
       stitchArrays({
         image: slots.images,
@@ -58,9 +50,6 @@ export default {
                     }),
                 })))),
         ])),
-
-      relations.actionLinks
-        .slot('actionLinks', slots.actionLinks),
     ]);
   },
 };
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
index 0433aaf1..29ac08b7 100644
--- a/src/content/dependencies/generateCoverGrid.js
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -16,22 +16,46 @@ export default {
     names: {validate: v => v.strictArrayOf(v.isHTML)},
     info: {validate: v => v.strictArrayOf(v.isHTML)},
 
+    // Differentiating from sparseArrayOf here - this list of classes should
+    // have the same length as the items above, i.e. nulls aren't going to be
+    // filtered out of it, but it is okay to *include* null (standing in for
+    // no classes for this grid item).
+    classes: {
+      validate: v =>
+        v.strictArrayOf(
+          v.optional(
+            v.anyOf(
+              v.isArray,
+              v.isString))),
+    },
+
     lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)},
     actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
   },
 
-  generate(relations, slots, {html, language}) {
-    return (
-      html.tag('div', {class: 'grid-listing'}, [
+  generate: (relations, slots, {html, language}) =>
+    html.tag('div', {class: 'grid-listing'},
+      {[html.onlyIfContent]: true},
+
+      [
         stitchArrays({
+          classes: slots.classes,
           image: slots.images,
           link: slots.links,
           name: slots.names,
           info: slots.info,
-        }).map(({image, link, name, info}, index) =>
+        }).map(({classes, image, link, name, info}, index) =>
             link.slots({
-              attributes: {class: ['grid-item', 'box']},
+              attributes: [
+                {class: ['grid-item', 'box']},
+
+                (classes
+                  ? {class: classes}
+                  : null),
+              ],
+
               colorContext: 'image-box',
+
               content: [
                 image.slots({
                   thumb: 'medium',
@@ -44,16 +68,23 @@ export default {
                       : false),
                 }),
 
-                html.tag('span', {[html.onlyIfContent]: true},
+                html.tag('span',
+                  {[html.onlyIfContent]: true},
+
                   language.sanitize(name)),
 
-                html.tag('span', {[html.onlyIfContent]: true},
-                  language.sanitize(info)),
+                html.tag('span',
+                  {[html.onlyIfContent]: true},
+
+                  language.$('misc.coverGrid.details.accent', {
+                    [language.onlyIfOptions]: ['details'],
+
+                    details: info,
+                  })),
               ],
             })),
 
         relations.actionLinks
           .slot('actionLinks', slots.actionLinks),
-      ]));
-  },
+      ]),
 };
diff --git a/src/content/dependencies/generateDatetimestampTemplate.js b/src/content/dependencies/generateDatetimestampTemplate.js
index d9ed036a..a92d15fc 100644
--- a/src/content/dependencies/generateDatetimestampTemplate.js
+++ b/src/content/dependencies/generateDatetimestampTemplate.js
@@ -31,8 +31,10 @@ export default {
           slots.mainContent),
 
       tooltip:
-        slots.tooltip?.slots({
-          attributes: [{class: 'datetimestamp-tooltip'}],
-        }),
+        (html.isBlank(slots.tooltip)
+          ? null
+          : slots.tooltip.slots({
+              attributes: [{class: 'datetimestamp-tooltip'}],
+            })),
     }),
 };
diff --git a/src/content/dependencies/generateDotSwitcherTemplate.js b/src/content/dependencies/generateDotSwitcherTemplate.js
new file mode 100644
index 00000000..22205922
--- /dev/null
+++ b/src/content/dependencies/generateDotSwitcherTemplate.js
@@ -0,0 +1,41 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    options: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    initialOptionIndex: {type: 'number'},
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('span', {class: 'dot-switcher'},
+      {[html.onlyIfContent]: true},
+      {[html.noEdgeWhitespace]: true},
+      {[html.joinChildren]: ''},
+
+      slots.attributes,
+
+      slots.options
+        .map((option, index) =>
+          html.tag('span',
+            {[html.onlyIfContent]: true},
+
+            html.resolve(option, {normalize: 'tag'})
+              .onlyIfSiblings &&
+                {[html.onlyIfSiblings]: true},
+
+            index === slots.initialOptionIndex &&
+              {class: 'current'},
+
+            [
+              html.metatag('imaginary-sibling'),
+              option,
+            ]))),
+};
diff --git a/src/content/dependencies/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..84ab549d 100644
--- a/src/content/dependencies/generateFlashActGalleryPage.js
+++ b/src/content/dependencies/generateFlashActGalleryPage.js
@@ -1,4 +1,4 @@
-import {stitchArrays} from '#sugar';
+import striptags from 'striptags';
 
 export default {
   contentDependencies: [
@@ -8,10 +8,11 @@ export default {
     'generatePageLayout',
     'image',
     'linkFlash',
+    'linkFlashAct',
     'linkFlashIndex',
   ],
 
-  extraDependencies: ['html', 'language'],
+  extraDependencies: ['language'],
 
   relations: (relation, act) => ({
     layout:
@@ -20,6 +21,9 @@ export default {
     flashIndexLink:
       relation('linkFlashIndex'),
 
+    flashActNavLink:
+      relation('linkFlashAct', act),
+
     flashActNavAccent:
       relation('generateFlashActNavAccent', act),
 
@@ -31,7 +35,7 @@ export default {
 
     coverGridImages:
       act.flashes
-        .map(_flash => relation('image')),
+        .map(flash => relation('image', flash.coverArtwork)),
 
     flashLinks:
       act.flashes
@@ -44,48 +48,38 @@ export default {
 
     flashNames:
       act.flashes.map(flash => flash.name),
-
-    flashCoverPaths:
-      act.flashes.map(flash =>
-        ['media.flashArt', flash.directory, flash.coverArtFileExtension])
   }),
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title:
-        language.$('flashPage.title', {
-          flash: new html.Tag(null, null, data.name),
-        }),
-
-      color: data.color,
-      headingMode: 'static',
-
-      mainClasses: ['flash-index'],
-      mainContent: [
-        relations.coverGrid.slots({
-          links: relations.flashLinks,
-          names: data.flashNames,
-          lazy: 6,
-
-          images:
-            stitchArrays({
-              image: relations.coverGridImages,
-              path: data.flashCoverPaths,
-            }).map(({image, path}) =>
-                image.slot('path', path)),
-        }),
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {html: relations.flashIndexLink},
-        {auto: 'current'},
-      ],
-
-      navBottomRowContent: relations.flashActNavAccent,
-
-      leftSidebar: relations.sidebar,
-    });
-  },
+  generate: (data, relations, {language}) =>
+    language.encapsulate('flashPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            flash: striptags(data.name),
+          }),
+
+        color: data.color,
+        headingMode: 'static',
+
+        mainClasses: ['flash-index'],
+        mainContent: [
+          relations.coverGrid.slots({
+            links: relations.flashLinks,
+            images: relations.coverGridImages,
+            names: data.flashNames,
+            lazy: 6,
+          }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.flashIndexLink},
+          {html: relations.flashActNavLink},
+        ],
+
+        navBottomRowContent: relations.flashActNavAccent,
+
+        leftSidebar: relations.sidebar,
+      })),
 };
diff --git a/src/content/dependencies/generateFlashActNavAccent.js b/src/content/dependencies/generateFlashActNavAccent.js
index 424948f9..c4ec77b8 100644
--- a/src/content/dependencies/generateFlashActNavAccent.js
+++ b/src/content/dependencies/generateFlashActNavAccent.js
@@ -1,16 +1,17 @@
-import {atOffset, empty} from '#sugar';
+import {atOffset} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generatePreviousNextLinks',
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
     'linkFlashAct',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
+  extraDependencies: ['wikiData'],
 
-  sprawl({flashActData}) {
-    return {flashActData};
-  },
+  sprawl: ({flashActData}) =>
+    ({flashActData}),
 
   query(sprawl, flashAct) {
     // Like with generateFlashNavAccent, don't sort chronologically here.
@@ -29,43 +30,35 @@ export default {
     return {previousFlashAct, nextFlashAct};
   },
 
-  relations(relation, query) {
-    const relations = {};
-
-    if (query.previousFlashAct || query.nextFlashAct) {
-      relations.previousNextLinks =
-        relation('generatePreviousNextLinks');
-
-      relations.previousFlashActLink =
-        (query.previousFlashAct
-          ? relation('linkFlashAct', query.previousFlashAct)
-          : null);
-
-      relations.nextFlashActLink =
-        (query.nextFlashAct
-          ? relation('linkFlashAct', query.nextFlashAct)
-          : null);
-    }
-
-    return relations;
-  },
-
-  generate(relations, {html, language}) {
-    const {content: previousNextLinks = []} =
-      relations.previousNextLinks &&
-        relations.previousNextLinks.slots({
-          previousLink: relations.previousFlashActLink,
-          nextLink: relations.nextFlashActLink,
-        });
-
-    const allLinks = [
-      ...previousNextLinks,
-    ].filter(Boolean);
-
-    if (empty(allLinks)) {
-      return html.blank();
-    }
-
-    return `(${language.formatUnitList(allLinks)})`;
-  },
+  relations: (relation, query) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+
+    previousFlashActLink:
+      (query.previousFlashAct
+        ? relation('linkFlashAct', query.previousFlashAct)
+        : null),
+
+    nextFlashActLink:
+      (query.nextFlashAct
+        ? relation('linkFlashAct', query.nextFlashAct)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.switcher.slots({
+      links: [
+        relations.previousLink
+          .slot('link', relations.previousFlashActLink),
+
+        relations.nextLink
+          .slot('link', relations.nextFlashActLink),
+      ],
+    }),
 };
diff --git a/src/content/dependencies/generateFlashActSidebarCurrentActBox.js b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js
index c5426a41..6d152c7c 100644
--- a/src/content/dependencies/generateFlashActSidebarCurrentActBox.js
+++ b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js
@@ -44,10 +44,11 @@ export default {
 
           [
             html.tag('summary',
-              html.tag('span', {class: 'group-name'},
-                (data.customListTerminology
-                  ? language.sanitize(data.customListTerminology)
-                  : language.$('flashSidebar.flashList.entriesInThisSection')))),
+              html.tag('span',
+                html.tag('b',
+                  (data.customListTerminology
+                    ? language.sanitize(data.customListTerminology)
+                    : language.$('flashSidebar.flashList.entriesInThisSection'))))),
 
             html.tag('ul',
               relations.flashLinks
diff --git a/src/content/dependencies/generateFlashActSidebarSideMapBox.js b/src/content/dependencies/generateFlashActSidebarSideMapBox.js
index 3d261ec3..7b26ef31 100644
--- a/src/content/dependencies/generateFlashActSidebarSideMapBox.js
+++ b/src/content/dependencies/generateFlashActSidebarSideMapBox.js
@@ -68,8 +68,8 @@ export default {
 
               [
                 html.tag('summary',
-                  html.tag('span', {class: 'group-name'},
-                    sideName)),
+                  html.tag('span',
+                    html.tag('b', sideName))),
 
                 html.tag('ul',
                   actLinks.map((actLink, actIndex) =>
diff --git a/src/content/dependencies/generateFlashArtworkColumn.js b/src/content/dependencies/generateFlashArtworkColumn.js
new file mode 100644
index 00000000..5987df9e
--- /dev/null
+++ b/src/content/dependencies/generateFlashArtworkColumn.js
@@ -0,0 +1,11 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations: (relation, flash) => ({
+    coverArtwork:
+      relation('generateCoverArtwork', flash.coverArtwork),
+  }),
+
+  generate: (relations) =>
+    relations.coverArtwork,
+};
diff --git a/src/content/dependencies/generateFlashCoverArtwork.js b/src/content/dependencies/generateFlashCoverArtwork.js
deleted file mode 100644
index 374fa3f8..00000000
--- a/src/content/dependencies/generateFlashCoverArtwork.js
+++ /dev/null
@@ -1,12 +0,0 @@
-export default {
-  contentDependencies: ['generateCoverArtwork'],
-
-  relations: (relation) =>
-    ({coverArtwork: relation('generateCoverArtwork')}),
-
-  data: (flash) =>
-    ({path: ['media.flashArt', flash.directory, flash.coverArtFileExtension]}),
-
-  generate: (data, relations) =>
-    relations.coverArtwork.slot('path', data.path),
-};
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index 36bfabae..2788406c 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -53,7 +53,7 @@ export default {
     actCoverGridImages:
       query.flashActs
         .map(act => act.flashes
-          .map(() => relation('image'))),
+          .map(flash => relation('image', flash.coverArtwork))),
   }),
 
   data: (query) => ({
@@ -73,82 +73,72 @@ export default {
       query.flashActs
         .map(act => act.flashes
           .map(flash => flash.name)),
-
-    actCoverGridPaths:
-      query.flashActs
-        .map(act => act.flashes
-          .map(flash => ['media.flashArt', flash.directory, flash.coverArtFileExtension])),
   }),
 
   generate: (data, relations, {html, language}) =>
-    relations.layout.slots({
-      title: language.$('flashIndex.title'),
-      headingMode: 'static',
-
-      mainClasses: ['flash-index'],
-      mainContent: [
-        !empty(data.jumpLinkLabels) && [
-          html.tag('p', {class: 'quick-info'},
-            language.$('misc.jumpTo')),
-
-          html.tag('ul', {class: 'quick-info'},
-            stitchArrays({
-              colorStyle: relations.jumpLinkColorStyles,
-              anchor: data.jumpLinkAnchors,
-              label: data.jumpLinkLabels,
-            }).map(({colorStyle, anchor, label}) =>
-                html.tag('li',
-                  html.tag('a',
-                    {href: '#' + anchor},
-                    colorStyle,
-                    label)))),
-        ],
+    language.encapsulate('flashIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+        headingMode: 'static',
+
+        mainClasses: ['flash-index'],
+        mainContent: [
+          html.tags([
+            html.tag('p', {class: 'quick-info'},
+              {[html.onlyIfSiblings]: true},
+              language.$('misc.jumpTo')),
+
+            html.tag('ul', {class: 'quick-info'},
+              {[html.onlyIfContent]: true},
+              stitchArrays({
+                colorStyle: relations.jumpLinkColorStyles,
+                anchor: data.jumpLinkAnchors,
+                label: data.jumpLinkLabels,
+              }).map(({colorStyle, anchor, label}) =>
+                  html.tag('li',
+                    html.tag('a',
+                      {href: '#' + anchor},
+                      colorStyle,
+                      label)))),
+          ]),
 
-        stitchArrays({
-          colorStyle: relations.actColorStyles,
-          actLink: relations.actLinks,
-          anchor: data.actAnchors,
-
-          coverGrid: relations.actCoverGrids,
-          coverGridImages: relations.actCoverGridImages,
-          coverGridLinks: relations.actCoverGridLinks,
-          coverGridNames: data.actCoverGridNames,
-          coverGridPaths: data.actCoverGridPaths,
-        }).map(({
-            colorStyle,
-            actLink,
-            anchor,
-
-            coverGrid,
-            coverGridImages,
-            coverGridLinks,
-            coverGridNames,
-            coverGridPaths,
-          }, index) => [
-            html.tag('h2',
-              {id: anchor},
+          stitchArrays({
+            colorStyle: relations.actColorStyles,
+            actLink: relations.actLinks,
+            anchor: data.actAnchors,
+
+            coverGrid: relations.actCoverGrids,
+            coverGridImages: relations.actCoverGridImages,
+            coverGridLinks: relations.actCoverGridLinks,
+            coverGridNames: data.actCoverGridNames,
+          }).map(({
               colorStyle,
-              actLink),
-
-            coverGrid.slots({
-              links: coverGridLinks,
-              names: coverGridNames,
-              lazy: index === 0 ? 4 : true,
-
-              images:
-                stitchArrays({
-                  image: coverGridImages,
-                  path: coverGridPaths,
-                }).map(({image, path}) =>
-                    image.slot('path', path)),
-            }),
-          ]),
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {auto: 'current'},
-      ],
-    }),
+              actLink,
+              anchor,
+
+              coverGrid,
+              coverGridImages,
+              coverGridLinks,
+              coverGridNames,
+            }, index) => [
+              html.tag('h2',
+                {id: anchor},
+                colorStyle,
+                actLink),
+
+              coverGrid.slots({
+                links: coverGridLinks,
+                images: coverGridImages,
+                names: coverGridNames,
+                lazy: index === 0 ? 4 : true,
+              }),
+            ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index 05964936..095e43c4 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -2,11 +2,12 @@ import {empty} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateCommentarySection',
+    'generateAdditionalNamesBox',
+    'generateCommentaryEntry',
     'generateContentHeading',
     'generateContributionList',
     'generateFlashActSidebar',
-    'generateFlashCoverArtwork',
+    'generateFlashArtworkColumn',
     'generateFlashNavAccent',
     'generatePageLayout',
     'generateTrackList',
@@ -19,180 +20,183 @@ export default {
   query(flash) {
     const query = {};
 
-    if (flash.page || !empty(flash.urls)) {
-      query.urls = [];
+    query.urls = [];
 
-      if (flash.page) {
-        query.urls.push(`https://homestuck.com/story/${flash.page}`);
-      }
+    if (flash.page) {
+      query.urls.push(`https://homestuck.com/story/${flash.page}`);
+    }
 
-      if (!empty(flash.urls)) {
-        query.urls.push(...flash.urls);
-      }
+    if (!empty(flash.urls)) {
+      query.urls.push(...flash.urls);
     }
 
     return query;
   },
 
-  relations(relation, query, flash) {
-    const relations = {};
-    const sections = relations.sections = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.sidebar =
-      relation('generateFlashActSidebar', flash.act, flash);
-
-    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);
-    }
-
-    // Section: navigation bar
-
-    const nav = sections.nav = {};
+  relations: (relation, query, flash) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    nav.flashActLink =
-      relation('linkFlashAct', flash.act);
+    sidebar:
+      relation('generateFlashActSidebar', flash.act, flash),
 
-    nav.flashNavAccent =
-      relation('generateFlashNavAccent', flash);
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', flash.additionalNames),
 
-    // Section: Featured tracks
+    externalLinks:
+      query.urls
+        .map(url => relation('linkExternal', url)),
 
-    if (!empty(flash.featuredTracks)) {
-      const featuredTracks = sections.featuredTracks = {};
+    artworkColumn:
+      relation('generateFlashArtworkColumn', flash),
 
-      featuredTracks.heading =
-        relation('generateContentHeading');
+    contentHeading:
+      relation('generateContentHeading'),
 
-      featuredTracks.list =
-        relation('generateTrackList', flash.featuredTracks);
-    }
+    flashActLink:
+      relation('linkFlashAct', flash.act),
 
-    // Section: Contributors
+    flashNavAccent:
+      relation('generateFlashNavAccent', flash),
 
-    if (!empty(flash.contributorContribs)) {
-      const contributors = sections.contributors = {};
+    featuredTracksList:
+      relation('generateTrackList', flash.featuredTracks),
 
-      contributors.heading =
-        relation('generateContentHeading');
+    contributorContributionList:
+      relation('generateContributionList', flash.contributorContribs),
 
-      contributors.list =
-        relation('generateContributionList', flash.contributorContribs);
-    }
+    artistCommentaryEntries:
+      flash.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
 
-    // Section: Artist commentary
+    creditSourceEntries:
+      flash.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
+  }),
 
-    if (flash.commentary) {
-      sections.artistCommentary =
-        relation('generateCommentarySection', flash.commentary);
-    }
+  data: (_query, flash) => ({
+    name:
+      flash.name,
 
-    return relations;
-  },
+    color:
+      flash.color,
 
-  data(query, flash) {
-    const data = {};
+    date:
+      flash.date,
+  }),
 
-    data.name = flash.name;
-    data.color = flash.color;
-    data.date = flash.date;
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('flashPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            flash: data.name,
+          }),
 
-    return data;
-  },
+        color: data.color,
+        headingMode: 'sticky',
 
-  generate(data, relations, {html, language}) {
-    const {sections: sec} = relations;
+        additionalNames: relations.additionalNamesBox,
 
-    return relations.layout.slots({
-      title:
-        language.$('flashPage.title', {
-          flash: data.name,
-        }),
+        artworkColumnContent: relations.artworkColumn,
 
-      color: data.color,
-      headingMode: 'sticky',
-
-      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.artistCommentaryEntries) &&
+                language.encapsulate(capsule, 'readCommentary', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#artist-commentary'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.creditSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#credit-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'features'},
+                title:
+                  language.$('releaseInfo.tracksFeatured', {
+                    flash: html.tag('i', data.name),
+                  }),
               }),
+
+            relations.featuredTracksList,
           ]),
 
-        sec.featuredTracks && [
-          sec.featuredTracks.heading
-            .slots({
-              id: 'features',
-              title:
-                language.$('releaseInfo.tracksFeatured', {
-                  flash: html.tag('i', data.name),
-                }),
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'contributors'},
+                title: language.$('releaseInfo.contributors'),
+              }),
+
+            relations.contributorContributionList.slots({
+              chronologyKind: 'flash',
             }),
+          ]),
 
-          sec.featuredTracks.list,
-        ],
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'artist-commentary'},
+                title: language.$('misc.artistCommentary'),
+              }),
 
-        sec.contributors && [
-          sec.contributors.heading
-            .slots({
-              id: 'contributors',
-              title: language.$('releaseInfo.contributors'),
-            }),
+            relations.artistCommentaryEntries,
+          ]),
 
-          sec.contributors.list,
-        ],
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'credit-sources'},
+                title: language.$('misc.creditSources'),
+              }),
 
-        sec.artistCommentary,
-      ],
+            relations.creditSourceEntries,
+          ]),
+        ],
 
-      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/generateFlashNavAccent.js b/src/content/dependencies/generateFlashNavAccent.js
index 55e056dc..0f5d2d6b 100644
--- a/src/content/dependencies/generateFlashNavAccent.js
+++ b/src/content/dependencies/generateFlashNavAccent.js
@@ -1,16 +1,17 @@
-import {atOffset, empty} from '#sugar';
+import {atOffset} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generatePreviousNextLinks',
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
     'linkFlash',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({flashActData}) {
-    return {flashActData};
-  },
+  sprawl: ({flashActData}) =>
+    ({flashActData}),
 
   query(sprawl, flash) {
     // Don't sort chronologically here. The previous/next buttons should match
@@ -31,43 +32,35 @@ export default {
     return {previousFlash, nextFlash};
   },
 
-  relations(relation, query) {
-    const relations = {};
-
-    if (query.previousFlash || query.nextFlash) {
-      relations.previousNextLinks =
-        relation('generatePreviousNextLinks');
-
-      relations.previousFlashLink =
-        (query.previousFlash
-          ? relation('linkFlash', query.previousFlash)
-          : null);
-
-      relations.nextFlashLink =
-        (query.nextFlash
-          ? relation('linkFlash', query.nextFlash)
-          : null);
-    }
-
-    return relations;
-  },
-
-  generate(relations, {html, language}) {
-    const {content: previousNextLinks = []} =
-      relations.previousNextLinks &&
-        relations.previousNextLinks.slots({
-          previousLink: relations.previousFlashLink,
-          nextLink: relations.nextFlashLink,
-        });
-
-    const allLinks = [
-      ...previousNextLinks,
-    ].filter(Boolean);
-
-    if (empty(allLinks)) {
-      return html.blank();
-    }
-
-    return `(${language.formatUnitList(allLinks)})`;
-  },
+  relations: (relation, query) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+
+    previousFlashLink:
+      (query.previousFlash
+        ? relation('linkFlash', query.previousFlash)
+        : null),
+
+    nextFlashLink:
+      (query.nextFlash
+        ? relation('linkFlash', query.nextFlash)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.switcher.slots({
+      links: [
+        relations.previousLink
+          .slot('link', relations.previousFlashLink),
+
+        relations.nextLink
+          .slot('link', relations.nextFlashLink),
+      ],
+    }),
 };
diff --git a/src/content/dependencies/generateGridActionLinks.js b/src/content/dependencies/generateGridActionLinks.js
index f5b1aaa6..585a02b9 100644
--- a/src/content/dependencies/generateGridActionLinks.js
+++ b/src/content/dependencies/generateGridActionLinks.js
@@ -1,5 +1,3 @@
-import {empty} from '#sugar';
-
 export default {
   extraDependencies: ['html'],
 
@@ -7,16 +5,12 @@ export default {
     actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
   },
 
-  generate(slots, {html}) {
-    if (empty(slots.actionLinks)) {
-      return html.blank();
-    }
+  generate: (slots, {html}) =>
+    html.tag('div', {class: 'grid-actions'},
+      {[html.onlyIfContent]: true},
 
-    return (
-      html.tag('div', {class: 'grid-actions'},
-        slots.actionLinks
-          .filter(Boolean)
-          .map(link => link
-            .slot('attributes', {class: ['grid-item', 'box']}))));
-  },
+      (slots.actionLinks ?? [])
+        .filter(link => link && !html.isBlank(link))
+        .map(link => link
+          .slot('attributes', {class: ['grid-item', 'box']}))),
 };
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
index d07847c6..d51366ca 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',
@@ -52,9 +53,12 @@ export default {
 
       relations.carouselImages =
         carouselAlbums
-          .map(album => relation('image', album.artTags));
+          .map(album => relation('image', album.coverArtworks[0]));
     }
 
+    relations.quickDescription =
+      relation('generateQuickDescription', group);
+
     relations.coverGrid =
       relation('generateCoverGrid');
 
@@ -65,7 +69,7 @@ export default {
     relations.gridImages =
       albums.map(album =>
         (album.hasCoverArt
-          ? relation('image', album.artTags)
+          ? relation('image', album.coverArtworks[0])
           : relation('image')));
 
     return relations;
@@ -82,35 +86,19 @@ export default {
 
     data.numAlbums = albums.length;
     data.numTracks = tracks.length;
-    data.totalDuration = getTotalDuration(tracks, {originalReleasesOnly: true});
+    data.totalDuration = getTotalDuration(tracks, {mainReleasesOnly: true});
 
     data.gridNames = albums.map(album => album.name);
     data.gridDurations = albums.map(album => getTotalDuration(album.tracks));
     data.gridNumTracks = albums.map(album => album.tracks.length);
 
-    data.gridPaths =
-      albums.map(album =>
-        (album.hasCoverArt
-          ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-          : null));
-
-    const carouselAlbums = filterItemsForCarousel(group.featuredAlbums);
-
-    if (!empty(group.featuredAlbums)) {
-      data.carouselPaths =
-        carouselAlbums.map(album =>
-          (album.hasCoverArt
-            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-            : null));
-    }
-
     return data;
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout
-      .slots({
-        title: language.$('groupGalleryPage.title', {group: data.name}),
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupGalleryPage', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title', {group: data.name}),
         headingMode: 'static',
 
         color: data.color,
@@ -120,16 +108,13 @@ export default {
           relations.coverCarousel
             ?.slots({
               links: relations.carouselLinks,
-              images:
-                stitchArrays({
-                  image: relations.carouselImages,
-                  path: data.carouselPaths,
-                }).map(({image, path}) =>
-                    image.slot('path', path)),
+              images: relations.carouselImages,
             }),
 
+          relations.quickDescription,
+
           html.tag('p', {class: 'quick-info'},
-            language.$('groupGalleryPage.infoLine', {
+            language.$(pageCapsule, 'infoLine', {
               tracks:
                 html.tag('b',
                   language.countTracks(data.numTracks, {
@@ -153,25 +138,25 @@ export default {
             .slots({
               links: relations.gridLinks,
               names: data.gridNames,
+
               images:
                 stitchArrays({
                   image: relations.gridImages,
-                  path: data.gridPaths,
                   name: data.gridNames,
-                }).map(({image, path, name}) =>
+                }).map(({image, name}) =>
                     image.slots({
-                      path,
                       missingSourceContent:
-                        language.$('misc.albumGrid.noCoverArt', {
+                        language.$('misc.coverGrid.noCoverArt', {
                           album: name,
                         }),
                     })),
+
               info:
                 stitchArrays({
                   numTracks: data.gridNumTracks,
                   duration: data.gridDurations,
                 }).map(({numTracks, duration}) =>
-                    language.$('misc.albumGrid.details', {
+                    language.$('misc.coverGrid.details.albumLength', {
                       tracks: language.countTracks(numTracks, {unit: true}),
                       time: language.formatDuration(duration),
                     })),
@@ -193,6 +178,5 @@ export default {
 
         secondaryNav:
           relations.secondaryNav ?? null,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
index b5b456aa..7b9c2afa 100644
--- a/src/content/dependencies/generateGroupInfoPage.js
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -1,210 +1,168 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
-    'generateAbsoluteDatetimestamp',
     'generateColorStyleAttribute',
-    'generateContentHeading',
+    'generateGroupInfoPageAlbumsSection',
     'generateGroupNavLinks',
     'generateGroupSecondaryNav',
     'generateGroupSidebar',
     'generatePageLayout',
-    'linkAlbum',
+    'linkArtist',
     'linkExternal',
-    'linkGroupGallery',
-    'linkGroup',
     'transformContent',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({wikiInfo}) {
-    return {
-      enableGroupUI: wikiInfo.enableGroupUI,
-    };
-  },
-
-  query(sprawl, group) {
-    const albums =
-      group.albums;
-
-    const albumGroups =
-      albums
-        .map(album => album.groups);
-
-    const albumOtherCategory =
-      albumGroups
-        .map(groups => groups
-          .map(group => group.category)
-          .find(category => category !== group.category));
-
-    const albumOtherGroups =
-      stitchArrays({
-        groups: albumGroups,
-        category: albumOtherCategory,
-      }).map(({groups, category}) =>
-          groups
-            .filter(group => group.category === category));
-
-    return {albums, albumOtherGroups};
-  },
-
-  relations(relation, query, sprawl, group) {
-    const relations = {};
-    const sec = relations.sections = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.navLinks =
-      relation('generateGroupNavLinks', group);
-
-    if (sprawl.enableGroupUI) {
-      relations.secondaryNav =
-        relation('generateGroupSecondaryNav', group);
-
-      relations.sidebar =
-        relation('generateGroupSidebar', group);
-    }
-
-    sec.info = {};
-
-    if (!empty(group.urls)) {
-      sec.info.visitLinks =
-        group.urls
-          .map(url => relation('linkExternal', url));
-    }
-
-    if (group.description) {
-      sec.info.description =
-        relation('transformContent', group.description);
-    }
-
-    if (!empty(query.albums)) {
-      sec.albums = {};
-
-      sec.albums.heading =
-        relation('generateContentHeading');
-
-      sec.albums.galleryLink =
-        relation('linkGroupGallery', group);
-
-      sec.albums.albumColorStyles =
-        query.albums
-          .map(album => relation('generateColorStyleAttribute', album.color));
-
-      sec.albums.albumLinks =
-        query.albums
-          .map(album => relation('linkAlbum', album));
-
-      sec.albums.otherGroupLinks =
-        query.albumOtherGroups
-          .map(groups => groups
-            .map(group => relation('linkGroup', group)));
-
-      sec.albums.datetimestamps =
-        group.albums.map(album =>
-          (album.date
-            ? relation('generateAbsoluteDatetimestamp', album.date)
-            : null));
-    }
-
-    return relations;
-  },
+  sprawl: ({wikiInfo}) => ({
+    enableGroupUI:
+      wikiInfo.enableGroupUI,
+
+    wikiColor:
+      wikiInfo.color,
+  }),
+
+  query: (_sprawl, group) => ({
+    aliasLinkedArtists:
+      group.closelyLinkedArtists
+        .filter(({annotation}) =>
+          annotation === 'alias'),
+
+    generalLinkedArtists:
+      group.closelyLinkedArtists
+        .filter(({annotation}) =>
+          annotation !== 'alias'),
+  }),
+
+  relations: (relation, query, sprawl, group) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    navLinks:
+      relation('generateGroupNavLinks', group),
+
+    secondaryNav:
+      (sprawl.enableGroupUI
+        ? relation('generateGroupSecondaryNav', group)
+        : null),
+
+    sidebar:
+      (sprawl.enableGroupUI
+        ? relation('generateGroupSidebar', group)
+        : null),
+
+    wikiColorAttribute:
+      relation('generateColorStyleAttribute', sprawl.wikiColor),
+
+    closeArtistLinks:
+      query.generalLinkedArtists
+        .map(({artist}) => relation('linkArtist', artist)),
+
+    aliasArtistLinks:
+      query.aliasLinkedArtists
+        .map(({artist}) => relation('linkArtist', artist)),
+
+    visitLinks:
+      group.urls
+        .map(url => relation('linkExternal', url)),
+
+    description:
+      relation('transformContent', group.description),
+
+    albumSection:
+      relation('generateGroupInfoPageAlbumsSection', group),
+  }),
+
+  data: (query, _sprawl, group) => ({
+    name:
+      group.name,
+
+    color:
+      group.color,
+
+    closeArtistAnnotations:
+      query.generalLinkedArtists
+        .map(({annotation}) => annotation),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupInfoPage', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title', {group: data.name}),
+        headingMode: 'sticky',
+        color: data.color,
 
-  data(query, sprawl, group) {
-    const data = {};
+        mainContent: [
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate(pageCapsule, 'closelyLinkedArtists', capsule => [
+              language.encapsulate(capsule, capsule => {
+                const [workingCapsule, option] =
+                  (relations.closeArtistLinks.length === 0
+                    ? [null, null]
+                 : relations.closeArtistLinks.length === 1
+                    ? [language.encapsulate(capsule, 'one'), 'artist']
+                    : [language.encapsulate(capsule, 'multiple'), 'artists']);
+
+                if (!workingCapsule) return html.blank();
+
+                return language.$(workingCapsule, {
+                  [option]:
+                    language.formatUnitList(
+                      stitchArrays({
+                        link: relations.closeArtistLinks,
+                        annotation: data.closeArtistAnnotations,
+                      }).map(({link, annotation}) =>
+                          language.encapsulate(capsule, 'artist', workingCapsule => {
+                            const workingOptions = {};
+
+                            workingOptions.artist =
+                              link.slots({
+                                attributes: [relations.wikiColorAttribute],
+                              });
+
+                            if (annotation) {
+                              workingCapsule += '.withAnnotation';
+                              workingOptions.annotation = annotation;
+                            }
+
+                            return language.$(workingCapsule, workingOptions);
+                          }))),
+                });
+              }),
 
-    data.name = group.name;
-    data.color = group.color;
+              language.$(capsule, 'aliases', {
+                [language.onlyIfOptions]: ['aliases'],
 
-    return data;
-  },
+                aliases:
+                  language.formatConjunctionList(
+                    relations.aliasArtistLinks.map(link =>
+                      link.slots({
+                        attributes: [relations.wikiColorAttribute],
+                      }))),
+              }),
+            ])),
 
-  generate(data, relations, {html, language}) {
-    const {sections: sec} = relations;
+          html.tag('p',
+            {[html.onlyIfContent]: true},
 
-    return relations.layout
-      .slots({
-        title: language.$('groupInfoPage.title', {group: data.name}),
-        headingMode: 'sticky',
-        color: data.color,
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
 
-        mainContent: [
-          sec.info.visitLinks &&
-            html.tag('p',
-              language.$('releaseInfo.visitOn', {
-                links:
-                  language.formatDisjunctionList(
-                    sec.info.visitLinks
-                      .map(link => link.slot('context', 'group'))),
-              })),
+              links:
+                language.formatDisjunctionList(
+                  relations.visitLinks
+                    .map(link => link.slot('context', 'group'))),
+            })),
 
           html.tag('blockquote',
             {[html.onlyIfContent]: true},
-            sec.info.description
-              ?.slot('mode', 'multiline')),
-
-          sec.albums && [
-            sec.albums.heading
-              .slots({
-                tag: 'h2',
-                title: language.$('groupInfoPage.albumList.title'),
-              }),
+            relations.description.slot('mode', 'multiline')),
 
-            html.tag('p',
-              language.$('groupInfoPage.viewAlbumGallery', {
-                link:
-                  sec.albums.galleryLink
-                    .slot('content', language.$('groupInfoPage.viewAlbumGallery.link')),
-              })),
-
-            html.tag('ul',
-              stitchArrays({
-                albumLink: sec.albums.albumLinks,
-                otherGroupLinks: sec.albums.otherGroupLinks,
-                datetimestamp: sec.albums.datetimestamps,
-                albumColorStyle: sec.albums.albumColorStyles,
-              }).map(({
-                  albumLink,
-                  otherGroupLinks,
-                  datetimestamp,
-                  albumColorStyle,
-                }) => {
-                  const prefix = 'groupInfoPage.albumList.item';
-                  const parts = [prefix];
-                  const options = {};
-
-                  options.album =
-                    albumLink.slot('color', false);
-
-                  if (datetimestamp) {
-                    parts.push('withYear');
-                    options.yearAccent =
-                      language.$(prefix, 'yearAccent', {
-                        year:
-                          datetimestamp.slots({style: 'year', tooltip: true}),
-                      });
-                  }
-
-                  if (!empty(otherGroupLinks)) {
-                    parts.push('withOtherGroup');
-                    options.otherGroupAccent =
-                      html.tag('span', {class: 'other-group-accent'},
-                        language.$(prefix, 'otherGroupAccent', {
-                          groups:
-                            language.formatConjunctionList(
-                              otherGroupLinks.map(groupLink =>
-                                groupLink.slot('color', false))),
-                        }));
-                  }
-
-                  return (
-                    html.tag('li',
-                      albumColorStyle,
-                      language.$(...parts, options)));
-                })),
-          ],
+          relations.albumSection,
         ],
 
         leftSidebar:
@@ -217,6 +175,5 @@ export default {
         navLinks: relations.navLinks.content,
 
         secondaryNav: relations.secondaryNav ?? null,
-      });
-  },
+      })),
 };
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js b/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js
new file mode 100644
index 00000000..df42598d
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js
@@ -0,0 +1,47 @@
+import {sortChronologically} from '#sort';
+
+export default {
+  contentDependencies: ['generateGroupInfoPageAlbumsListItem'],
+
+  extraDependencies: ['html'],
+
+  query: (group) => ({
+    // Typically, a latestFirst: false (default) chronological sort would be
+    // appropriate here, but navigation between adjacent albums in a group is a
+    // rather "essential" movement or relationship in the wiki, and we consider
+    // the sorting order of a group's gallery page (latestFirst: true) to be
+    // "canonical" in this regard. We exactly match its sort here, but reverse
+    // it, to still present earlier albums preceding later ones.
+    albums:
+      sortChronologically(group.albums.slice(), {latestFirst: true})
+        .reverse(),
+  }),
+
+  relations: (relation, query, group) => ({
+    items:
+      query.albums
+        .map(album =>
+          relation('generateGroupInfoPageAlbumsListItem',
+            album,
+            group)),
+  }),
+
+  slots: {
+    hidden: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('ul',
+      {id: 'group-album-list-by-date'},
+
+      slots.hidden && {style: 'display: none'},
+
+      {[html.onlyIfContent]: true},
+
+      relations.items
+        .map(item =>
+          item.slot('accentMode', 'groups'))),
+};
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js b/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js
new file mode 100644
index 00000000..bcd5d288
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js
@@ -0,0 +1,87 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateGroupInfoPageAlbumsListItem',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (group) => ({
+    closelyLinkedArtists:
+      group.closelyLinkedArtists
+        .map(({artist}) => artist),
+  }),
+
+  relations: (relation, _query, group) => ({
+    seriesHeadings:
+      group.serieses
+        .map(() => relation('generateContentHeading')),
+
+    seriesItems:
+      group.serieses
+        .map(series => series.albums
+          .map(album =>
+            relation('generateGroupInfoPageAlbumsListItem',
+              album,
+              group))),
+  }),
+
+  data: (query, group) => ({
+    seriesNames:
+      group.serieses
+        .map(series => series.name),
+
+    seriesItemsShowArtists:
+      group.serieses.map(series =>
+        (series.showAlbumArtists === 'all'
+          ? new Array(series.albums.length).fill(true)
+       : series.showAlbumArtists === 'differing'
+          ? series.albums.map(album =>
+              album.artistContribs
+                .map(contrib => contrib.artist)
+                .some(artist => !query.closelyLinkedArtists.includes(artist)))
+          : new Array(series.albums.length).fill(false))),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupInfoPage.albumList', listCapsule =>
+      html.tag('dl',
+        {id: 'group-album-list-by-series'},
+        {class: 'group-series-list'},
+
+        {[html.onlyIfContent]: true},
+
+        stitchArrays({
+          name: data.seriesNames,
+          itemsShowArtists: data.seriesItemsShowArtists,
+          heading: relations.seriesHeadings,
+          items: relations.seriesItems,
+        }).map(({
+            name,
+            itemsShowArtists,
+            heading,
+            items,
+          }) =>
+            html.tags([
+              heading.slots({
+                tag: 'dt',
+                title:
+                  language.$(listCapsule, 'series', {
+                    series: name,
+                  }),
+              }),
+
+              html.tag('dd',
+                html.tag('ul',
+                  stitchArrays({
+                    item: items,
+                    showArtists: itemsShowArtists,
+                  }).map(({item, showArtists}) =>
+                      item.slots({
+                        accentMode:
+                          (showArtists ? 'artists' : null),
+                      })))),
+            ])))),
+};
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
new file mode 100644
index 00000000..99e7e8ff
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
@@ -0,0 +1,136 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateArtistCredit',
+    'generateColorStyleAttribute',
+    'linkAlbum',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (album, group) => {
+    const otherCategory =
+      album.groups
+        .map(group => group.category)
+        .find(category => category !== group.category);
+
+    const otherGroups =
+      album.groups
+        .filter(group => group.category === otherCategory);
+
+    return {otherGroups};
+  },
+
+  relations: (relation, query, album, _group) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute', album.color),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    datetimestamp:
+      (album.date
+        ? relation('generateAbsoluteDatetimestamp', album.date)
+        : null),
+
+    artistCredit:
+      relation('generateArtistCredit', album.artistContribs, []),
+
+    otherGroupLinks:
+      query.otherGroups
+        .map(group => relation('linkGroup', group)),
+  }),
+
+  data: (_query, album, group) => ({
+    groupName:
+      group.name,
+
+    notFromThisGroup:
+      !group.albums.includes(album),
+  }),
+
+  slots: {
+    accentMode: {
+      validate: v => v.is('groups', 'artists'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('li',
+      relations.colorStyle,
+
+      language.encapsulate('groupInfoPage.albumList.item', itemCapsule =>
+        language.encapsulate(itemCapsule, workingCapsule => {
+          const workingOptions = {};
+
+          workingOptions.album =
+            relations.albumLink.slot('color', false);
+
+          const yearCapsule = language.encapsulate(itemCapsule, 'withYear');
+
+          if (relations.datetimestamp) {
+            workingCapsule += '.withYear';
+            workingOptions.yearAccent =
+              language.$(yearCapsule, 'accent', {
+                year:
+                  relations.datetimestamp.slots({style: 'year', tooltip: true}),
+              });
+          }
+
+          const otherGroupCapsule = language.encapsulate(itemCapsule, 'withOtherGroup');
+
+          if (
+            (slots.accentMode === 'groups' ||
+             slots.accentMode === null) &&
+            data.notFromThisGroup
+          ) {
+            workingCapsule += '.withOtherGroup';
+            workingOptions.otherGroupAccent =
+              html.tag('span', {class: 'other-group-accent'},
+                language.$(otherGroupCapsule, 'notFromThisGroup', {
+                  group:
+                    data.groupName,
+                }));
+          } else if (
+            slots.accentMode === 'groups' &&
+            !empty(relations.otherGroupLinks)
+          ) {
+            workingCapsule += '.withOtherGroup';
+            workingOptions.otherGroupAccent =
+              html.tag('span', {class: 'other-group-accent'},
+                language.$(otherGroupCapsule, 'accent', {
+                  groups:
+                    language.formatConjunctionList(
+                      relations.otherGroupLinks.map(groupLink =>
+                        groupLink.slot('color', false))),
+                }));
+          }
+
+          const artistCapsule = language.encapsulate(itemCapsule, 'withArtists');
+          const {artistCredit} = relations;
+
+          artistCredit.setSlots({
+            normalStringKey:
+              artistCapsule + '.by',
+
+            featuringStringKey:
+              artistCapsule + '.featuring',
+
+            normalFeaturingStringKey:
+              artistCapsule + '.by.featuring',
+          });
+
+          if (slots.accentMode === 'artists' && !html.isBlank(artistCredit)) {
+            workingCapsule += '.withArtists';
+            workingOptions.by =
+              html.tag('span', {class: 'by'},
+                html.metatag('chunkwrap', {split: ','},
+                  html.resolve(artistCredit)));
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }))),
+};
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsSection.js b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js
new file mode 100644
index 00000000..0b678e9d
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js
@@ -0,0 +1,93 @@
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateGroupInfoPageAlbumsListByDate',
+    'generateGroupInfoPageAlbumsListBySeries',
+    'generateIntrapageDotSwitcher',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, group) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+
+    galleryLink:
+      relation('linkGroupGallery', group),
+
+    albumsListByDate:
+      relation('generateGroupInfoPageAlbumsListByDate', group),
+
+    albumsListBySeries:
+      relation('generateGroupInfoPageAlbumsListBySeries', group),
+
+    viewSwitcher:
+      relation('generateIntrapageDotSwitcher'),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('groupInfoPage', pageCapsule =>
+      language.encapsulate(pageCapsule, 'albumList', listCapsule =>
+        html.tags([
+          relations.contentHeading
+            .slots({
+              tag: 'h2',
+              title: language.$(listCapsule, 'title'),
+            }),
+
+          html.tag('p',
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(pageCapsule, 'viewAlbumGallery', viewAlbumGalleryCapsule =>
+              language.encapsulate(viewAlbumGalleryCapsule, workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.link =
+                  relations.galleryLink
+                    .slot('content',
+                      language.$(viewAlbumGalleryCapsule, 'link'));
+
+                if (
+                  !html.isBlank(relations.albumsListByDate) &&
+                  !html.isBlank(relations.albumsListBySeries)
+                ) {
+                  workingCapsule += '.withViewSwitcher';
+                  workingOptions.viewSwitcher =
+                    html.tag('span', {class: 'group-view-switcher'},
+                      language.encapsulate(pageCapsule, 'viewSwitcher', switcherCapsule =>
+                        language.$(switcherCapsule, {
+                          options:
+                            relations.viewSwitcher.slots({
+                              initialOptionIndex: 0,
+
+                              titles: [
+                                language.$(switcherCapsule, 'bySeries'),
+                                language.$(switcherCapsule, 'byDate'),
+                              ],
+
+                              targetIDs: [
+                                'group-album-list-by-series',
+                                'group-album-list-by-date',
+                              ],
+                            }),
+                        })));
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              }))),
+
+          ((!html.isBlank(relations.albumsListByDate) &&
+            !html.isBlank(relations.albumsListBySeries))
+
+            ? [
+                relations.albumsListBySeries,
+                relations.albumsListByDate.slot('hidden', true),
+              ]
+
+            : [
+                relations.albumsListBySeries,
+                relations.albumsListByDate,
+              ]),
+        ]))),
+};
diff --git a/src/content/dependencies/generateGroupNavAccent.js b/src/content/dependencies/generateGroupNavAccent.js
new file mode 100644
index 00000000..0e4ebe8a
--- /dev/null
+++ b/src/content/dependencies/generateGroupNavAccent.js
@@ -0,0 +1,53 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'linkGroup',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, group) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    infoLink:
+      relation('linkGroup', group),
+
+    galleryLink:
+      (empty(group.albums)
+        ? null
+        : relation('linkGroupGallery', group)),
+  }),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate: (relations, slots, {language}) =>
+    relations.switcher.slots({
+      links: [
+        relations.infoLink.slots({
+          attributes: [
+            slots.currentExtra === null &&
+              {class: 'current'},
+          ],
+
+          content: language.$('misc.nav.info'),
+        }),
+
+        relations.galleryLink?.slots({
+          attributes: [
+            slots.currentExtra === 'gallery' &&
+              {class: 'current'},
+          ],
+
+          content: language.$('misc.nav.gallery'),
+        }),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateGroupNavLinks.js b/src/content/dependencies/generateGroupNavLinks.js
index 5cde2ab4..bdc3ee4c 100644
--- a/src/content/dependencies/generateGroupNavLinks.js
+++ b/src/content/dependencies/generateGroupNavLinks.js
@@ -1,48 +1,25 @@
-import {empty} from '#sugar';
-
 export default {
-  contentDependencies: [
-    'linkGroup',
-    'linkGroupGallery',
-  ],
-
+  contentDependencies: ['generateGroupNavAccent', 'linkGroup'],
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupCategoryData, wikiInfo}) {
-    return {
-      groupCategoryData,
-      enableGroupUI: wikiInfo.enableGroupUI,
-      enableListings: wikiInfo.enableListings,
-    };
-  },
-
-  relations(relation, sprawl, group) {
-    if (!sprawl.enableGroupUI) {
-      return {};
-    }
-
-    const relations = {};
+  sprawl: ({groupCategoryData, wikiInfo}) => ({
+    groupCategoryData,
+    enableGroupUI: wikiInfo.enableGroupUI,
+    enableListings: wikiInfo.enableListings,
+  }),
 
-    relations.mainLink =
-      relation('linkGroup', group);
+  relations: (relation, _sprawl, group) => ({
+    mainLink:
+      relation('linkGroup', group),
 
-    relations.infoLink =
-      relation('linkGroup', group);
+    accent:
+      relation('generateGroupNavAccent', group),
+  }),
 
-    if (!empty(group.albums)) {
-      relations.galleryLink =
-        relation('linkGroupGallery', group);
-    }
-
-    return relations;
-  },
-
-  data(sprawl) {
-    return {
-      enableGroupUI: sprawl.enableGroupUI,
-      enableListings: sprawl.enableListings,
-    };
-  },
+  data: (sprawl, _group) => ({
+    enableGroupUI: sprawl.enableGroupUI,
+    enableListings: sprawl.enableListings,
+  }),
 
   slots: {
     showExtraLinks: {type: 'boolean', default: false},
@@ -52,53 +29,31 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {language}) {
-    if (!data.enableGroupUI) {
-      return [
-        {auto: 'home'},
-        {auto: 'current'},
-      ];
-    }
-
-    const infoLink =
-      relations.infoLink.slots({
-        attributes: {class: slots.currentExtra === null && 'current'},
-        content: language.$('misc.nav.info'),
-      });
-
-    const extraLinks = [
-      relations.galleryLink?.slots({
-        attributes: {class: slots.currentExtra === 'gallery' && 'current'},
-        content: language.$('misc.nav.gallery'),
-      }),
-    ];
-
-    const extrasPart =
-      (empty(extraLinks)
-        ? ''
-        : language.formatUnitList([infoLink, ...extraLinks]));
-
-    const accent =
-      (extrasPart
-        ? `(${extrasPart})`
-        : null);
-
-    return [
-      {auto: 'home'},
-
-      data.enableListings &&
-        {
-          path: ['localized.listingIndex'],
-          title: language.$('listingIndex.title'),
-        },
-
-      {
-        accent,
-        html:
-          language.$('groupPage.nav.group', {
-            group: relations.mainLink,
-          }),
-      },
-    ].filter(Boolean);
-  },
+  generate: (data, relations, slots, {language}) =>
+    (data.enableGroupUI
+      ? [
+          {auto: 'home'},
+
+          data.enableListings &&
+            {
+              path: ['localized.listingIndex'],
+              title: language.$('listingIndex.title'),
+            },
+
+          {
+            html:
+              language.$('groupPage.nav.group', {
+                group: relations.mainLink,
+              }),
+
+            accent:
+              relations.accent
+                .slot('currentExtra', slots.currentExtra),
+          },
+        ].filter(Boolean)
+
+      : [
+          {auto: 'home'},
+          {auto: 'current'},
+        ]),
 };
diff --git a/src/content/dependencies/generateGroupSecondaryNav.js b/src/content/dependencies/generateGroupSecondaryNav.js
index 17eb5083..c48f3142 100644
--- a/src/content/dependencies/generateGroupSecondaryNav.js
+++ b/src/content/dependencies/generateGroupSecondaryNav.js
@@ -1,100 +1,20 @@
-import {atOffset} from '#sugar';
-
 export default {
   contentDependencies: [
-    'generateColorStyleAttribute',
-    'generatePreviousNextLinks',
     'generateSecondaryNav',
-    'linkGroupDynamically',
-    'linkListing',
+    'generateGroupSecondaryNavCategoryPart',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
-
-  sprawl: ({listingSpec, wikiInfo}) => ({
-    groupsByCategoryListing:
-      (wikiInfo.enableListings
-        ? listingSpec
-            .find(l => l.directory === 'groups/by-category')
-        : null),
-  }),
-
-  query(sprawl, group) {
-    const groups = group.category.groups;
-    const index = groups.indexOf(group);
-
-    return {
-      previousGroup:
-        atOffset(groups, index, -1),
-
-      nextGroup:
-        atOffset(groups, index, +1),
-    };
-  },
-
-  relations(relation, query, sprawl, group) {
-    const relations = {};
-
-    relations.secondaryNav =
-      relation('generateSecondaryNav');
-
-    if (sprawl.groupsByCategoryListing) {
-      relations.categoryLink =
-        relation('linkListing', sprawl.groupsByCategoryListing);
-    }
+  relations: (relation, group) => ({
+    secondaryNav:
+      relation('generateSecondaryNav'),
 
-    relations.colorStyle =
-      relation('generateColorStyleAttribute', group.category.color);
-
-    if (query.previousGroup || query.nextGroup) {
-      relations.previousNextLinks =
-        relation('generatePreviousNextLinks');
-    }
-
-    relations.previousGroupLink =
-      (query.previousGroup
-        ? relation('linkGroupDynamically', query.previousGroup)
-        : null);
-
-    relations.nextGroupLink =
-      (query.nextGroup
-        ? relation('linkGroupDynamically', query.nextGroup)
-        : null);
-
-    return relations;
-  },
-
-  data: (query, sprawl, group) => ({
-    categoryName: group.category.name,
+    categoryPart:
+      relation('generateGroupSecondaryNavCategoryPart', group.category, group),
   }),
 
-  generate(data, relations, {html, language}) {
-    const {content: previousNextPart} =
-      relations.previousNextLinks.slots({
-        previousLink: relations.previousGroupLink,
-        nextLink: relations.nextGroupLink,
-        id: true,
-      });
-
-    const {categoryLink} = relations;
-
-    categoryLink?.setSlot('content', data.categoryName);
-
-    return relations.secondaryNav.slots({
-      class: 'nav-links-groups',
-      content:
-        (relations.previousGroupLink || relations.nextGroupLink
-          ? html.tag('span', {class: 'nav-link'},
-              relations.colorStyle.slot('context', 'primary-only'),
-
-              [
-                categoryLink?.slot('color', false),
-                `(${language.formatUnitList(previousNextPart)})`,
-              ])
-       : categoryLink
-          ? html.tag('span', {class: 'nav-link'},
-              categoryLink)
-          : html.blank()),
-    });
-  },
+  generate: (relations) =>
+    relations.secondaryNav.slots({
+      attributes: {class: 'nav-links-groups'},
+      content: relations.categoryPart,
+    }),
 };
diff --git a/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js b/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js
new file mode 100644
index 00000000..b2adb9f8
--- /dev/null
+++ b/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js
@@ -0,0 +1,79 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateSecondaryNavParentSiblingsPart',
+    'linkGroupDynamically',
+    'linkListing',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({listingSpec, wikiInfo}) => ({
+    groupsByCategoryListing:
+      (wikiInfo.enableListings
+        ? listingSpec
+            .find(l => l.directory === 'groups/by-category')
+        : null),
+  }),
+
+  query(sprawl, category, group) {
+    const groups = category.groups;
+    const index = groups.indexOf(group);
+
+    return {
+      previousGroup:
+        atOffset(groups, index, -1),
+
+      nextGroup:
+        atOffset(groups, index, +1),
+    };
+  },
+
+  relations: (relation, query, sprawl, category, group) => ({
+    parentSiblingsPart:
+      relation('generateSecondaryNavParentSiblingsPart'),
+
+    categoryLink:
+      (sprawl.groupsByCategoryListing
+        ? relation('linkListing', sprawl.groupsByCategoryListing)
+        : null),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', group.category.color),
+
+    previousGroupLink:
+      (query.previousGroup
+        ? relation('linkGroupDynamically', query.previousGroup)
+        : null),
+
+    nextGroupLink:
+      (query.nextGroup
+        ? relation('linkGroupDynamically', query.nextGroup)
+        : null),
+  }),
+
+  data: (_query, _sprawl, category, _group) => ({
+    name: category.name,
+  }),
+
+  generate: (data, relations, {language}) =>
+    relations.parentSiblingsPart.slots({
+      colorStyle: relations.colorStyle,
+      id: true,
+
+      mainLink:
+        (relations.categoryLink
+          ? relations.categoryLink.slots({
+              content: language.sanitize(data.name),
+            })
+          : null),
+
+      previousLink: relations.previousGroupLink,
+      nextLink: relations.nextGroupLink,
+
+      stringsKey: 'groupPage.secondaryNav.category',
+      mainLinkOption: 'category',
+    }),
+};
diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js
index 3abb3392..0888cbbe 100644
--- a/src/content/dependencies/generateGroupSidebar.js
+++ b/src/content/dependencies/generateGroupSidebar.js
@@ -2,6 +2,7 @@ export default {
   contentDependencies: [
     'generateGroupSidebarCategoryDetails',
     'generatePageSidebar',
+    'generatePageSidebarBox',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
@@ -12,6 +13,9 @@ export default {
     sidebar:
       relation('generatePageSidebar'),
 
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
     categoryDetails:
       sprawl.groupCategoryData.map(category =>
         relation('generateGroupSidebarCategoryDetails', category, group)),
@@ -25,15 +29,18 @@ export default {
 
   generate: (relations, slots, {html, language}) =>
     relations.sidebar.slots({
-      attributes: {class: 'category-map-sidebar-box'},
-
-      content: [
-        html.tag('h1',
-          language.$('groupSidebar.title')),
-
-        relations.categoryDetails
-          .map(details =>
-            details.slot('currentExtra', slots.currentExtra)),
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'category-map-sidebar-box'},
+          content: [
+            html.tag('h1',
+              language.$('groupSidebar.title')),
+
+            relations.categoryDetails
+              .map(details =>
+                details.slot('currentExtra', slots.currentExtra)),
+          ],
+        }),
       ],
     }),
 };
diff --git a/src/content/dependencies/generateGroupSidebarCategoryDetails.js b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
index 69de373b..208ccd07 100644
--- a/src/content/dependencies/generateGroupSidebarCategoryDetails.js
+++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
@@ -46,37 +46,36 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {html, language}) {
-    return html.tag('details',
-      data.isCurrentCategory &&
-        {class: 'current', open: true},
-
-      [
-        html.tag('summary',
-          relations.colorStyle,
-
-          html.tag('span',
-            language.$('groupSidebar.groupList.category', {
-              category:
-                html.tag('span', {class: 'group-name'},
-                  data.name),
-            }))),
-
-        html.tag('ul',
-          stitchArrays(({
-            infoLink: relations.groupInfoLinks,
-            galleryLink: relations.groupGalleryLinks,
-          })).map(({infoLink, galleryLink}, index) =>
-                html.tag('li',
-                  index === data.currentGroupIndex &&
-                    {class: 'current'},
-
-                  language.$('groupSidebar.groupList.item', {
-                    group:
-                      (slots.currentExtra === 'gallery'
-                        ? galleryLink ?? infoLink
-                        : infoLink),
-                  })))),
-      ]);
-  },
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('groupSidebar.groupList', capsule =>
+      html.tag('details',
+        data.isCurrentCategory &&
+          {class: 'current', open: true},
+
+        [
+          html.tag('summary',
+            relations.colorStyle,
+
+            html.tag('span',
+              language.$(capsule, 'category', {
+                category:
+                  html.tag('b', data.name),
+              }))),
+
+          html.tag('ul',
+            stitchArrays(({
+              infoLink: relations.groupInfoLinks,
+              galleryLink: relations.groupGalleryLinks,
+            })).map(({infoLink, galleryLink}, index) =>
+                  html.tag('li',
+                    index === data.currentGroupIndex &&
+                      {class: 'current'},
+
+                    language.$(capsule, 'item', {
+                      group:
+                        (slots.currentExtra === 'gallery'
+                          ? galleryLink ?? infoLink
+                          : infoLink),
+                    })))),
+        ])),
 };
diff --git a/src/content/dependencies/generateImageOverlay.js b/src/content/dependencies/generateImageOverlay.js
new file mode 100644
index 00000000..cfb78a1b
--- /dev/null
+++ b/src/content/dependencies/generateImageOverlay.js
@@ -0,0 +1,50 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  generate: ({html, language}) =>
+    html.tag('div', {id: 'image-overlay-container'},
+      html.tag('div', {id: 'image-overlay-content-container'}, [
+        html.tag('span', {id: 'image-overlay-image-area'},
+          html.tag('span', {id: 'image-overlay-image-layout'}, [
+            html.tag('img', {id: 'image-overlay-image'}),
+            html.tag('img', {id: 'image-overlay-image-thumb'}),
+          ])),
+
+        html.tag('div', {id: 'image-overlay-action-container'},
+          language.encapsulate('releaseInfo.viewOriginalFile', capsule => [
+            html.tag('div', {id: 'image-overlay-action-content-without-size'},
+              language.$(capsule, {
+                link: html.tag('a', {class: 'image-overlay-view-original'},
+                  language.$(capsule, 'link')),
+              })),
+
+            html.tag('div', {id: 'image-overlay-action-content-with-size'}, [
+              language.$(capsule, 'withSize', {
+                link:
+                  html.tag('a', {class: 'image-overlay-view-original'},
+                    language.$(capsule, 'link')),
+
+                size:
+                  html.tag('span',
+                    {[html.joinChildren]: ''},
+                    [
+                      html.tag('span', {id: 'image-overlay-file-size-kilobytes'},
+                        language.$('count.fileSize.kilobytes', {
+                          kilobytes:
+                            html.tag('span', {class: 'image-overlay-file-size-count'}),
+                        })),
+
+                      html.tag('span', {id: 'image-overlay-file-size-megabytes'},
+                        language.$('count.fileSize.megabytes', {
+                          megabytes:
+                            html.tag('span', {class: 'image-overlay-file-size-count'}),
+                        })),
+                    ]),
+              }),
+
+              html.tag('span', {id: 'image-overlay-file-size-warning'},
+                language.$(capsule, 'sizeWarning')),
+            ]),
+          ])),
+      ])),
+};
diff --git a/src/content/dependencies/generateInterpageDotSwitcher.js b/src/content/dependencies/generateInterpageDotSwitcher.js
new file mode 100644
index 00000000..5a33444e
--- /dev/null
+++ b/src/content/dependencies/generateInterpageDotSwitcher.js
@@ -0,0 +1,31 @@
+export default {
+  contentDependencies: ['generateDotSwitcherTemplate'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    template:
+      relation('generateDotSwitcherTemplate'),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    links: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+  },
+
+  generate: (relations, slots) =>
+    relations.template.slots({
+      attributes: [
+        {class: 'interpage'},
+        slots.attributes,
+      ],
+
+      // TODO: Do something to set a class on a link to the current page??
+      options: slots.links,
+    }),
+};
diff --git a/src/content/dependencies/generateIntrapageDotSwitcher.js b/src/content/dependencies/generateIntrapageDotSwitcher.js
new file mode 100644
index 00000000..1d58367d
--- /dev/null
+++ b/src/content/dependencies/generateIntrapageDotSwitcher.js
@@ -0,0 +1,49 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateDotSwitcherTemplate'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    template:
+      relation('generateDotSwitcherTemplate'),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    initialOptionIndex: {type: 'number'},
+
+    titles: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    targetIDs: {
+      validate: v => v.strictArrayOf(v.isString),
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    relations.template.slots({
+      attributes: [
+        {class: 'intrapage'},
+        slots.attributes,
+      ],
+
+      initialOptionIndex: slots.initialOptionIndex,
+
+      options:
+        stitchArrays({
+          title: slots.titles,
+          targetID: slots.targetIDs,
+        }).map(({title, targetID}) =>
+            html.tag('a', {href: '#'},
+              {'data-target-id': targetID},
+              {[html.onlyIfContent]: true},
+
+              language.sanitize(title))),
+    }),
+};
diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
index 43a78cb3..deb8c4ea 100644
--- a/src/content/dependencies/generateListAllAdditionalFilesChunk.js
+++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
@@ -42,49 +42,49 @@ 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('b', 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/generateListingIndexList.js b/src/content/dependencies/generateListingIndexList.js
index ed153652..78622e6e 100644
--- a/src/content/dependencies/generateListingIndexList.js
+++ b/src/content/dependencies/generateListingIndexList.js
@@ -107,8 +107,8 @@ export default {
 
                 [
                   html.tag('summary',
-                    html.tag('span', {class: 'group-name'},
-                      targetTitle)),
+                    html.tag('span',
+                      html.tag('b', targetTitle))),
 
                   listingLinkList,
                 ])));
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
index 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/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js
index 1e5c8bfc..aeac05cf 100644
--- a/src/content/dependencies/generateListingSidebar.js
+++ b/src/content/dependencies/generateListingSidebar.js
@@ -2,6 +2,7 @@ export default {
   contentDependencies: [
     'generateListingIndexList',
     'generatePageSidebar',
+    'generatePageSidebarBox',
     'linkListingIndex',
   ],
 
@@ -11,6 +12,9 @@ export default {
     sidebar:
       relation('generatePageSidebar'),
 
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
     listingIndexLink:
       relation('linkListingIndex'),
 
@@ -20,10 +24,14 @@ export default {
 
   generate: (relations, {html}) =>
     relations.sidebar.slots({
-      attributes: {class: 'listing-map-sidebar-box'},
-      content: [
-        html.tag('h1', relations.listingIndexLink),
-        relations.listingIndexList.slot('mode', 'sidebar'),
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'listing-map-sidebar-box'},
+          content: [
+            html.tag('h1', relations.listingIndexLink),
+            relations.listingIndexList.slot('mode', 'sidebar'),
+          ],
+        }),
       ],
     }),
 };
diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js
new file mode 100644
index 00000000..4f9c22f1
--- /dev/null
+++ b/src/content/dependencies/generateLyricsEntry.js
@@ -0,0 +1,25 @@
+export default {
+  contentDependencies: [
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    content:
+      relation('transformContent', entry.body),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('div', {class: 'lyrics-entry'},
+      slots.attributes,
+
+      relations.content.slot('mode', 'lyrics')),
+};
diff --git a/src/content/dependencies/generateLyricsSection.js b/src/content/dependencies/generateLyricsSection.js
new file mode 100644
index 00000000..f6b719a9
--- /dev/null
+++ b/src/content/dependencies/generateLyricsSection.js
@@ -0,0 +1,81 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateIntrapageDotSwitcher',
+    'generateLyricsEntry',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entries) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    switcher:
+      relation('generateIntrapageDotSwitcher'),
+
+    entries:
+      entries
+        .map(entry => relation('generateLyricsEntry', entry)),
+
+    annotations:
+      entries
+        .map(entry => entry.annotation)
+        .map(annotation => relation('transformContent', annotation)),
+  }),
+
+  data: (entries) => ({
+    ids:
+      Array.from(
+        {length: entries.length},
+        (_, index) => 'lyrics-entry-' + index),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo.lyrics', capsule =>
+      html.tags([
+        relations.heading
+          .slots({
+            attributes: {id: 'lyrics'},
+            title: language.$(capsule),
+          }),
+
+        html.tag('p', {class: 'lyrics-switcher'},
+          {[html.onlyIfContent]: true},
+
+          language.$(capsule, 'switcher', {
+            [language.onlyIfOptions]: ['entries'],
+
+            entries:
+              relations.switcher.slots({
+                initialOptionIndex: 0,
+
+                titles:
+                  relations.annotations.map(annotation =>
+                    annotation.slots({
+                      mode: 'inline',
+                      textOnly: true,
+                    })),
+
+                targetIDs:
+                  data.ids,
+              }),
+          })),
+
+        stitchArrays({
+          entry: relations.entries,
+          id: data.ids,
+        }).map(({entry, id}, index) =>
+            entry.slots({
+              attributes: [
+                {id},
+
+                index >= 1 &&
+                  {style: 'display: none'},
+              ],
+            })),
+      ])),
+};
diff --git a/src/content/dependencies/generateNewsEntryNavAccent.js b/src/content/dependencies/generateNewsEntryNavAccent.js
new file mode 100644
index 00000000..5d168e41
--- /dev/null
+++ b/src/content/dependencies/generateNewsEntryNavAccent.js
@@ -0,0 +1,40 @@
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
+    'linkNewsEntry',
+  ],
+
+  relations: (relation, previousEntry, nextEntry) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+
+    previousEntryLink:
+      (previousEntry
+        ? relation('linkNewsEntry', previousEntry)
+        : null),
+
+    nextEntryLink:
+      (nextEntry
+        ? relation('linkNewsEntry', nextEntry)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.switcher.slots({
+      links: [
+        relations.previousLink
+          .slot('link', relations.previousEntryLink),
+
+        relations.nextLink
+          .slot('link', relations.nextEntryLink),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateNewsEntryPage.js b/src/content/dependencies/generateNewsEntryPage.js
index bcba7194..4abd87d1 100644
--- a/src/content/dependencies/generateNewsEntryPage.js
+++ b/src/content/dependencies/generateNewsEntryPage.js
@@ -3,10 +3,9 @@ import {atOffset} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateNewsEntryNavAccent',
     'generateNewsEntryReadAnotherLinks',
     'generatePageLayout',
-    'generatePreviousNextLinks',
-    'linkNewsEntry',
     'linkNewsIndex',
     'transformContent',
   ],
@@ -31,101 +30,76 @@ export default {
     return {previousEntry, nextEntry};
   },
 
-  relations(relation, query, sprawl, newsEntry) {
-    const relations = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.content =
-      relation('transformContent', newsEntry.content);
-
-    relations.newsIndexLink =
-      relation('linkNewsIndex');
-
-    relations.currentEntryLink =
-      relation('linkNewsEntry', newsEntry);
-
-    if (query.previousEntry || query.nextEntry) {
-      relations.previousNextLinks =
-        relation('generatePreviousNextLinks');
-
-      relations.readAnotherLinks =
-        relation('generateNewsEntryReadAnotherLinks',
-          newsEntry,
-          query.previousEntry,
-          query.nextEntry);
-
-      if (query.previousEntry) {
-        relations.previousEntryNavLink =
-          relation('linkNewsEntry', query.previousEntry);
-      }
-
-      if (query.nextEntry) {
-        relations.nextEntryNavLink =
-          relation('linkNewsEntry', query.nextEntry);
-      }
-    }
-
-    return relations;
-  },
-
-  data(query, sprawl, newsEntry) {
-    return {
-      name: newsEntry.name,
-      date: newsEntry.date,
-
-      daysSincePreviousEntry:
-        query.previousEntry &&
-          Math.round((newsEntry.date - query.previousEntry.date) / 86400000),
-
-      daysUntilNextEntry:
-        query.nextEntry &&
-          Math.round((query.nextEntry.date - newsEntry.date) / 86400000),
-
-      previousEntryDate:
-        query.previousEntry?.date,
-
-      nextEntryDate:
-        query.nextEntry?.date,
-    };
-  },
-
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title:
-        language.$('newsEntryPage.title', {
-          entry: data.name,
-        }),
-
-      headingMode: 'sticky',
-
-      mainClasses: ['long-content'],
-      mainContent: [
-        html.tag('p',
-          language.$('newsEntryPage.published', {
-            date: language.formatDate(data.date),
-          })),
-
-        relations.content,
-        relations.readAnotherLinks,
-      ],
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {html: relations.newsIndexLink},
-        {
-          auto: 'current',
-          accent:
-            (relations.previousNextLinks
-              ? `(${language.formatUnitList(relations.previousNextLinks.slots({
-                  previousLink: relations.previousEntryNavLink ?? null,
-                  nextLink: relations.nextEntryNavLink ?? null,
-                }).content)})`
-              : null),
-        },
-      ],
-    });
-  },
+  relations: (relation, query, sprawl, newsEntry) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    content:
+      relation('transformContent', newsEntry.content),
+
+    newsIndexLink:
+      relation('linkNewsIndex'),
+
+    readAnotherLinks:
+      relation('generateNewsEntryReadAnotherLinks',
+        newsEntry,
+        query.previousEntry,
+        query.nextEntry),
+
+    navAccent:
+      relation('generateNewsEntryNavAccent',
+        query.previousEntry,
+        query.nextEntry),
+  }),
+
+  data: (query, sprawl, newsEntry) => ({
+    name: newsEntry.name,
+    date: newsEntry.date,
+
+    daysSincePreviousEntry:
+      query.previousEntry &&
+        Math.round((newsEntry.date - query.previousEntry.date) / 86400000),
+
+    daysUntilNextEntry:
+      query.nextEntry &&
+        Math.round((query.nextEntry.date - newsEntry.date) / 86400000),
+
+    previousEntryDate:
+      query.previousEntry?.date,
+
+    nextEntryDate:
+      query.nextEntry?.date,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('newsEntryPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            entry: data.name,
+          }),
+
+        headingMode: 'sticky',
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          html.tag('p',
+            language.$(pageCapsule, 'published', {
+              date: language.formatDate(data.date),
+            })),
+
+          relations.content,
+          relations.readAnotherLinks,
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.newsIndexLink},
+          {
+            auto: 'current',
+            accent: relations.navAccent,
+          },
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateNewsIndexPage.js b/src/content/dependencies/generateNewsIndexPage.js
index 539af804..02964ce8 100644
--- a/src/content/dependencies/generateNewsIndexPage.js
+++ b/src/content/dependencies/generateNewsIndexPage.js
@@ -57,37 +57,38 @@ export default {
     };
   },
 
-  generate(data, relations, {html, language}) {
-    return relations.layout.slots({
-      title: language.$('newsIndex.title'),
-      headingMode: 'sticky',
-
-      mainClasses: ['long-content', 'news-index'],
-      mainContent:
-        stitchArrays({
-          entryLink: relations.entryLinks,
-          viewRestLink: relations.viewRestLinks,
-          content: relations.entryContents,
-          date: data.entryDates,
-          directory: data.entryDirectories,
-        }).map(({entryLink, viewRestLink, content, date, directory}) =>
-            html.tag('article', {id: directory}, [
-              html.tag('h2', [
-                html.tag('time', language.formatDate(date)),
-                entryLink,
-              ]),
-
-              content,
-
-              viewRestLink
-                ?.slot('content', language.$('newsIndex.entry.viewRest')),
-            ])),
-
-      navLinkStyle: 'hierarchical',
-      navLinks: [
-        {auto: 'home'},
-        {auto: 'current'},
-      ],
-    });
-  },
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('newsIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+        headingMode: 'sticky',
+
+        mainClasses: ['long-content', 'news-index'],
+        mainContent:
+          stitchArrays({
+            entryLink: relations.entryLinks,
+            viewRestLink: relations.viewRestLinks,
+            content: relations.entryContents,
+            date: data.entryDates,
+            directory: data.entryDirectories,
+          }).map(({entryLink, viewRestLink, content, date, directory}) =>
+              language.encapsulate(pageCapsule, 'entry', entryCapsule =>
+                html.tag('article', {id: directory}, [
+                  html.tag('h2', [
+                    html.tag('time', language.formatDate(date)),
+                    entryLink,
+                  ]),
+
+                  content,
+
+                  viewRestLink
+                    ?.slot('content', language.$(entryCapsule, 'viewRest')),
+                ]))),
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateNextLink.js b/src/content/dependencies/generateNextLink.js
new file mode 100644
index 00000000..2e48cd2b
--- /dev/null
+++ b/src/content/dependencies/generateNextLink.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['generatePreviousNextLink'],
+
+  relations: (relation) => ({
+    link:
+      relation('generatePreviousNextLink'),
+  }),
+
+  generate: (relations) =>
+    relations.link.slots({
+      direction: 'next',
+    }),
+};
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index cbfc905a..0acf401c 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -1,38 +1,40 @@
 import {openAggregate} from '#aggregate';
-import {empty} from '#sugar';
+import {atOffset, empty, repeat} from '#sugar';
 
 export default {
   contentDependencies: [
     'generateColorStyleRules',
     'generateFooterLocalizationLinks',
+    'generateImageOverlay',
+    'generatePageSidebar',
+    'generateSearchSidebarBox',
     'generateStickyHeadingContainer',
     'transformContent',
   ],
 
   extraDependencies: [
-    'cachebust',
     'getColors',
     'html',
     'language',
     'pagePath',
+    'pagePathStringFromRoot',
     'to',
     'wikiData',
   ],
 
-  sprawl({wikiInfo}) {
-    return {
-      footerContent: wikiInfo.footerContent,
-      wikiColor: wikiInfo.color,
-      wikiName: wikiInfo.nameShort,
-    };
-  },
+  sprawl: ({wikiInfo}) => ({
+    enableSearch: wikiInfo.enableSearch,
+    footerContent: wikiInfo.footerContent,
+    wikiColor: wikiInfo.color,
+    wikiName: wikiInfo.nameShort,
+    canonicalBase: wikiInfo.canonicalBase,
+  }),
 
-  data({wikiColor, wikiName}) {
-    return {
-      wikiColor,
-      wikiName,
-    };
-  },
+  data: (sprawl) => ({
+    wikiColor: sprawl.wikiColor,
+    wikiName: sprawl.wikiName,
+    canonicalBase: sprawl.canonicalBase,
+  }),
 
   relations(relation, sprawl) {
     const relations = {};
@@ -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);
@@ -51,6 +61,9 @@ export default {
     relations.colorStyleRules =
       relation('generateColorStyleRules');
 
+    relations.imageOverlay =
+      relation('generateImageOverlay');
+
     return relations;
   },
 
@@ -61,6 +74,16 @@ export default {
     },
 
     showWikiNameInTitle: {
+      validate: v => v.is(true, false, 'auto'),
+      default: 'auto',
+    },
+
+    subtitle: {
+      type: 'html',
+      mutable: false,
+    },
+
+    showSearch: {
       type: 'boolean',
       default: true,
     },
@@ -70,7 +93,7 @@ export default {
       mutable: false,
     },
 
-    cover: {
+    artworkColumnContent: {
       type: 'html',
       mutable: false,
     },
@@ -209,11 +232,11 @@ export default {
   },
 
   generate(data, relations, slots, {
-    cachebust,
     getColors,
     html,
     language,
     pagePath,
+    pagePathStringFromRoot,
     to,
   }) {
     const colors = getColors(slots.color ?? data.wikiColor);
@@ -227,6 +250,29 @@ export default {
     const mainContentHTML = html.tags([slots.mainContent]).toString();
     const hasID = id => mainContentHTML.includes(`id="${id}"`);
 
+    const oEmbedJSONHref =
+      (hasSocialEmbed && data.canonicalBase
+        ? data.canonicalBase +
+          pagePathStringFromRoot +
+          'oembed.json'
+        : null);
+
+    const canonicalHref =
+      (data.canonicalBase
+        ? data.canonicalBase + pagePathStringFromRoot
+        : null);
+
+    const firstItemInArtworkColumn =
+      html.smooth(slots.artworkColumnContent)
+        .content[0];
+
+    const primaryCover =
+      (firstItemInArtworkColumn &&
+       html.resolve(firstItemInArtworkColumn, {normalize: 'tag'})
+         .attributes.has('class', 'cover-artwork')
+        ? firstItemInArtworkColumn
+        : null);
+
     const titleContentsHTML =
       (html.isBlank(slots.title)
         ? null
@@ -241,12 +287,26 @@ export default {
       (html.isBlank(slots.title)
         ? null
      : slots.headingMode === 'sticky'
-        ? relations.stickyHeadingContainer.slots({
-            title: titleContentsHTML,
-            cover: slots.cover,
-          })
+        ? [
+            relations.stickyHeadingContainer.slots({
+              title: titleContentsHTML,
+              cover: primaryCover,
+            }),
+
+            relations.stickyHeadingContainer.clone().slots({
+              rootAttributes: {inert: true},
+            }),
+          ]
         : html.tag('h1', titleContentsHTML));
 
+    // TODO: There could be neat interactions with the sticky heading here,
+    // but for now subtitle is totally separate.
+    const subtitleHTML =
+      (html.isBlank(slots.subtitle)
+        ? null
+        : html.tag('h2', {class: 'page-subtitle'},
+            language.sanitize(slots.subtitle)));
+
     let footerContent = slots.footerContent;
 
     if (html.isBlank(footerContent) && relations.defaultFooterContent) {
@@ -261,12 +321,19 @@ export default {
       html.tag('main', {id: 'content'},
         {class: slots.mainClasses},
 
+        !html.isBlank(subtitleHTML) &&
+          {class: 'has-subtitle'},
+
         [
           titleHTML,
 
-          html.tag('div', {id: 'cover-art-container'},
+          html.tag('div', {id: 'artwork-column'},
             {[html.onlyIfContent]: true},
-            slots.cover),
+            {class: 'isolate-tooltip-z-indexing'},
+
+            slots.artworkColumnContent),
+
+          subtitleHTML,
 
           slots.additionalNames,
 
@@ -307,34 +374,32 @@ export default {
 
             slots.navLinks
               ?.filter(Boolean)
-              ?.map((cur, i) => {
+              ?.map((cur, i, entries) => {
                 let content;
 
                 if (cur.html) {
                   content = cur.html;
                 } else {
+                  const attributes = html.attributes();
                   let title;
-                  let href;
 
                   switch (cur.auto) {
                     case 'home':
                       title = data.wikiName;
-                      href = to('localized.home');
+                      attributes.set('href', to('localized.home'));
                       break;
                     case 'current':
                       title = slots.title;
-                      href = '';
+                      attributes.set('href', '');
                       break;
                     case null:
                     case undefined:
                       title = cur.title;
-                      href = to(...cur.path);
+                      attributes.set('href', to(...cur.path));
                       break;
                   }
 
-                  content = html.tag('a',
-                    {href},
-                    title);
+                  content = html.tag('a', attributes, title);
                 }
 
                 const showAsCurrent =
@@ -343,63 +408,106 @@ export default {
                   (slots.navLinkStyle === 'hierarchical' &&
                     i === slots.navLinks.length - 1);
 
-                return (
+                const navLink =
                   html.tag('span', {class: 'nav-link'},
                     showAsCurrent &&
                       {class: 'current'},
 
-                    i > 0 &&
-                      {class: 'has-divider'},
-
                     [
                       html.tag('span', {class: 'nav-link-content'},
-                        // Use inline-block styling on the content span,
-                        // rather than wrapping the whole nav-link in a proper
-                        // blockwrap, so that if the content spans multiple
-                        // lines, it'll kick the accent down beneath it.
-                        i > 0 &&
-                          {class: 'blockwrap'},
-
                         content),
 
                       html.tag('span', {class: 'nav-link-accent'},
+                        {[html.noEdgeWhitespace]: true},
                         {[html.onlyIfContent]: true},
-                        cur.accent),
-                    ]));
+
+                        language.$('misc.navAccent', {
+                          [language.onlyIfOptions]: ['links'],
+                          links: cur.accent,
+                        })),
+                    ]);
+
+                if (slots.navLinkStyle === 'index') {
+                  return navLink;
+                }
+
+                const prev =
+                  atOffset(entries, i, -1);
+
+                if (
+                  prev &&
+                  prev.releaseRestToWrapTogether !== true &&
+                  (prev.releaseRestToWrapTogether === false ||
+                   prev.auto === 'home')
+                ) {
+                  return navLink;
+                } else {
+                  return html.metatag('blockwrap', navLink);
+                }
               })),
 
           html.tag('div', {class: 'nav-bottom-row'},
             {[html.onlyIfContent]: true},
-            slots.navBottomRowContent),
+
+            language.$('misc.navAccent', {
+              [language.onlyIfOptions]: ['links'],
+              links: slots.navBottomRowContent,
+            })),
 
           html.tag('div', {class: 'nav-content'},
             {[html.onlyIfContent]: true},
             slots.navContent),
         ]);
 
-    const 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;
+    let sidebarsInContentColumn = false;
+
+    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left', willShowSearch);
+    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right', false);
+
+    if (willShowSearch) {
+      if (html.isBlank(leftSidebar)) {
+        sidebarsInContentColumn = true;
+        showingSidebarLeft = true;
+      }
+
+      leftSidebar.setSlot(
+        'boxes',
+        html.tags([
+          relations.searchBox,
+          leftSidebar.getSlotValue('boxes'),
+        ]));
+    }
 
     const hasSidebarLeft = !html.isBlank(html.resolve(leftSidebar));
     const hasSidebarRight = !html.isBlank(html.resolve(rightSidebar));
 
-    const collapseSidebars =
-      (hasSidebarLeft
-        ? leftSidebar.getSlotValue('collapse')
-        : true) &&
-      (hasSidebarRight
-        ? rightSidebar.getSlotValue('collapse')
-        : true);
+    showingSidebarLeft ??= hasSidebarLeft;
+    showingSidebarRight ??= hasSidebarRight;
 
     const processSkippers = skipperList =>
       skipperList
@@ -407,8 +515,11 @@ export default {
           (condition === undefined
             ? hasID(id)
             : condition))
+
         .map(({id, string}) =>
           html.tag('span', {class: 'skipper'},
+            {'data-for': id},
+
             html.tag('a',
               {href: `#${id}`},
               language.$('misc.skippers', string))));
@@ -458,51 +569,36 @@ export default {
               {id: 'additional-files', string: 'additionalFiles'},
               {id: 'commentary', string: 'commentary'},
               {id: 'artist-commentary', string: 'artistCommentary'},
+              {id: 'credit-sources', string: 'creditSources'},
             ])),
         ]);
 
-    const imageOverlayHTML = html.tag('div', {id: 'image-overlay-container'},
-      html.tag('div', {id: 'image-overlay-content-container'}, [
-        html.tag('a', {id: 'image-overlay-image-container'}, [
-          html.tag('img', {id: 'image-overlay-image'}),
-          html.tag('img', {id: 'image-overlay-image-thumb'}),
-        ]),
-        html.tag('div', {id: 'image-overlay-action-container'}, [
-          html.tag('div', {id: 'image-overlay-action-content-without-size'},
-            language.$('releaseInfo.viewOriginalFile', {
-              link: html.tag('a', {class: 'image-overlay-view-original'},
-                language.$('releaseInfo.viewOriginalFile.link')),
-            })),
+    const styleRulesCSS =
+      html.resolve(slots.styleRules, {normalize: 'string'});
 
-          html.tag('div', {id: 'image-overlay-action-content-with-size'}, [
-            language.$('releaseInfo.viewOriginalFile.withSize', {
-              link:
-                html.tag('a', {class: 'image-overlay-view-original'},
-                  language.$('releaseInfo.viewOriginalFile.link')),
-
-              size:
-                html.tag('span',
-                  {[html.joinChildren]: ''},
-                  [
-                    html.tag('span', {id: 'image-overlay-file-size-kilobytes'},
-                      language.$('count.fileSize.kilobytes', {
-                        kilobytes:
-                          html.tag('span', {class: 'image-overlay-file-size-count'}),
-                      })),
-
-                    html.tag('span', {id: 'image-overlay-file-size-megabytes'},
-                      language.$('count.fileSize.megabytes', {
-                        megabytes:
-                          html.tag('span', {class: 'image-overlay-file-size-count'}),
-                      })),
-                  ]),
-            }),
+    const fallbackBackgroundStyleRule =
+      (styleRulesCSS.match(/body::before[^}]*background-image:/)
+        ? ''
+        : `body::before {\n` +
+          `    background-image: url("${to('media.path', 'bg.jpg')}");\n` +
+          `}`);
 
-            html.tag('span', {id: 'image-overlay-file-size-warning'},
-              language.$('releaseInfo.viewOriginalFile.sizeWarning')),
-          ]),
-        ]),
-      ]));
+    const goshFrigginDarnitStyleRule =
+      `.image-media-link::after {\n` +
+      `    mask-image: url("${to('staticMisc.path', 'image.svg')}");\n` +
+      `}`;
+
+    const numWallpaperParts =
+      html.resolve(slots.styleRules, {normalize: 'string'})
+        .match(/\.wallpaper-part:nth-child/g)
+        ?.length ?? 0;
+
+    const wallpaperPartsHTML =
+      html.tag('div', {class: 'wallpaper-parts'},
+        {[html.onlyIfContent]: true},
+
+        repeat(numWallpaperParts, () =>
+          html.tag('div', {class: 'wallpaper-part'})));
 
     const layoutHTML = [
       navHTML,
@@ -512,15 +608,11 @@ export default {
 
       slots.secondaryNav,
 
-      html.tag('div', {class: 'layout-columns'},
-        !collapseSidebars &&
-          {class: 'vertical-when-thin'},
-
-        [
-          leftSidebar,
-          mainHTML,
-          rightSidebar,
-        ]),
+      html.tag('div', {class: 'layout-columns'}, [
+        leftSidebar,
+        mainHTML,
+        rightSidebar,
+      ]),
 
       slots.bannerPosition === 'bottom' &&
         slots.banner,
@@ -543,6 +635,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')},
 
         [
@@ -550,14 +644,30 @@ export default {
 
           html.tag('head', [
             html.tag('title',
-              (slots.showWikiNameInTitle
-                ? language.formatString('misc.pageTitle.withWikiName', {
-                    title: slots.title,
-                    wikiName: data.wikiName,
-                  })
-                : language.formatString('misc.pageTitle', {
-                    title: slots.title,
-                  }))),
+              language.encapsulate('misc.pageTitle', workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.title = slots.title;
+
+                if (!html.isBlank(slots.subtitle)) {
+                  workingCapsule += '.withSubtitle';
+                  workingOptions.subtitle = slots.subtitle;
+                }
+
+                const showWikiName =
+                  (slots.showWikiNameInTitle === true
+                    ? true
+                 : slots.showWikiNameInTitle === 'auto'
+                    ? html.isBlank(slots.subtitle)
+                    : false);
+
+                if (showWikiName) {
+                  workingCapsule += '.withWikiName';
+                  workingOptions.wikiName = data.wikiName;
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })),
 
             html.tag('meta', {charset: 'utf-8'}),
             html.tag('meta', {
@@ -589,13 +699,15 @@ export default {
               Object.entries(meta)
                 .filter(([key, value]) => value)
                 .map(([key, value]) => html.tag('meta', {[key]: value}))),
+            */
 
-            canonical &&
+            canonicalHref &&
               html.tag('link', {
                 rel: 'canonical',
-                href: canonical,
+                href: canonicalHref,
               }),
 
+            /*
             ...(
               localizedCanonical
                 .map(({lang, href}) => html.tag('link', {
@@ -603,7 +715,6 @@ export default {
                   hreflang: lang,
                   href,
                 }))),
-
             */
 
             hasSocialEmbed &&
@@ -611,37 +722,55 @@ export default {
                 .clone()
                 .slot('mode', 'html'),
 
+            oEmbedJSONHref &&
+              html.tag('link', {
+                type: 'application/json+oembed',
+                href: oEmbedJSONHref,
+              }),
+
             html.tag('link', {
               rel: 'stylesheet',
-              href: to('shared.staticFile', 'site6.css', cachebust),
+              href: to('staticCSS.path', 'site.css'),
             }),
 
             html.tag('style', [
               relations.colorStyleRules
                 .slot('color', slots.color ?? data.wikiColor),
+
+              fallbackBackgroundStyleRule,
+              goshFrigginDarnitStyleRule,
               slots.styleRules,
             ]),
 
             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/index.js'),
             }),
           ]),
 
           html.tag('body',
             [
-              html.tag('div', {id: 'page-container'},
-                (hasSidebarLeft || hasSidebarRight
-                  ? {class: 'has-one-sidebar'}
-                  : {class: 'has-zero-sidebars'}),
+              wallpaperPartsHTML,
 
-                hasSidebarLeft && hasSidebarRight &&
-                  {class: 'has-two-sidebars'},
+              html.tag('div', {id: 'page-container'},
+                showingSidebarLeft &&
+                  {class: 'showing-sidebar-left'},
 
-                hasSidebarLeft &&
-                  {class: 'has-sidebar-left'},
+                showingSidebarRight &&
+                  {class: 'showing-sidebar-right'},
 
-                hasSidebarRight &&
-                  {class: 'has-sidebar-right'},
+                sidebarsInContentColumn &&
+                  {class: 'sidebars-in-content-column'},
 
                 [
                   skippersHTML,
@@ -649,12 +778,7 @@ export default {
                 ]),
 
               // infoCardHTML,
-              imageOverlayHTML,
-
-              html.tag('script', {
-                type: 'module',
-                src: to('shared.staticFile', 'client3.js', cachebust),
-              }),
+              relations.imageOverlay,
             ]),
         ])
     ]).toString();
diff --git a/src/content/dependencies/generatePageSidebar.js b/src/content/dependencies/generatePageSidebar.js
index a7da3d1d..d3b55580 100644
--- a/src/content/dependencies/generatePageSidebar.js
+++ b/src/content/dependencies/generatePageSidebar.js
@@ -1,29 +1,16 @@
 export default {
-  contentDependencies: ['generatePageSidebarBox'],
   extraDependencies: ['html'],
 
-  relations: (relation) => ({
-    box:
-      relation('generatePageSidebarBox'),
-  }),
-
   slots: {
-    // Content is a flat HTML array. It'll all be placed into one sidebar box
-    // if specified.
-    content: {
-      type: 'html',
-      mutable: false,
-    },
-
-    // Attributes to apply to the whole sidebar. If specifying multiple
-    // sections, this be added to the containing sidebar-column, arr - specify
-    // attributes on each section if that's more suitable.
+    // Attributes to apply to the whole sidebar. This be added to the
+    // containing sidebar-column, arr - specify attributes on each section if
+    // that's more suitable.
     attributes: {
       type: 'attributes',
       mutable: false,
     },
 
-    // Chunks of content to be split into separate boxes in the sidebar.
+    // Content boxes to line up vertically in the sidebar.
     boxes: {
       type: 'html',
       mutable: false,
@@ -32,27 +19,16 @@ 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',
     },
 
-    // Collapsing sidebars disappear when the viewport is sufficiently
-    // thin. (This is the default.) Override as false to make the sidebar
-    // stay visible in thinner viewports, where the page layout will be
-    // reflowed so the sidebar is as wide as the screen and appears below
-    // nav, above the main content.
-    collapse: {
-      type: 'boolean',
-      default: true,
-    },
-
     // Wide sidebars generally take up more horizontal space in the normal
     // page layout, and should be used if the content of the sidebar has
     // a greater than typical focus compared to main content.
@@ -60,9 +36,19 @@ 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(relations, slots, {html}) {
+  generate(slots, {html}) {
     const attributes =
       html.attributes({class: [
         'sidebar-column',
@@ -71,33 +57,34 @@ export default {
 
     attributes.add(slots.attributes);
 
-    if (slots.class) {
-      attributes.add('class', slots.class);
-    }
-
     if (slots.wide) {
       attributes.add('class', 'wide');
     }
 
-    if (!slots.collapse) {
-      attributes.add('class', 'no-hide');
-    }
-
     if (slots.stickyMode !== 'static') {
       attributes.add('class', `sticky-${slots.stickyMode}`);
     }
 
-    const boxes =
-      (!html.isBlank(slots.boxes)
-        ? slots.boxes
-     : !html.isBlank(slots.content)
-        ? relations.box.slot('content', slots.content)
-        : html.blank());
+    const {content: boxes} = html.smooth(slots.boxes);
+
+    const allBoxesCollapsible =
+      boxes.every(box =>
+        html.resolve(box)
+          .attributes
+          .has('class', 'collapsible'));
+
+    if (allBoxesCollapsible) {
+      attributes.add('class', 'all-boxes-collapsible');
+    }
+
+    if (slots.initiallyHidden) {
+      attributes.add('class', 'initially-hidden');
+    }
 
-    if (html.isBlank(boxes)) {
+    if (html.isBlank(slots.boxes) && !slots.initiallyHidden) {
       return html.blank();
     } else {
-      return html.tag('div', attributes, boxes);
+      return html.tag('div', attributes, slots.boxes);
     }
   },
 };
diff --git a/src/content/dependencies/generatePageSidebarBox.js b/src/content/dependencies/generatePageSidebarBox.js
index 51835452..26b30494 100644
--- a/src/content/dependencies/generatePageSidebarBox.js
+++ b/src/content/dependencies/generatePageSidebarBox.js
@@ -11,10 +11,20 @@ export default {
       type: 'attributes',
       mutable: false,
     },
+
+    collapsible: {
+      type: 'boolean',
+      default: true,
+    },
   },
 
   generate: (slots, {html}) =>
     html.tag('div', {class: 'sidebar'},
+      {[html.onlyIfContent]: true},
+
+      slots.collapsible &&
+        {class: 'collapsible'},
+
       slots.attributes,
       slots.content),
 };
diff --git a/src/content/dependencies/generatePageSidebarConjoinedBox.js b/src/content/dependencies/generatePageSidebarConjoinedBox.js
index 05b1d469..7974c707 100644
--- a/src/content/dependencies/generatePageSidebarConjoinedBox.js
+++ b/src/content/dependencies/generatePageSidebarConjoinedBox.js
@@ -32,11 +32,7 @@ export default {
           .map((content, index, {length}) => [
             content,
             index < length - 1 &&
-              html.tag('hr', {
-                style:
-                  `border-color: var(--primary-color); ` +
-                  `border-style: none none dotted none`,
-              }),
+              html.tag('hr', {class: 'cute'}),
           ]),
     }),
 };
diff --git a/src/content/dependencies/generatePreviousLink.js b/src/content/dependencies/generatePreviousLink.js
new file mode 100644
index 00000000..775367f9
--- /dev/null
+++ b/src/content/dependencies/generatePreviousLink.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['generatePreviousNextLink'],
+
+  relations: (relation) => ({
+    link:
+      relation('generatePreviousNextLink'),
+  }),
+
+  generate: (relations) =>
+    relations.link.slots({
+      direction: 'previous',
+    }),
+};
diff --git a/src/content/dependencies/generatePreviousNextLink.js b/src/content/dependencies/generatePreviousNextLink.js
new file mode 100644
index 00000000..afae1228
--- /dev/null
+++ b/src/content/dependencies/generatePreviousNextLink.js
@@ -0,0 +1,58 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    link: {
+      type: 'html',
+      mutable: true,
+    },
+
+    direction: {
+      validate: v => v.is('previous', 'next'),
+    },
+
+    id: {
+      type: 'boolean',
+      default: true,
+    },
+
+    showWithoutLink: {
+      type: 'boolean',
+      default: true,
+    },
+  },
+
+  generate(slots, {html, language}) {
+    if (!slots.direction) {
+      return html.blank();
+    }
+
+    const attributes = html.attributes();
+
+    if (slots.id) {
+      attributes.set('id', `${slots.direction}-button`);
+    }
+
+    if (html.isBlank(slots.link)) {
+      if (slots.showWithoutLink) {
+        return (
+          html.tag('a', {class: 'inert-previous-next-link'},
+            attributes,
+            language.$('misc.nav', slots.direction)));
+      } else {
+        return html.blank();
+      }
+    }
+
+    return html.resolve(slots.link, {
+      slots: {
+        tooltipStyle: 'browser',
+        color: false,
+        attributes,
+
+        content:
+          language.$('misc.nav', slots.direction),
+      }
+    });
+  },
+};
diff --git a/src/content/dependencies/generatePreviousNextLinks.js b/src/content/dependencies/generatePreviousNextLinks.js
deleted file mode 100644
index 9771de39..00000000
--- a/src/content/dependencies/generatePreviousNextLinks.js
+++ /dev/null
@@ -1,50 +0,0 @@
-export default {
-  // Returns an array with the slotted previous and next links, prepared
-  // for inclusion in a page's navigation bar. Include with other links
-  // in the nav bar and then join them all as a unit list, for example.
-
-  extraDependencies: ['html', 'language'],
-
-  slots: {
-    previousLink: {
-      type: 'html',
-      mutable: true,
-    },
-
-    nextLink: {
-      type: 'html',
-      mutable: true,
-    },
-
-    id: {
-      type: 'boolean',
-      default: true,
-    },
-  },
-
-  generate(slots, {html, language}) {
-    const previousNext = [];
-
-    if (!html.isBlank(slots.previousLink)) {
-      previousNext.push(
-        slots.previousLink.slots({
-          tooltipStyle: 'browser',
-          color: false,
-          attributes: {id: slots.id && 'previous-button'},
-          content: language.$('misc.nav.previous'),
-        }));
-    }
-
-    if (!html.isBlank(slots.nextLink)) {
-      previousNext.push(
-        slots.nextLink.slots({
-          tooltipStyle: 'browser',
-          color: false,
-          attributes: {id: slots.id && 'next-button'},
-          content: language.$('misc.nav.next'),
-        }));
-    }
-
-    return previousNext;
-  },
-};
diff --git a/src/content/dependencies/generateQuickDescription.js b/src/content/dependencies/generateQuickDescription.js
new file mode 100644
index 00000000..e144503e
--- /dev/null
+++ b/src/content/dependencies/generateQuickDescription.js
@@ -0,0 +1,134 @@
+export default {
+  contentDependencies: ['transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  query: (thing) => ({
+    hasDescription:
+      !!thing.description,
+
+    hasLongerDescription:
+      thing.description &&
+      thing.descriptionShort &&
+      thing.descriptionShort !== thing.description,
+  }),
+
+  relations: (relation, query, thing) => ({
+    description:
+      (query.hasLongerDescription || !thing.description
+        ? null
+        : relation('transformContent', thing.description)),
+
+    descriptionShort:
+      (query.hasLongerDescription
+        ? relation('transformContent', thing.descriptionShort)
+        : null),
+
+    descriptionLong:
+      (query.hasLongerDescription
+        ? relation('transformContent', thing.description)
+        : null),
+  }),
+
+  data: (query) => ({
+    hasDescription: query.hasDescription,
+    hasLongerDescription: query.hasLongerDescription,
+  }),
+
+  slots: {
+    extraReadingLinks: {
+      validate: v => v.sparseArrayOf(v.isHTML),
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const prefix = 'misc.quickDescription';
+
+    const actionsWithoutLongerDescription =
+      (data.hasLongerDescription
+        ? null
+     : slots.extraReadingLinks
+        ? language.$(prefix, 'readMore', {
+            links:
+              language.formatDisjunctionList(slots.extraReadingLinks),
+          })
+        : null);
+
+    const wrapExpandCollapseLink = (expandCollapse, content) =>
+      html.tag('a', {class: `${expandCollapse}-link`},
+        {href: '#'},
+        content);
+
+    const actionsWhenCollapsed =
+      (data.hasLongerDescription && slots.extraReadingLinks
+        ? language.$(prefix, 'expandDescription.orReadMore', {
+            links:
+              language.formatDisjunctionList(slots.extraReadingLinks),
+            expand:
+              wrapExpandCollapseLink('expand',
+                language.$(prefix, 'expandDescription.orReadMore.expand')),
+          })
+     : data.hasLongerDescription
+        ? language.$(prefix, 'expandDescription', {
+            expand:
+              wrapExpandCollapseLink('expand',
+                language.$(prefix, 'expandDescription.expand')),
+          })
+        : null);
+
+    const actionsWhenExpanded =
+      (data.hasLongerDescription && slots.extraReadingLinks
+        ? language.$(prefix, 'collapseDescription.orReadMore', {
+            links:
+              language.formatDisjunctionList(slots.extraReadingLinks),
+            collapse:
+              wrapExpandCollapseLink('collapse',
+                language.$(prefix, 'collapseDescription.orReadMore.collapse')),
+          })
+     : data.hasLongerDescription
+        ? language.$(prefix, 'collapseDescription', {
+            collapse:
+              wrapExpandCollapseLink('collapse',
+                language.$(prefix, 'collapseDescription.collapse')),
+          })
+        : null);
+
+    const wrapActions = (attributes, children) =>
+      html.tag('p', {class: 'quick-description-actions'},
+        {[html.onlyIfContent]: true},
+        attributes,
+
+        children);
+
+    const wrapContent = (attributes, content) =>
+      html.tag('blockquote', {class: 'description-content'},
+        {[html.onlyIfContent]: true},
+        attributes,
+
+        content?.slot('mode', 'multiline'));
+
+    return (
+      html.tag('div', {class: 'quick-description'},
+        {[html.onlyIfContent]: true},
+
+        data.hasLongerDescription &&
+          {class: 'collapsed'},
+
+        !data.hasLongerDescription &&
+        !slots.extraReadingLinks &&
+          {class: 'has-content-only'},
+
+        !data.hasDescription &&
+        slots.extraReadingLinks &&
+          {class: 'has-external-links-only'},
+
+        [
+          wrapContent(null, relations.description),
+          wrapContent({class: 'short'}, relations.descriptionShort),
+          wrapContent({class: 'long'}, relations.descriptionLong),
+
+          wrapActions(null, actionsWithoutLongerDescription),
+          wrapActions({class: 'when-collapsed'}, actionsWhenCollapsed),
+          wrapActions({class: 'when-expanded'}, actionsWhenExpanded),
+        ]));
+  },
+};
diff --git a/src/content/dependencies/generateReferencedArtworksPage.js b/src/content/dependencies/generateReferencedArtworksPage.js
new file mode 100644
index 00000000..154b4762
--- /dev/null
+++ b/src/content/dependencies/generateReferencedArtworksPage.js
@@ -0,0 +1,100 @@
+export default {
+  contentDependencies: [
+    'generateCoverArtwork',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkAnythingMan',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, artwork) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    cover:
+      relation('generateCoverArtwork', artwork),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      artwork.referencedArtworks.map(({artwork}) =>
+        relation('linkAnythingMan', artwork.thing)),
+
+    images:
+      artwork.referencedArtworks.map(({artwork}) =>
+        relation('image', artwork)),
+  }),
+
+  data: (artwork) => ({
+    color:
+      artwork.thing.color,
+
+    count:
+      artwork.referencedArtworks.length,
+
+    names:
+      artwork.referencedArtworks
+        .map(({artwork}) => artwork.thing.name),
+
+    coverArtistNames:
+      artwork.referencedArtworks
+        .map(({artwork}) =>
+          artwork.artistContribs
+            .map(contrib => contrib.artist.name)),
+  }),
+
+  slots: {
+    styleRules: {type: 'html', mutable: false},
+
+    title: {type: 'html', mutable: false},
+
+    navLinks: {validate: v => v.isArray},
+    navBottomRowContent: {type: 'html', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('referencedArtworksPage', pageCapsule =>
+      relations.layout.slots({
+        title: slots.title,
+        subtitle: language.$(pageCapsule, 'subtitle'),
+
+        color: data.color,
+        styleRules: slots.styleRules,
+
+        artworkColumnContent:
+          relations.cover.slots({
+            showArtistDetails: true,
+          }),
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          html.tag('p', {class: 'quick-info'},
+            language.$(pageCapsule, 'statsLine', {
+              artworks:
+                language.countArtworks(data.count, {
+                  unit: true,
+                }),
+            })),
+
+          relations.coverGrid.slots({
+            links: relations.links,
+            images: relations.images,
+            names: data.names,
+
+            info:
+              data.coverArtistNames.map(names =>
+                language.$('misc.coverGrid.details.coverArtists', {
+                  artists:
+                    language.formatUnitList(names),
+                })),
+          }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: slots.navLinks,
+        navBottomRowContent: slots.navBottomRowContent,
+      })),
+};
diff --git a/src/content/dependencies/generateReferencingArtworksPage.js b/src/content/dependencies/generateReferencingArtworksPage.js
new file mode 100644
index 00000000..55977b37
--- /dev/null
+++ b/src/content/dependencies/generateReferencingArtworksPage.js
@@ -0,0 +1,100 @@
+export default {
+  contentDependencies: [
+    'generateCoverArtwork',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkAnythingMan',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, artwork) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    cover:
+      relation('generateCoverArtwork', artwork),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      artwork.referencedByArtworks.map(({artwork}) =>
+        relation('linkAnythingMan', artwork.thing)),
+
+    images:
+      artwork.referencedByArtworks.map(({artwork}) =>
+        relation('image', artwork)),
+  }),
+
+  data: (artwork) => ({
+    color:
+      artwork.thing.color,
+
+    count:
+      artwork.referencedByArtworks.length,
+
+    names:
+      artwork.referencedByArtworks
+        .map(({artwork}) => artwork.thing.name),
+
+    coverArtistNames:
+      artwork.referencedByArtworks
+        .map(({artwork}) =>
+          artwork.artistContribs
+            .map(contrib => contrib.artist.name)),
+  }),
+
+  slots: {
+    styleRules: {type: 'html', mutable: false},
+
+    title: {type: 'html', mutable: false},
+
+    navLinks: {validate: v => v.isArray},
+    navBottomRowContent: {type: 'html', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('referencingArtworksPage', pageCapsule =>
+      relations.layout.slots({
+        title: slots.title,
+        subtitle: language.$(pageCapsule, 'subtitle'),
+
+        color: data.color,
+        styleRules: slots.styleRules,
+
+        artworkColumnContent:
+          relations.cover.slots({
+            showArtistDetails: true,
+          }),
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          html.tag('p', {class: 'quick-info'},
+            language.$(pageCapsule, 'statsLine', {
+              artworks:
+                language.countArtworks(data.count, {
+                  unit: true,
+                }),
+            })),
+
+          relations.coverGrid.slots({
+            links: relations.links,
+            images: relations.images,
+            names: data.names,
+
+            info:
+              data.coverArtistNames.map(names =>
+                language.$('misc.coverGrid.details.coverArtists', {
+                  artists:
+                    language.formatUnitList(names),
+                })),
+          }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: slots.navLinks,
+        navBottomRowContent: slots.navBottomRowContent,
+      })),
+};
diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js
index 2e6c4709..016e0a2c 100644
--- a/src/content/dependencies/generateReleaseInfoContributionsLine.js
+++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js
@@ -1,42 +1,31 @@
-import {empty} from '#sugar';
-
 export default {
-  contentDependencies: ['linkContribution'],
-  extraDependencies: ['html', 'language'],
-
-  relations(relation, contributions) {
-    if (empty(contributions)) {
-      return {};
-    }
+  contentDependencies: ['generateArtistCredit'],
+  extraDependencies: ['html'],
 
-    return {
-      contributionLinks:
-        contributions
-          .map(contrib => relation('linkContribution', contrib)),
-    };
-  },
+  relations: (relation, contributions) => ({
+    credit:
+      relation('generateArtistCredit', contributions, []),
+  }),
 
   slots: {
     stringKey: {type: 'string'},
+    featuringStringKey: {type: 'string'},
 
-    showContribution: {type: 'boolean', default: true},
-    showIcons: {type: 'boolean', default: true},
+    chronologyKind: {type: 'string'},
   },
 
-  generate(relations, slots, {html, language}) {
-    if (!relations.contributionLinks) {
-      return html.blank();
-    }
+  generate: (relations, slots) =>
+    relations.credit.slots({
+      showAnnotation: true,
+      showExternalLinks: true,
+      showChronology: true,
+      showWikiEdits: true,
 
-    return language.$(slots.stringKey, {
-      artists:
-        language.formatConjunctionList(
-          relations.contributionLinks.map(link =>
-            link.slots({
-              showContribution: slots.showContribution,
-              showIcons: slots.showIcons,
-              iconMode: 'tooltip',
-            }))),
-    });
-  },
+      trimAnnotation: false,
+
+      chronologyKind: slots.chronologyKind,
+
+      normalStringKey: slots.stringKey,
+      normalFeaturingStringKey: slots.featuringStringKey,
+    }),
 };
diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js
new file mode 100644
index 00000000..188a678f
--- /dev/null
+++ b/src/content/dependencies/generateSearchSidebarBox.js
@@ -0,0 +1,62 @@
+export default {
+  contentDependencies: ['generatePageSidebarBox'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('misc.search', capsule =>
+      relations.sidebarBox.slots({
+        attributes: {class: 'wiki-search-sidebar-box'},
+        collapsible: false,
+
+        content: [
+          html.tag('label', {class: 'wiki-search-label'},
+            html.tag('input', {class: 'wiki-search-input'},
+              {type: 'search'},
+
+              {
+                placeholder:
+                  language.$(capsule, 'placeholder').toString(),
+              })),
+
+          html.tag('template', {class: 'wiki-search-preparing-string'},
+            language.$(capsule, 'preparing')),
+
+          html.tag('template', {class: 'wiki-search-loading-data-string'},
+            language.$(capsule, 'loadingData')),
+
+          html.tag('template', {class: 'wiki-search-searching-string'},
+            language.$(capsule, 'searching')),
+
+          html.tag('template', {class: 'wiki-search-failed-string'},
+            language.$(capsule, 'failed')),
+
+          html.tag('template', {class: 'wiki-search-no-results-string'},
+            language.$(capsule, 'noResults')),
+
+          html.tag('template', {class: 'wiki-search-current-result-string'},
+            language.$(capsule, 'currentResult')),
+
+          html.tag('template', {class: 'wiki-search-end-search-string'},
+            language.$(capsule, 'endSearch')),
+
+          language.encapsulate(capsule, 'resultKind', capsule => [
+            html.tag('template', {class: 'wiki-search-album-result-kind-string'},
+              language.$(capsule, 'album')),
+
+            html.tag('template', {class: 'wiki-search-artist-result-kind-string'},
+              language.$(capsule, 'artist')),
+
+            html.tag('template', {class: 'wiki-search-group-result-kind-string'},
+              language.$(capsule, 'group')),
+
+            html.tag('template', {class: 'wiki-search-tag-result-kind-string'},
+              language.$(capsule, 'artTag')),
+          ]),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateSecondaryNav.js b/src/content/dependencies/generateSecondaryNav.js
index e9aef66e..9ce7ce9b 100644
--- a/src/content/dependencies/generateSecondaryNav.js
+++ b/src/content/dependencies/generateSecondaryNav.js
@@ -7,14 +7,24 @@ export default {
       mutable: false,
     },
 
-    class: {
-      validate: v => v.anyOf(v.isString, v.sparseArrayOf(v.isString)),
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    alwaysVisible: {
+      type: 'boolean',
+      default: false,
     },
   },
 
   generate: (slots, {html}) =>
     html.tag('nav', {id: 'secondary-nav'},
       {[html.onlyIfContent]: true},
-      {class: slots.class},
+      slots.attributes,
+
+      slots.alwaysVisible &&
+        {class: 'always-visible'},
+
       slots.content),
 };
diff --git a/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js b/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js
new file mode 100644
index 00000000..f204f1fb
--- /dev/null
+++ b/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js
@@ -0,0 +1,115 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
+    'linkAlbumDynamically',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+  }),
+
+  slots: {
+    showPreviousNext: {
+      type: 'boolean',
+      default: true,
+    },
+
+    id: {
+      type: 'boolean',
+      default: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    colorStyle: {
+      type: 'html',
+      mutable: true,
+    },
+
+    mainLink: {
+      type: 'html',
+      mutable: true,
+    },
+
+    previousLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    nextLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    stringsKey: {
+      type: 'string',
+    },
+
+    mainLinkOption: {
+      type: 'string',
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    html.tag('span',
+      {[html.onlyIfContent]: true},
+      {[html.noEdgeWhitespace]: true},
+
+      slots.attributes,
+
+      !html.isBlank(slots.colorStyle) &&
+        slots.colorStyle
+          .slot('context', 'primary-only'),
+
+      language.encapsulate(slots.stringsKey, workingCapsule => {
+        const workingOptions = {
+          [language.onlyIfOptions]: [slots.mainLinkOption],
+        };
+
+        workingOptions[slots.mainLinkOption] =
+          (html.isBlank(slots.mainLink)
+            ? null
+            : slots.mainLink
+                .slot('color', false));
+
+        if (slots.showPreviousNext) addPreviousNext: {
+          if (html.isBlank(slots.previousLink) && html.isBlank(slots.nextLink)) {
+            break addPreviousNext;
+          }
+
+          workingCapsule += '.withPreviousNext';
+          workingOptions.previousNext =
+            relations.switcher.slots({
+              links: [
+                relations.previousLink.slots({
+                  id: slots.id,
+                  link: slots.previousLink,
+                }),
+
+                relations.nextLink.slots({
+                  id: slots.id,
+                  link: slots.nextLink,
+                }),
+              ],
+            });
+        }
+
+        return language.$(workingCapsule, workingOptions);
+      })),
+};
diff --git a/src/content/dependencies/generateSocialEmbed.js b/src/content/dependencies/generateSocialEmbed.js
index 0144c7fb..513ea518 100644
--- a/src/content/dependencies/generateSocialEmbed.js
+++ b/src/content/dependencies/generateSocialEmbed.js
@@ -1,5 +1,5 @@
 export default {
-  extraDependencies: ['html', 'language', 'wikiData'],
+  extraDependencies: ['absoluteTo', 'html', 'language', 'wikiData'],
 
   sprawl({wikiInfo}) {
     return {
@@ -23,10 +23,10 @@ export default {
 
     headingContent: {type: 'string'},
     headingLink: {type: 'string'},
-    imagePath: {type: 'string'},
+    imagePath: {validate: v => v.strictArrayOf(v.isString)},
   },
 
-  generate(data, slots, {html, language}) {
+  generate(data, slots, {absoluteTo, html, language}) {
     switch (slots.mode) {
       case 'html':
         return html.tags([
@@ -40,17 +40,22 @@ export default {
             }),
 
           slots.imagePath &&
-            html.tag('meta', {property: 'og:image', content: slots.imagePath}),
+            html.tag('meta', {
+              property: 'og:image',
+              content: absoluteTo(...slots.imagePath),
+            }),
         ]);
 
       case 'json':
         return JSON.stringify({
           author_name:
             (slots.headingContent
-              ? language.$('misc.socialEmbed.heading', {
-                  wikiName: data.shortWikiName,
-                  heading: slots.headingContent,
-                })
+              ? html.resolve(
+                  language.$('misc.socialEmbed.heading', {
+                    wikiName: data.shortWikiName,
+                    heading: slots.headingContent,
+                  }),
+                  {normalize: 'string'})
               : undefined),
 
           author_url:
diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js
index 9becfb26..ec3062a3 100644
--- a/src/content/dependencies/generateStickyHeadingContainer.js
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -2,6 +2,11 @@ export default {
   extraDependencies: ['html'],
 
   slots: {
+    rootAttributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
     title: {
       type: 'html',
       mutable: false,
@@ -13,22 +18,42 @@ export default {
     },
   },
 
-  generate: (slots, {html}) =>
-    html.tag('div', {class: 'content-sticky-heading-container'},
+  generate: (slots, {html}) => html.tags([
+    html.tag('div', {class: 'content-sticky-heading-root'},
+      slots.rootAttributes,
+
       !html.isBlank(slots.cover) &&
         {class: 'has-cover'},
 
-      [
-        html.tag('div', {class: 'content-sticky-heading-row'}, [
-          html.tag('h1', slots.title),
-
+      html.tag('div', {class: 'content-sticky-heading-anchor'},
+        html.tag('div', {class: 'content-sticky-heading-container'},
           !html.isBlank(slots.cover) &&
-            html.tag('div', {class: 'content-sticky-heading-cover-container'},
-              html.tag('div', {class: 'content-sticky-heading-cover'},
-                slots.cover.slot('mode', 'thumbnail'))),
-        ]),
-
-        html.tag('div', {class: 'content-sticky-subheading-row'},
-          html.tag('h2', {class: 'content-sticky-subheading'})),
-      ]),
+            {class: 'has-cover'},
+
+          [
+            html.tag('div', {class: 'content-sticky-heading-row'}, [
+              html.tag('h1', [
+                html.tag('span', {class: 'reference-collapsed-heading'},
+                  {inert: true},
+
+                  slots.title.clone()),
+
+                slots.title,
+              ]),
+
+              html.tag('div', {class: 'content-sticky-heading-cover-container'},
+                {[html.onlyIfContent]: true},
+
+                html.tag('div', {class: 'content-sticky-heading-cover'},
+                  {[html.onlyIfContent]: true},
+
+                  (html.isBlank(slots.cover)
+                    ? html.blank()
+                    : slots.cover.slot('mode', 'thumbnail')))),
+            ]),
+
+            html.tag('div', {class: 'content-sticky-subheading-row'},
+              html.tag('h2', {class: 'content-sticky-subheading'})),
+          ]))),
+  ]),
 };
diff --git a/src/content/dependencies/generateTextWithTooltip.js b/src/content/dependencies/generateTextWithTooltip.js
index 462557d1..49ce1f61 100644
--- a/src/content/dependencies/generateTextWithTooltip.js
+++ b/src/content/dependencies/generateTextWithTooltip.js
@@ -36,6 +36,7 @@ export default {
     if (hasTooltip) {
       attributes = attributes.clone();
       attributes.add({
+        [html.onlyIfContent]: true,
         [html.joinChildren]: '',
         [html.noEdgeWhitespace]: true,
         class: 'text-with-tooltip',
@@ -45,11 +46,19 @@ export default {
     const textPart =
       (hasTooltip && slots.customInteractionCue
         ? html.tag('span', {class: 'hoverable'},
+            {[html.onlyIfContent]: true},
+
             slots.text)
+
      : hasTooltip
         ? html.tag('span', {class: 'hoverable'},
+            {[html.onlyIfContent]: true},
+
             html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
+              {[html.onlyIfContent]: true},
+
               slots.text))
+
         : slots.text);
 
     const content =
diff --git a/src/content/dependencies/generateTooltip.js b/src/content/dependencies/generateTooltip.js
index 81f74aec..b09ee230 100644
--- a/src/content/dependencies/generateTooltip.js
+++ b/src/content/dependencies/generateTooltip.js
@@ -21,10 +21,14 @@ export default {
   generate: (slots, {html}) =>
     html.tag('span', {class: 'tooltip'},
       {[html.noEdgeWhitespace]: true},
+      {[html.onlyIfContent]: true},
+      {[html.onlyIfSiblings]: true},
       slots.attributes,
 
       html.tag('span', {class: 'tooltip-content'},
         {[html.noEdgeWhitespace]: true},
+        {[html.onlyIfContent]: true},
         slots.contentAttributes,
+
         slots.content)),
 };
diff --git a/src/content/dependencies/generateTrackAdditionalNamesBox.js b/src/content/dependencies/generateTrackAdditionalNamesBox.js
deleted file mode 100644
index bad04b74..00000000
--- a/src/content/dependencies/generateTrackAdditionalNamesBox.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import {empty} from '#sugar';
-
-export default {
-  contentDependencies: ['generateAdditionalNamesBox'],
-  extraDependencies: ['html'],
-
-  query: (track) => {
-    const {
-      additionalNames: own,
-      sharedAdditionalNames: shared,
-      inferredAdditionalNames: inferred,
-    } = track;
-
-    if (empty(own) && empty(shared) && empty(inferred)) {
-      return {combinedList: []};
-    }
-
-    const firstFilter =
-      (empty(own)
-        ? new Set()
-        : new Set(own.map(({name}) => name)));
-
-    const sharedFiltered =
-      shared.filter(({name}) => !firstFilter.has(name))
-
-    const secondFilter =
-      new Set([
-        ...firstFilter,
-        ...sharedFiltered.map(({name}) => name),
-      ]);
-
-    const inferredFiltered =
-      inferred.filter(({name}) => !secondFilter.has(name));
-
-    return {
-      combinedList: [
-        ...own,
-        ...sharedFiltered,
-        ...inferredFiltered,
-      ],
-    };
-  },
-
-  relations: (relation, query) => ({
-    box:
-      (empty(query.combinedList)
-        ? null
-        : relation('generateAdditionalNamesBox', query.combinedList)),
-  }),
-
-  generate: (relations, {html}) =>
-    relations.box ?? html.blank(),
-};
diff --git a/src/content/dependencies/generateTrackArtistCommentarySection.js b/src/content/dependencies/generateTrackArtistCommentarySection.js
new file mode 100644
index 00000000..e3041d3a
--- /dev/null
+++ b/src/content/dependencies/generateTrackArtistCommentarySection.js
@@ -0,0 +1,157 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCommentaryEntry',
+    'generateContentHeading',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    otherSecondaryReleasesWithCommentary:
+      track.otherReleases
+        .filter(track => !track.isMainRelease)
+        .filter(track => !empty(track.commentary)),
+  }),
+
+  relations: (relation, query, track) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+
+    mainReleaseTrackLink:
+      (track.isSecondaryRelease
+        ? relation('linkTrack', track.mainReleaseTrack)
+        : null),
+
+    mainReleaseArtistCommentaryEntries:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.commentary
+            .map(entry => relation('generateCommentaryEntry', entry))
+        : null),
+
+    thisReleaseAlbumLink:
+      relation('linkAlbum', track.album),
+
+    artistCommentaryEntries:
+      track.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
+
+    otherReleaseTrackLinks:
+      query.otherSecondaryReleasesWithCommentary
+        .map(track => relation('linkTrack', track)),
+  }),
+
+  data: (query, track) => ({
+    name:
+      track.name,
+
+    isSecondaryRelease:
+      track.isSecondaryRelease,
+
+    mainReleaseName:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.name
+        : null),
+
+    mainReleaseAlbumName:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.album.name
+        : null),
+
+    mainReleaseAlbumColor:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.album.color
+        : null),
+
+    otherReleaseAlbumNames:
+      query.otherSecondaryReleasesWithCommentary
+        .map(track => track.album.name),
+
+    otherReleaseAlbumColors:
+      query.otherSecondaryReleasesWithCommentary
+        .map(track => track.album.color),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('misc.artistCommentary', capsule =>
+      html.tags([
+        relations.contentHeading.clone()
+          .slots({
+            attributes: {id: 'artist-commentary'},
+            title: language.$('misc.artistCommentary'),
+          }),
+
+        data.isSecondaryRelease &&
+          html.tags([
+            html.tag('p', {class: ['drop', 'commentary-drop']},
+              {[html.onlyIfSiblings]: true},
+
+              language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.album =
+                  relations.mainReleaseTrackLink.slots({
+                    content:
+                      data.mainReleaseAlbumName,
+
+                    color:
+                      data.mainReleaseAlbumColor,
+                  });
+
+                if (data.name !== data.mainReleaseName) {
+                  workingCapsule += '.namedDifferently';
+                  workingOptions.name =
+                    html.tag('i', data.mainReleaseName);
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })),
+
+            relations.mainReleaseArtistCommentaryEntries,
+          ]),
+
+        html.tags([
+          data.isSecondaryRelease &&
+          !html.isBlank(relations.mainReleaseArtistCommentaryEntries) &&
+            html.tag('p', {class: ['drop', 'commentary-drop']},
+              {[html.onlyIfSiblings]: true},
+
+              language.$(capsule, 'info.releaseSpecific', {
+                album:
+                  relations.thisReleaseAlbumLink,
+              })),
+
+          relations.artistCommentaryEntries,
+        ]),
+
+        html.tag('p', {class: ['drop', 'commentary-drop']},
+          {[html.onlyIfContent]: true},
+
+          language.encapsulate(capsule, 'info.seeSpecificReleases', workingCapsule => {
+            const workingOptions = {};
+
+            workingOptions[language.onlyIfOptions] = ['albums'];
+
+            workingOptions.albums =
+              language.formatUnitList(
+                stitchArrays({
+                  trackLink: relations.otherReleaseTrackLinks,
+                  albumName: data.otherReleaseAlbumNames,
+                  albumColor: data.otherReleaseAlbumColors,
+                }).map(({trackLink, albumName, albumColor}) =>
+                    trackLink.slots({
+                      content: language.sanitize(albumName),
+                      color: albumColor,
+                    })));
+
+            if (!html.isBlank(relations.artistCommentaryEntries)) {
+              workingCapsule += '.withMainCommentary';
+            }
+
+            return language.$(workingCapsule, workingOptions);
+          })),
+      ])),
+};
diff --git a/src/content/dependencies/generateTrackArtworkColumn.js b/src/content/dependencies/generateTrackArtworkColumn.js
new file mode 100644
index 00000000..f06d735b
--- /dev/null
+++ b/src/content/dependencies/generateTrackArtworkColumn.js
@@ -0,0 +1,33 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+  extraDependencies: ['html'],
+
+  relations: (relation, track) => ({
+    albumCover:
+      (!track.hasUniqueCoverArt && track.album.hasCoverArt
+        ? relation('generateCoverArtwork', track.album.coverArtworks[0])
+        : null),
+
+    trackCovers:
+      (track.hasUniqueCoverArt
+        ? track.trackArtworks.map(artwork =>
+            relation('generateCoverArtwork', artwork))
+        : []),
+  }),
+
+  generate: (relations, {html}) =>
+    html.tags([
+      relations.albumCover?.slots({
+        showOriginDetails: true,
+        showArtTagDetails: true,
+        showReferenceDetails: true,
+      }),
+
+      relations.trackCovers.map(cover =>
+        cover.slots({
+          showOriginDetails: true,
+          showArtTagDetails: true,
+          showReferenceDetails: true,
+        })),
+    ]),
+};
diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js
deleted file mode 100644
index a241eaf2..00000000
--- a/src/content/dependencies/generateTrackCoverArtwork.js
+++ /dev/null
@@ -1,34 +0,0 @@
-export default {
-  contentDependencies: ['generateCoverArtwork'],
-
-  relations: (relation, track) => ({
-    coverArtwork:
-      relation('generateCoverArtwork',
-        (track.hasUniqueCoverArt
-          ? track.artTags
-          : track.album.artTags)),
-  }),
-
-  data: (track) => ({
-    path:
-      (track.hasUniqueCoverArt
-        ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
-        : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension]),
-
-    color:
-      track.color,
-
-    dimensions:
-      (track.hasUniqueCoverArt
-        ? track.coverArtDimensions
-        : track.album.coverArtDimensions),
-  }),
-
-  generate: (data, relations) =>
-    relations.coverArtwork.slots({
-      path: data.path,
-      color: data.color,
-      dimensions: data.dimensions,
-    }),
-};
-
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 1b5fbbf8..11d179ad 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -1,339 +1,138 @@
-import {sortAlbumsTracksChronologically, sortFlashesChronologically}
-  from '#sort';
-import {empty, stitchArrays} from '#sugar';
-
-import getChronologyRelations from '../util/getChronologyRelations.js';
-
 export default {
   contentDependencies: [
-    'generateAbsoluteDatetimestamp',
-    'generateAdditionalFilesShortcut',
+    'generateAdditionalNamesBox',
     'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
     'generateAlbumStyleRules',
-    'generateChronologyLinks',
-    'generateColorStyleAttribute',
-    'generateCommentarySection',
+    'generateCommentaryEntry',
     'generateContentHeading',
     'generateContributionList',
+    'generateLyricsSection',
     'generatePageLayout',
-    'generateRelativeDatetimestamp',
-    'generateTrackAdditionalNamesBox',
-    'generateTrackCoverArtwork',
+    'generateTrackArtistCommentarySection',
+    'generateTrackArtworkColumn',
+    'generateTrackInfoPageFeaturedByFlashesList',
+    'generateTrackInfoPageOtherReleasesList',
     'generateTrackList',
     'generateTrackListDividedByGroups',
+    'generateTrackNavLinks',
     'generateTrackReleaseInfo',
     'generateTrackSocialEmbed',
     'linkAlbum',
-    'linkArtist',
-    'linkFlash',
     'linkTrack',
     'transformContent',
   ],
 
-  extraDependencies: ['html', 'language', 'wikiData'],
-
-  sprawl({wikiInfo}) {
-    return {
-      divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups,
-      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
-    };
-  },
-
-  relations(relation, sprawl, track) {
-    const relations = {};
-    const sections = relations.sections = {};
-    const {album} = track;
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.albumStyleRules =
-      relation('generateAlbumStyleRules', track.album, track);
-
-    relations.socialEmbed =
-      relation('generateTrackSocialEmbed', track);
-
-    relations.artistChronologyContributions =
-      getChronologyRelations(track, {
-        contributions: [
-          ...track.artistContribs ?? [],
-          ...track.contributorContribs ?? [],
-        ],
-
-        linkArtist: artist => relation('linkArtist', artist),
-        linkThing: track => relation('linkTrack', track),
-
-        getThings(artist) {
-          const getDate = thing => thing.date;
-
-          const things = [
-            ...artist.tracksAsArtist,
-            ...artist.tracksAsContributor,
-          ].filter(getDate);
-
-          return sortAlbumsTracksChronologically(things, {getDate});
-        },
-      });
-
-    relations.coverArtistChronologyContributions =
-      getChronologyRelations(track, {
-        contributions: track.coverArtistContribs ?? [],
-
-        linkArtist: artist => relation('linkArtist', artist),
-
-        linkThing: trackOrAlbum =>
-          (trackOrAlbum.album
-            ? relation('linkTrack', trackOrAlbum)
-            : relation('linkAlbum', trackOrAlbum)),
-
-        getThings(artist) {
-          const getDate = thing => thing.coverArtDate ?? thing.date;
-
-          const things = [
-            ...artist.albumsAsCoverArtist,
-            ...artist.tracksAsCoverArtist,
-          ].filter(getDate);
-
-          return sortAlbumsTracksChronologically(things, {getDate});
-        },
-      }),
-
-    relations.albumLink =
-      relation('linkAlbum', track.album);
-
-    relations.trackLink =
-      relation('linkTrack', track);
-
-    relations.albumNavAccent =
-      relation('generateAlbumNavAccent', track.album, track);
-
-    relations.chronologyLinks =
-      relation('generateChronologyLinks');
-
-    relations.sidebar =
-      relation('generateAlbumSidebar', track.album, track);
-
-    const additionalFilesSection = additionalFiles => ({
-      heading: relation('generateContentHeading'),
-      list: relation('generateAlbumAdditionalFilesList', album, additionalFiles),
-    });
-
-    // This'll take care of itself being blank if there's nothing to show here.
-    relations.additionalNamesBox =
-      relation('generateTrackAdditionalNamesBox', track);
+  extraDependencies: ['html', 'language'],
 
-    if (track.hasUniqueCoverArt || album.hasCoverArt) {
-      relations.cover =
-        relation('generateTrackCoverArtwork', track);
-    }
+  query: (track) => ({
+    mainReleaseTrack:
+      (track.isMainRelease
+        ? track
+        : track.mainReleaseTrack),
+  }),
 
-    // Section: Release info
+  relations: (relation, query, track) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    relations.releaseInfo =
-      relation('generateTrackReleaseInfo', track);
+    albumStyleRules:
+      relation('generateAlbumStyleRules', track.album, track),
 
-    // Section: Extra links
+    socialEmbed:
+      relation('generateTrackSocialEmbed', track),
 
-    const extra = sections.extra = {};
+    navLinks:
+      relation('generateTrackNavLinks', track),
 
-    if (!empty(track.additionalFiles)) {
-      extra.additionalFilesShortcut =
-        relation('generateAdditionalFilesShortcut', track.additionalFiles);
-    }
+    albumNavAccent:
+      relation('generateAlbumNavAccent', track.album, track),
 
-    // Section: Other releases
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', track.album),
 
-    if (!empty(track.otherReleases)) {
-      const otherReleases = sections.otherReleases = {};
+    sidebar:
+      relation('generateAlbumSidebar', track.album, track),
 
-      otherReleases.heading =
-        relation('generateContentHeading');
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', track.additionalNames),
 
-      otherReleases.colorStyles =
-        track.otherReleases
-          .map(track => relation('generateColorStyleAttribute', track.color));
+    artworkColumn:
+      relation('generateTrackArtworkColumn', track),
 
-      otherReleases.trackLinks =
-        track.otherReleases
-          .map(track => relation('linkTrack', track));
+    contentHeading:
+      relation('generateContentHeading'),
 
-      otherReleases.albumLinks =
-        track.otherReleases
-          .map(track => relation('linkAlbum', track.album));
+    releaseInfo:
+      relation('generateTrackReleaseInfo', track),
 
-      otherReleases.datetimestamps =
-        track.otherReleases.map(track2 =>
-          (track2.date
-            ? (track.date
-                ? relation('generateRelativeDatetimestamp',
-                    track2.date,
-                    track.date)
-                : relation('generateAbsoluteDatetimestamp',
-                    track2.date))
-            : null));
+    otherReleasesList:
+      relation('generateTrackInfoPageOtherReleasesList', track),
 
-      otherReleases.items =
-        track.otherReleases.map(track => ({
-          trackLink: relation('linkTrack', track),
-          albumLink: relation('linkAlbum', track.album),
-        }));
-    }
+    contributorContributionList:
+      relation('generateContributionList', track.contributorContribs),
 
-    // Section: Contributors
+    referencedTracksList:
+      relation('generateTrackList', track.referencedTracks),
 
-    if (!empty(track.contributorContribs)) {
-      const contributors = sections.contributors = {};
+    sampledTracksList:
+      relation('generateTrackList', track.sampledTracks),
 
-      contributors.heading =
-        relation('generateContentHeading');
+    referencedByTracksList:
+      relation('generateTrackListDividedByGroups',
+        query.mainReleaseTrack.referencedByTracks),
 
-      contributors.list =
-        relation('generateContributionList', track.contributorContribs);
-    }
+    sampledByTracksList:
+      relation('generateTrackListDividedByGroups',
+        query.mainReleaseTrack.sampledByTracks),
 
-    // Section: Referenced tracks
+    flashesThatFeatureList:
+      relation('generateTrackInfoPageFeaturedByFlashesList', track),
 
-    if (!empty(track.referencedTracks)) {
-      const references = sections.references = {};
+    lyricsSection:
+      relation('generateLyricsSection', track.lyrics),
 
-      references.heading =
-        relation('generateContentHeading');
+    sheetMusicFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        track.album,
+        track.sheetMusicFiles),
 
-      references.list =
-        relation('generateTrackList', track.referencedTracks);
-    }
+    midiProjectFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        track.album,
+        track.midiProjectFiles),
 
-    // Section: Sampled tracks
+    additionalFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        track.album,
+        track.additionalFiles),
 
-    if (!empty(track.sampledTracks)) {
-      const samples = sections.samples = {};
+    artistCommentarySection:
+      relation('generateTrackArtistCommentarySection', track),
 
-      samples.heading =
-        relation('generateContentHeading');
+    creditSourceEntries:
+      track.creditSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
+  }),
 
-      samples.list =
-        relation('generateTrackList', track.sampledTracks);
-    }
+  data: (_query, track) => ({
+    name:
+      track.name,
 
-    // Section: Tracks that reference
+    color:
+      track.color,
+  }),
 
-    if (!empty(track.referencedByTracks)) {
-      const referencedBy = sections.referencedBy = {};
-
-      referencedBy.heading =
-        relation('generateContentHeading');
-
-      referencedBy.list =
-        relation('generateTrackListDividedByGroups',
-          track.referencedByTracks,
-          sprawl.divideTrackListsByGroups);
-    }
-
-    // Section: Tracks that sample
-
-    if (!empty(track.sampledByTracks)) {
-      const sampledBy = sections.sampledBy = {};
-
-      sampledBy.heading =
-        relation('generateContentHeading');
-
-      sampledBy.list =
-        relation('generateTrackListDividedByGroups',
-          track.sampledByTracks,
-          sprawl.divideTrackListsByGroups);
-    }
-
-    // Section: Flashes that feature
-
-    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,
-
-      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,
@@ -341,12 +140,8 @@ export default {
         color: data.color,
         styleRules: [relations.albumStyleRules],
 
-        cover:
-          (relations.cover
-            ? relations.cover.slots({
-                alt: language.$('misc.alt.trackCover'),
-              })
-            : null),
+        artworkColumnContent:
+          relations.artworkColumn,
 
         mainContent: [
           relations.releaseInfo,
@@ -355,225 +150,212 @@ 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 &&
-                sec.extra.additionalFilesShortcut,
+              !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
-              .slots({
-                id: 'also-released-as',
-                title: language.$('releaseInfo.alsoReleasedAs'),
-              }),
+              !html.isBlank(relations.creditSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#credit-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
 
-            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,
+                }),
+            ])),
 
-            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
-              .slots({
-                id: 'lyrics',
-                title: language.$('releaseInfo.lyrics'),
-              }),
+          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'},
 
-            html.tag('blockquote',
-              sec.lyrics.content
-                .slot('mode', 'lyrics')),
-          ],
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
 
-          sec.sheetMusicFiles && [
-            sec.sheetMusicFiles.heading
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                })),
+
+            relations.flashesThatFeatureList,
+          ]),
+
+          relations.lyricsSection,
+
+          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,
+          ]),
+
+          relations.artistCommentarySection,
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'credit-sources'},
+                title: language.$('misc.creditSources'),
+              }),
 
-          sec.artistCommentary,
+            relations.creditSourceEntries,
+          ]),
         ],
 
         navLinkStyle: 'hierarchical',
-        navLinks: [
-          {auto: 'home'},
-          {html: relations.albumLink.slot('color', false)},
-          {
-            html:
-              (data.hasTrackNumbers
-                ? language.$('trackPage.nav.track.withNumber', {
-                    number: data.trackNumber,
-                    track: relations.trackLink
-                      .slot('attributes', {class: 'current'}),
-                  })
-                : language.$('trackPage.nav.track', {
-                    track: relations.trackLink
-                      .slot('attributes', {class: 'current'}),
-                  })),
-          },
-        ],
+        navLinks: html.resolve(relations.navLinks),
 
         navBottomRowContent:
           relations.albumNavAccent.slots({
@@ -581,25 +363,14 @@ export default {
             showExtraLinks: false,
           }),
 
-        navContent:
-          relations.chronologyLinks.slots({
-            chronologyInfoSets: [
-              {
-                headingString: 'misc.chronology.heading.track',
-                contributions: relations.artistChronologyContributions,
-              },
-              {
-                headingString: 'misc.chronology.heading.coverArt',
-                contributions: relations.coverArtistChronologyContributions,
-              },
-            ],
-          }),
+        secondaryNav:
+          relations.secondaryNav
+            .slot('mode', 'track'),
 
         leftSidebar: relations.sidebar,
 
         socialEmbed: relations.socialEmbed,
-      });
-  },
+      })),
 };
 
 /*
diff --git a/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js
new file mode 100644
index 00000000..61654512
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js
@@ -0,0 +1,63 @@
+import {sortFlashesChronologically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkFlash', 'linkTrack'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    enableFlashesAndGames:
+      wikiInfo.enableFlashesAndGames,
+  }),
+
+  query: (sprawl, track) => ({
+    sortedFeatures:
+      (sprawl.enableFlashesAndGames
+        ? sortFlashesChronologically(
+            track.allReleases.flatMap(track =>
+              track.featuredInFlashes.map(flash => ({
+                flash,
+                track,
+
+                // These properties are only used for the sort.
+                act: flash.act,
+                date: flash.date,
+              }))))
+        : []),
+  }),
+
+  relations: (relation, query, _sprawl, track) => ({
+    flashLinks:
+      query.sortedFeatures
+        .map(({flash}) => relation('linkFlash', flash)),
+
+    trackLinks:
+      query.sortedFeatures
+        .map(({track: directlyFeaturedTrack}) =>
+          (directlyFeaturedTrack === track
+            ? null
+         : directlyFeaturedTrack.name === track.name
+            ? null
+            : relation('linkTrack', directlyFeaturedTrack))),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tag('ul',
+      {[html.onlyIfContent]: true},
+
+      stitchArrays({
+        flashLink: relations.flashLinks,
+        trackLink: relations.trackLinks,
+      }).map(({flashLink, trackLink}) => {
+          const attributes = html.attributes();
+          const parts = ['releaseInfo.flashesThatFeature.item'];
+          const options = {flash: flashLink};
+
+          if (trackLink) {
+            parts.push('asDifferentRelease');
+            options.track = trackLink;
+          }
+
+          return html.tag('li', attributes, language.$(...parts, options));
+        })),
+};
diff --git a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js
new file mode 100644
index 00000000..ebd76577
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js
@@ -0,0 +1,42 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    trackLinks:
+      track.otherReleases
+        .map(track => relation('linkTrack', track)),
+  }),
+
+  data: (track) => ({
+    albumNames:
+      track.otherReleases
+        .map(track => track.album.name),
+
+    albumColors:
+      track.otherReleases
+        .map(track => track.album.color),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    html.tag('p',
+      {[html.onlyIfContent]: true},
+
+      language.$('releaseInfo.alsoReleasedOn', {
+        [language.onlyIfOptions]: ['albums'],
+
+        albums:
+          language.formatConjunctionList(
+            stitchArrays({
+              trackLink: relations.trackLinks,
+              albumName: data.albumNames,
+              albumColor: data.albumColors,
+            }).map(({trackLink, albumName, albumColor}) =>
+                trackLink.slots({
+                  content: language.sanitize(albumName),
+                  color: albumColor,
+                }))),
+      })),
+};
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
index 3c36d248..53a32536 100644
--- a/src/content/dependencies/generateTrackList.js
+++ b/src/content/dependencies/generateTrackList.js
@@ -1,59 +1,28 @@
-import {empty, stitchArrays} from '#sugar';
-
 export default {
-  contentDependencies: ['linkTrack', 'linkContribution'],
-
-  extraDependencies: ['html', 'language'],
+  contentDependencies: ['generateTrackListItem'],
+  extraDependencies: ['html'],
 
-  relations(relation, tracks) {
-    if (empty(tracks)) {
-      return {};
-    }
-
-    return {
-      trackLinks:
-        tracks
-          .map(track => relation('linkTrack', track)),
-
-      contributionLinks:
-        tracks
-          .map(track =>
-            (empty(track.artistContribs)
-              ? null
-              : track.artistContribs
-                  .map(contrib => relation('linkContribution', contrib)))),
-    };
-  },
+  relations: (relation, tracks) => ({
+    items:
+      tracks
+        .map(track => relation('generateTrackListItem', track, [])),
+  }),
 
   slots: {
-    showContribution: {type: 'boolean', default: false},
-    showIcons: {type: 'boolean', default: false},
+    colorMode: {
+      validate: v => v.is('none', 'track', 'line'),
+      default: 'track',
+    },
   },
 
-  generate(relations, slots, {html, language}) {
-    return (
-      html.tag('ul',
-        stitchArrays({
-          trackLink: relations.trackLinks,
-          contributionLinks: relations.contributionLinks,
-        }).map(({trackLink, contributionLinks}) =>
-            html.tag('li',
-              (empty(contributionLinks)
-                ? trackLink
-                : language.$('trackList.item.withArtists', {
-                    track: trackLink,
-                    by:
-                      html.tag('span', {class: 'by'},
-                        html.metatag('chunkwrap', {split: ','},
-                          language.$('trackList.item.withArtists.by', {
-                            artists:
-                              language.formatConjunctionList(
-                                contributionLinks.map(link =>
-                                  link.slots({
-                                    showContribution: slots.showContribution,
-                                    showIcons: slots.showIcons,
-                                  }))),
-                          }))),
-                  }))))));
-  },
+  generate: (relations, slots, {html}) =>
+    html.tag('ul',
+      {[html.onlyIfContent]: true},
+
+      relations.items.map(item =>
+        item.slots({
+          showArtists: true,
+          showDuration: false,
+          colorMode: slots.colorMode,
+        }))),
 };
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
index e070ac35..230868d6 100644
--- a/src/content/dependencies/generateTrackListDividedByGroups.js
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -1,53 +1,145 @@
-import {empty} from '#sugar';
-
-import groupTracksByGroup from '../util/groupTracksByGroup.js';
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
 
 export default {
-  contentDependencies: ['generateTrackList', 'linkGroup'],
-  extraDependencies: ['html', 'language'],
+  contentDependencies: [
+    'generateContentHeading',
+    'generateTrackList',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    divideTrackListsByGroups:
+      wikiInfo.divideTrackListsByGroups,
+  }),
 
-  relations(relation, tracks, groups) {
-    if (empty(tracks)) {
-      return {};
+  query(sprawl, tracks) {
+    const dividingGroups = sprawl.divideTrackListsByGroups;
+
+    const groupings = new Map();
+    const ungroupedTracks = [];
+
+    // Entry order matters! Add blank lists for each group
+    // in the order that those groups are provided.
+    for (const group of dividingGroups) {
+      groupings.set(group, []);
     }
 
-    if (empty(groups)) {
-      return {
-        flatList:
-          relation('generateTrackList', tracks),
-      };
+    for (const track of tracks) {
+      const firstMatchingGroup =
+        dividingGroups.find(group => group.albums.includes(track.album));
+
+      if (firstMatchingGroup) {
+        groupings.get(firstMatchingGroup).push(track);
+      } else {
+        ungroupedTracks.push(track);
+      }
     }
 
-    const lists = groupTracksByGroup(tracks, groups);
+    const groups = Array.from(groupings.keys());
+    const groupedTracks = Array.from(groupings.values());
 
-    return {
-      groupedLists:
-        Array.from(lists.entries()).map(([groupOrOther, tracks]) => ({
-          ...(groupOrOther === 'other'
-                ? {other: true}
-                : {groupLink: relation('linkGroup', groupOrOther)}),
+    // Drop the empty lists, so just the groups which
+    // at least a single track matched are left.
+    filterMultipleArrays(
+      groups,
+      groupedTracks,
+      (_group, tracks) => !empty(tracks));
 
-          list:
-            relation('generateTrackList', tracks),
-        })),
-    };
+    return {groups, groupedTracks, ungroupedTracks};
   },
 
-  generate(relations, {html, language}) {
-    if (relations.flatList) {
-      return relations.flatList;
-    }
+  relations: (relation, query, sprawl, tracks) => ({
+    flatList:
+      (empty(sprawl.divideTrackListsByGroups)
+        ? relation('generateTrackList', tracks)
+        : null),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    groupLinks:
+      query.groups
+        .map(group => relation('linkGroup', group)),
+
+    groupedTrackLists:
+      query.groupedTracks
+        .map(tracks => relation('generateTrackList', tracks)),
+
+    ungroupedTrackList:
+      (empty(query.ungroupedTracks)
+        ? null
+        : relation('generateTrackList', query.ungroupedTracks)),
+  }),
 
-    return html.tag('dl',
-      relations.groupedLists.map(({other, groupLink, list}) => [
-        html.tag('dt',
-          (other
-            ? language.$('trackList.group.fromOther')
-            : language.$('trackList.group', {
-                group: groupLink
-              }))),
-
-        html.tag('dd', list),
-      ]));
+  data: (query, _sprawl, _tracks) => ({
+    groupNames:
+      query.groups
+        .map(group => group.name),
+  }),
+
+  slots: {
+    headingString: {
+      type: 'string',
+    },
   },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.flatList ??
+
+    html.tag('dl',
+      {[html.onlyIfContent]: true},
+
+      language.encapsulate('trackList', listCapsule => [
+        stitchArrays({
+          groupName: data.groupNames,
+          groupLink: relations.groupLinks,
+          trackList: relations.groupedTrackLists,
+        }).map(({
+            groupName,
+            groupLink,
+            trackList,
+          }) => [
+            language.encapsulate(listCapsule, 'fromGroup', capsule =>
+              (slots.headingString
+                ? relations.contentHeading.clone().slots({
+                    tag: 'dt',
+
+                    title:
+                      language.$(capsule, {
+                        group: groupLink
+                      }),
+
+                    stickyTitle:
+                      language.$(slots.headingString, 'sticky', 'fromGroup', {
+                        group: groupName,
+                      }),
+                  })
+                : html.tag('dt',
+                    language.$(capsule, {
+                      group: groupLink
+                    })))),
+
+            html.tag('dd', trackList),
+          ]),
+
+        relations.ungroupedTrackList && [
+          language.encapsulate(listCapsule, 'fromOther', capsule =>
+            (slots.headingString
+              ? relations.contentHeading.clone().slots({
+                  tag: 'dt',
+
+                  title:
+                    language.$(capsule),
+
+                  stickyTitle:
+                    language.$(slots.headingString, 'sticky', 'fromOther'),
+                })
+              : html.tag('dt',
+                  language.$(capsule)))),
+
+          html.tag('dd', relations.ungroupedTrackList),
+        ],
+      ])),
 };
diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js
new file mode 100644
index 00000000..887b6f03
--- /dev/null
+++ b/src/content/dependencies/generateTrackListItem.js
@@ -0,0 +1,106 @@
+export default {
+  contentDependencies: [
+    'generateArtistCredit',
+    'generateColorStyleAttribute',
+    'generateTrackListMissingDuration',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track, contextContributions) => ({
+    trackLink:
+      relation('linkTrack', track),
+
+    credit:
+      relation('generateArtistCredit',
+        track.artistContribs,
+        contextContributions),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', track.color),
+
+    missingDuration:
+      (track.duration
+        ? null
+        : relation('generateTrackListMissingDuration')),
+  }),
+
+  data: (track, _contextContributions) => ({
+    duration:
+      track.duration ?? 0,
+
+    trackHasDuration:
+      !!track.duration,
+  }),
+
+  slots: {
+    // showArtists enables showing artists *at all.* It doesn't take precedence
+    // over behavior which automatically collapses (certain) artists because of
+    // provided context contributions.
+    showArtists: {
+      type: 'boolean',
+      default: true,
+    },
+
+    // If true and the track doesn't have a duration, a missing-duration cue
+    // will be displayed instead.
+    showDuration: {
+      type: 'boolean',
+      default: false,
+    },
+
+    colorMode: {
+      validate: v => v.is('none', 'track', 'line'),
+      default: 'track',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('trackList.item', itemCapsule =>
+      html.tag('li',
+        slots.colorMode === 'line' &&
+          relations.colorStyle.slot('context', 'primary-only'),
+
+        language.encapsulate(itemCapsule, workingCapsule => {
+          const workingOptions = {};
+
+          workingOptions.track =
+            relations.trackLink
+              .slot('color', slots.colorMode === 'track');
+
+          if (slots.showDuration) {
+            workingCapsule += '.withDuration';
+            workingOptions.duration =
+              (data.trackHasDuration
+                ? language.$(itemCapsule, 'withDuration.duration', {
+                    duration:
+                      language.formatDuration(data.duration),
+                  })
+                : relations.missingDuration);
+          }
+
+          const artistCapsule = language.encapsulate(itemCapsule, 'withArtists');
+
+          relations.credit.setSlots({
+            normalStringKey:
+              artistCapsule + '.by',
+
+            featuringStringKey:
+              artistCapsule + '.featuring',
+
+            normalFeaturingStringKey:
+              artistCapsule + '.by.featuring',
+          });
+
+          if (!html.isBlank(relations.credit)) {
+            workingCapsule += '.withArtists';
+            workingOptions.by =
+              html.tag('span', {class: 'by'},
+                html.metatag('chunkwrap', {split: ','},
+                  html.resolve(relations.credit)));
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }))),
+};
diff --git a/src/content/dependencies/generateTrackListMissingDuration.js b/src/content/dependencies/generateTrackListMissingDuration.js
new file mode 100644
index 00000000..b5917982
--- /dev/null
+++ b/src/content/dependencies/generateTrackListMissingDuration.js
@@ -0,0 +1,35 @@
+export default {
+  contentDependencies: ['generateTextWithTooltip', 'generateTooltip'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateTooltip'),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('trackList.item.withDuration', itemCapsule =>
+      language.encapsulate(itemCapsule, 'duration', durationCapsule =>
+        relations.textWithTooltip.slots({
+          attributes: {class: 'missing-duration'},
+          customInteractionCue: true,
+
+          text:
+            language.$(durationCapsule, {
+              duration:
+                html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
+                  language.$(durationCapsule, 'missing')),
+            }),
+
+          tooltip:
+            relations.tooltip.slots({
+              attributes: {class: 'missing-duration-tooltip'},
+
+              content:
+                language.$(durationCapsule, 'missing.info'),
+            }),
+        }))),
+};
diff --git a/src/content/dependencies/generateTrackNavLinks.js b/src/content/dependencies/generateTrackNavLinks.js
new file mode 100644
index 00000000..6a8b7c64
--- /dev/null
+++ b/src/content/dependencies/generateTrackNavLinks.js
@@ -0,0 +1,64 @@
+export default {
+  contentDependencies: ['linkAlbum', 'linkTrack'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    albumLink:
+      relation('linkAlbum', track.album),
+
+    trackLink:
+      relation('linkTrack', track),
+  }),
+
+  data: (track) => ({
+    hasTrackNumbers:
+      track.album.hasTrackNumbers,
+
+    trackNumber:
+      track.trackNumber,
+  }),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('referenced-art', 'referencing-art'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('trackPage.nav', navCapsule => [
+      {auto: 'home'},
+
+      {html: relations.albumLink.slot('color', false)},
+
+      {
+        html:
+          language.encapsulate(navCapsule, 'track', workingCapsule => {
+            const workingOptions = {};
+
+            workingOptions.track =
+              relations.trackLink
+                .slot('attributes', {class: 'current'});
+
+            if (data.hasTrackNumbers) {
+              workingCapsule += '.withNumber';
+              workingOptions.number = data.trackNumber;
+            }
+
+            return language.$(workingCapsule, workingOptions);
+          }),
+
+        accent:
+          html.tag('a',
+            {[html.onlyIfContent]: true},
+
+            {href: ''},
+            {class: 'current'},
+
+            (slots.currentExtra === 'referenced-art'
+              ? language.$('referencedArtworksPage.subtitle')
+           : slots.currentExtra === 'referencing-art'
+              ? language.$('referencingArtworksPage.subtitle')
+              : null)),
+      },
+    ]),
+};
diff --git a/src/content/dependencies/generateTrackReferencedArtworksPage.js b/src/content/dependencies/generateTrackReferencedArtworksPage.js
new file mode 100644
index 00000000..93438c5b
--- /dev/null
+++ b/src/content/dependencies/generateTrackReferencedArtworksPage.js
@@ -0,0 +1,47 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleRules',
+    'generateBackToTrackLink',
+    'generateReferencedArtworksPage',
+    'generateTrackNavLinks',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    page:
+      relation('generateReferencedArtworksPage', track.trackArtworks[0]),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', track.album, track),
+
+    navLinks:
+      relation('generateTrackNavLinks', track),
+
+    backToTrackLink:
+      relation('generateBackToTrackLink', track),
+  }),
+
+  data: (track) => ({
+    name:
+      track.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.page.slots({
+      title:
+        language.$('trackPage.title', {
+          track:
+            data.name,
+        }),
+
+      styleRules: [relations.albumStyleRules],
+
+      navLinks:
+        html.resolve(
+          relations.navLinks
+            .slot('currentExtra', 'referenced-art')),
+
+      navBottomRowContent: relations.backToTrackLink,
+    }),
+};
diff --git a/src/content/dependencies/generateTrackReferencingArtworksPage.js b/src/content/dependencies/generateTrackReferencingArtworksPage.js
new file mode 100644
index 00000000..e9818bad
--- /dev/null
+++ b/src/content/dependencies/generateTrackReferencingArtworksPage.js
@@ -0,0 +1,47 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleRules',
+    'generateBackToTrackLink',
+    'generateReferencingArtworksPage',
+    'generateTrackNavLinks',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    page:
+      relation('generateReferencingArtworksPage', track.trackArtworks[0]),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', track.album, track),
+
+    navLinks:
+      relation('generateTrackNavLinks', track),
+
+    backToTrackLink:
+      relation('generateBackToTrackLink', track),
+  }),
+
+  data: (track) => ({
+    name:
+      track.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.page.slots({
+      title:
+        language.$('trackPage.title', {
+          track:
+            data.name,
+        }),
+
+      styleRules: [relations.albumStyleRules],
+
+      navLinks:
+        html.resolve(
+          relations.navLinks
+            .slot('currentExtra', 'referencing-art')),
+
+      navBottomRowContent: relations.backToTrackLink,
+    }),
+};
diff --git a/src/content/dependencies/generateTrackReleaseBox.js b/src/content/dependencies/generateTrackReleaseBox.js
new file mode 100644
index 00000000..ef02e2b9
--- /dev/null
+++ b/src/content/dependencies/generateTrackReleaseBox.js
@@ -0,0 +1,46 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generatePageSidebarBox',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', track.album.color),
+
+    trackLink:
+      relation('linkTrack', track),
+  }),
+
+  data: (track) => ({
+    albumName:
+      track.album.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumSidebar.releaseBox', boxCapsule =>
+      relations.box.slots({
+        attributes: [
+          {class: 'track-release-sidebar-box'},
+          relations.colorStyle,
+        ],
+
+        content: [
+          html.tag('h1',
+            language.$(boxCapsule, 'title', {
+              album:
+                relations.trackLink.slots({
+                  color: false,
+                  content:
+                    language.sanitize(data.albumName),
+                }),
+            })),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 3bdeaa4f..54e462c7 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -14,11 +14,6 @@ export default {
     relations.artistContributionLinks =
       relation('generateReleaseInfoContributionsLine', track.artistContribs);
 
-    if (track.hasUniqueCoverArt) {
-      relations.coverArtistContributionsLine =
-        relation('generateReleaseInfoContributionsLine', track.coverArtistContribs);
-    }
-
     if (!empty(track.urls)) {
       relations.externalLinks =
         track.urls.map(url =>
@@ -37,7 +32,6 @@ export default {
 
     if (
       track.hasUniqueCoverArt &&
-      track.coverArtDate &&
       +track.coverArtDate !== +track.date
     ) {
       data.coverArtDate = track.coverArtDate;
@@ -47,44 +41,42 @@ export default {
   },
 
   generate: (data, relations, {html, language}) =>
-    html.tags([
-      html.tag('p',
-        {[html.onlyIfContent]: true},
-        {[html.joinChildren]: html.tag('br')},
-
-        [
-          relations.artistContributionLinks
-            .slots({stringKey: 'releaseInfo.by'}),
-
-          relations.coverArtistContributionsLine
-            ?.slots({stringKey: 'releaseInfo.coverArtBy'}),
-
-          data.date &&
-            language.$('releaseInfo.released', {
-              date: language.formatDate(data.date),
+    language.encapsulate('releaseInfo', capsule =>
+      html.tags([
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.artistContributionLinks.slots({
+              stringKey: capsule + '.by',
+              featuringStringKey: capsule + '.by.featuring',
+              chronologyKind: 'track',
             }),
 
-          data.coverArtDate &&
-            language.$('releaseInfo.artReleased', {
-              date: language.formatDate(data.coverArtDate),
+            language.$(capsule, 'released', {
+              [language.onlyIfOptions]: ['date'],
+              date: language.formatDate(data.date),
             }),
 
-          data.duration &&
-            language.$('releaseInfo.duration', {
+            language.$(capsule, 'duration', {
+              [language.onlyIfOptions]: ['duration'],
               duration: language.formatDuration(data.duration),
             }),
-        ]),
-
-      html.tag('p',
-        (relations.externalLinks
-          ? language.$('releaseInfo.listenOn', {
-              links:
-                language.formatDisjunctionList(
-                  relations.externalLinks
-                    .map(link => link.slot('context', 'track'))),
-            })
-          : language.$('releaseInfo.listenOn.noLinks', {
-              name: html.tag('i', data.name),
-            }))),
-    ]),
+          ]),
+
+        html.tag('p',
+          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..7cb37af2 100644
--- a/src/content/dependencies/generateTrackSocialEmbed.js
+++ b/src/content/dependencies/generateTrackSocialEmbed.js
@@ -4,7 +4,7 @@ export default {
     'generateTrackSocialEmbedDescription',
   ],
 
-  extraDependencies: ['absoluteTo', 'language', 'urls'],
+  extraDependencies: ['absoluteTo', 'language'],
 
   relations(relation, track) {
     return {
@@ -39,48 +39,30 @@ 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}) =>
+    language.encapsulate('trackPage.socialEmbed', embedCapsule =>
+      relations.socialEmbed.slots({
+        title:
+          language.$(embedCapsule, 'title', {
+            track: data.trackName,
+          }),
 
-      headingContent:
-        language.$('trackPage.socialEmbed.heading', {
-          album: data.albumName,
-        }),
+        description:
+          relations.description,
 
-      headingLink:
-        absoluteTo('localized.album', data.albumDirectory),
+        headingContent:
+          language.$(embedCapsule, 'heading', {
+            album: data.albumName,
+          }),
 
-      imagePath:
-        (data.imageSource === 'album'
-          ? '/' +
-            urls
-              .from('shared.root')
-              .to('media.albumCover', data.albumDirectory, data.coverArtFileExtension)
-       : data.imageSource === 'track'
-          ? '/' +
-            urls
-              .from('shared.root')
-              .to('media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension)
-          : null),
-    });
-  },
-};
+        headingLink:
+          absoluteTo('localized.album', data.albumDirectory),
 
-/*
-        socialEmbed: {
-          heading: language.$('trackPage.socialEmbed.heading', {
-            album: track.album.name,
-          }),
-          headingLink: absoluteTo('localized.album', album.directory),
-          title: language.$('trackPage.socialEmbed.title', {
-            track: track.name,
-          }),
-          description: getSocialEmbedDescription({getArtistString, language}),
-          image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}),
-          color: track.color,
-        },
-*/
+        imagePath:
+          (data.imageSource === 'album'
+            ? ['media.albumCover', data.albumDirectory, data.coverArtFileExtension]
+         : data.imageSource === 'track'
+            ? ['media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension]
+            : null),
+      })),
+};
diff --git a/src/content/dependencies/generateTrackSocialEmbedDescription.js b/src/content/dependencies/generateTrackSocialEmbedDescription.js
index cf21eadf..4706aa26 100644
--- a/src/content/dependencies/generateTrackSocialEmbedDescription.js
+++ b/src/content/dependencies/generateTrackSocialEmbedDescription.js
@@ -1,38 +1,39 @@
+import {empty} from '#sugar';
+
 export default {
-  generate() {
-  },
-};
+  extraDependencies: ['html', 'language'],
+
+  data: (track) => ({
+    artistNames:
+      track.artistContribs
+        .map(contrib => contrib.artist.name),
+
+    coverArtistNames:
+      track.coverArtistContribs
+        .map(contrib => contrib.artist.name),
+  }),
 
-/*
-  const getSocialEmbedDescription = ({
-    getArtistString: _getArtistString,
-    language,
-  }) => {
-    const hasArtists = !empty(track.artistContribs);
-    const hasCoverArtists = !empty(track.coverArtistContribs);
-    const getArtistString = (contribs) =>
-      _getArtistString(contribs, {
-        // We don't want to put actual HTML tags in social embeds (sadly
-        // they don't get parsed and displayed, generally speaking), so
-        // override the link argument so that artist "links" just show
-        // their names.
-        link: {artist: (artist) => artist.name},
-      });
-    if (!hasArtists && !hasCoverArtists) return '';
-    return language.formatString(
-      'trackPage.socialEmbed.body' +
-        [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists']
-          .filter(Boolean)
-          .join(''),
-      Object.fromEntries(
-        [
-          hasArtists && ['artists', getArtistString(track.artistContribs)],
-          hasCoverArtists && [
-            'coverArtists',
-            getArtistString(track.coverArtistContribs),
-          ],
-        ].filter(Boolean)
-      )
-    );
-  };
-*/
+  generate: (data, {html, language}) =>
+    language.encapsulate('trackPage.socialEmbed.body', baseCapsule =>
+      language.encapsulate(baseCapsule, workingCapsule => {
+        const workingOptions = {};
+
+        if (!empty(data.artistNames)) {
+          workingCapsule += '.withArtists';
+          workingOptions.artists =
+            language.formatConjunctionList(data.artistNames);
+        }
+
+        if (!empty(data.coverArtistNames)) {
+          workingCapsule += '.withCoverArtists';
+          workingOptions.coverArtists =
+            language.formatConjunctionList(data.coverArtistNames);
+        }
+
+        if (workingCapsule === baseCapsule) {
+          return html.blank();
+        } else {
+          return language.$(workingCapsule, workingOptions);
+        }
+      })),
+};
diff --git a/src/content/dependencies/generateUnsafeMunchy.js b/src/content/dependencies/generateUnsafeMunchy.js
new file mode 100644
index 00000000..c11aadc7
--- /dev/null
+++ b/src/content/dependencies/generateUnsafeMunchy.js
@@ -0,0 +1,10 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    contentSource: {type: 'string'},
+  },
+
+  generate: (slots, {html}) =>
+    new html.Tag(null, null, slots.contentSource),
+};
diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js
deleted file mode 100644
index a19f104c..00000000
--- a/src/content/dependencies/generateWikiHomeAlbumsRow.js
+++ /dev/null
@@ -1,150 +0,0 @@
-import {empty, stitchArrays} from '#sugar';
-import {getNewAdditions, getNewReleases} from '#wiki-data';
-
-export default {
-  contentDependencies: [
-    'generateWikiHomeContentRow',
-    'generateCoverCarousel',
-    'generateCoverGrid',
-    'image',
-    'linkAlbum',
-    'transformContent',
-  ],
-
-  extraDependencies: ['language', 'wikiData'],
-
-  sprawl({albumData}, row) {
-    const sprawl = {};
-
-    switch (row.sourceGroup) {
-      case 'new-releases':
-        sprawl.albums = getNewReleases(row.countAlbumsFromGroup, {albumData});
-        break;
-
-      case 'new-additions':
-        sprawl.albums = getNewAdditions(row.countAlbumsFromGroup, {albumData});
-        break;
-
-      default:
-        sprawl.albums =
-          (row.sourceGroup
-            ? row.sourceGroup.albums
-                .slice()
-                .reverse()
-                .filter(album => album.isListedOnHomepage)
-                .slice(0, row.countAlbumsFromGroup)
-            : []);
-    }
-
-    if (!empty(row.sourceAlbums)) {
-      sprawl.albums.push(...row.sourceAlbums);
-    }
-
-    return sprawl;
-  },
-
-  relations(relation, sprawl, row) {
-    const relations = {};
-
-    relations.contentRow =
-      relation('generateWikiHomeContentRow', row);
-
-    if (row.displayStyle === 'grid') {
-      relations.coverGrid =
-        relation('generateCoverGrid');
-    }
-
-    if (row.displayStyle === 'carousel') {
-      relations.coverCarousel =
-        relation('generateCoverCarousel');
-    }
-
-    relations.links =
-      sprawl.albums
-        .map(album => relation('linkAlbum', album));
-
-    relations.images =
-      sprawl.albums
-        .map(album => relation('image', album.artTags));
-
-    if (row.actionLinks) {
-      relations.actionLinks =
-        row.actionLinks
-          .map(content => relation('transformContent', content));
-    }
-
-    return relations;
-  },
-
-  data(sprawl, row) {
-    const data = {};
-
-    data.displayStyle = row.displayStyle;
-
-    if (row.displayStyle === 'grid') {
-      data.names =
-        sprawl.albums
-          .map(album => album.name);
-    }
-
-    data.paths =
-      sprawl.albums
-        .map(album =>
-          (album.hasCoverArt
-            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-            : null));
-
-    return data;
-  },
-
-  generate(data, relations, {language}) {
-    // Grids and carousels share some slots! Very convenient.
-    const commonSlots = {};
-
-    commonSlots.links =
-      relations.links;
-
-    commonSlots.images =
-      stitchArrays({
-        image: relations.images,
-        path: data.paths,
-        name: data.names ?? data.paths.slice().fill(null),
-      }).map(({image, path, name}) =>
-          image.slots({
-            path,
-            missingSourceContent:
-              name &&
-                language.$('misc.albumGrid.noCoverArt', {
-                  album: name,
-                }),
-            }));
-
-    commonSlots.actionLinks =
-      (relations.actionLinks
-        ? relations.actionLinks
-            .map(contents =>
-              contents
-                .slot('mode', 'single-link')
-                .content)
-        : null);
-
-    let content;
-
-    switch (data.displayStyle) {
-      case 'grid':
-        content =
-          relations.coverGrid.slots({
-            ...commonSlots,
-            names: data.names,
-          });
-        break;
-
-      case 'carousel':
-        content =
-          relations.coverCarousel.slots(commonSlots);
-        break;
-    }
-
-    return relations.contentRow.slots({content});
-  },
-};
diff --git a/src/content/dependencies/generateWikiHomeContentRow.js b/src/content/dependencies/generateWikiHomeContentRow.js
deleted file mode 100644
index 27b12e55..00000000
--- a/src/content/dependencies/generateWikiHomeContentRow.js
+++ /dev/null
@@ -1,28 +0,0 @@
-export default {
-  contentDependencies: ['generateColorStyleAttribute'],
-  extraDependencies: ['html'],
-
-  relations: (relation, row) => ({
-    colorStyle:
-      relation('generateColorStyleAttribute', row.color),
-  }),
-
-  data: (row) =>
-    ({name: row.name}),
-
-  slots: {
-    content: {
-      type: 'html',
-      mutable: false,
-    },
-  },
-
-  generate: (data, relations, slots, {html}) =>
-    html.tag('section', {class: 'row'},
-      relations.colorStyle,
-
-      [
-        html.tag('h2', data.name),
-        slots.content,
-      ]),
-};
diff --git a/src/content/dependencies/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js
deleted file mode 100644
index e054edda..00000000
--- a/src/content/dependencies/generateWikiHomeNewsBox.js
+++ /dev/null
@@ -1,85 +0,0 @@
-import {empty, stitchArrays} from '#sugar';
-
-export default {
-  contentDependencies: [
-    'generatePageSidebarBox',
-    'linkNewsEntry',
-    'transformContent',
-  ],
-
-  extraDependencies: ['html', 'language', 'wikiData'],
-
-  sprawl: ({newsData}) => ({
-    entries:
-      newsData.slice(0, 3),
-  }),
-
-  relations: (relation, sprawl) => ({
-    box:
-      relation('generatePageSidebarBox'),
-
-    entryContents:
-      sprawl.entries
-        .map(entry => relation('transformContent', entry.contentShort)),
-
-    entryMainLinks:
-      sprawl.entries
-        .map(entry => relation('linkNewsEntry', entry)),
-
-    entryReadMoreLinks:
-      sprawl.entries
-        .map(entry =>
-          entry.contentShort !== entry.content &&
-            relation('linkNewsEntry', entry)),
-  }),
-
-  data: (sprawl) => ({
-    entryDates:
-      sprawl.entries
-        .map(entry => entry.date),
-  }),
-
-  generate(data, relations, {html, language}) {
-    if (empty(relations.entryContents)) {
-      return html.blank();
-    }
-
-    return relations.box.slots({
-      attributes: {class: 'latest-news-sidebar-box'},
-      content: [
-        html.tag('h1', language.$('homepage.news.title')),
-
-        stitchArrays({
-          date: data.entryDates,
-          content: relations.entryContents,
-          mainLink: relations.entryMainLinks,
-          readMoreLink: relations.entryReadMoreLinks,
-        }).map(({
-            date,
-            content,
-            mainLink,
-            readMoreLink,
-          }, index) =>
-            html.tag('article', {class: 'news-entry'},
-              index === 0 &&
-                {class: 'first-news-entry'},
-
-              [
-                html.tag('h2', [
-                  html.tag('time', language.formatDate(date)),
-                  mainLink,
-                ]),
-
-                content.slot('thumb', 'medium'),
-
-                html.tag('p',
-                  {[html.onlyIfContent]: true},
-                  readMoreLink
-                    ?.slots({
-                      content: language.$('homepage.news.entry.viewRest'),
-                    })),
-              ])),
-      ],
-    });
-  },
-};
diff --git a/src/content/dependencies/generateWikiHomePage.js b/src/content/dependencies/generateWikiHomePage.js
deleted file mode 100644
index 35461d03..00000000
--- a/src/content/dependencies/generateWikiHomePage.js
+++ /dev/null
@@ -1,115 +0,0 @@
-export default {
-  contentDependencies: [
-    'generatePageLayout',
-    'generatePageSidebar',
-    'generatePageSidebarBox',
-    'generateWikiHomeAlbumsRow',
-    'generateWikiHomeNewsBox',
-    'transformContent',
-  ],
-
-  extraDependencies: ['wikiData'],
-
-  sprawl({wikiInfo}) {
-    return {
-      wikiName: wikiInfo.name,
-
-      enableNews: wikiInfo.enableNews,
-    };
-  },
-
-  relations(relation, sprawl, homepageLayout) {
-    const relations = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.sidebar =
-      relation('generatePageSidebar');
-
-    if (homepageLayout.sidebarContent) {
-      relations.customSidebarBox =
-        relation('generatePageSidebarBox');
-
-      relations.customSidebarContent =
-        relation('transformContent', homepageLayout.sidebarContent);
-    }
-
-    if (sprawl.enableNews) {
-      relations.newsSidebarBox =
-        relation('generateWikiHomeNewsBox');
-    }
-
-    if (homepageLayout.navbarLinks) {
-      relations.customNavLinkContents =
-        homepageLayout.navbarLinks
-          .map(content => relation('transformContent', content));
-    }
-
-    relations.contentRows =
-      homepageLayout.rows.map(row => {
-        switch (row.type) {
-          case 'albums':
-            return relation('generateWikiHomeAlbumsRow', row);
-          default:
-            return null;
-        }
-      });
-
-    return relations;
-  },
-
-  data(sprawl) {
-    return {
-      wikiName: sprawl.wikiName,
-    };
-  },
-
-  generate(data, relations) {
-    return relations.layout.slots({
-      title: data.wikiName,
-      showWikiNameInTitle: false,
-
-      mainClasses: ['top-index'],
-      headingMode: 'static',
-
-      mainContent: [
-        relations.contentRows,
-      ],
-
-      leftSidebar:
-        relations.sidebar.slots({
-          collapse: false,
-          wide: true,
-
-          boxes: [
-            relations.customSidebarContent &&
-              relations.customSidebarBox.slots({
-                attributes: {class: 'custom-content-sidebar-box'},
-                content:
-                  relations.customSidebarContent
-                    .slot('mode', 'multiline'),
-              }),
-
-            relations.newsSidebarBox,
-          ],
-        }),
-
-      navLinkStyle: 'index',
-      navLinks: [
-        {auto: 'home', current: true},
-
-        ...(
-          relations.customNavLinkContents
-            ?.map(content => ({
-              html:
-                content.slots({
-                  mode: 'single-link',
-                  preferShortLinkNames: true,
-                }),
-            }))
-          ?? []),
-      ],
-    });
-  },
-};
diff --git a/src/content/dependencies/generateWikiHomepageActionsRow.js b/src/content/dependencies/generateWikiHomepageActionsRow.js
new file mode 100644
index 00000000..9f501099
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageActionsRow.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['generateGridActionLinks', 'transformContent'],
+
+  relations: (relation, row) => ({
+    template:
+      relation('generateGridActionLinks'),
+
+    links:
+      row.actionLinks
+        .map(content => relation('transformContent', content)),
+  }),
+
+  generate: (relations) =>
+    relations.template.slots({
+      actionLinks:
+        relations.links
+          .map(contents =>
+            contents
+              .slot('mode', 'single-link')
+              .content),
+    }),
+};
diff --git a/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js
new file mode 100644
index 00000000..b45bfc19
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['generateCoverCarousel', 'image', 'linkAlbum'],
+
+  relations: (relation, row) => ({
+    coverCarousel:
+      relation('generateCoverCarousel'),
+
+    links:
+      row.albums
+        .map(album => relation('linkAlbum', album)),
+
+    images:
+      row.albums
+        .map(album => relation('image', album.coverArtworks[0])),
+  }),
+
+  generate: (relations) =>
+    relations.coverCarousel.slots({
+      links: relations.links,
+      images: relations.images,
+    }),
+};
diff --git a/src/content/dependencies/generateWikiHomepageAlbumGridRow.js b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js
new file mode 100644
index 00000000..a00136ba
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js
@@ -0,0 +1,78 @@
+import {empty, stitchArrays} from '#sugar';
+import {getNewAdditions, getNewReleases} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateCoverGrid', 'image', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}, row) {
+    const sprawl = {};
+
+    switch (row.sourceGroup) {
+      case 'new-releases':
+        sprawl.albums = getNewReleases(row.countAlbumsFromGroup, {albumData});
+        break;
+
+      case 'new-additions':
+        sprawl.albums = getNewAdditions(row.countAlbumsFromGroup, {albumData});
+        break;
+
+      default:
+        sprawl.albums =
+          (row.sourceGroup
+            ? row.sourceGroup.albums
+                .slice()
+                .reverse()
+                .filter(album => album.isListedOnHomepage)
+                .slice(0, row.countAlbumsFromGroup)
+            : []);
+    }
+
+    if (!empty(row.sourceAlbums)) {
+      sprawl.albums.push(...row.sourceAlbums);
+    }
+
+    return sprawl;
+  },
+
+  relations: (relation, sprawl, _row) => ({
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      sprawl.albums
+        .map(album => relation('linkAlbum', album)),
+
+    images:
+      sprawl.albums
+        .map(album =>
+          relation('image',
+            (album.hasCoverArt
+              ? album.coverArtworks[0]
+              : null))),
+  }),
+
+  data: (sprawl, _row) => ({
+    names:
+      sprawl.albums
+        .map(album => album.name),
+  }),
+
+  generate: (data, relations, {language}) =>
+    relations.coverGrid.slots({
+      links: relations.links,
+      names: data.names,
+
+      images:
+        stitchArrays({
+          image: relations.images,
+          name: data.names,
+        }).map(({image, name}) =>
+            image.slots({
+              missingSourceContent:
+                language.$('misc.coverGrid.noCoverArt', {
+                  album: name,
+                }),
+              })),
+    }),
+};
diff --git a/src/content/dependencies/generateWikiHomepageNewsBox.js b/src/content/dependencies/generateWikiHomepageNewsBox.js
new file mode 100644
index 00000000..83a27695
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageNewsBox.js
@@ -0,0 +1,86 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkNewsEntry',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({newsData}) => ({
+    entries:
+      newsData.slice(0, 3),
+  }),
+
+  relations: (relation, sprawl) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    entryContents:
+      sprawl.entries
+        .map(entry => relation('transformContent', entry.contentShort)),
+
+    entryMainLinks:
+      sprawl.entries
+        .map(entry => relation('linkNewsEntry', entry)),
+
+    entryReadMoreLinks:
+      sprawl.entries
+        .map(entry =>
+          entry.contentShort !== entry.content &&
+            relation('linkNewsEntry', entry)),
+  }),
+
+  data: (sprawl) => ({
+    entryDates:
+      sprawl.entries
+        .map(entry => entry.date),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('homepage.news', boxCapsule =>
+      relations.box.slots({
+        attributes: {class: 'latest-news-sidebar-box'},
+        collapsible: false,
+
+        content: [
+          html.tag('h1',
+            {[html.onlyIfSiblings]: true},
+            language.$(boxCapsule, 'title')),
+
+          stitchArrays({
+            date: data.entryDates,
+            content: relations.entryContents,
+            mainLink: relations.entryMainLinks,
+            readMoreLink: relations.entryReadMoreLinks,
+          }).map(({
+              date,
+              content,
+              mainLink,
+              readMoreLink,
+            }, index) =>
+              language.encapsulate(boxCapsule, 'entry', entryCapsule =>
+                html.tag('article', {class: 'news-entry'},
+                  index === 0 &&
+                    {class: 'first-news-entry'},
+
+                  [
+                    html.tag('h2', [
+                      html.tag('time', language.formatDate(date)),
+                      mainLink,
+                    ]),
+
+                    content.slot('thumb', 'medium'),
+
+                    html.tag('p',
+                      {[html.onlyIfContent]: true},
+                      readMoreLink
+                        ?.slots({
+                          content: language.$(entryCapsule, 'viewRest'),
+                        })),
+                  ]))),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateWikiHomepagePage.js b/src/content/dependencies/generateWikiHomepagePage.js
new file mode 100644
index 00000000..8c09a007
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepagePage.js
@@ -0,0 +1,97 @@
+export default {
+  contentDependencies: [
+    'generatePageLayout',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'generateWikiHomepageNewsBox',
+    'generateWikiHomepageSection',
+    'transformContent',
+  ],
+
+  extraDependencies: ['wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    wikiName:
+      wikiInfo.name,
+
+    enableNews:
+      wikiInfo.enableNews,
+  }),
+
+  relations: (relation, sprawl, homepageLayout) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    sidebar:
+      relation('generatePageSidebar'),
+
+    customSidebarBox:
+      relation('generatePageSidebarBox'),
+
+    customSidebarContent:
+      relation('transformContent', homepageLayout.sidebarContent),
+
+    newsSidebarBox:
+      (sprawl.enableNews
+        ? relation('generateWikiHomepageNewsBox')
+        : null),
+
+    customNavLinkContents:
+      homepageLayout.navbarLinks
+        .map(content => relation('transformContent', content)),
+
+    sections:
+      homepageLayout.sections
+        .map(section => relation('generateWikiHomepageSection', section)),
+  }),
+
+  data: (sprawl) => ({
+    wikiName:
+      sprawl.wikiName,
+  }),
+
+  generate: (data, relations) =>
+    relations.layout.slots({
+      title: data.wikiName,
+      showWikiNameInTitle: false,
+
+      mainClasses: ['top-index'],
+      headingMode: 'static',
+
+      mainContent: [
+        relations.sections,
+      ],
+
+      leftSidebar:
+        relations.sidebar.slots({
+          wide: true,
+
+          boxes: [
+            relations.customSidebarBox.slots({
+              attributes: {class: 'custom-content-sidebar-box'},
+              collapsible: false,
+
+              content:
+                relations.customSidebarContent
+                  .slot('mode', 'multiline'),
+            }),
+
+            relations.newsSidebarBox,
+          ],
+        }),
+
+      navLinkStyle: 'index',
+      navLinks: [
+        {auto: 'home', current: true},
+
+        ...
+          relations.customNavLinkContents.map(content => ({
+            html:
+              content.slots({
+                mode: 'single-link',
+                preferShortLinkNames: true,
+              }),
+          })),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateWikiHomepageSection.js b/src/content/dependencies/generateWikiHomepageSection.js
new file mode 100644
index 00000000..49a474da
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageSection.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateWikiHomepageActionsRow',
+    'generateWikiHomepageAlbumCarouselRow',
+    'generateWikiHomepageAlbumGridRow',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, homepageSection) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute', homepageSection.color),
+
+    rows:
+      homepageSection.rows.map(row =>
+        (row.type === 'actions'
+          ? relation('generateWikiHomepageActionsRow', row)
+       : row.type === 'album carousel'
+          ? relation('generateWikiHomepageAlbumCarouselRow', row)
+       : row.type === 'album grid'
+          ? relation('generateWikiHomepageAlbumGridRow', row)
+          : null)),
+  }),
+
+  data: (homepageSection) => ({
+    name:
+      homepageSection.name,
+  }),
+
+  generate: (data, relations, {html}) =>
+    html.tag('section',
+      relations.colorStyle,
+
+      [
+        html.tag('h2', data.name),
+        relations.rows,
+      ]),
+};
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index 6b24f386..bf47b14f 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -1,12 +1,11 @@
-import {logInfo, logWarn} from '#cli';
+import {logWarn} from '#cli';
 import {empty} from '#sugar';
 
 export default {
   extraDependencies: [
-    'cachebust',
     'checkIfImagePathHasCachedThumbnails',
     'getDimensionsOfImagePath',
-    'getSizeOfImagePath',
+    'getSizeOfMediaFile',
     'getThumbnailEqualOrSmaller',
     'getThumbnailsAvailableForDimensions',
     'html',
@@ -17,75 +16,83 @@ export default {
 
   contentDependencies: ['generateColorStyleAttribute'],
 
-  relations: (relation) => ({
+  relations: (relation, _artwork) => ({
     colorStyle:
       relation('generateColorStyleAttribute'),
   }),
 
-  data(artTags) {
-    const data = {};
-
-    if (artTags) {
-      data.contentWarnings =
-        artTags
-          .filter(tag => tag.isContentWarning)
-          .map(tag => tag.name);
-    } else {
-      data.contentWarnings = null;
-    }
-
-    return data;
-  },
+  data: (artwork) => ({
+    path:
+      (artwork
+        ? artwork.path
+        : null),
+
+    warnings:
+      (artwork
+        ? artwork.artTags
+            .filter(artTag => artTag.isContentWarning)
+            .map(artTag => artTag.name)
+        : null),
+
+    dimensions:
+      (artwork
+        ? artwork.dimensions
+        : null),
+  }),
 
   slots: {
-    src: {type: 'string'},
-
-    path: {
-      validate: v => v.validateArrayItems(v.isString),
-    },
-
     thumb: {type: 'string'},
 
+    reveal: {type: 'boolean', default: true},
+    lazy: {type: 'boolean', default: false},
+    square: {type: 'boolean', default: false},
+
     link: {
       validate: v => v.anyOf(v.isBoolean, v.isString),
       default: false,
     },
 
-    color: {
-      validate: v => v.isColor,
-    },
+    color: {validate: v => v.isColor},
 
-    warnings: {
-      validate: v => v.looseArrayOf(v.isString),
+    // Added to the .image-container.
+    attributes: {
+      type: 'attributes',
+      mutable: false,
     },
 
-    reveal: {type: 'boolean', default: true},
-    lazy: {type: 'boolean', default: false},
+    // Added to the <img> itself.
+    alt: {type: 'string'},
 
-    square: {type: 'boolean', default: false},
+    // Specify 'src' or 'path', or the path will be used from the artwork.
+    // If none of the above is present, the message in missingSourceContent
+    // will be displayed instead.
 
-    dimensions: {
-      validate: v => v.isDimensions,
-    },
-
-    alt: {type: 'string'},
+    src: {type: 'string'},
 
-    attributes: {
-      type: 'attributes',
-      mutable: false,
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
     },
 
     missingSourceContent: {
       type: 'html',
       mutable: false,
     },
+
+    // These will also be used from the artwork if not specified as slots.
+
+    warnings: {
+      validate: v => v.looseArrayOf(v.isString),
+    },
+
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
   },
 
   generate(data, relations, slots, {
-    cachebust,
     checkIfImagePathHasCachedThumbnails,
     getDimensionsOfImagePath,
-    getSizeOfImagePath,
+    getSizeOfMediaFile,
     getThumbnailEqualOrSmaller,
     getThumbnailsAvailableForDimensions,
     html,
@@ -93,15 +100,14 @@ export default {
     missingImagePaths,
     to,
   }) {
-    let originalSrc;
-
-    if (slots.src) {
-      originalSrc = slots.src;
-    } else if (!empty(slots.path)) {
-      originalSrc = to(...slots.path);
-    } else {
-      originalSrc = '';
-    }
+    const originalSrc =
+      (slots.src
+        ? slots.src
+     : slots.path
+        ? to(...slots.path)
+     : data.path
+        ? to(...data.path)
+        : '');
 
     // TODO: This feels janky. It's necessary to deal with static content that
     // includes strings like <img src="media/misc/foo.png">, but processing the
@@ -119,44 +125,31 @@ export default {
     const isMissingImageFile =
       missingImagePaths.includes(mediaSrc);
 
-    if (isMissingImageFile) {
-      logInfo`No image file for ${mediaSrc} - build again for list of missing images.`;
-    }
-
     const willLink =
       !isMissingImageFile &&
       (typeof slots.link === 'string' || slots.link);
 
-    const contentWarnings =
-      slots.warnings ??
-      data.contentWarnings;
+    const warnings = slots.warnings ?? data.warnings;
+    const dimensions = slots.dimensions ?? data.dimensions;
 
     const willReveal =
       slots.reveal &&
       originalSrc &&
       !isMissingImageFile &&
-      !empty(contentWarnings);
-
-    const hasBothDimensions =
-      !!(slots.dimensions &&
-         slots.dimensions[0] !== null &&
-         slots.dimensions[1] !== null);
-
-    const willSquare =
-      (hasBothDimensions
-        ? slots.dimensions[0] === slots.dimensions[1]
-        : slots.square);
+      !empty(warnings);
 
     const imgAttributes = html.attributes([
       {class: 'image'},
 
       slots.alt && {alt: slots.alt},
 
-      slots.dimensions?.[0] &&
-        {width: slots.dimensions[0]},
+      dimensions &&
+      dimensions[0] &&
+        {width: dimensions[0]},
 
-      slots.dimensions?.[1] &&
-        {width: slots.dimensions[1]},
+      dimensions &&
+      dimensions[1] &&
+        {height: dimensions[1]},
     ]);
 
     const isPlaceholder =
@@ -176,13 +169,13 @@ export default {
     if (willReveal) {
       reveal = [
         html.tag('img', {class: 'reveal-symbol'},
-          {src: to('shared.staticFile', 'warning.svg', cachebust)}),
+          {src: to('staticMisc.path', 'warning.svg')}),
 
         html.tag('br'),
 
         html.tag('span', {class: 'reveal-warnings'},
           language.$('misc.contentWarnings.warnings', {
-            warnings: language.formatUnitList(contentWarnings),
+            warnings: language.formatUnitList(warnings),
           })),
 
         html.tag('br'),
@@ -237,19 +230,17 @@ export default {
 
       const originalDimensions = getDimensionsOfImagePath(mediaSrc);
       const availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions);
-      const originalLength = Math.max(originalDimensions[0], originalDimensions[1]);
 
       const fileSize =
         (willLink && mediaSrc
-          ? getSizeOfImagePath(mediaSrc)
+          ? getSizeOfMediaFile(mediaSrc)
           : null);
 
       imgAttributes.add([
         fileSize &&
           {'data-original-size': fileSize},
 
-        originalLength &&
-          {'data-original-length': originalLength},
+        {'data-dimensions': originalDimensions.join('x')},
 
         !empty(availableThumbs) &&
           {'data-thumbs':
@@ -338,14 +329,14 @@ export default {
 
       wrapped =
         html.tag('div', {class: 'image-outer-area'},
-          willSquare &&
+          slots.square &&
             {class: 'square-content'},
 
           wrapped);
 
       wrapped =
         html.tag('div', {class: 'image-container'},
-          willSquare &&
+          slots.square &&
             {class: 'square'},
 
           typeof slots.link === 'string' &&
diff --git a/src/content/dependencies/linkAlbumDynamically.js b/src/content/dependencies/linkAlbumDynamically.js
index 3adc64df..45f8c2a9 100644
--- a/src/content/dependencies/linkAlbumDynamically.js
+++ b/src/content/dependencies/linkAlbumDynamically.js
@@ -1,14 +1,61 @@
+import {empty} from '#sugar';
+
 export default {
-  contentDependencies: ['linkAlbumGallery', 'linkAlbum'],
-  extraDependencies: ['pagePath'],
+  contentDependencies: [
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'pagePath'],
 
   relations: (relation, album) => ({
-    galleryLink: relation('linkAlbumGallery', album),
-    infoLink: relation('linkAlbum', album),
+    galleryLink:
+      relation('linkAlbumGallery', album),
+
+    infoLink:
+      relation('linkAlbum', album),
+
+    commentaryLink:
+      relation('linkAlbumCommentary', album),
   }),
 
-  generate: (relations, {pagePath}) =>
-    (pagePath[0] === 'albumGallery'
+  data: (album) => ({
+    albumDirectory:
+      album.directory,
+
+    albumHasCommentary:
+      !empty(album.commentary),
+  }),
+
+  slots: {
+    linkCommentaryPages: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots, {pagePath}) =>
+     // When linking to an album *from* an album commentary page,
+     // if the link is to the *same* album, then the effective target
+     // of the link is really the album's commentary, so scroll to it.
+    (pagePath[0] === 'albumCommentary' &&
+     pagePath[1] === data.albumDirectory &&
+     data.albumHasCommentary
+      ? relations.infoLink.slots({
+          anchor: true,
+          hash: 'album-commentary',
+        })
+
+     // When linking to *another* album from an album commentary page,
+     // the target is (by default) still just the album (its info page).
+     // But this can be customized per-link!
+   : pagePath[0] === 'albumCommentary' &&
+     slots.linkCommentaryPages
+      ? relations.commentaryLink
+
+   : pagePath[0] === 'albumGallery'
       ? relations.galleryLink
+
       : relations.infoLink),
 };
diff --git a/src/content/dependencies/linkAlbumReferencedArtworks.js b/src/content/dependencies/linkAlbumReferencedArtworks.js
new file mode 100644
index 00000000..ba51b5e3
--- /dev/null
+++ b/src/content/dependencies/linkAlbumReferencedArtworks.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumReferencedArtworks', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAlbumReferencingArtworks.js b/src/content/dependencies/linkAlbumReferencingArtworks.js
new file mode 100644
index 00000000..4d5e799d
--- /dev/null
+++ b/src/content/dependencies/linkAlbumReferencingArtworks.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumReferencingArtworks', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAnythingMan.js b/src/content/dependencies/linkAnythingMan.js
new file mode 100644
index 00000000..e408c1b2
--- /dev/null
+++ b/src/content/dependencies/linkAnythingMan.js
@@ -0,0 +1,28 @@
+export default {
+  contentDependencies: [
+    'linkAlbum',
+    'linkArtwork',
+    'linkFlash',
+    'linkTrack',
+  ],
+
+  query: (thing) => ({
+    referenceType: thing.constructor[Symbol.for('Thing.referenceType')],
+  }),
+
+  relations: (relation, query, thing) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbum', thing)
+     : query.referenceType === 'artwork'
+        ? relation('linkArtwork', thing)
+     : query.referenceType === 'flash'
+        ? relation('linkFlash', thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrack', thing)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.link,
+};
diff --git a/src/content/dependencies/linkArtTagDynamically.js b/src/content/dependencies/linkArtTagDynamically.js
new file mode 100644
index 00000000..964258e1
--- /dev/null
+++ b/src/content/dependencies/linkArtTagDynamically.js
@@ -0,0 +1,14 @@
+export default {
+  contentDependencies: ['linkArtTagGallery', 'linkArtTagInfo'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, artTag) => ({
+    galleryLink: relation('linkArtTagGallery', artTag),
+    infoLink: relation('linkArtTagInfo', artTag),
+  }),
+
+  generate: (relations, {pagePath}) =>
+    (pagePath[0] === 'artTagInfo'
+      ? relations.infoLink
+      : relations.galleryLink),
+};
diff --git a/src/content/dependencies/linkArtTagGallery.js b/src/content/dependencies/linkArtTagGallery.js
new file mode 100644
index 00000000..a92b69c1
--- /dev/null
+++ b/src/content/dependencies/linkArtTagGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artTag) =>
+    ({link: relation('linkThing', 'localized.artTagGallery', artTag)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtTag.js b/src/content/dependencies/linkArtTagInfo.js
index 7ddb7786..409cb3c0 100644
--- a/src/content/dependencies/linkArtTag.js
+++ b/src/content/dependencies/linkArtTagInfo.js
@@ -2,7 +2,7 @@ export default {
   contentDependencies: ['linkThing'],
 
   relations: (relation, artTag) =>
-    ({link: relation('linkThing', 'localized.tag', artTag)}),
+    ({link: relation('linkThing', 'localized.artTagInfo', artTag)}),
 
   generate: (relations) => relations.link,
 };
diff --git a/src/content/dependencies/linkArtwork.js b/src/content/dependencies/linkArtwork.js
new file mode 100644
index 00000000..8cd6f359
--- /dev/null
+++ b/src/content/dependencies/linkArtwork.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: ['linkAlbum', 'linkTrack'],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Symbol.for('Thing.referenceType')],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbum', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrack', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.link,
+};
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index 41ce1146..c658d461 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -1,145 +1,85 @@
-import {empty, stitchArrays} from '#sugar';
-
 export default {
   contentDependencies: [
+    'generateContributionTooltip',
     'generateTextWithTooltip',
-    'generateTooltip',
     'linkArtist',
-    'linkExternalAsIcon',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  relations(relation, contribution) {
-    const relations = {};
+  relations: (relation, contribution) => ({
+    artistLink:
+      relation('linkArtist', contribution.artist),
 
-    relations.artistLink =
-      relation('linkArtist', contribution.who);
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
 
-    relations.textWithTooltip =
-      relation('generateTextWithTooltip');
+    tooltip:
+      relation('generateContributionTooltip', contribution),
+  }),
 
-    relations.tooltip =
-      relation('generateTooltip');
+  data: (contribution) => ({
+    annotation: contribution.annotation,
+    urls: contribution.artist.urls,
+  }),
 
-    if (!empty(contribution.who.urls)) {
-      relations.artistIcons =
-        contribution.who.urls
-          .map(url => relation('linkExternalAsIcon', url));
-    }
+  slots: {
+    showAnnotation: {type: 'boolean', default: false},
+    showExternalLinks: {type: 'boolean', default: false},
+    showChronology: {type: 'boolean', default: false},
 
-    return relations;
-  },
+    trimAnnotation: {type: 'boolean', default: false},
 
-  data(contribution) {
-    return {
-      what: contribution.what,
-      urls: contribution.who.urls,
-    };
-  },
-
-  slots: {
-    showContribution: {type: 'boolean', default: false},
-    showIcons: {type: 'boolean', default: false},
     preventWrapping: {type: 'boolean', default: true},
+    preventTooltip: {type: 'boolean', default: false},
 
-    iconMode: {
-      validate: v => v.is('inline', 'tooltip'),
-      default: 'inline'
-    },
+    chronologyKind: {type: 'string'},
   },
 
-  generate(data, relations, slots, {html, language}) {
-    const hasContribution = !!(slots.showContribution && data.what);
-    const hasExternalIcons = !!(slots.showIcons && relations.artistIcons);
-
-    const parts = ['misc.artistLink'];
-    const options = {};
-
-    options.artist =
-      (hasExternalIcons && slots.iconMode === 'tooltip'
-        ? relations.textWithTooltip.slots({
-            customInteractionCue: true,
-
-            text:
-              relations.artistLink.slots({
-                attributes: {class: 'text-with-tooltip-interaction-cue'},
-              }),
-
-            tooltip:
-              relations.tooltip.slots({
-                attributes:
-                  {class: ['icons', 'icons-tooltip']},
-
-                contentAttributes:
-                  {[html.joinChildren]: ''},
-
-                content:
-                  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.what;
-    }
-
-    if (hasExternalIcons && slots.iconMode === 'inline') {
-      parts.push('withExternalLinks');
-      options.links =
-        html.tag('span', {class: ['icons', 'icons-inline']},
-          {[html.noEdgeWhitespace]: true},
-          language.formatUnitList(
-            relations.artistIcons
-              .slice(0, 4)
-              .map(icon => icon.slot('context', 'artist'))));
-    }
-
-    const contributionPart =
-      language.formatString(...parts, options);
-
-    if (!hasContribution && !hasExternalIcons) {
-      return contributionPart;
-    }
-
-    return (
-      html.tag('span', {class: 'contribution'},
-        {[html.noEdgeWhitespace]: true},
-
-        parts.length > 1 &&
-        slots.preventWrapping &&
-          {class: 'nowrap'},
-
-        contributionPart));
-  },
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('span', {class: 'contribution'},
+      {[html.noEdgeWhitespace]: true},
+
+      slots.preventWrapping &&
+        {class: 'nowrap'},
+
+      language.encapsulate('misc.artistLink', workingCapsule => {
+        const workingOptions = {};
+
+        // Filling slots early is necessary to actually give the tooltip
+        // content. Otherwise, the coming-up html.isBlank() always reports
+        // the tooltip as blank!
+        relations.tooltip.setSlots({
+          showExternalLinks: slots.showExternalLinks,
+          showChronology: slots.showChronology,
+          chronologyKind: slots.chronologyKind,
+        });
+
+        workingOptions.artist =
+          (html.isBlank(relations.tooltip) || slots.preventTooltip
+            ? relations.artistLink
+            : relations.textWithTooltip.slots({
+                customInteractionCue: true,
+
+                text:
+                  relations.artistLink.slots({
+                    attributes: {class: 'text-with-tooltip-interaction-cue'},
+                  }),
+
+                tooltip:
+                  relations.tooltip,
+              }));
+
+        const annotation =
+          (slots.trimAnnotation
+            ? data.annotation?.replace(/^edits for wiki(: )?/, '')
+            : data.annotation);
+
+        if (slots.showAnnotation && annotation) {
+          workingCapsule += '.withContribution';
+          workingOptions.contrib = annotation;
+        }
+
+        return language.formatString(workingCapsule, workingOptions);
+      })),
 };
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
index f6b47db7..073c821e 100644
--- a/src/content/dependencies/linkExternal.js
+++ b/src/content/dependencies/linkExternal.js
@@ -11,6 +11,11 @@ export default {
       mutable: false,
     },
 
+    suffixNormalContent: {
+      type: 'html',
+      mutable: false,
+    },
+
     style: {
       // This awkward syntax is because the slot descriptor validator can't
       // differentiate between a function that returns a validator (the usual
@@ -131,6 +136,16 @@ export default {
       linkAttributes.set('target', '_blank');
     }
 
+    if (!html.isBlank(slots.suffixNormalContent)) {
+      linkContent =
+        html.tags([
+          linkContent,
+
+          html.tag('span', {class: 'normal-content'},
+            slots.suffixNormalContent),
+        ], {[html.joinChildren]: ''});
+    }
+
     return html.tag('a', linkAttributes, linkContent);
   },
 };
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
deleted file mode 100644
index 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/linkFlashAct.js b/src/content/dependencies/linkFlashAct.js
index fbb819ed..82c23325 100644
--- a/src/content/dependencies/linkFlashAct.js
+++ b/src/content/dependencies/linkFlashAct.js
@@ -1,14 +1,22 @@
 export default {
-  contentDependencies: ['linkThing'],
-  extraDependencies: ['html'],
+  contentDependencies: ['generateUnsafeMunchy', 'linkThing'],
 
-  relations: (relation, flashAct) =>
-    ({link: relation('linkThing', 'localized.flashActGallery', flashAct)}),
+  relations: (relation, flashAct) => ({
+    unsafeMunchy:
+      relation('generateUnsafeMunchy'),
 
-  data: (flashAct) =>
-    ({name: flashAct.name}),
+    link:
+      relation('linkThing', 'localized.flashActGallery', flashAct),
+  }),
 
-  generate: (data, relations, {html}) =>
-    relations.link
-      .slot('content', new html.Tag(null, null, data.name)),
+  data: (flashAct) => ({
+    name: flashAct.name,
+  }),
+
+  generate: (data, relations) =>
+    relations.link.slots({
+      content:
+        relations.unsafeMunchy
+          .slot('contentSource', data.name),
+    }),
 };
diff --git a/src/content/dependencies/linkFlashSide.js b/src/content/dependencies/linkFlashSide.js
new file mode 100644
index 00000000..b77ca65a
--- /dev/null
+++ b/src/content/dependencies/linkFlashSide.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['linkFlashAct'],
+
+  relations: (relation, flashSide) => ({
+    link:
+      relation('linkFlashAct', flashSide.acts[0]),
+  }),
+
+  data: (flashSide) => ({
+    name:
+      flashSide.name,
+
+    color:
+      flashSide.color,
+  }),
+
+  generate: (data, relations) =>
+    relations.link.slots({
+      content: data.name,
+      color: data.color,
+    }),
+};
diff --git a/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js
new file mode 100644
index 00000000..ec856631
--- /dev/null
+++ b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js
@@ -0,0 +1,62 @@
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {chunkArtistTrackContributions} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateColorStyleAttribute'],
+  extraDependencies: ['html', 'language'],
+
+  query(track, artist) {
+    const relevantInfoPageChunkingContributions =
+      track.allReleases
+        .flatMap(release => [
+          ...release.artistContribs,
+          ...release.contributorContribs,
+        ])
+        .filter(c => c.artist === artist);
+
+    sortContributionsChronologically(
+      relevantInfoPageChunkingContributions,
+      sortAlbumsTracksChronologically);
+
+    const contributionChunks =
+      chunkArtistTrackContributions(relevantInfoPageChunkingContributions);
+
+    const trackChunks =
+      contributionChunks
+        .map(chunksInAlbum => chunksInAlbum
+          .map(chunksInTrack => chunksInTrack[0].thing));
+
+    const trackChunksForThisAlbum =
+      trackChunks
+        .filter(tracks => tracks[0].album === track.album);
+
+    const containingChunkIndex =
+      trackChunksForThisAlbum
+        .findIndex(tracks => tracks.includes(track));
+
+    return {containingChunkIndex};
+  },
+
+  relations: (relation, _query, track, _artist) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute', track.album.color),
+  }),
+
+  data: (query, track, _artist) => ({
+    albumName:
+      track.album.name,
+
+    albumDirectory:
+      track.album.directory,
+
+    containingChunkIndex:
+      query.containingChunkIndex,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    html.tag('a',
+      {href: `#tracks-${data.albumDirectory}-${data.containingChunkIndex}`},
+      relations.colorStyle.slot('context', 'primary-only'),
+      language.sanitize(data.albumName)),
+};
diff --git a/src/content/dependencies/linkPathFromMedia.js b/src/content/dependencies/linkPathFromMedia.js
index 34a2b857..d71c69f8 100644
--- a/src/content/dependencies/linkPathFromMedia.js
+++ b/src/content/dependencies/linkPathFromMedia.js
@@ -1,13 +1,64 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: ['linkTemplate'],
 
+  extraDependencies: [
+    'checkIfImagePathHasCachedThumbnails',
+    'getDimensionsOfImagePath',
+    'getSizeOfMediaFile',
+    'getThumbnailsAvailableForDimensions',
+    'html',
+    'to',
+  ],
+
   relations: (relation) =>
     ({link: relation('linkTemplate')}),
 
   data: (path) =>
     ({path}),
 
-  generate: (data, relations) =>
-    relations.link
-      .slot('path', ['media.path', data.path]),
+  generate(data, relations, {
+    checkIfImagePathHasCachedThumbnails,
+    getDimensionsOfImagePath,
+    getSizeOfMediaFile,
+    getThumbnailsAvailableForDimensions,
+    html,
+    to,
+  }) {
+    const attributes = html.attributes();
+
+    if (checkIfImagePathHasCachedThumbnails(data.path)) {
+      const dimensions = getDimensionsOfImagePath(data.path);
+      const availableThumbs = getThumbnailsAvailableForDimensions(dimensions);
+      const fileSize = getSizeOfMediaFile(data.path);
+
+      const embedSrc =
+        to('thumb.path', data.path.replace(/\.(png|jpg)$/, '.tack.jpg'));
+
+      attributes.add([
+        {class: 'image-media-link'},
+
+        {'data-embed-src': embedSrc},
+
+        fileSize &&
+          {'data-original-size': fileSize},
+
+        {'data-dimensions': dimensions.join('x')},
+
+        !empty(availableThumbs) &&
+          {'data-thumbs':
+              availableThumbs
+                .map(([name, size]) => `${name}:${size}`)
+                .join(' ')},
+      ]);
+    }
+
+    relations.link.setSlots({
+      attributes,
+      path: ['media.path', data.path],
+    });
+
+    return relations.link;
+  },
 };
diff --git a/src/content/dependencies/linkReferencedArtworks.js b/src/content/dependencies/linkReferencedArtworks.js
new file mode 100644
index 00000000..c456b808
--- /dev/null
+++ b/src/content/dependencies/linkReferencedArtworks.js
@@ -0,0 +1,24 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'linkAlbumReferencedArtworks',
+    'linkTrackReferencedArtworks',
+  ],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbumReferencedArtworks', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrackReferencedArtworks', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkReferencingArtworks.js b/src/content/dependencies/linkReferencingArtworks.js
new file mode 100644
index 00000000..0cfca4db
--- /dev/null
+++ b/src/content/dependencies/linkReferencingArtworks.js
@@ -0,0 +1,24 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'linkAlbumReferencingArtworks',
+    'linkTrackReferencingArtworks',
+  ],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbumReferencingArtworks', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrackReferencingArtworks', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js
index 63cc82e8..4f853dc4 100644
--- a/src/content/dependencies/linkTemplate.js
+++ b/src/content/dependencies/linkTemplate.js
@@ -26,6 +26,11 @@ export default {
       type: 'html',
       mutable: false,
     },
+
+    suffixNormalContent: {
+      type: 'html',
+      mutable: false,
+    },
   },
 
   generate(slots, {
@@ -61,13 +66,22 @@ export default {
       attributes.set('title', slots.tooltip);
     }
 
-    const content =
+    const mainContent =
       (html.isBlank(slots.content)
         ? language.$('misc.missingLinkContent')
-        : striptags(html.resolve(slots.content, {normalize: 'string'}), {
-            disallowedTags: new Set(['a']),
-          }));
+        : striptags(
+            html.resolve(slots.content, {normalize: 'string'}),
+            {disallowedTags: new Set(['a'])}));
+
+    const allContent =
+      (html.isBlank(slots.suffixNormalContent)
+        ? mainContent
+        : html.tags([
+            mainContent,
+            html.tag('span', {class: 'normal-content'},
+              slots.suffixNormalContent),
+          ], {[html.joinChildren]: ''}));
 
-    return html.tag('a', attributes, content);
+    return html.tag('a', attributes, allContent);
   },
 }
diff --git a/src/content/dependencies/linkTrackDynamically.js b/src/content/dependencies/linkTrackDynamically.js
index 242cd4cb..bbcf1c34 100644
--- a/src/content/dependencies/linkTrackDynamically.js
+++ b/src/content/dependencies/linkTrackDynamically.js
@@ -1,3 +1,5 @@
+import {empty} from '#sugar';
+
 export default {
   contentDependencies: ['linkTrack'],
   extraDependencies: ['pagePath'],
@@ -14,7 +16,7 @@ export default {
       track.album.directory,
 
     trackHasCommentary:
-      !!track.commentary,
+      !empty(track.commentary),
   }),
 
   generate(data, relations, {pagePath}) {
diff --git a/src/content/dependencies/linkTrackReferencedArtworks.js b/src/content/dependencies/linkTrackReferencedArtworks.js
new file mode 100644
index 00000000..b4cb08fe
--- /dev/null
+++ b/src/content/dependencies/linkTrackReferencedArtworks.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, track) =>
+    ({link: relation('linkThing', 'localized.trackReferencedArtworks', track)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkTrackReferencingArtworks.js b/src/content/dependencies/linkTrackReferencingArtworks.js
new file mode 100644
index 00000000..c9c9f4d1
--- /dev/null
+++ b/src/content/dependencies/linkTrackReferencingArtworks.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, track) =>
+    ({link: relation('linkThing', 'localized.trackReferencingArtworks', track)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkWikiHome.js b/src/content/dependencies/linkWikiHomepage.js
index d8d3d0a0..d8d3d0a0 100644
--- a/src/content/dependencies/linkWikiHome.js
+++ b/src/content/dependencies/linkWikiHomepage.js
diff --git a/src/content/dependencies/listArtTagNetwork.js b/src/content/dependencies/listArtTagNetwork.js
index b3a54747..93dd4ce8 100644
--- a/src/content/dependencies/listArtTagNetwork.js
+++ b/src/content/dependencies/listArtTagNetwork.js
@@ -1 +1,366 @@
-export default {generate() {}};
+import {sortAlphabetically} from '#sort';
+import {empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtTagInfo'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({artTagData}) {
+    return {artTagData};
+  },
+
+  query(sprawl, spec) {
+    const artTags =
+      sprawl.artTagData.filter(artTag => !artTag.isContentWarning);
+
+    const rootArtTags =
+      artTags
+        .filter(artTag => !empty(artTag.directDescendantArtTags))
+        .filter(artTag =>
+          empty(artTag.directAncestorArtTags) ||
+          artTag.directAncestorArtTags.length >= 2);
+
+    sortAlphabetically(rootArtTags);
+
+    rootArtTags.sort(
+      ({directAncestorArtTags: ancestorsA},
+       {directAncestorArtTags: ancestorsB}) =>
+        ancestorsA.length - ancestorsB.length);
+
+    const getStats = (artTag) => ({
+      directUses:
+        artTag.directlyFeaturedInArtworks.length,
+
+      // Not currently displayed
+      directAndIndirectUses:
+        unique([
+          ...artTag.indirectlyFeaturedInArtworks,
+          ...artTag.directlyFeaturedInArtworks,
+        ]).length,
+
+      totalUses:
+        [
+          ...artTag.directlyFeaturedInArtworks,
+          ...
+            artTag.allDescendantArtTags
+              .flatMap(artTag => artTag.directlyFeaturedInArtworks),
+        ].length,
+
+      descendants:
+        artTag.allDescendantArtTags.length,
+
+      leaves:
+        (empty(artTag.directDescendantArtTags)
+          ? null
+          : artTag.allDescendantArtTags
+              .filter(artTag => empty(artTag.directDescendantArtTags))
+              .length),
+    });
+
+    const recursive = (artTag, depth) => {
+      const descendantNodes =
+        (empty(artTag.directDescendantArtTags)
+          ? null
+       : depth > 0 && artTag.directAncestorArtTags.length >= 2
+          ? null
+          : artTag.directDescendantArtTags
+              .map(artTag => recursive(artTag, depth + 1)));
+
+      descendantNodes?.sort(
+        ({descendantNodes: descendantNodesA},
+         {descendantNodes: descendantNodesB}) =>
+            (descendantNodesA ? 1 : 0)
+          - (descendantNodesB ? 1 : 0));
+
+      const recursiveGetRootAncestor = ancestorArtTag =>
+        (ancestorArtTag.directAncestorArtTags.length === 1
+          ? recursiveGetRootAncestor(ancestorArtTag.directAncestorArtTags[0])
+          : ancestorArtTag);
+
+      const ancestorRootArtTags =
+        (depth === 0 && !empty(artTag.directAncestorArtTags)
+          ? unique(artTag.directAncestorArtTags.map(recursiveGetRootAncestor))
+          : null);
+
+      const stats = getStats(artTag);
+
+      return {
+        artTag,
+        stats,
+        descendantNodes,
+        ancestorRootArtTags,
+      };
+    };
+
+    const uppermostRootTags =
+      artTags
+        .filter(artTag => !empty(artTag.directDescendantArtTags))
+        .filter(artTag => empty(artTag.directAncestorArtTags));
+
+    const orphanArtTags =
+      artTags
+        .filter(artTag => empty(artTag.directDescendantArtTags))
+        .filter(artTag => empty(artTag.directAncestorArtTags));
+
+    return {
+      spec,
+
+      rootNodes:
+        rootArtTags
+          .map(artTag => recursive(artTag, 0)),
+
+      uppermostRootTags,
+      orphanArtTags,
+    };
+  },
+
+  relations(relation, query) {
+    const recursive = queryNode => ({
+      artTagLink:
+        relation('linkArtTagInfo', queryNode.artTag),
+
+      ancestorTagLinks:
+        queryNode.ancestorRootArtTags
+          ?.map(artTag => relation('linkArtTagInfo', artTag))
+          ?? null,
+
+      descendantNodes:
+        queryNode.descendantNodes
+          ?.map(recursive)
+          ?? null,
+    });
+
+    return {
+      page:
+        relation('generateListingPage', query.spec),
+
+      rootNodes:
+        query.rootNodes.map(recursive),
+
+      uppermostRootTagLinks:
+        query.uppermostRootTags
+          .map(artTag => relation('linkArtTagInfo', artTag)),
+
+      orphanArtTagLinks:
+        query.orphanArtTags
+          .map(artTag => relation('linkArtTagInfo', artTag)),
+    };
+  },
+
+  data(query) {
+    const rootArtTags = query.rootNodes.map(({artTag}) => artTag);
+
+    const recursive = queryNode => ({
+      directory:
+        queryNode.artTag.directory,
+
+      directUses:
+        queryNode.stats.directUses,
+
+      totalUses:
+        queryNode.stats.totalUses,
+
+      descendants:
+        queryNode.stats.descendants,
+
+      leaves:
+        queryNode.stats.leaves,
+
+      representsRoot:
+        rootArtTags.includes(queryNode.artTag),
+
+      ancestorTagDirectories:
+        queryNode.ancestorRootArtTags
+          ?.map(artTag => artTag.directory)
+          ?? null,
+
+      descendantNodes:
+        queryNode.descendantNodes
+          ?.map(recursive)
+          ?? null,
+    });
+
+    return {
+      rootNodes:
+        query.rootNodes.map(recursive),
+
+      uppermostRootTagDirectories:
+        query.uppermostRootTags
+          .map(artTag => artTag.directory),
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    const prefix = `listingPage.listArtTags.network`;
+
+    const wrapTagWithJumpTo = (dataNode, relationsNode, depth) =>
+      (depth === 0
+        ? relationsNode.artTagLink
+     : dataNode.representsRoot
+        ? language.$(prefix, 'tag.jumpToRoot', {
+            tag:
+              relationsNode.artTagLink.slots({
+                anchor: true,
+                hash: dataNode.directory,
+              }),
+          })
+        : relationsNode.artTagLink);
+
+    const wrapTagWithStats = (dataNode, relationsNode, depth) => [
+      html.tag('span', {class: 'network-tag'},
+        language.$(prefix, 'tag', {
+          tag:
+            wrapTagWithJumpTo(dataNode, relationsNode, depth),
+        })),
+
+      html.tag('span', {class: 'network-tag'},
+        {class: 'with-stat'},
+        {style: 'display: none'},
+
+        language.$(prefix, 'tag.withStat', {
+          tag:
+            wrapTagWithJumpTo(dataNode, relationsNode, depth),
+
+          stat:
+            html.tag('span', {class: 'network-tag-stat'},
+              language.$(prefix, 'tag.withStat.stat', {
+                stat: [
+                  html.tag('span', {class: 'network-tag-direct-uses-stat'},
+                    dataNode.directUses.toString()),
+
+                  html.tag('span', {class: 'network-tag-total-uses-stat'},
+                    dataNode.totalUses.toString()),
+
+                  html.tag('span', {class: 'network-tag-descendants-stat'},
+                    dataNode.descendants.toString()),
+
+                  html.tag('span', {class: 'network-tag-leaves-stat'},
+                    (dataNode.leaves === null
+                      ? language.$(prefix, 'tag.withStat.notApplicable')
+                      : dataNode.leaves.toString())),
+                ],
+              })),
+        }))
+    ];
+
+    const recursive = (dataNode, relationsNode, depth) => [
+      html.tag('dt',
+        {
+          id: depth === 0 && dataNode.directory,
+          class: depth % 2 === 0 ? 'even' : 'odd',
+        },
+
+        (depth === 0
+          ? (relationsNode.ancestorTagLinks
+              ? language.$(prefix, 'root.withAncestors', {
+                  tag:
+                    wrapTagWithStats(dataNode, relationsNode, depth),
+
+                  ancestors:
+                    language.formatUnitList(
+                      stitchArrays({
+                        link: relationsNode.ancestorTagLinks,
+                        directory: dataNode.ancestorTagDirectories,
+                      }).map(({link, directory}) =>
+                          link.slots({
+                            anchor: true,
+                            hash: directory,
+                          }))),
+                })
+              : language.$(prefix, 'root.jumpToTop', {
+                  tag:
+                    wrapTagWithStats(dataNode, relationsNode, depth),
+
+                  link:
+                    html.tag('a', {href: '#top'},
+                      language.$(prefix, 'root.jumpToTop.link')),
+                }))
+          : wrapTagWithStats(dataNode, relationsNode, depth))),
+
+      dataNode.descendantNodes &&
+      relationsNode.descendantNodes &&
+        html.tag('dd',
+          {class: depth % 2 === 0 ? 'even' : 'odd'},
+          html.tag('dl',
+            stitchArrays({
+              dataNode: dataNode.descendantNodes,
+              relationsNode: relationsNode.descendantNodes,
+            }).map(({dataNode, relationsNode}) =>
+                recursive(dataNode, relationsNode, depth + 1)))),
+    ];
+
+    return relations.page.slots({
+      type: 'custom',
+
+      content: [
+        html.tag('p', {id: 'network-stat-line'},
+          language.$(prefix, 'statLine', {
+            stat: [
+              html.tag('a', {id: 'network-stat-none'},
+                {href: '#'},
+                language.$(prefix, 'statLine.none')),
+
+              html.tag('a', {id: 'network-stat-total-uses'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.totalUses')),
+
+              html.tag('a', {id: 'network-stat-direct-uses'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.directUses')),
+
+              html.tag('a', {id: 'network-stat-descendants'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.descendants')),
+
+              html.tag('a', {id: 'network-stat-leaves'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.leaves')),
+            ],
+          })),
+
+        html.tag('dl', {id: 'network-top-dl'}, [
+          html.tag('dt', {id: 'top'},
+            language.$(prefix, 'jumpToRoot.title')),
+
+          html.tag('dd',
+            html.tag('ul',
+              stitchArrays({
+                link: relations.uppermostRootTagLinks,
+                directory: data.uppermostRootTagDirectories,
+              }).map(({link, directory}) =>
+                  html.tag('li',
+                    language.$(prefix, 'jumpToRoot.item', {
+                      tag:
+                        link.slots({
+                          anchor: true,
+                          hash: directory,
+                        }),
+                    }))))),
+
+          stitchArrays({
+            dataNode: data.rootNodes,
+            relationsNode: relations.rootNodes,
+          }).map(({dataNode, relationsNode}) =>
+              recursive(dataNode, relationsNode, 0)),
+
+          !empty(relations.orphanArtTagLinks) && [
+            html.tag('dt',
+              language.$(prefix, 'orphanArtTags.title')),
+
+            html.tag('dd',
+              html.tag('ul',
+                relations.orphanArtTagLinks.map(orphanArtTagLink =>
+                  html.tag('li',
+                    language.$(prefix, 'orphanArtTags.item', {
+                      tag: orphanArtTagLink,
+                    }))))),
+          ],
+        ]),
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/listTagsByName.js b/src/content/dependencies/listArtTagsByName.js
index d7022a55..1df9dfff 100644
--- a/src/content/dependencies/listTagsByName.js
+++ b/src/content/dependencies/listArtTagsByName.js
@@ -1,8 +1,8 @@
 import {sortAlphabetically} from '#sort';
-import {stitchArrays} from '#sugar';
+import {stitchArrays, unique} from '#sugar';
 
 export default {
-  contentDependencies: ['generateListingPage', 'linkArtTag'],
+  contentDependencies: ['generateListingPage', 'linkArtTagGallery'],
   extraDependencies: ['language', 'wikiData'],
 
   sprawl({artTagData}) {
@@ -16,7 +16,7 @@ export default {
       artTags:
         sortAlphabetically(
           artTagData
-            .filter(tag => !tag.isContentWarning)),
+            .filter(artTag => !artTag.isContentWarning)),
     };
   },
 
@@ -26,15 +26,18 @@ export default {
 
       artTagLinks:
         query.artTags
-          .map(tag => relation('linkArtTag', tag)),
+          .map(artTag => relation('linkArtTagGallery', artTag)),
     };
   },
 
   data(query) {
     return {
       counts:
-        query.artTags
-          .map(tag => tag.taggedInThings.length),
+        query.artTags.map(artTag =>
+          unique([
+            ...artTag.indirectlyFeaturedInArtworks,
+            ...artTag.directlyFeaturedInArtworks,
+          ]).length),
     };
   },
 
diff --git a/src/content/dependencies/listArtTagsByUses.js b/src/content/dependencies/listArtTagsByUses.js
new file mode 100644
index 00000000..eca7f1c6
--- /dev/null
+++ b/src/content/dependencies/listArtTagsByUses.js
@@ -0,0 +1,54 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtTagGallery'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl: ({artTagData}) =>
+    ({artTagData}),
+
+  query({artTagData}, spec) {
+    const artTags =
+      sortAlphabetically(
+        artTagData
+          .filter(artTag => !artTag.isContentWarning));
+
+    const counts =
+      artTags.map(artTag =>
+        unique([
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
+        ]).length);
+
+    filterByCount(artTags, counts);
+    sortByCount(artTags, counts, {greatestFirst: true});
+
+    return {spec, artTags, counts};
+  },
+
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    artTagLinks:
+      query.artTags
+        .map(artTag => relation('linkArtTagGallery', artTag)),
+  }),
+
+  data: (query) =>
+    ({counts: query.counts}),
+
+  generate: (data, relations, {language}) =>
+    relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artTagLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            tag: link,
+            timesUsed: language.countTimesUsed(count, {unit: true}),
+          })),
+    }),
+};
diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js
index 0af586cd..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..17096cfc 100644
--- a/src/content/dependencies/listArtistsByGroup.js
+++ b/src/content/dependencies/listArtistsByGroup.js
@@ -1,6 +1,13 @@
 import {sortAlphabetically} from '#sort';
-import {empty, filterMultipleArrays, stitchArrays, unique} from '#sugar';
-import {getArtistNumContributions} from '#wiki-data';
+
+import {
+  empty,
+  filterByCount,
+  filterMultipleArrays,
+  stitchArrays,
+  transposeArrays,
+  unique,
+} from '#sugar';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
@@ -15,29 +22,74 @@ export default {
       sortAlphabetically(
         sprawl.artistData.filter(artist => !artist.isAlias));
 
-    const groups =
+    const interestingGroups =
       sprawl.wikiInfo.divideTrackListsByGroups;
 
-    if (empty(groups)) {
-      return {spec, artists};
+    if (empty(interestingGroups)) {
+      return {spec};
     }
 
-    const artistGroups =
+    // We don't actually care about *which* things belong to each group, only
+    // how many belong to each group. So we'll just compute a list of all the
+    // (interesting) groups that each of each artists' things belongs to.
+    const artistThingGroups =
       artists.map(artist =>
-        unique(
-          unique([
-            ...artist.albumsAsAny,
-            ...artist.tracksAsAny.map(track => track.album),
-          ]).flatMap(album => album.groups)))
-
-    const artistsByGroup =
-      groups.map(group =>
-        artists.filter((artist, index) => artistGroups[index].includes(group)));
-
-    filterMultipleArrays(groups, artistsByGroup,
-      (group, artists) => !empty(artists));
-
-    return {spec, groups, artistsByGroup};
+        ([
+          (unique(
+            ([
+              artist.albumArtistContributions
+                .map(contrib => contrib.thing),
+              artist.albumCoverArtistContributions
+                .map(contrib => contrib.thing.thing),
+              artist.albumWallpaperArtistContributions
+                .map(contrib => contrib.thing.thing),
+              artist.albumBannerArtistContributions
+                .map(contrib => contrib.thing.thing),
+            ]).flat()
+          )).map(album => album.groups),
+          (unique(
+            ([
+              artist.trackArtistContributions
+                .map(contrib => contrib.thing),
+              artist.trackContributorContributions
+                .map(contrib => contrib.thing),
+              artist.trackCoverArtistContributions
+                .map(contrib => contrib.thing.thing),
+            ]).flat()
+          )).map(track => track.album.groups),
+        ]).flat()
+          .map(groups => groups
+            .filter(group => interestingGroups.includes(group))));
+
+    const [artistsByGroup, countsByGroup] =
+      transposeArrays(interestingGroups.map(group => {
+        const counts =
+          artistThingGroups
+            .map(thingGroups => thingGroups
+              .filter(thingGroups => thingGroups.includes(group))
+              .length);
+
+        const filteredArtists = artists.slice();
+
+        filterByCount(filteredArtists, counts);
+
+        return [filteredArtists, counts];
+      }));
+
+    const groups = interestingGroups;
+
+    filterMultipleArrays(
+      groups,
+      artistsByGroup,
+      countsByGroup,
+      (_group, artists, _counts) => !empty(artists));
+
+    return {
+      spec,
+      groups,
+      artistsByGroup,
+      countsByGroup,
+    };
   },
 
   relations(relation, query) {
@@ -46,12 +98,6 @@ export default {
     relations.page =
       relation('generateListingPage', query.spec);
 
-    if (query.artists) {
-      relations.artistLinks =
-        query.artists
-          .map(artist => relation('linkArtist', artist));
-    }
-
     if (query.artistsByGroup) {
       relations.groupLinks =
         query.groups
@@ -69,65 +115,43 @@ export default {
   data(query) {
     const data = {};
 
-    if (query.artists) {
-      data.counts =
-        query.artists
-          .map(artist => getArtistNumContributions(artist));
-    }
-
     if (query.artistsByGroup) {
       data.groupDirectories =
         query.groups
           .map(group => group.directory);
 
       data.countsByGroup =
-        query.artistsByGroup
-          .map(artists => artists
-            .map(artist => getArtistNumContributions(artist)));
+        query.countsByGroup;
     }
 
     return data;
   },
 
-  generate(data, relations, {language}) {
-    return (
-      (relations.artistLinksByGroup
-        ? relations.page.slots({
-            type: 'chunks',
-
-            showSkipToSection: true,
-            chunkIDs:
-              data.groupDirectories
-                .map(directory => `contributed-to-${directory}`),
-
-            chunkTitles:
-              relations.groupLinks.map(groupLink => ({
-                group: groupLink,
-              })),
-
-            chunkRows:
-              stitchArrays({
-                artistLinks: relations.artistLinksByGroup,
-                counts: data.countsByGroup,
-              }).map(({artistLinks, counts}) =>
-                  stitchArrays({
-                    link: artistLinks,
-                    count: counts,
-                  }).map(({link, count}) => ({
-                      artist: link,
-                      contributions: language.countContributions(count, {unit: true}),
-                    }))),
-          })
-        : relations.page.slots({
-            type: 'rows',
-            rows:
-              stitchArrays({
-                link: relations.artistLinks,
-                count: data.counts,
-              }).map(({link, count}) => ({
-                  artist: link,
-                  contributions: language.countContributions(count, {unit: true}),
-                })),
-          })));
-  },
+  generate: (data, relations, {language}) =>
+    relations.page.slots({
+      type: 'chunks',
+
+      showSkipToSection: true,
+      chunkIDs:
+        data.groupDirectories
+          .map(directory => `contributed-to-${directory}`),
+
+      chunkTitles:
+        relations.groupLinks.map(groupLink => ({
+          group: groupLink,
+        })),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: relations.artistLinksByGroup,
+          counts: data.countsByGroup,
+        }).map(({artistLinks, counts}) =>
+            stitchArrays({
+              link: artistLinks,
+              count: counts,
+            }).map(({link, count}) => ({
+                artist: link,
+                contributions: language.countContributions(count, {unit: true}),
+              }))),
+    }),
 };
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
index 0f709577..2a8d1b4c 100644
--- a/src/content/dependencies/listArtistsByLatestContribution.js
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -83,7 +83,8 @@ export default {
       });
     };
 
-    const getArtists = (thing, key) => thing[key].map(({who}) => who);
+    const getArtists = (thing, key) =>
+      thing[key].map(({artist}) => artist);
 
     const albumsLatestFirst = sortAlbumsTracksChronologically(sprawl.albumData.slice());
     const tracksLatestFirst = sortAlbumsTracksChronologically(sprawl.trackData.slice());
@@ -97,13 +98,16 @@ export default {
       ])) {
         // Might combine later with 'track' of the same album and date.
         considerDate(artist, album.coverArtDate ?? album.date, album, 'artwork');
+        // '?? album.date' is kept here because wallpaper and banner may
+        // technically be present for an album w/o cover art, therefore
+        // also no cover art date.
       }
     }
 
     for (const track of tracksLatestFirst) {
       for (const artist of getArtists(track, 'coverArtistContribs')) {
         // No special effect if artist already has 'artwork' for the same album and date.
-        considerDate(artist, track.coverArtDate ?? track.date, track.album, 'artwork');
+        considerDate(artist, track.coverArtDate, track.album, 'artwork');
       }
 
       for (const artist of new Set([
diff --git a/src/content/dependencies/listGroupsByDuration.js b/src/content/dependencies/listGroupsByDuration.js
index da2f26db..c79e1bc4 100644
--- a/src/content/dependencies/listGroupsByDuration.js
+++ b/src/content/dependencies/listGroupsByDuration.js
@@ -16,7 +16,7 @@ export default {
       groups.map(group =>
         getTotalDuration(
           group.albums.flatMap(album => album.tracks),
-          {originalReleasesOnly: true}));
+          {mainReleasesOnly: true}));
 
     filterByCount(groups, durations);
     sortByCount(groups, durations, {greatestFirst: true});
diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js
index ab2eca93..79bba441 100644
--- a/src/content/dependencies/listRandomPageLinks.js
+++ b/src/content/dependencies/listRandomPageLinks.js
@@ -74,20 +74,22 @@ export default {
   },
 
   generate(data, relations, {html, language}) {
+    const capsule = language.encapsulate('listingPage.other.randomPages');
+
     const miscellaneousChunkRows = [
-      {
+      language.encapsulate(capsule, 'chunk.item.randomArtist', capsule => ({
         stringsKey: 'randomArtist',
 
         mainLink:
           html.tag('a',
             {href: '#', 'data-random': 'artist'},
-            language.$('listingPage.other.randomPages.chunk.item.randomArtist.mainLink')),
+            language.$(capsule, 'mainLink')),
 
         atLeastTwoContributions:
           html.tag('a',
             {href: '#', 'data-random': 'artist-more-than-one-contrib'},
-            language.$('listingPage.other.randomPages.chunk.item.randomArtist.atLeastTwoContributions')),
-      },
+            language.$(capsule, 'atLeastTwoContributions')),
+      })),
 
       {stringsKey: 'randomAlbumWholeSite'},
       {stringsKey: 'randomTrackWholeSite'},
@@ -104,24 +106,25 @@ export default {
 
       content: [
         html.tag('p',
-          language.$('listingPage.other.randomPages.chooseLinkLine', {
-            fromPart:
-              (relations.groupLinks
-                ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups')
-                : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups')),
+          language.encapsulate(capsule, 'chooseLinkLine', capsule =>
+            language.$(capsule, {
+              fromPart:
+                (relations.groupLinks
+                  ? language.$(capsule, 'fromPart.dividedByGroups')
+                  : language.$(capsule, 'fromPart.notDividedByGroups')),
 
-            browserSupportPart:
-              language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'),
-          })),
+              browserSupportPart:
+                language.$(capsule, 'browserSupportPart'),
+            }))),
 
         html.tag('p', {id: 'data-loading-line'},
-          language.$('listingPage.other.randomPages.dataLoadingLine')),
+          language.$(capsule, 'dataLoadingLine')),
 
         html.tag('p', {id: 'data-loaded-line'},
-          language.$('listingPage.other.randomPages.dataLoadedLine')),
+          language.$(capsule, 'dataLoadedLine')),
 
         html.tag('p', {id: 'data-error-line'},
-          language.$('listingPage.other.randomPages.dataErrorLine')),
+          language.$(capsule, 'dataErrorLine')),
       ],
 
       showSkipToSection: true,
@@ -148,17 +151,18 @@ export default {
 
         ...
           (relations.groupLinks
-            ? relations.groupLinks.map(() => ({
-                randomAlbum:
-                  html.tag('a',
-                    {href: '#', 'data-random': 'album-in-group-dl'},
-                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomAlbum')),
-
-                randomTrack:
-                  html.tag('a',
-                    {href: '#', 'data-random': 'track-in-group-dl'},
-                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomTrack')),
-              }))
+            ? relations.groupLinks.map(() =>
+                language.encapsulate(capsule, 'chunk.title.fromGroup.accent', capsule => ({
+                  randomAlbum:
+                    html.tag('a',
+                      {href: '#', 'data-random': 'album-in-group-dl'},
+                      language.$(capsule, 'randomAlbum')),
+
+                  randomTrack:
+                    html.tag('a',
+                      {href: '#', 'data-random': 'track-in-group-dl'},
+                      language.$(capsule, 'randomTrack')),
+                })))
             : [null]),
       ],
 
diff --git a/src/content/dependencies/listTagsByUses.js b/src/content/dependencies/listTagsByUses.js
deleted file mode 100644
index 00c700a5..00000000
--- a/src/content/dependencies/listTagsByUses.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import {sortAlphabetically, sortByCount} from '#sort';
-import {filterByCount, stitchArrays} from '#sugar';
-
-export default {
-  contentDependencies: ['generateListingPage', 'linkArtTag'],
-  extraDependencies: ['language', 'wikiData'],
-
-  sprawl({artTagData}) {
-    return {artTagData};
-  },
-
-  query({artTagData}, spec) {
-    const artTags =
-      sortAlphabetically(
-        artTagData
-          .filter(tag => !tag.isContentWarning));
-
-    const counts =
-      artTags
-        .map(tag => tag.taggedInThings.length);
-
-    filterByCount(artTags, counts);
-    sortByCount(artTags, counts, {greatestFirst: true});
-
-    return {spec, artTags, counts};
-  },
-
-  relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
-
-      artTagLinks:
-        query.artTags
-          .map(tag => relation('linkArtTag', tag)),
-    };
-  },
-
-  data(query) {
-    return {
-      counts:
-        query.artTags
-          .map(tag => tag.taggedInThings.length),
-    };
-  },
-
-  generate(data, relations, {language}) {
-    return relations.page.slots({
-      type: 'rows',
-      rows:
-        stitchArrays({
-          link: relations.artTagLinks,
-          count: data.counts,
-        }).map(({link, count}) => ({
-            tag: link,
-            timesUsed: language.countTimesUsed(count, {unit: true}),
-          })),
-    });
-  },
-};
diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js
index 01ce4e2d..dcfaeaf0 100644
--- a/src/content/dependencies/listTracksByDate.js
+++ b/src/content/dependencies/listTracksByDate.js
@@ -5,48 +5,54 @@ export default {
   contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
   extraDependencies: ['language', 'wikiData'],
 
-  sprawl({trackData}) {
-    return {trackData};
-  },
+  sprawl: ({trackData}) => ({trackData}),
 
   query({trackData}, spec) {
-    return {
-      spec,
+    const query = {spec};
+
+    query.tracks =
+      sortAlbumsTracksChronologically(
+        trackData.filter(track => track.date));
+
+    query.chunks =
+      chunkByProperties(query.tracks, ['album', 'date']);
 
-      chunks:
-        chunkByProperties(
-          sortAlbumsTracksChronologically(trackData.slice()),
-          ['album', 'date']),
-    };
+    return query;
   },
 
-  relations(relation, query) {
-    return {
-      page: relation('generateListingPage', query.spec),
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
 
-      albumLinks:
-        query.chunks
-          .map(({album}) => relation('linkAlbum', album)),
+    albumLinks:
+      query.chunks
+        .map(({album}) => relation('linkAlbum', album)),
 
-      trackLinks:
-        query.chunks
-          .map(({chunk}) => chunk
-            .map(track => relation('linkTrack', track))),
-    };
-  },
+    trackLinks:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(track => relation('linkTrack', track))),
+  }),
 
-  data(query) {
-    return {
-      dates:
-        query.chunks
-          .map(({date}) => date),
+  data: (query) => ({
+    dates:
+      query.chunks
+        .map(({date}) => date),
 
-      rereleases:
-        query.chunks.map(({chunk}) =>
-          chunk.map(track =>
-            track.originalReleaseTrack !== null)),
-    };
-  },
+    rereleases:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(track =>
+            // Check if the index of this track...
+            query.tracks.indexOf(track) >
+            // ...is greater than the *smallest* index
+            // of any of this track's *other* releases.
+            // (It won't be greater than its own index,
+            // so we can use otherReleases here, rather
+            // than allReleases.)
+            Math.min(...
+              track.otherReleases.map(t => query.tracks.indexOf(t))))),
+  }),
 
   generate(data, relations, {language}) {
     return relations.page.slots({
@@ -78,7 +84,7 @@ export default {
         data.rereleases.map(rereleases =>
           rereleases.map(rerelease =>
             (rerelease
-              ? {class: 'rerelease'}
+              ? {class: 'rerelease-line'}
               : null))),
     });
   },
diff --git a/src/content/dependencies/listTracksWithLyrics.js b/src/content/dependencies/listTracksWithLyrics.js
index a13a76f0..e6ab9d7d 100644
--- a/src/content/dependencies/listTracksWithLyrics.js
+++ b/src/content/dependencies/listTracksWithLyrics.js
@@ -2,7 +2,7 @@ export default {
   contentDependencies: ['listTracksWithExtra'],
 
   relations: (relation, spec) =>
-    ({page: relation('listTracksWithExtra', spec, 'lyrics', 'truthy')}),
+    ({page: relation('listTracksWithExtra', spec, 'lyrics', 'array')}),
 
   generate: (relations) =>
     relations.page,
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 0904cde6..1bbd45e2 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -2,10 +2,26 @@ import {bindFind} from '#find';
 import {replacerSpec, parseInput} from '#replacer';
 
 import {Marked} from 'marked';
+import striptags from 'striptags';
 
 const commonMarkedOptions = {
   headerIds: false,
   mangle: false,
+
+  tokenizer: {
+    url(src) {
+      // Don't link emails
+      const cap = this.rules.inline.url.exec(src);
+      if (cap?.[2] === '@') return;
+
+      // Use normal tokenizer url behavior otherwise
+      // Note that super.url doesn't work here because marked is binding or
+      // applying this function on the tokenizer instance - super.prop would
+      // just read the prototype of the containing object literal, not the
+      // rebound tokenizer. (Thanks MDN.)
+      return Object.getPrototypeOf(this).url.call(this, src);
+    },
+  },
 };
 
 const multilineMarked = new Marked({
@@ -45,7 +61,7 @@ export default {
   sprawl(wikiData, content) {
     const find = bindFind(wikiData);
 
-    const parsedNodes = parseInput(content);
+    const parsedNodes = parseInput(content ?? '');
 
     return {
       nodes: parsedNodes
@@ -169,6 +185,8 @@ export default {
               link: relation(name, arg),
               label: node.data.label,
               hash: node.data.hash,
+              name: arg?.name,
+              shortName: arg?.shortName ?? arg?.nameShort,
             }
           : getPlaceholder(node, content));
 
@@ -221,6 +239,16 @@ export default {
       default: true,
     },
 
+    absorbPunctuationFollowingExternalLinks: {
+      type: 'boolean',
+      default: true,
+    },
+
+    textOnly: {
+      type: 'boolean',
+      default: false,
+    },
+
     thumb: {
       validate: v => v.is('small', 'medium', 'large'),
       default: 'large',
@@ -232,11 +260,34 @@ export default {
     let internalLinkIndex = 0;
     let externalLinkIndex = 0;
 
+    let offsetTextNode = 0;
+
     const contentFromNodes =
-      data.nodes.map(node => {
+      data.nodes.map((node, index) => {
+        const nextNode = data.nodes[index + 1];
+
+        const absorbFollowingPunctuation = template => {
+          if (nextNode?.type !== 'text') {
+            return;
+          }
+
+          const text = nextNode.data;
+          const match = text.match(/^[.,;:?!…]+(?=[^\n]*[a-z])/i);
+          const suffix = match?.[0];
+          if (suffix) {
+            template.setSlot('suffixNormalContent', suffix);
+            offsetTextNode = suffix.length;
+          }
+        };
+
         switch (node.type) {
-          case 'text':
-            return {type: 'text', data: node.data};
+          case 'text': {
+            const text = node.data.slice(offsetTextNode);
+
+            offsetTextNode = 0;
+
+            return {type: 'text', data: text};
+          }
 
           case 'image': {
             const src =
@@ -262,6 +313,10 @@ export default {
                   height && {height},
                   style && {style},
 
+                  align === 'center' &&
+                  !link &&
+                    {class: 'align-center'},
+
                   pixelate &&
                     {class: 'pixelate'});
 
@@ -271,16 +326,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);
               }
@@ -325,13 +384,93 @@ export default {
             };
           }
 
+          case 'video': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {width, height, align, pixelate} = node;
+
+            const content =
+              html.tag('div', {class: 'content-video-container'},
+                align === 'center' &&
+                  {class: 'align-center'},
+
+                html.tag('video',
+                  src && {src},
+                  width && {width},
+                  height && {height},
+
+                  {controls: true},
+
+                  pixelate &&
+                    {class: 'pixelate'}));
+
+            return {
+              type: 'processed-video',
+              data: content,
+            };
+          }
+
+          case 'audio': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {align, inline} = node;
+
+            const audio =
+              html.tag('audio',
+                src && {src},
+
+                align === 'center' &&
+                inline &&
+                  {class: 'align-center'},
+
+                {controls: true});
+
+            const content =
+              (inline
+                ? audio
+                : html.tag('div', {class: 'content-audio-container'},
+                    align === 'center' &&
+                      {class: 'align-center'},
+
+                    audio));
+
+            return {
+              type: 'processed-audio',
+              data: content,
+            };
+          }
+
           case 'internal-link': {
             const nodeFromRelations = relations.internalLinks[internalLinkIndex++];
             if (nodeFromRelations.type === 'text') {
               return {type: 'text', data: nodeFromRelations.data};
             }
 
-            const {link, label, hash} = nodeFromRelations;
+            // TODO: This is a bit hacky, like the stuff below,
+            // but since we dressed it up in a utility function
+            // maybe it's okay...
+            const link =
+              html.resolve(
+                nodeFromRelations.link,
+                {slots: ['content', 'hash']});
+
+            const {label, hash, shortName, name} = nodeFromRelations;
+
+            if (slots.textOnly) {
+              if (label) {
+                return {type: 'text', data: label};
+              } else if (slots.preferShortLinkNames) {
+                return {type: 'text', data: shortName ?? name};
+              } else {
+                return {type: 'text', data: name};
+              }
+            }
 
             // These are removed from the typical combined slots({})-style
             // because we don't want to override slots that were already set
@@ -366,6 +505,18 @@ export default {
               link.setSlot('tooltipStyle', 'none');
             }
 
+            let doTheAbsorbyThing = false;
+
+            // TODO: This is just silly.
+            try {
+              const tag = html.resolve(link, {normalize: 'tag'});
+              doTheAbsorbyThing ||= tag.attributes.has('class', 'image-media-link');
+            } catch {}
+
+            if (doTheAbsorbyThing) {
+              absorbFollowingPunctuation(link);
+            }
+
             return {type: 'processed-internal-link', data: link};
           }
 
@@ -373,11 +524,19 @@ export default {
             const {label} = node.data;
             const externalLink = relations.externalLinks[externalLinkIndex++];
 
+            if (slots.textOnly) {
+              return {type: 'text', data: label};
+            }
+
             externalLink.setSlots({
               content: label,
               fromContent: true,
             });
 
+            if (slots.absorbPunctuationFollowingExternalLinks) {
+              absorbFollowingPunctuation(externalLink);
+            }
+
             if (slots.indicateExternalLinks) {
               externalLink.setSlots({
                 indicateExternal: true,
@@ -405,12 +564,19 @@ export default {
                 ? valueFn(replacerValue)
                 : replacerValue);
 
-            const contents =
+            const content =
               (htmlFn
                 ? htmlFn(value, {html, language})
                 : value);
 
-            return {type: 'text', data: contents.toString()};
+            const contentText =
+              html.resolve(content, {normalize: 'string'});
+
+            if (slots.textOnly) {
+              return {type: 'text', data: striptags(contentText)};
+            } else {
+              return {type: 'text', data: contentText};
+            }
           }
 
           default:
@@ -486,15 +652,19 @@ export default {
 
         const attributes = html.parseAttributes(match[1]);
 
-        // Images that were all on their own line need to be removed from
-        // the surrounding <p> tag that marked generates. The HTML parser
-        // treats a <div> that starts inside a <p> as a Crocker-class
-        // misgiving, and will treat you very badly if you feed it that.
-        if (attributes.get('data-type') === 'processed-image') {
-          if (!attributes.get('data-inline')) {
-            tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, '');
-            deleteParagraph = true;
-          }
+        // Images (or videos) that were all on their own line need to be
+        // removed from the surrounding <p> tag that marked generates.
+        // The HTML parser treats a <div> that starts inside a <p> as a
+        // Crocker-class misgiving, and will treat you very badly if you
+        // feed it that.
+        if (
+          (attributes.get('data-type') === 'processed-image' &&
+          !attributes.get('data-inline')) ||
+          attributes.get('data-type') === 'processed-video' ||
+          attributes.get('data-type') === 'processed-audio'
+        ) {
+          tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, '');
+          deleteParagraph = true;
         }
 
         const nonTextNodeIndex = match[2];
@@ -505,7 +675,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 +704,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');
 
@@ -564,25 +738,12 @@ export default {
 
       const markedInput =
         extractNonTextNodes({
-          getTextNodeContents(node, index) {
-            // First, replace line breaks that follow text content with
-            // <br> tags.
-            let content = node.data.replace(/(?!^)\n/gm, '<br>\n');
-
-            // Scrap line breaks that are at the end of a verse.
-            content = content.replace(/<br>$(?=\n\n)/gm, '');
-
-            // If the node started with a line break, and it's not the
-            // very first node, then whatever came before it was inline.
-            // (This is an assumption based on text links being basically
-            // the only tag that shows up in lyrics.) Since this text is
-            // following content that was already inline, restore that
-            // initial line break.
-            if (node.data[0] === '\n' && index !== 0) {
-              content = '<br>' + content;
-            }
-
-            return content;
+          getTextNodeContents(node) {
+            // Just insert <br> before every line break. The resulting
+            // text will appear all in one paragraph - this is expected
+            // for lyrics, and allows for multiple lines of proportional
+            // space between stanzas.
+            return node.data.replace(/\n/g, '<br>\n');
           },
         });
 
diff --git a/src/content/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js
deleted file mode 100644
index 67d6d5fa..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(({who}) => {
-    if (artistsSoFar.has(who)) {
-      return false;
-    } else {
-      artistsSoFar.add(who);
-      return true;
-    }
-  });
-
-  return contributions.map(({who}) => {
-    const things = Array.from(new Set(getThings(who)));
-
-    // 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(who),
-      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 1e7c7aa8..a089e325 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -1,79 +1,3 @@
-// Generally extendable class for caching properties and handling dependencies,
-// with a few key properties:
-//
-// 1) The behavior of every property is defined by its descriptor, which is a
-//    static value stored on the subclass (all instances share the same property
-//    descriptors).
-//
-//  1a) Additional properties may not be added past the time of object
-//      construction, and attempts to do so (including externally setting a
-//      property name which has no corresponding descriptor) will throw a
-//      TypeError. (This is done via an Object.seal(this) call after a newly
-//      created instance defines its own properties according to the descriptor
-//      on its constructor class.)
-//
-// 2) Properties may have two flags set: update and expose. Properties which
-//    update are provided values from the external. Properties which expose
-//    provide values to the external, generally dependent on other update
-//    properties (within the same object).
-//
-//  2a) Properties may be flagged as both updating and exposing. This is so
-//      that the same name may be used for both "output" and "input".
-//
-// 3) Exposed properties have values which are computations dependent on other
-//    properties, as described by a `compute` function on the descriptor.
-//    Depended-upon properties are explicitly listed on the descriptor next to
-//    this function, and are only provided as arguments to the function once
-//    listed.
-//
-//  3a) An exposed property may depend only upon updating properties, not other
-//      exposed properties (within the same object). This is to force the
-//      general complexity of a single object to be fairly simple: inputs
-//      directly determine outputs, with the only in-between step being the
-//      `compute` function, no multiple-layer dependencies. Note that this is
-//      only true within a given object - externally, values provided to one
-//      object's `update` may be (and regularly are) the exposed values of
-//      another object.
-//
-//  3b) If a property both updates and exposes, it is automatically regarded as
-//      a dependancy. (That is, its exposed value will depend on the value it is
-//      updated with.) Rather than a required `compute` function, these have an
-//      optional `transform` function, which takes the update value as its first
-//      argument and then the usual key-value dependencies as its second. If no
-//      `transform` function is provided, the expose value is the same as the
-//      update value.
-//
-// 4) Exposed properties are cached; that is, if no depended-upon properties are
-//    updated, the value of an exposed property is not recomputed.
-//
-//  4a) The cache for an exposed property is invalidated as soon as any of its
-//      dependencies are updated, but the cache itself is lazy: the exposed
-//      value will not be recomputed until it is again accessed. (Likewise, an
-//      exposed value won't be computed for the first time until it is first
-//      accessed.)
-//
-// 5) Updating a property may optionally apply validation checks before passing,
-//    declared by a `validate` function on the `update` block. This function
-//    should either throw an error (e.g. TypeError) or return false if the value
-//    is invalid.
-//
-// 6) Objects do not expect all updating properties to be provided at once.
-//    Incomplete objects are deliberately supported and enabled.
-//
-//  6a) The default value for every updating property is null; undefined is not
-//      accepted as a property value under any circumstances (it always errors).
-//      However, this default may be overridden by specifying a `default` value
-//      on a property's `update` block. (This value will be checked against
-//      the property's validate function.) Note that a property may always be
-//      updated to null, even if the default is non-null. (Null always bypasses
-//      the validate check.)
-//
-//  6b) It's required by the external consumer of an object to determine whether
-//      or not the object is ready for use (within the larger program). This is
-//      convenienced by the static CacheableObject.listAccessibleProperties()
-//      function, which provides a mapping of exposed property names to whether
-//      or not their dependencies are yet met.
-
 import {inspect as nodeInspect} from 'node:util';
 
 import {colors, ENABLE_COLOR} from '#cli';
@@ -83,226 +7,187 @@ function inspect(value) {
 }
 
 export default class CacheableObject {
-  #propertyUpdateValues = Object.create(null);
-  #propertyUpdateCacheInvalidators = Object.create(null);
-
-  // Note the constructor doesn't take an initial data source. Due to a quirk
-  // of JavaScript, private members can't be accessed before the superclass's
-  // constructor is finished processing - so if we call the overridden
-  // update() function from inside this constructor, it will error when
-  // writing to private members. Pretty bad!
-  //
-  // That means initial data must be provided by following up with update()
-  // after constructing the new instance of the Thing (sub)class.
-
-  constructor() {
-    this.#defineProperties();
-    this.#initializeUpdatingPropertyValues();
-
-    if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
-      return new Proxy(this, {
-        get: (obj, key) => {
-          if (!Object.hasOwn(obj, key)) {
-            if (key !== 'constructor') {
-              CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`);
-            }
-          }
-          return obj[key];
-        },
-      });
-    }
-  }
-
-  #initializeUpdatingPropertyValues() {
-    for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) {
-      const {flags, update} = descriptor;
-
-      if (!flags.update) {
-        continue;
-      }
-
-      if (update?.default) {
+  static propertyDescriptors = Symbol.for('CacheableObject.propertyDescriptors');
+  static constructorFinalized = Symbol.for('CacheableObject.constructorFinalized');
+  static propertyDependants = Symbol.for('CacheableObject.propertyDependants');
+
+  static cacheValid = Symbol.for('CacheableObject.cacheValid');
+  static updateValue = Symbol.for('CacheableObject.updateValues');
+
+  constructor({seal = true} = {}) {
+    this[CacheableObject.updateValue] = Object.create(null);
+    this[CacheableObject.cachedValue] = Object.create(null);
+    this[CacheableObject.cacheValid] = Object.create(null);
+
+    const propertyDescriptors = this.constructor[CacheableObject.propertyDescriptors];
+    for (const property of Reflect.ownKeys(propertyDescriptors)) {
+      const {flags, update} = propertyDescriptors[property];
+      if (!flags.update) continue;
+
+      if (
+        typeof update === 'object' &&
+        update !== null &&
+        'default' in update
+      ) {
         this[property] = update?.default;
       } else {
         this[property] = null;
       }
     }
+
+    if (seal) {
+      Object.seal(this);
+    }
   }
 
-  #defineProperties() {
-    if (!this.constructor.propertyDescriptors) {
-      throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`);
+  static finalizeCacheableObjectPrototype() {
+    if (Object.hasOwn(this, CacheableObject.constructorFinalized)) {
+      throw new Error(`Constructor ${this.name} already finalized`);
+    }
+
+    if (!this[CacheableObject.propertyDescriptors]) {
+      throw new Error(`Expected constructor ${this.name} to provide CacheableObject.propertyDescriptors`);
     }
 
-    for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) {
-      const {flags} = descriptor;
+    this[CacheableObject.propertyDependants] = Object.create(null);
+
+    const propertyDescriptors = this[CacheableObject.propertyDescriptors];
+    for (const property of Reflect.ownKeys(propertyDescriptors)) {
+      const {flags, update, expose} = propertyDescriptors[property];
 
       const definition = {
         configurable: false,
         enumerable: flags.expose,
       };
 
-      if (flags.update) {
-        definition.set = this.#getUpdateObjectDefinitionSetterFunction(property);
-      }
-
-      if (flags.expose) {
-        definition.get = this.#getExposeObjectDefinitionGetterFunction(property);
-      }
+      if (flags.update) setSetter: {
+        definition.set = function(newValue) {
+          if (newValue === undefined) {
+            throw new TypeError(`Properties cannot be set to undefined`);
+          }
 
-      Object.defineProperty(this, property, definition);
-    }
+          const oldValue = this[CacheableObject.updateValue][property];
 
-    Object.seal(this);
-  }
+          if (newValue === oldValue) {
+            return;
+          }
 
-  #getUpdateObjectDefinitionSetterFunction(property) {
-    const {update} = this.#getPropertyDescriptor(property);
-    const validate = update?.validate;
+          if (newValue !== null && update?.validate) {
+            try {
+              const result = update.validate(newValue);
+              if (result === undefined) {
+                throw new TypeError(`Validate function returned undefined`);
+              } else if (result !== true) {
+                throw new TypeError(`Validation failed for value ${newValue}`);
+              }
+            } catch (caughtError) {
+              throw new CacheableObjectPropertyValueError(
+                property, oldValue, newValue, {cause: caughtError});
+            }
+          }
 
-    return (newValue) => {
-      const oldValue = this.#propertyUpdateValues[property];
+          this[CacheableObject.updateValue][property] = newValue;
 
-      if (newValue === undefined) {
-        throw new TypeError(`Properties cannot be set to undefined`);
+          const dependants = this.constructor[CacheableObject.propertyDependants][property];
+          if (dependants) {
+            for (const dependant of dependants) {
+              this[CacheableObject.cacheValid][dependant] = false;
+            }
+          }
+        };
       }
 
-      if (newValue === oldValue) {
-        return;
-      }
+      if (flags.expose) setGetter: {
+        if (flags.update && !expose?.transform) {
+          definition.get = function() {
+            return this[CacheableObject.updateValue][property];
+          };
 
-      if (newValue !== null && validate) {
-        try {
-          const result = validate(newValue);
-          if (result === undefined) {
-            throw new TypeError(`Validate function returned undefined`);
-          } else if (result !== true) {
-            throw new TypeError(`Validation failed for value ${newValue}`);
-          }
-        } catch (caughtError) {
-          throw new CacheableObjectPropertyValueError(
-            property, oldValue, newValue, {cause: caughtError});
+          break setGetter;
         }
-      }
-
-      this.#propertyUpdateValues[property] = newValue;
-      this.#invalidateCachesDependentUpon(property);
-    };
-  }
 
-  #getPropertyDescriptor(property) {
-    return this.constructor.propertyDescriptors[property];
-  }
+        if (flags.update && expose?.compute) {
+          throw new Error(`Updating property ${property} has compute function, should be formatted as transform`);
+        }
 
-  #invalidateCachesDependentUpon(property) {
-    const invalidators = this.#propertyUpdateCacheInvalidators[property];
-    if (!invalidators) {
-      return;
-    }
+        if (!flags.update && !expose?.compute) {
+          throw new Error(`Exposed property ${property} does not update and is missing compute function`);
+        }
 
-    for (const invalidate of invalidators) {
-      invalidate();
-    }
-  }
+        definition.get = function() {
+          if (this[CacheableObject.cacheValid][property]) {
+            return this[CacheableObject.cachedValue][property];
+          }
 
-  #getExposeObjectDefinitionGetterFunction(property) {
-    const {flags} = this.#getPropertyDescriptor(property);
-    const compute = this.#getExposeComputeFunction(property);
-
-    if (compute) {
-      let cachedValue;
-      const checkCacheValid = this.#getExposeCheckCacheValidFunction(property);
-      return () => {
-        if (checkCacheValid()) {
-          return cachedValue;
-        } else {
-          return (cachedValue = compute());
-        }
-      };
-    } else if (!flags.update && !compute) {
-      throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-    } else {
-      return () => this.#propertyUpdateValues[property];
-    }
-  }
+          const dependencies = Object.create(null);
+          for (const key of expose.dependencies ?? []) {
+            switch (key) {
+              case 'this':
+                dependencies.this = this;
+                break;
 
-  #getExposeComputeFunction(property) {
-    const {flags, expose} = this.#getPropertyDescriptor(property);
+              case 'thisProperty':
+                dependencies.thisProperty = property;
+                break;
 
-    const compute = expose?.compute;
-    const transform = expose?.transform;
+              default:
+                dependencies[key] = this[CacheableObject.updateValue][key];
+                break;
+            }
+          }
 
-    if (flags.update && !transform) {
-      return null;
-    } else if (flags.update && compute) {
-      throw new Error(`Updating property ${property} has compute function, should be formatted as transform`);
-    } else if (!flags.update && !compute) {
-      throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-    }
+          const value =
+            (flags.update
+              ? expose.transform(this[CacheableObject.updateValue][property], dependencies)
+              : expose.compute(dependencies));
 
-    let getAllDependencies;
+          this[CacheableObject.cachedValue][property] = value;
+          this[CacheableObject.cacheValid][property] = true;
 
-    if (expose.dependencies?.length > 0) {
-      const dependencyKeys = expose.dependencies.slice();
-      const shouldReflect = dependencyKeys.includes('this');
+          return value;
+        };
+      }
 
-      getAllDependencies = () => {
-        const dependencies = Object.create(null);
+      if (flags.expose) recordAsDependant: {
+        const dependantsMap = this[CacheableObject.propertyDependants];
 
-        for (const key of dependencyKeys) {
-          dependencies[key] = this.#propertyUpdateValues[key];
+        if (flags.update && expose?.transform) {
+          if (dependantsMap[property]) {
+            dependantsMap[property].push(property);
+          } else {
+            dependantsMap[property] = [property];
+          }
         }
 
-        if (shouldReflect) {
-          dependencies.this = this;
+        for (const dependency of expose?.dependencies ?? []) {
+          switch (dependency) {
+            case 'this':
+            case 'thisProperty':
+              continue;
+
+            default: {
+              if (dependantsMap[dependency]) {
+                dependantsMap[dependency].push(property);
+              } else {
+                dependantsMap[dependency] = [property];
+              }
+            }
+          }
         }
+      }
 
-        return dependencies;
-      };
-    } else {
-      const dependencies = Object.create(null);
-      Object.freeze(dependencies);
-      getAllDependencies = () => dependencies;
+      Object.defineProperty(this.prototype, property, definition);
     }
 
-    if (flags.update) {
-      return () => transform(this.#propertyUpdateValues[property], getAllDependencies());
-    } else {
-      return () => compute(getAllDependencies());
-    }
+    this[CacheableObject.constructorFinalized] = true;
   }
 
-  #getExposeCheckCacheValidFunction(property) {
-    const {flags, expose} = this.#getPropertyDescriptor(property);
-
-    let valid = false;
-
-    const invalidate = () => {
-      valid = false;
-    };
-
-    const dependencyKeys = new Set(expose?.dependencies);
-
-    if (flags.update) {
-      dependencyKeys.add(property);
-    }
-
-    for (const key of dependencyKeys) {
-      if (this.#propertyUpdateCacheInvalidators[key]) {
-        this.#propertyUpdateCacheInvalidators[key].push(invalidate);
-      } else {
-        this.#propertyUpdateCacheInvalidators[key] = [invalidate];
-      }
-    }
+  static getPropertyDescriptor(property) {
+    return this[CacheableObject.propertyDescriptors][property];
+  }
 
-    return () => {
-      if (!valid) {
-        valid = true;
-        return false;
-      } else {
-        return true;
-      }
-    };
+  static hasPropertyDescriptor(property) {
+    return Object.hasOwn(this[CacheableObject.propertyDescriptors], property);
   }
 
   static cacheAllExposedProperties(obj) {
@@ -311,16 +196,16 @@ export default class CacheableObject {
       return;
     }
 
-    const {propertyDescriptors} = obj.constructor;
+    const {[CacheableObject.propertyDescriptors]: propertyDescriptors} =
+      obj.constructor;
 
     if (!propertyDescriptors) {
       console.warn('Missing property descriptors:', obj);
       return;
     }
 
-    for (const [property, descriptor] of Object.entries(propertyDescriptors)) {
-      const {flags} = descriptor;
-
+    for (const property of Reflect.ownKeys(propertyDescriptors)) {
+      const {flags} = propertyDescriptors[property];
       if (!flags.expose) {
         continue;
       }
@@ -329,30 +214,24 @@ export default class CacheableObject {
     }
   }
 
-  static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false;
-  static _invalidAccesses = new Set();
-
-  static showInvalidAccesses() {
-    if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
-      return;
-    }
-
-    if (!this._invalidAccesses.size) {
-      return;
+  static getUpdateValue(object, key) {
+    if (!object.constructor.hasPropertyDescriptor(key)) {
+      return undefined;
     }
 
-    console.log(`${this._invalidAccesses.size} unique invalid accesses:`);
-    for (const line of this._invalidAccesses) {
-      console.log(` - ${line}`);
-    }
+    return object[CacheableObject.updateValue][key] ?? null;
   }
 
-  static getUpdateValue(object, key) {
-    if (!Object.hasOwn(object, key)) {
-      return undefined;
-    }
+  static clone(object) {
+    const newObject = Reflect.construct(object.constructor, []);
+
+    this.copyUpdateValuesOnto(object, newObject);
+
+    return newObject;
+  }
 
-    return object.#propertyUpdateValues[key] ?? null;
+  static copyUpdateValuesOnto(source, target) {
+    Object.assign(target, source[CacheableObject.updateValue]);
   }
 }
 
@@ -360,8 +239,22 @@ export class CacheableObjectPropertyValueError extends Error {
   [Symbol.for('hsmusic.aggregate.translucent')] = true;
 
   constructor(property, oldValue, newValue, options) {
+    let inspectOldValue, inspectNewValue;
+
+    try {
+      inspectOldValue = inspect(oldValue);
+    } catch (error) {
+      inspectOldValue = colors.red(`(couldn't inspect)`);
+    }
+
+    try {
+      inspectNewValue = inspect(newValue);
+    } catch (error) {
+      inspectNewValue = colors.red(`(couldn't inspect)`);
+    }
+
     super(
-      `Error setting ${colors.green(property)} (${inspect(oldValue)} -> ${inspect(newValue)})`,
+      `Error setting ${colors.green(property)} (${inspectOldValue} -> ${inspectNewValue})`,
       options);
 
     this.property = property;
diff --git a/src/data/checks.js b/src/data/checks.js
index 44f3efd7..25863d2d 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -9,7 +9,6 @@ import {compareArrays, cut, cutStart, empty, getNestedProp, iterateMultiline}
   from '#sugar';
 import Thing from '#thing';
 import thingConstructors from '#things';
-import {commentaryRegexCaseSensitive} from '#wiki-data';
 
 import {
   annotateErrorWithIndex,
@@ -20,27 +19,43 @@ import {
   withAggregate,
 } from '#aggregate';
 
+import {
+  combineWikiDataArrays,
+  commentaryRegexCaseSensitive,
+  oldStyleLyricsDetectionRegex,
+} from '#wiki-data';
+
 function inspect(value, opts = {}) {
   return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
 
-// Warn about directories which are reused across more than one of the same type
-// of Thing. Directories are the unique identifier for most data objects across
-// the wiki, so we have to make sure they aren't duplicated!
-export function reportDuplicateDirectories(wikiData, {
+// Warn about problems to do with directories.
+//
+// * Duplicate directories: these are the unique identifier for referencable
+//   data objects across the wiki, so duplicates introduce ambiguity where it
+//   can't fit.
+//
+// * Missing directories: in almost all cases directories can be computed,
+//   but in particularly brutal internal cases, it might not be possible, and
+//   a thing's directory is just null. This leaves it unable to be referenced.
+//
+export function reportDirectoryErrors(wikiData, {
   getAllFindSpecs,
 }) {
   const duplicateSets = [];
+  const missingDirectoryThings = new Set();
 
   for (const findSpec of Object.values(getAllFindSpecs())) {
     if (!findSpec.bindTo) continue;
 
     const directoryPlaces = Object.create(null);
     const duplicateDirectories = new Set();
+
     const thingData = wikiData[findSpec.bindTo];
+    if (!thingData) continue;
 
     for (const thing of thingData) {
-      if (findSpec.include && !findSpec.include(thing)) {
+      if (findSpec.include && !findSpec.include(thing, thingConstructors)) {
         continue;
       }
 
@@ -50,6 +65,11 @@ export function reportDuplicateDirectories(wikiData, {
           : [thing.directory]);
 
       for (const directory of directories) {
+        if (directory === null || directory === undefined) {
+          missingDirectoryThings.add(thing);
+          continue;
+        }
+
         if (directory in directoryPlaces) {
           directoryPlaces[directory].push(thing);
           duplicateDirectories.add(directory);
@@ -59,8 +79,6 @@ export function reportDuplicateDirectories(wikiData, {
       }
     }
 
-    if (empty(duplicateDirectories)) continue;
-
     const sortedDuplicateDirectories =
       Array.from(duplicateDirectories)
         .sort((a, b) => {
@@ -75,8 +93,6 @@ export function reportDuplicateDirectories(wikiData, {
     }
   }
 
-  if (empty(duplicateSets)) return;
-
   // Multiple find functions may effectively have duplicates across the same
   // things. These only need to be reported once, because resolving one of them
   // will resolve the rest, so cut out duplicate sets before reporting.
@@ -84,6 +100,7 @@ export function reportDuplicateDirectories(wikiData, {
   const seenDuplicateSets = new Map();
   const deduplicateDuplicateSets = [];
 
+  iterateSets:
   for (const set of duplicateSets) {
     if (seenDuplicateSets.has(set.directory)) {
       const placeLists = seenDuplicateSets.get(set.directory);
@@ -95,7 +112,7 @@ export function reportDuplicateDirectories(wikiData, {
         // Two artists named Foodog aren't going to match two tracks named
         // Foodog.
         if (compareArrays(places, set.places, {checkOrder: false})) {
-          continue;
+          continue iterateSets;
         }
       }
 
@@ -107,12 +124,20 @@ export function reportDuplicateDirectories(wikiData, {
     deduplicateDuplicateSets.push(set);
   }
 
-  withAggregate({message: `Duplicate directories found`}, ({push}) => {
+  withAggregate({message: `Directory errors detected`}, ({push}) => {
     for (const {directory, places} of deduplicateDuplicateSets) {
       push(new Error(
         `Duplicate directory ${colors.green(`"${directory}"`)}:\n` +
         places.map(thing => ` - ` + inspect(thing)).join('\n')));
     }
+
+    if (!empty(missingDirectoryThings)) {
+      push(new Error(
+        `Couldn't figure out an implicit directory for:\n` +
+        Array.from(missingDirectoryThings)
+          .map(thing => `- ` + inspect(thing))
+          .join('\n')));
+    }
   });
 }
 
@@ -152,6 +177,7 @@ function getFieldPropertyMessage(yamlDocumentSpec, property) {
 // any errors). At the same time, we remove errored references from the thing's
 // data array.
 export function filterReferenceErrors(wikiData, {
+  find,
   bindFind,
 }) {
   const referenceSpec = [
@@ -163,9 +189,14 @@ export function filterReferenceErrors(wikiData, {
       bannerArtistContribs: '_contrib',
       groups: 'group',
       artTags: '_artTag',
+      referencedArtworks: '_artwork',
       commentary: '_commentary',
     }],
 
+    ['artTagData', {
+      directDescendantArtTags: 'artTag',
+    }],
+
     ['flashData', {
       commentary: '_commentary',
     }],
@@ -174,7 +205,13 @@ export function filterReferenceErrors(wikiData, {
       groups: 'group',
     }],
 
-    ['homepageLayout.rows', {
+    ['homepageLayout.sections.rows', {
+      _include: row => row.type === 'album carousel',
+      albums: 'album',
+    }],
+
+    ['homepageLayout.sections.rows', {
+      _include: row => row.type === 'album grid',
       sourceGroup: '_homepageSourceGroup',
       sourceAlbums: 'album',
     }],
@@ -188,14 +225,19 @@ export function filterReferenceErrors(wikiData, {
       flashes: 'flash',
     }],
 
+    ['groupData', {
+      serieses: '_serieses',
+    }],
+
     ['trackData', {
       artistContribs: '_contrib',
       contributorContribs: '_contrib',
       coverArtistContribs: '_contrib',
-      referencedTracks: '_trackNotRerelease',
-      sampledTracks: '_trackNotRerelease',
+      referencedTracks: '_trackMainReleasesOnly',
+      sampledTracks: '_trackMainReleasesOnly',
       artTags: '_artTag',
-      originalReleaseTrack: '_trackNotRerelease',
+      referencedArtworks: '_artwork',
+      mainReleaseTrack: '_trackMainReleasesOnly',
       commentary: '_commentary',
     }],
 
@@ -210,11 +252,23 @@ export function filterReferenceErrors(wikiData, {
   const aggregate = openAggregate({message: `Errors validating between-thing references in data`});
   for (const [thingDataProp, propSpec] of referenceSpec) {
     const thingData = getNestedProp(wikiData, thingDataProp);
-    const things = Array.isArray(thingData) ? thingData : [thingData];
+    const things =
+      (Array.isArray(thingData)
+        ? thingData.flat(Infinity)
+        : [thingData]);
+
     aggregate.nest({message: `Reference errors in ${colors.green('wikiData.' + thingDataProp)}`}, ({nest}) => {
       for (const thing of things) {
+        if (propSpec._include && !propSpec._include(thing)) {
+          continue;
+        }
+
         nest({message: `Reference errors in ${inspect(thing)}`}, ({nest, push, filter}) => {
           for (const [property, findFnKey] of Object.entries(propSpec)) {
+            if (property === '_include') {
+              continue;
+            }
+
             let value = CacheableObject.getUpdateValue(thing, property);
             let writeProperty = true;
 
@@ -238,6 +292,15 @@ export function filterReferenceErrors(wikiData, {
                 // need writing, humm...)
                 writeProperty = false;
                 break;
+
+              case '_serieses':
+                if (value) {
+                  // Doesn't report on which series has the error, but...
+                  value = value.flatMap(series => series.albums);
+                }
+
+                writeProperty = false;
+                break;
             }
 
             if (value === undefined) {
@@ -252,6 +315,21 @@ export function filterReferenceErrors(wikiData, {
             let findFn;
 
             switch (findFnKey) {
+              case '_artwork': {
+                const mixed =
+                  find.mixed({
+                    album: find.albumPrimaryArtwork,
+                    track: find.trackPrimaryArtwork,
+                  });
+
+                const data =
+                  wikiData.artworkData;
+
+                findFn = ref => mixed(ref.reference, data, {mode: 'error'});
+
+                break;
+              }
+
               case '_artTag':
                 findFn = boundFind.artTag;
                 break;
@@ -261,7 +339,7 @@ export function filterReferenceErrors(wikiData, {
                 break;
 
               case '_contrib':
-                findFn = contribRef => findArtistOrAlias(contribRef.who);
+                findFn = contribRef => findArtistOrAlias(contribRef.artist);
                 break;
 
               case '_homepageSourceGroup':
@@ -274,29 +352,36 @@ export function filterReferenceErrors(wikiData, {
                 };
                 break;
 
-              case '_trackNotRerelease':
+              case '_serieses':
+                findFn = boundFind.album;
+                break;
+
+              case '_trackArtwork':
+                findFn = ref => boundFind.track(ref.reference);
+                break;
+
+              case '_trackMainReleasesOnly':
                 findFn = trackRef => {
                   const track = boundFind.track(trackRef);
-                  const originalRef = track && CacheableObject.getUpdateValue(track, 'originalReleaseTrack');
+                  const mainRef = track && CacheableObject.getUpdateValue(track, 'mainReleaseTrack');
 
-                  if (originalRef) {
-                    // It's possible for the original to not actually exist, in this case.
-                    // It should still be reported since the 'Originally Released As' field
-                    // was present.
-                    const original = boundFind.track(originalRef, {mode: 'quiet'});
+                  if (mainRef) {
+                    // It's possible for the main release to not actually exist, in this case.
+                    // It should still be reported since the 'Main Release' field was present.
+                    const main = boundFind.track(mainRef, {mode: 'quiet'});
 
                     // Prefer references by name, but only if it's unambiguous.
-                    const originalByName =
-                      (original
-                        ? boundFind.track(original.name, {mode: 'quiet'})
+                    const mainByName =
+                      (main
+                        ? boundFind.track(main.name, {mode: 'quiet'})
                         : null);
 
                     const shouldBeMessage =
-                      (originalByName
-                        ? colors.green(original.name)
-                     : original
-                        ? colors.green('track:' + original.directory)
-                        : colors.green(originalRef));
+                      (mainByName
+                        ? colors.green(main.name)
+                     : main
+                        ? colors.green('track:' + main.directory)
+                        : colors.green(mainRef));
 
                     throw new Error(`Reference ${colors.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`);
                   }
@@ -311,6 +396,10 @@ export function filterReferenceErrors(wikiData, {
             }
 
             const suppress = fn => conditionallySuppressError(error => {
+              // We're not suppressing any errors at the moment.
+              // An old suppression is kept below for reference.
+
+              /*
               if (property === 'sampledTracks') {
                 // Suppress "didn't match anything" errors in particular, just for samples.
                 // In hsmusic-data we have a lot of "stub" sample data which don't have
@@ -323,6 +412,7 @@ export function filterReferenceErrors(wikiData, {
                   return true;
                 }
               }
+              */
 
               return false;
             }, fn);
@@ -483,12 +573,22 @@ export function reportContentTextErrors(wikiData, {
     annotation: 'commentary annotation',
   };
 
+  const newStyleLyricsShape = {
+    body: 'lyrics body',
+    artistDisplayText: 'lyrics artist display text',
+    annotation: 'lyrics annotation',
+  };
+
   const contentTextSpec = [
     ['albumData', {
       additionalFiles: additionalFileShape,
       commentary: commentaryShape,
     }],
 
+    ['artTagData', {
+      description: '_content',
+    }],
+
     ['artistData', {
       contextNotes: '_content',
     }],
@@ -524,7 +624,8 @@ export function reportContentTextErrors(wikiData, {
     ['trackData', {
       additionalFiles: additionalFileShape,
       commentary: commentaryShape,
-      lyrics: '_content',
+      creditSources: commentaryShape,
+      lyrics: '_lyrics',
       midiProjectFiles: additionalFileShape,
       sheetMusicFiles: additionalFileShape,
     }],
@@ -647,8 +748,9 @@ export function reportContentTextErrors(wikiData, {
         for (const thing of things) {
           nest({message: `Content text errors in ${inspect(thing)}`}, ({nest, push}) => {
 
-            for (const [property, shape] of Object.entries(propSpec)) {
-              const value = thing[property];
+            for (let [property, shape] of Object.entries(propSpec)) {
+              const rawValue = CacheableObject.getUpdateValue(thing, property);
+              let value = thing[property];
 
               if (value === undefined) {
                 push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`));
@@ -659,6 +761,15 @@ export function reportContentTextErrors(wikiData, {
                 continue;
               }
 
+              if (shape === '_lyrics') {
+                if (oldStyleLyricsDetectionRegex.test(rawValue)) {
+                  value = rawValue;
+                  shape = '_content';
+                } else {
+                  shape = newStyleLyricsShape;
+                }
+              }
+
               const fieldPropertyMessage =
                 getFieldPropertyMessage(
                   thing.constructor[Thing.yamlDocumentSpec],
@@ -702,3 +813,49 @@ export function reportContentTextErrors(wikiData, {
     }
   });
 }
+
+export function reportOrphanedArtworks(wikiData) {
+  const aggregate =
+    openAggregate({message: `Artwork objects are orphaned`});
+
+  const assess = ({
+    message,
+    filterThing,
+    filterContribs,
+    link,
+  }) => {
+    aggregate.nest({message: `Orphaned ${message}`}, ({push}) => {
+      const ostensibleArtworks =
+        wikiData.artworkData
+          .filter(artwork =>
+            artwork.thing instanceof filterThing &&
+            artwork.artistContribsFromThingProperty === filterContribs);
+
+      const orphanedArtworks =
+        ostensibleArtworks
+          .filter(artwork => !artwork.thing[link].includes(artwork));
+
+      for (const artwork of orphanedArtworks) {
+        push(new Error(`Orphaned: ${inspect(artwork)}`));
+      }
+    });
+  };
+
+  const {Album, Track} = thingConstructors;
+
+  assess({
+    message: `album cover artworks`,
+    filterThing: Album,
+    filterContribs: 'coverArtistContribs',
+    link: 'coverArtworks',
+  });
+
+  assess({
+    message: `track artworks`,
+    filterThing: Track,
+    filterContribs: 'coverArtistContribs',
+    link: 'trackArtworks',
+  });
+
+  aggregate.close();
+}
diff --git a/src/data/composite.js b/src/data/composite.js
index 7a98c424..f31c4069 100644
--- a/src/data/composite.js
+++ b/src/data/composite.js
@@ -29,6 +29,7 @@ input.value = _valueIntoToken('input.value');
 input.dependency = _valueIntoToken('input.dependency');
 
 input.myself = () => Symbol.for(`hsmusic.composite.input.myself`);
+input.thisProperty = () => Symbol.for('hsmusic.composite.input.thisProperty');
 
 input.updateValue = _valueIntoToken('input.updateValue');
 
@@ -71,30 +72,22 @@ function getInputTokenValue(token) {
   }
 }
 
-function getStaticInputMetadata(inputOptions) {
+function getStaticInputMetadata(inputMapping) {
   const metadata = {};
 
-  for (const [name, token] of Object.entries(inputOptions)) {
-    if (typeof token === 'string') {
-      metadata[input.staticDependency(name)] = token;
-      metadata[input.staticValue(name)] = null;
-    } else if (isInputToken(token)) {
-      const tokenShape = getInputTokenShape(token);
-      const tokenValue = getInputTokenValue(token);
-
-      metadata[input.staticDependency(name)] =
-        (tokenShape === 'input.dependency'
-          ? tokenValue
-          : null);
-
-      metadata[input.staticValue(name)] =
-        (tokenShape === 'input.value'
-          ? tokenValue
-          : null);
-    } else {
-      metadata[input.staticDependency(name)] = null;
-      metadata[input.staticValue(name)] = null;
-    }
+  for (const [name, token] of Object.entries(inputMapping)) {
+    const tokenShape = getInputTokenShape(token);
+    const tokenValue = getInputTokenValue(token);
+
+    metadata[input.staticDependency(name)] =
+      (tokenShape === 'input.dependency'
+        ? tokenValue
+        : null);
+
+    metadata[input.staticValue(name)] =
+      (tokenShape === 'input.value'
+        ? tokenValue
+        : null);
   }
 
   return metadata;
@@ -284,6 +277,7 @@ export function templateCompositeFrom(description) {
               'input.value',
               'input.dependency',
               'input.myself',
+              'input.thisProperty',
               'input.updateValue',
             ].includes(tokenShape)) {
               expectedValueProvidingTokenInputNames.push(name);
@@ -340,7 +334,29 @@ export function templateCompositeFrom(description) {
       }
     });
 
-    const inputMetadata = getStaticInputMetadata(inputOptions);
+    const inputMapping = {};
+    if ('inputs' in description) {
+      for (const [name, token] of Object.entries(description.inputs)) {
+        const tokenValue = getInputTokenValue(token);
+        if (name in inputOptions) {
+          if (typeof inputOptions[name] === 'string') {
+            inputMapping[name] = input.dependency(inputOptions[name]);
+          } else {
+            // This is always an input token, since only a string or
+            // an input token is a valid input option (asserted above).
+            inputMapping[name] = inputOptions[name];
+          }
+        } else if (tokenValue.defaultValue) {
+          inputMapping[name] = input.value(tokenValue.defaultValue);
+        } else if (tokenValue.defaultDependency) {
+          inputMapping[name] = input.dependency(tokenValue.defaultDependency);
+        } else {
+          inputMapping[name] = input.value(null);
+        }
+      }
+    }
+
+    const inputMetadata = getStaticInputMetadata(inputMapping);
 
     const expectedOutputNames =
       (Array.isArray(description.outputs)
@@ -412,25 +428,6 @@ export function templateCompositeFrom(description) {
         }
 
         if ('inputs' in description) {
-          const inputMapping = {};
-
-          for (const [name, token] of Object.entries(description.inputs)) {
-            const tokenValue = getInputTokenValue(token);
-            if (name in inputOptions) {
-              if (typeof inputOptions[name] === 'string') {
-                inputMapping[name] = input.dependency(inputOptions[name]);
-              } else {
-                inputMapping[name] = inputOptions[name];
-              }
-            } else if (tokenValue.defaultValue) {
-              inputMapping[name] = input.value(tokenValue.defaultValue);
-            } else if (tokenValue.defaultDependency) {
-              inputMapping[name] = input.dependency(tokenValue.defaultDependency);
-            } else {
-              inputMapping[name] = input.value(null);
-            }
-          }
-
           finalDescription.inputMapping = inputMapping;
           finalDescription.inputDescriptions = description.inputs;
         }
@@ -529,7 +526,10 @@ export function compositeFrom(description) {
         ? compositeFrom(step.toResolvedComposition())
         : step));
 
-  const inputMetadata = getStaticInputMetadata(description.inputMapping ?? {});
+  const inputMetadata =
+    (description.inputMapping
+      ? getStaticInputMetadata(description.inputMapping)
+      : {});
 
   function _mapDependenciesToOutputs(providedDependencies) {
     if (!description.outputs) {
@@ -567,6 +567,8 @@ export function compositeFrom(description) {
             return token;
           case 'input.myself':
             return 'this';
+          case 'input.thisProperty':
+            return 'thisProperty';
           default:
             return null;
         }
@@ -721,6 +723,8 @@ export function compositeFrom(description) {
               return (tokenValue.startsWith('#') ? null : tokenValue);
             case 'input.myself':
               return 'this';
+            case 'input.thisProperty':
+              return 'thisProperty';
             default:
               return null;
           }
@@ -752,6 +756,9 @@ export function compositeFrom(description) {
     anyStepsUseUpdateValue ||
     anyStepsUpdate;
 
+  const stepsFirstTimeCalling =
+    Array.from({length: steps.length}).fill(true);
+
   const stepEntries = stitchArrays({
     step: steps,
     stepComposes: stepsCompose,
@@ -774,16 +781,9 @@ export function compositeFrom(description) {
       (step.annotation ? ` (${step.annotation})` : ``);
 
     aggregate.nest({message}, ({push}) => {
-      if (isBase && stepComposes !== compositionNests) {
-        return push(new TypeError(
-          (compositionNests
-            ? `Base must compose, this composition is nestable`
-            : `Base must not compose, this composition isn't nestable`)));
-      } else if (!isBase && !stepComposes) {
+      if (!isBase && !stepComposes) {
         return push(new TypeError(
-          (compositionNests
-            ? `All steps must compose`
-            : `All steps (except base) must compose`)));
+          `All steps leading up to base must compose`));
       }
 
       if (
@@ -877,6 +877,8 @@ export function compositeFrom(description) {
               return valueSoFar;
             case 'input.myself':
               return initialDependencies['this'];
+            case 'input.thisProperty':
+              return initialDependencies['thisProperty'];
             case 'input':
               return initialDependencies[token];
             default:
@@ -907,8 +909,16 @@ export function compositeFrom(description) {
       debug(() => colors.bright(`begin composition - not transforming`));
     }
 
-    for (let i = 0; i < steps.length; i++) {
-      const step = steps[i];
+    for (
+      const [i, {
+        step,
+        stepComposes,
+      }] of
+        stitchArrays({
+          step: steps,
+          stepComposes: stepsCompose,
+        }).entries()
+    ) {
       const isBase = i === steps.length - 1;
 
       debug(() => [
@@ -968,7 +978,16 @@ export function compositeFrom(description) {
           (expectingTransform
             ? {[input.updateValue()]: valueSoFar}
             : {}),
-        [input.myself()]: initialDependencies?.['this'] ?? null,
+
+        [input.myself()]:
+          (initialDependencies && Object.hasOwn(initialDependencies, 'this')
+            ? initialDependencies.this
+            : null),
+
+        [input.thisProperty()]:
+          (initialDependencies && Object.hasOwn(initialDependencies, 'thisProperty')
+            ? initialDependencies.thisProperty
+            : null),
       };
 
       const selectDependencies =
@@ -983,6 +1002,8 @@ export function compositeFrom(description) {
               return dependency;
             case 'input.myself':
               return input.myself();
+            case 'input.thisProperty':
+              return input.thisProperty();
             case 'input.dependency':
               return tokenValue;
             case 'input.updateValue':
@@ -1016,26 +1037,175 @@ export function compositeFrom(description) {
       const naturalEvaluate = () => {
         const [name, ...argsLayout] = getExpectedEvaluation();
 
-        let args;
+        let args = argsLayout;
 
-        if (isBase && !compositionNests) {
-          args =
-            argsLayout.filter(arg => arg !== continuationSymbol);
+        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;
 
           ({continuation, continuationStorage} =
             _prepareContinuation(callingTransformForThisStep));
 
           args =
-            argsLayout.map(arg =>
+            args.map(arg =>
               (arg === continuationSymbol
                 ? continuation
                 : arg));
+        } else {
+          args =
+            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!
@@ -1091,11 +1261,6 @@ export function compositeFrom(description) {
 
       if (result !== continuationSymbol) {
         debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]);
-
-        if (compositionNests) {
-          throw new TypeError(`Inferred early-exit is disallowed in nested compositions`);
-        }
-
         debug(() => colors.bright(`end composition - exit (inferred)`));
 
         return result;
@@ -1216,6 +1381,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/helpers/performAvailabilityCheck.js b/src/data/composite/control-flow/helpers/performAvailabilityCheck.js
new file mode 100644
index 00000000..0e44ab59
--- /dev/null
+++ b/src/data/composite/control-flow/helpers/performAvailabilityCheck.js
@@ -0,0 +1,19 @@
+import {empty} from '#sugar';
+
+export default function performAvailabilityCheck(value, mode) {
+  switch (mode) {
+    case 'null':
+      return value !== undefined && value !== null;
+
+    case 'empty':
+      return value !== undefined && !empty(value);
+
+    case 'falsy':
+      return !!value && (!Array.isArray(value) || !empty(value));
+
+    case 'index':
+      return typeof value === 'number' && value >= 0;
+  }
+
+  return undefined;
+}
diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js
index 7fad88b2..7e137a14 100644
--- a/src/data/composite/control-flow/index.js
+++ b/src/data/composite/control-flow/index.js
@@ -9,6 +9,8 @@ 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 withAvailabilityFilter} from './withAvailabilityFilter.js';
 export {default as withResultOfAvailabilityCheck} from './withResultOfAvailabilityCheck.js';
diff --git a/src/data/composite/control-flow/raiseOutputWithoutDependency.js b/src/data/composite/control-flow/raiseOutputWithoutDependency.js
index 3d04f8a9..03d8036a 100644
--- a/src/data/composite/control-flow/raiseOutputWithoutDependency.js
+++ b/src/data/composite/control-flow/raiseOutputWithoutDependency.js
@@ -17,7 +17,7 @@ export default templateCompositeFrom({
 
   outputs: ({
     [input.staticValue('output')]: output,
-  }) => Object.keys(output ?? {}),
+  }) => Object.keys(output),
 
   steps: () => [
     withResultOfAvailabilityCheck({
diff --git a/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js b/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js
index ffa83a94..3c39f5ba 100644
--- a/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js
+++ b/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js
@@ -16,7 +16,7 @@ export default templateCompositeFrom({
 
   outputs: ({
     [input.staticValue('output')]: output,
-  }) => Object.keys(output ?? {}),
+  }) => Object.keys(output),
 
   steps: () => [
     withResultOfAvailabilityCheck({
diff --git a/src/data/composite/control-flow/withAvailabilityFilter.js b/src/data/composite/control-flow/withAvailabilityFilter.js
new file mode 100644
index 00000000..cfea998e
--- /dev/null
+++ b/src/data/composite/control-flow/withAvailabilityFilter.js
@@ -0,0 +1,40 @@
+// Performs the same availability check across all items of a list, providing
+// a list that's suitable anywhere a filter is expected.
+//
+// Accepts the same mode options as withResultOfAvailabilityCheck.
+//
+// See also:
+//  - withFilteredList
+//  - withResultOfAvailabilityCheck
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+
+import performAvailabilityCheck from './helpers/performAvailabilityCheck.js';
+
+export default templateCompositeFrom({
+  annotation: `withAvailabilityFilter`,
+
+  inputs: {
+    from: input({type: 'array'}),
+    mode: inputAvailabilityCheckMode(),
+  },
+
+  outputs: ['#availabilityFilter'],
+
+  steps: () => [
+    {
+      dependencies: [input('from'), input('mode')],
+      compute: (continuation, {
+        [input('from')]: list,
+        [input('mode')]: mode,
+      }) => continuation({
+        ['#availabilityFilter']:
+          list.map(value =>
+            performAvailabilityCheck(value, mode)),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/withResultOfAvailabilityCheck.js b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js
index a6942014..c5221a62 100644
--- a/src/data/composite/control-flow/withResultOfAvailabilityCheck.js
+++ b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js
@@ -17,15 +17,18 @@
 //  - exitWithoutUpdateValue
 //  - exposeDependencyOrContinue
 //  - exposeUpdateValueOrContinue
+//  - exposeWhetherDependencyAvailable
 //  - raiseOutputWithoutDependency
 //  - raiseOutputWithoutUpdateValue
+//  - withAvailabilityFilter
 //
 
 import {input, templateCompositeFrom} from '#composite';
-import {empty} from '#sugar';
 
 import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
 
+import performAvailabilityCheck from './helpers/performAvailabilityCheck.js';
+
 export default templateCompositeFrom({
   annotation: `withResultOfAvailabilityCheck`,
 
@@ -39,33 +42,13 @@ export default templateCompositeFrom({
   steps: () => [
     {
       dependencies: [input('from'), input('mode')],
-
       compute: (continuation, {
         [input('from')]: value,
         [input('mode')]: mode,
-      }) => {
-        let availability;
-
-        switch (mode) {
-          case 'null':
-            availability = value !== undefined && value !== null;
-            break;
-
-          case 'empty':
-            availability = value !== undefined && !empty(value);
-            break;
-
-          case 'falsy':
-            availability = !!value && (!Array.isArray(value) || !empty(value));
-            break;
-
-          case 'index':
-            availability = typeof value === 'number' && value >= 0;
-            break;
-        }
-
-        return continuation({'#availability': availability});
-      },
+      }) => continuation({
+        ['#availability']:
+          performAvailabilityCheck(value, mode),
+      }),
     },
   ],
 });
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..46a3dc81 100644
--- a/src/data/composite/data/index.js
+++ b/src/data/composite/data/index.js
@@ -3,15 +3,33 @@
 // 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 withStretchedList} from './withStretchedList.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..44c1661d 100644
--- a/src/data/composite/data/withFilteredList.js
+++ b/src/data/composite/data/withFilteredList.js
@@ -2,26 +2,17 @@
 // corresponding items in a list. Items which correspond to a truthy value
 // are kept, and the rest are excluded from the output list.
 //
-// TODO: It would be neat to apply an availability check here, e.g. to allow
-// not providing a filter at all and performing the check on the contents of
-// the list (though on the filter, if present, is fine too). But that's best
-// done by some shmancy-fancy mapping support in composite.js, so a bit out
-// of reach for now (apart from proving uses built on top of a more boring
-// implementation).
+// If the flip option is set, only items corresponding with a *falsy* value in
+// the filter are kept.
 //
 // TODO: There should be two outputs - one for the items included according to
 // the filter, and one for the items excluded.
 //
 // See also:
+//  - withAvailabilityFilter
 //  - withMappedList
 //  - withSortedList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFlattenedList, withUnflattenedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
 
@@ -31,19 +22,28 @@ export default templateCompositeFrom({
   inputs: {
     list: input({type: 'array'}),
     filter: input({type: 'array'}),
+
+    flip: input({
+      type: 'boolean',
+      defaultValue: false,
+    }),
   },
 
   outputs: ['#filteredList'],
 
   steps: () => [
     {
-      dependencies: [input('list'), input('filter')],
+      dependencies: [input('list'), input('filter'), input('flip')],
       compute: (continuation, {
         [input('list')]: list,
         [input('filter')]: filter,
+        [input('flip')]: flip,
       }) => continuation({
         '#filteredList':
-          list.filter((item, index) => filter[index]),
+          list.filter((_item, index) =>
+            (flip
+              ? !filter[index]
+              :  filter[index])),
       }),
     },
   ],
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..cd32058e 100644
--- a/src/data/composite/data/withMappedList.js
+++ b/src/data/composite/data/withMappedList.js
@@ -1,18 +1,16 @@
 // Applies a map function to each item in a list, just like a normal JavaScript
 // map.
 //
+// Pass a filter (e.g. from withAvailabilityFilter) to process only items
+// kept by the filter. Other items will be left as-is.
+//
 // See also:
 //  - withFilteredList
 //  - withSortedList
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFlattenedList, withUnflattenedList
-//  - withPropertyFromList, withPropertiesFromList
-//
 
 import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
 
 export default templateCompositeFrom({
   annotation: `withMappedList`,
@@ -20,19 +18,31 @@ export default templateCompositeFrom({
   inputs: {
     list: input({type: 'array'}),
     map: input({type: 'function'}),
+
+    filter: input({
+      type: 'array',
+      defaultValue: null,
+    }),
   },
 
   outputs: ['#mappedList'],
 
   steps: () => [
     {
-      dependencies: [input('list'), input('map')],
+      dependencies: [input('list'), input('map'), input('filter')],
       compute: (continuation, {
         [input('list')]: list,
         [input('map')]: mapFn,
+        [input('filter')]: filter,
       }) => continuation({
         ['#mappedList']:
-          list.map(mapFn),
+          stitchArrays({
+            item: list,
+            keep: filter ?? Array.from(list, () => true),
+          }).map(({item, keep}, index) =>
+              (keep
+                ? mapFn(item, index, list)
+                : item)),
       }),
     },
   ],
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..760095c2 100644
--- a/src/data/composite/data/withPropertyFromList.js
+++ b/src/data/composite/data/withPropertyFromList.js
@@ -5,17 +5,15 @@
 // original list are kept null here. Objects which don't have the specified
 // property are retained in-place as null.
 //
+// If the `internal` input is true, this reads the CacheableObject update value
+// of each object rather than its exposed value.
+//
 // See also:
 //  - withPropertiesFromList
 //  - withPropertyFromObject
 //
-// More list utilities:
-//  - excludeFromList
-//  - fillMissingListItems
-//  - withFilteredList, withMappedList, withSortedList
-//  - withFlattenedList, withUnflattenedList
-//
 
+import CacheableObject from '#cacheable-object';
 import {input, templateCompositeFrom} from '#composite';
 
 function getOutputName({list, property, prefix}) {
@@ -32,6 +30,7 @@ export default templateCompositeFrom({
     list: input({type: 'array'}),
     property: input({type: 'string'}),
     prefix: input.staticValue({type: 'string', defaultValue: null}),
+    internal: input({type: 'boolean', defaultValue: false}),
   },
 
   outputs: ({
@@ -43,13 +42,26 @@ export default templateCompositeFrom({
 
   steps: () => [
     {
-      dependencies: [input('list'), input('property')],
+      dependencies: [
+        input('list'),
+        input('property'),
+        input('internal'),
+      ],
+
       compute: (continuation, {
         [input('list')]: list,
         [input('property')]: property,
+        [input('internal')]: internal,
       }) => continuation({
         ['#values']:
-          list.map(item => item[property] ?? null),
+          list.map(item =>
+            (item === null
+              ? null
+           : internal
+              ? CacheableObject.getUpdateValue(item, property)
+                  ?? null
+              : item[property]
+                  ?? null)),
       }),
     },
 
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/withStretchedList.js b/src/data/composite/data/withStretchedList.js
new file mode 100644
index 00000000..46733064
--- /dev/null
+++ b/src/data/composite/data/withStretchedList.js
@@ -0,0 +1,36 @@
+// Repeats each item in a list in-place by a corresponding length.
+
+import {input, templateCompositeFrom} from '#composite';
+import {repeat, stitchArrays} from '#sugar';
+import {isNumber, validateArrayItems} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withStretchedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+
+    lengths: input({
+      validate: validateArrayItems(isNumber),
+    }),
+  },
+
+  outputs: ['#stretchedList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('lengths')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('lengths')]: lengths,
+      }) => continuation({
+        ['#stretchedList']:
+          stitchArrays({
+            item: list,
+            length: lengths,
+          }).map(({item, length}) => repeat(length, [item]))
+            .flat(),
+      }),
+    },
+  ],
+});
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 8139f10e..dfc6864f 100644
--- a/src/data/composite/things/album/index.js
+++ b/src/data/composite/things/album/index.js
@@ -1,2 +1,2 @@
+export {default as withHasCoverArt} from './withHasCoverArt.js';
 export {default as withTracks} from './withTracks.js';
-export {default as withTrackSections} from './withTrackSections.js';
diff --git a/src/data/composite/things/album/withHasCoverArt.js b/src/data/composite/things/album/withHasCoverArt.js
new file mode 100644
index 00000000..fd3f2894
--- /dev/null
+++ b/src/data/composite/things/album/withHasCoverArt.js
@@ -0,0 +1,64 @@
+// TODO: This shouldn't be coded as an Album-specific thing,
+// or even really to do with cover artworks in particular, either.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {fillMissingListItems, withFlattenedList, withPropertyFromList}
+  from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: 'withHasCoverArt',
+
+  outputs: ['#hasCoverArt'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: 'coverArtistContribs',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability'],
+      compute: (continuation, {
+        ['#availability']: availability,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              ['#hasCoverArt']: true,
+            })
+          : continuation()),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: 'coverArtworks',
+      mode: input.value('empty'),
+      output: input.value({'#hasCoverArt': false}),
+    }),
+
+    withPropertyFromList({
+      list: 'coverArtworks',
+      property: input.value('artistContribs'),
+      internal: input.value(true),
+    }),
+
+    // Since we're getting the update value for each artwork's artistContribs,
+    // it may not be set at all, and in that case won't be exposing as [].
+    fillMissingListItems({
+      list: '#coverArtworks.artistContribs',
+      fill: input.value([]),
+    }),
+
+    withFlattenedList({
+      list: '#coverArtworks.artistContribs',
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#flattenedList',
+      mode: input.value('empty'),
+    }).outputs({
+      '#availability': '#hasCoverArt',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/album/withTrackSections.js b/src/data/composite/things/album/withTrackSections.js
deleted file mode 100644
index 0a1ebebc..00000000
--- a/src/data/composite/things/album/withTrackSections.js
+++ /dev/null
@@ -1,127 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import find from '#find';
-import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
-import {isTrackSectionList} from '#validators';
-
-import {exitWithoutDependency, exitWithoutUpdateValue}
-  from '#composite/control-flow';
-import {withResolvedReferenceList} from '#composite/wiki-data';
-
-import {
-  fillMissingListItems,
-  withFlattenedList,
-  withPropertiesFromList,
-  withUnflattenedList,
-} from '#composite/data';
-
-export default templateCompositeFrom({
-  annotation: `withTrackSections`,
-
-  outputs: ['#trackSections'],
-
-  steps: () => [
-    exitWithoutDependency({
-      dependency: 'ownTrackData',
-      value: input.value([]),
-    }),
-
-    exitWithoutUpdateValue({
-      mode: input.value('empty'),
-      value: input.value([]),
-    }),
-
-    // TODO: input.updateValue description down here is a kludge.
-    withPropertiesFromList({
-      list: input.updateValue({
-        validate: isTrackSectionList,
-      }),
-      prefix: input.value('#sections'),
-      properties: input.value([
-        'tracks',
-        'dateOriginallyReleased',
-        'isDefaultTrackSection',
-        'name',
-        'color',
-      ]),
-    }),
-
-    fillMissingListItems({
-      list: '#sections.tracks',
-      fill: input.value([]),
-    }),
-
-    fillMissingListItems({
-      list: '#sections.isDefaultTrackSection',
-      fill: input.value(false),
-    }),
-
-    fillMissingListItems({
-      list: '#sections.name',
-      fill: input.value('Unnamed Track Section'),
-    }),
-
-    fillMissingListItems({
-      list: '#sections.color',
-      fill: input.dependency('color'),
-    }),
-
-    withFlattenedList({
-      list: '#sections.tracks',
-    }).outputs({
-      ['#flattenedList']: '#trackRefs',
-      ['#flattenedIndices']: '#sections.startIndex',
-    }),
-
-    withResolvedReferenceList({
-      list: '#trackRefs',
-      data: 'ownTrackData',
-      notFoundMode: input.value('null'),
-      find: input.value(find.track),
-    }).outputs({
-      ['#resolvedReferenceList']: '#tracks',
-    }),
-
-    withUnflattenedList({
-      list: '#tracks',
-      indices: '#sections.startIndex',
-    }).outputs({
-      ['#unflattenedList']: '#sections.tracks',
-    }),
-
-    {
-      dependencies: [
-        '#sections.tracks',
-        '#sections.name',
-        '#sections.color',
-        '#sections.dateOriginallyReleased',
-        '#sections.isDefaultTrackSection',
-        '#sections.startIndex',
-      ],
-
-      compute: (continuation, {
-        '#sections.tracks': tracks,
-        '#sections.name': name,
-        '#sections.color': color,
-        '#sections.dateOriginallyReleased': dateOriginallyReleased,
-        '#sections.isDefaultTrackSection': isDefaultTrackSection,
-        '#sections.startIndex': startIndex,
-      }) => {
-        filterMultipleArrays(
-          tracks, name, color, dateOriginallyReleased, isDefaultTrackSection, startIndex,
-          tracks => !empty(tracks));
-
-        return continuation({
-          ['#trackSections']:
-            stitchArrays({
-              tracks,
-              name,
-              color,
-              dateOriginallyReleased,
-              isDefaultTrackSection,
-              startIndex,
-            }),
-        });
-      },
-    },
-  ],
-});
diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js
index fff3d5ae..835ee570 100644
--- a/src/data/composite/things/album/withTracks.js
+++ b/src/data/composite/things/album/withTracks.js
@@ -1,9 +1,8 @@
 import {input, templateCompositeFrom} from '#composite';
-import find from '#find';
 
-import {exitWithoutDependency, raiseOutputWithoutDependency}
-  from '#composite/control-flow';
-import {withResolvedReferenceList} from '#composite/wiki-data';
+import {withFlattenedList, withPropertyFromList} from '#composite/data';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
 
 export default templateCompositeFrom({
   annotation: `withTracks`,
@@ -11,41 +10,20 @@ export default templateCompositeFrom({
   outputs: ['#tracks'],
 
   steps: () => [
-    exitWithoutDependency({
-      dependency: 'ownTrackData',
-      value: input.value([]),
-    }),
-
     raiseOutputWithoutDependency({
       dependency: 'trackSections',
-      mode: input.value('empty'),
-      output: input.value({
-        ['#tracks']: [],
-      }),
+      output: input.value({'#tracks': []}),
     }),
 
-    {
-      dependencies: ['trackSections'],
-      compute: (continuation, {trackSections}) =>
-        continuation({
-          '#trackRefs': trackSections
-            .flatMap(section => section.tracks ?? []),
-        }),
-    },
-
-    withResolvedReferenceList({
-      list: '#trackRefs',
-      data: 'ownTrackData',
-      find: input.value(find.track),
+    withPropertyFromList({
+      list: 'trackSections',
+      property: input.value('tracks'),
     }),
 
-    {
-      dependencies: ['#resolvedReferenceList'],
-      compute: (continuation, {
-        ['#resolvedReferenceList']: resolvedReferenceList,
-      }) => continuation({
-        ['#tracks']: resolvedReferenceList,
-      })
-    },
+    withFlattenedList({
+      list: '#trackSections.tracks',
+    }).outputs({
+      ['#flattenedList']: '#tracks',
+    }),
   ],
 });
diff --git a/src/data/composite/things/art-tag/index.js b/src/data/composite/things/art-tag/index.js
new file mode 100644
index 00000000..bbd38293
--- /dev/null
+++ b/src/data/composite/things/art-tag/index.js
@@ -0,0 +1,2 @@
+export {default as withAllDescendantArtTags} from './withAllDescendantArtTags.js';
+export {default as withAncestorArtTagBaobabTree} from './withAncestorArtTagBaobabTree.js';
diff --git a/src/data/composite/things/art-tag/withAllDescendantArtTags.js b/src/data/composite/things/art-tag/withAllDescendantArtTags.js
new file mode 100644
index 00000000..795f96cd
--- /dev/null
+++ b/src/data/composite/things/art-tag/withAllDescendantArtTags.js
@@ -0,0 +1,44 @@
+// Gets all the art tags which descend from this one - that means its own direct
+// descendants, but also all the direct and indirect desceands of each of those!
+// The results aren't specially sorted, but they won't contain any duplicates
+// (for example if two descendant tags both route deeper to end up including
+// some of the same tags).
+
+import {input, templateCompositeFrom} from '#composite';
+import {unique} from '#sugar';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withResolvedReferenceList} from '#composite/wiki-data';
+import {soupyFind} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withAllDescendantArtTags`,
+
+  outputs: ['#allDescendantArtTags'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'directDescendantArtTags',
+      mode: input.value('empty'),
+      output: input.value({'#allDescendantArtTags': []})
+    }),
+
+    withResolvedReferenceList({
+      list: 'directDescendantArtTags',
+      find: soupyFind.input('artTag'),
+    }),
+
+    {
+      dependencies: ['#resolvedReferenceList'],
+      compute: (continuation, {
+        ['#resolvedReferenceList']: directDescendantArtTags,
+      }) => continuation({
+        ['#allDescendantArtTags']:
+          unique([
+            ...directDescendantArtTags,
+            ...directDescendantArtTags.flatMap(artTag => artTag.allDescendantArtTags),
+          ]),
+      }),
+    },
+  ],
+})
diff --git a/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js b/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js
new file mode 100644
index 00000000..e084a42b
--- /dev/null
+++ b/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js
@@ -0,0 +1,46 @@
+// Gets all the art tags which are ancestors of this one as a "baobab tree" -
+// what you'd typically think of as roots are all up in the air! Since this
+// really is backwards from the way that the art tag tree is written in data,
+// chances are pretty good that there will be many of the exact same "leaf"
+// nodes - art tags which don't themselves have any ancestors. In the actual
+// data structure, each node is a Map, with keys for each ancestor and values
+// for each ancestor's own baobab (thus a branching structure, just like normal
+// trees in this regard).
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withReverseReferenceList} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withAncestorArtTagBaobabTree`,
+
+  outputs: ['#ancestorArtTagBaobabTree'],
+
+  steps: () => [
+    withReverseReferenceList({
+      reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'),
+    }).outputs({
+      ['#reverseReferenceList']: '#directAncestorArtTags',
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#directAncestorArtTags',
+      mode: input.value('empty'),
+      output: input.value({'#ancestorArtTagBaobabTree': new Map()}),
+    }),
+
+    {
+      dependencies: ['#directAncestorArtTags'],
+      compute: (continuation, {
+        ['#directAncestorArtTags']: directAncestorArtTags,
+      }) => continuation({
+        ['#ancestorArtTagBaobabTree']:
+          new Map(
+            directAncestorArtTags
+              .map(artTag => [artTag, artTag.ancestorArtTagBaobabTree])),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/artist/artistTotalDuration.js b/src/data/composite/things/artist/artistTotalDuration.js
new file mode 100644
index 00000000..b8a205fe
--- /dev/null
+++ b/src/data/composite/things/artist/artistTotalDuration.js
@@ -0,0 +1,69 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withFilteredList, withPropertyFromList} from '#composite/data';
+import {withContributionListSums, withReverseReferenceList}
+  from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `artistTotalDuration`,
+
+  compose: false,
+
+  steps: () => [
+    withReverseReferenceList({
+      reverse: soupyReverse.input('trackArtistContributionsBy'),
+    }).outputs({
+      '#reverseReferenceList': '#contributionsAsArtist',
+    }),
+
+    withReverseReferenceList({
+      reverse: soupyReverse.input('trackContributorContributionsBy'),
+    }).outputs({
+      '#reverseReferenceList': '#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('isMainRelease'),
+    }),
+
+    withFilteredList({
+      list: '#allContributions',
+      filter: '#allContributions.thing.isMainRelease',
+    }).outputs({
+      '#filteredList': '#mainReleaseContributions',
+    }),
+
+    withContributionListSums({
+      list: '#mainReleaseContributions',
+    }),
+
+    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/artwork/index.js b/src/data/composite/things/artwork/index.js
new file mode 100644
index 00000000..b92bff72
--- /dev/null
+++ b/src/data/composite/things/artwork/index.js
@@ -0,0 +1 @@
+export {default as withDate} from './withDate.js';
diff --git a/src/data/composite/things/artwork/withDate.js b/src/data/composite/things/artwork/withDate.js
new file mode 100644
index 00000000..5e05b814
--- /dev/null
+++ b/src/data/composite/things/artwork/withDate.js
@@ -0,0 +1,41 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withDate`,
+
+  inputs: {
+    from: input({
+      defaultDependency: 'date',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#date'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: date,
+      }) =>
+        (date
+          ? continuation.raiseOutput({'#date': date})
+          : continuation()),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: 'dateFromThingProperty',
+      output: input.value({'#date': null}),
+    }),
+
+    withPropertyFromObject({
+      object: 'thing',
+      property: 'dateFromThingProperty',
+    }).outputs({
+      ['#value']: '#date',
+    }),
+  ],
+})
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..a74e6db3
--- /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} 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..1e9019b8
--- /dev/null
+++ b/src/data/composite/things/contribution/thingPropertyMatches.js
@@ -0,0 +1,46 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `thingPropertyMatches`,
+
+  compose: false,
+
+  inputs: {
+    value: input({type: 'string'}),
+  },
+
+  steps: () => [
+    {
+      dependencies: ['thing', 'thingProperty'],
+
+      compute: (continuation, {thing, thingProperty}) =>
+        continuation({
+          ['#thingProperty']:
+            (thing.constructor[Symbol.for('Thing.referenceType')] === 'artwork'
+              ? thing.artistContribsFromThingProperty
+              : thingProperty),
+        }),
+    },
+
+    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..4042e78f
--- /dev/null
+++ b/src/data/composite/things/contribution/thingReferenceTypeMatches.js
@@ -0,0 +1,66 @@
+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: (continuation, {
+        ['#thing.constructor']: constructor,
+        [input('value')]: value,
+      }) =>
+        (constructor[Symbol.for('Thing.referenceType')] === value
+          ? continuation.exit(true)
+       : constructor[Symbol.for('Thing.referenceType')] === 'artwork'
+          ? continuation()
+          : continuation.exit(false)),
+    },
+
+    withPropertyFromObject({
+      object: 'thing',
+      property: input.value('thing'),
+    }),
+
+    withPropertyFromObject({
+      object: '#thing.thing',
+      property: input.value('constructor'),
+    }),
+
+    {
+      dependencies: [
+        '#thing.thing.constructor',
+        input('value'),
+      ],
+
+      compute: ({
+        ['#thing.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..175d6cbb
--- /dev/null
+++ b/src/data/composite/things/contribution/withContainingReverseContributionList.js
@@ -0,0 +1,80 @@
+// Get the artist's contribution list containing this property. Although that
+// list literally includes both dated and dateless contributions, here, if the
+// current contribution is dateless, the list is filtered to only include
+// dateless contributions from the same immediately nearby context.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  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']: '#list',
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: 'date',
+    }).outputs({
+      ['#availability']: '#hasDate',
+    }),
+
+    {
+      dependencies: ['#hasDate', '#list'],
+      compute: (continuation, {
+        ['#hasDate']: hasDate,
+        ['#list']: list,
+      }) =>
+        (hasDate
+          ? continuation.raiseOutput({
+              ['#containingReverseContributionList']:
+                list.filter(contrib => contrib.date),
+            })
+          : continuation({
+              ['#list']:
+                list.filter(contrib => !contrib.date),
+            })),
+    },
+
+    {
+      dependencies: ['#list', 'thing'],
+      compute: (continuation, {
+        ['#list']: list,
+        ['thing']: thing,
+      }) => continuation({
+        ['#containingReverseContributionList']:
+          (thing.album
+            ? list.filter(contrib => contrib.thing.album === thing.album)
+            : list),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/contribution/withContributionArtist.js b/src/data/composite/things/contribution/withContributionArtist.js
new file mode 100644
index 00000000..5f81c716
--- /dev/null
+++ b/src/data/composite/things/contribution/withContributionArtist.js
@@ -0,0 +1,26 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withResolvedReference} from '#composite/wiki-data';
+import {soupyFind} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withContributionArtist`,
+
+  inputs: {
+    ref: input({
+      type: 'string',
+      defaultDependency: 'artist',
+    }),
+  },
+
+  outputs: ['#artist'],
+
+  steps: () => [
+    withResolvedReference({
+      ref: input('ref'),
+      find: soupyFind.input('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/flash-act/withFlashSide.js b/src/data/composite/things/flash-act/withFlashSide.js
index 64daa1fb..e09f06e6 100644
--- a/src/data/composite/things/flash-act/withFlashSide.js
+++ b/src/data/composite/things/flash-act/withFlashSide.js
@@ -2,9 +2,10 @@
 // If there's no side whose list of flash acts includes this act, the output
 // dependency will be null.
 
-import {input, templateCompositeFrom} from '#composite';
+import {templateCompositeFrom} from '#composite';
 
 import {withUniqueReferencingThing} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
 
 export default templateCompositeFrom({
   annotation: `withFlashSide`,
@@ -13,8 +14,7 @@ export default templateCompositeFrom({
 
   steps: () => [
     withUniqueReferencingThing({
-      data: 'flashSideData',
-      list: input.value('acts'),
+      reverse: soupyReverse.input('flashSidesWhoseActsInclude'),
     }).outputs({
       ['#uniqueReferencingThing']: '#flashSide',
     }),
diff --git a/src/data/composite/things/flash/withFlashAct.js b/src/data/composite/things/flash/withFlashAct.js
index 652b8bfb..87922aff 100644
--- a/src/data/composite/things/flash/withFlashAct.js
+++ b/src/data/composite/things/flash/withFlashAct.js
@@ -2,9 +2,10 @@
 // If there's no flash whose list of flashes includes this flash, the output
 // dependency will be null.
 
-import {input, templateCompositeFrom} from '#composite';
+import {templateCompositeFrom} from '#composite';
 
 import {withUniqueReferencingThing} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
 
 export default templateCompositeFrom({
   annotation: `withFlashAct`,
@@ -13,8 +14,7 @@ export default templateCompositeFrom({
 
   steps: () => [
     withUniqueReferencingThing({
-      data: 'flashActData',
-      list: input.value('flashes'),
+      reverse: soupyReverse.input('flashActsWhoseFlashesInclude'),
     }).outputs({
       ['#uniqueReferencingThing']: '#flashAct',
     }),
diff --git a/src/data/composite/things/track-section/index.js b/src/data/composite/things/track-section/index.js
new file mode 100644
index 00000000..f11a2ab5
--- /dev/null
+++ b/src/data/composite/things/track-section/index.js
@@ -0,0 +1,3 @@
+export {default as withAlbum} from './withAlbum.js';
+export {default as withContinueCountingFrom} from './withContinueCountingFrom.js';
+export {default as withStartCountingFrom} from './withStartCountingFrom.js';
diff --git a/src/data/composite/things/track-section/withAlbum.js b/src/data/composite/things/track-section/withAlbum.js
new file mode 100644
index 00000000..e257062e
--- /dev/null
+++ b/src/data/composite/things/track-section/withAlbum.js
@@ -0,0 +1,20 @@
+// Gets the track section's album.
+
+import {templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withAlbum`,
+
+  outputs: ['#album'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      reverse: soupyReverse.input('albumsWhoseTrackSectionsInclude'),
+    }).outputs({
+      ['#uniqueReferencingThing']: '#album',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track-section/withContinueCountingFrom.js b/src/data/composite/things/track-section/withContinueCountingFrom.js
new file mode 100644
index 00000000..e034b7a5
--- /dev/null
+++ b/src/data/composite/things/track-section/withContinueCountingFrom.js
@@ -0,0 +1,25 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import withStartCountingFrom from './withStartCountingFrom.js';
+
+export default templateCompositeFrom({
+  annotation: `withContinueCountingFrom`,
+
+  outputs: ['#continueCountingFrom'],
+
+  steps: () => [
+    withStartCountingFrom(),
+
+    {
+      dependencies: ['#startCountingFrom', 'tracks'],
+      compute: (continuation, {
+        ['#startCountingFrom']: startCountingFrom,
+        ['tracks']: tracks,
+      }) => continuation({
+        ['#continueCountingFrom']:
+          startCountingFrom +
+          tracks.length,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track-section/withStartCountingFrom.js b/src/data/composite/things/track-section/withStartCountingFrom.js
new file mode 100644
index 00000000..ef345327
--- /dev/null
+++ b/src/data/composite/things/track-section/withStartCountingFrom.js
@@ -0,0 +1,64 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withNearbyItemFromList, withPropertyFromObject} from '#composite/data';
+
+import withAlbum from './withAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withStartCountingFrom`,
+
+  inputs: {
+    from: input({
+      type: 'number',
+      defaultDependency: 'startCountingFrom',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#startCountingFrom'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: from,
+      }) =>
+        (from === null
+          ? continuation()
+          : continuation.raiseOutput({'#startCountingFrom': from})),
+    },
+
+    withAlbum(),
+
+    raiseOutputWithoutDependency({
+      dependency: '#album',
+      output: input.value({'#startCountingFrom': 1}),
+    }),
+
+    withPropertyFromObject({
+      object: '#album',
+      property: input.value('trackSections'),
+    }),
+
+    withNearbyItemFromList({
+      list: '#album.trackSections',
+      item: input.myself(),
+      offset: input.value(-1),
+    }).outputs({
+      '#nearbyItem': '#previousTrackSection',
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#previousTrackSection',
+      output: input.value({'#startCountingFrom': 1}),
+    }),
+
+    withPropertyFromObject({
+      object: '#previousTrackSection',
+      property: input.value('continueCountingFrom'),
+    }).outputs({
+      '#previousTrackSection.continueCountingFrom': '#startCountingFrom',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js
index cc723a24..e789e736 100644
--- a/src/data/composite/things/track/index.js
+++ b/src/data/composite/things/track/index.js
@@ -1,11 +1,17 @@
 export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js';
-export {default as inferredAdditionalNameList} from './inferredAdditionalNameList.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 inheritContributionListFromMainRelease} from './inheritContributionListFromMainRelease.js';
+export {default as inheritFromMainRelease} from './inheritFromMainRelease.js';
+export {default as withAllReleases} from './withAllReleases.js';
 export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js';
 export {default as withContainingTrackSection} from './withContainingTrackSection.js';
+export {default as withCoverArtistContribs} from './withCoverArtistContribs.js';
+export {default as withDate} from './withDate.js';
+export {default as withDirectorySuffix} from './withDirectorySuffix.js';
 export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js';
+export {default as withMainRelease} from './withMainRelease.js';
 export {default as withOtherReleases} from './withOtherReleases.js';
 export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js';
+export {default as withPropertyFromMainRelease} from './withPropertyFromMainRelease.js';
+export {default as withSuffixDirectoryFromAlbum} from './withSuffixDirectoryFromAlbum.js';
+export {default as withTrackArtDate} from './withTrackArtDate.js';
+export {default as withTrackNumber} from './withTrackNumber.js';
diff --git a/src/data/composite/things/track/inferredAdditionalNameList.js b/src/data/composite/things/track/inferredAdditionalNameList.js
deleted file mode 100644
index 58e8d2a1..00000000
--- a/src/data/composite/things/track/inferredAdditionalNameList.js
+++ /dev/null
@@ -1,67 +0,0 @@
-// Infers additional name entries from other releases that were titled
-// differently; the corresponding releases are stored in eacn entry's "from"
-// array, which will include multiple items, if more than one other release
-// shares the same name differing from this one's.
-
-import {input, templateCompositeFrom} from '#composite';
-import {chunkByProperties} from '#sugar';
-
-import {exitWithoutDependency} from '#composite/control-flow';
-import {withFilteredList, withPropertyFromList} from '#composite/data';
-import {withThingsSortedAlphabetically} from '#composite/wiki-data';
-
-import withOtherReleases from './withOtherReleases.js';
-
-export default templateCompositeFrom({
-  annotation: `inferredAdditionalNameList`,
-
-  compose: false,
-
-  steps: () => [
-    withOtherReleases(),
-
-    exitWithoutDependency({
-      dependency: '#otherReleases',
-      mode: input.value('empty'),
-      value: input.value([]),
-    }),
-
-    withPropertyFromList({
-      list: '#otherReleases',
-      property: input.value('name'),
-    }),
-
-    {
-      dependencies: ['#otherReleases.name', 'name'],
-      compute: (continuation, {
-        ['#otherReleases.name']: releaseNames,
-        ['name']: ownName,
-      }) => continuation({
-        ['#nameFilter']:
-          releaseNames.map(name => name !== ownName),
-      }),
-    },
-
-    withFilteredList({
-      list: '#otherReleases',
-      filter: '#nameFilter',
-    }).outputs({
-      '#filteredList': '#differentlyNamedReleases',
-    }),
-
-    withThingsSortedAlphabetically({
-      things: '#differentlyNamedReleases',
-    }).outputs({
-      '#sortedThings': '#differentlyNamedReleases',
-    }),
-
-    {
-      dependencies: ['#differentlyNamedReleases'],
-      compute: ({
-        ['#differentlyNamedReleases']: releases,
-      }) =>
-        chunkByProperties(releases, ['name'])
-          .map(({name, chunk}) => ({name, from: chunk})),
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/inheritContributionListFromMainRelease.js b/src/data/composite/things/track/inheritContributionListFromMainRelease.js
new file mode 100644
index 00000000..89252feb
--- /dev/null
+++ b/src/data/composite/things/track/inheritContributionListFromMainRelease.js
@@ -0,0 +1,44 @@
+// Like inheritFromMainRelease, but tuned for contributions.
+// Recontextualizes 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 withPropertyFromMainRelease
+  from './withPropertyFromMainRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `inheritContributionListFromMainRelease`,
+
+  steps: () => [
+    withPropertyFromMainRelease({
+      property: input.thisProperty(),
+      notFoundValue: input.value([]),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#isSecondaryRelease',
+      mode: input.value('falsy'),
+    }),
+
+    withRecontextualizedContributionList({
+      list: '#mainReleaseValue',
+    }),
+
+    withDate(),
+
+    withRedatedContributionList({
+      list: '#mainReleaseValue',
+      date: '#date',
+    }),
+
+    exposeDependency({
+      dependency: '#mainReleaseValue',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/inheritFromMainRelease.js b/src/data/composite/things/track/inheritFromMainRelease.js
new file mode 100644
index 00000000..b1cbb65e
--- /dev/null
+++ b/src/data/composite/things/track/inheritFromMainRelease.js
@@ -0,0 +1,41 @@
+// Early exits with the value for the same property as specified on the
+// main release, if this track is a secondary release, and otherwise continues
+// without providing any further dependencies.
+//
+// Like withMainRelease, this will early exit (with notFoundValue) if the
+// main release is specified by reference and that reference doesn't
+// resolve to anything.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+
+import withPropertyFromMainRelease
+  from './withPropertyFromMainRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `inheritFromMainRelease`,
+
+  inputs: {
+    notFoundValue: input({
+      defaultValue: null,
+    }),
+  },
+
+  steps: () => [
+    withPropertyFromMainRelease({
+      property: input.thisProperty(),
+      notFoundValue: input('notFoundValue'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#isSecondaryRelease',
+      mode: input.value('falsy'),
+    }),
+
+    exposeDependency({
+      dependency: '#mainReleaseValue',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/inheritFromOriginalRelease.js b/src/data/composite/things/track/inheritFromOriginalRelease.js
deleted file mode 100644
index 27ed1387..00000000
--- a/src/data/composite/things/track/inheritFromOriginalRelease.js
+++ /dev/null
@@ -1,50 +0,0 @@
-// Early exits with a value inherited from the original release, if
-// this track is a rerelease, and otherwise continues with no further
-// dependencies provided. If allowOverride is true, then the continuation
-// will also be called if the original release exposed the requested
-// property as null.
-//
-// Like withOriginalRelease, this will early exit (with notFoundValue) if the
-// original release is specified by reference and that reference doesn't
-// resolve to anything.
-
-import {input, templateCompositeFrom} from '#composite';
-
-import withOriginalRelease from './withOriginalRelease.js';
-
-export default templateCompositeFrom({
-  annotation: `inheritFromOriginalRelease`,
-
-  inputs: {
-    property: input({type: 'string'}),
-    allowOverride: input({type: 'boolean', defaultValue: false}),
-    notFoundValue: input({defaultValue: null}),
-  },
-
-  steps: () => [
-    withOriginalRelease({
-      notFoundValue: input('notFoundValue'),
-    }),
-
-    {
-      dependencies: [
-        '#originalRelease',
-        input('property'),
-        input('allowOverride'),
-      ],
-
-      compute: (continuation, {
-        ['#originalRelease']: originalRelease,
-        [input('property')]: originalProperty,
-        [input('allowOverride')]: allowOverride,
-      }) => {
-        if (!originalRelease) return continuation();
-
-        const value = originalRelease[originalProperty];
-        if (allowOverride && value === null) return continuation();
-
-        return continuation.exit(value);
-      },
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/sharedAdditionalNameList.js b/src/data/composite/things/track/sharedAdditionalNameList.js
deleted file mode 100644
index 1806ec80..00000000
--- a/src/data/composite/things/track/sharedAdditionalNameList.js
+++ /dev/null
@@ -1,38 +0,0 @@
-// Compiles additional names directly provided by other releases.
-
-import {input, templateCompositeFrom} from '#composite';
-
-import {exitWithoutDependency, exposeDependency}
-  from '#composite/control-flow';
-import {withFlattenedList, withPropertyFromList} from '#composite/data';
-
-import withOtherReleases from './withOtherReleases.js';
-
-export default templateCompositeFrom({
-  annotation: `sharedAdditionalNameList`,
-
-  compose: false,
-
-  steps: () => [
-    withOtherReleases(),
-
-    exitWithoutDependency({
-      dependency: '#otherReleases',
-      mode: input.value('empty'),
-      value: input.value([]),
-    }),
-
-    withPropertyFromList({
-      list: '#otherReleases',
-      property: input.value('additionalNames'),
-    }),
-
-    withFlattenedList({
-      list: '#otherReleases.additionalNames',
-    }),
-
-    exposeDependency({
-      dependency: '#flattenedList',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/track/trackReverseReferenceList.js b/src/data/composite/things/track/trackReverseReferenceList.js
deleted file mode 100644
index 44940ae7..00000000
--- a/src/data/composite/things/track/trackReverseReferenceList.js
+++ /dev/null
@@ -1,38 +0,0 @@
-// Like a normal reverse reference list ("objects which reference this object
-// under a specified property"), only excluding rereleases from the possible
-// outputs. While it's useful to travel from a rerelease to the tracks it
-// references, rereleases aren't generally relevant from the perspective of
-// the tracks *being* referenced. Apart from hiding rereleases from lists on
-// the site, it also excludes keeps them from relational data processing, such
-// as on the "Tracks - by Times Referenced" listing page.
-
-import {input, templateCompositeFrom} from '#composite';
-import {withReverseReferenceList} from '#composite/wiki-data';
-
-export default templateCompositeFrom({
-  annotation: `trackReverseReferenceList`,
-
-  compose: false,
-
-  inputs: {
-    list: input({type: 'string'}),
-  },
-
-  steps: () => [
-    withReverseReferenceList({
-      data: 'trackData',
-      list: input('list'),
-    }),
-
-    {
-      flags: {expose: true},
-      expose: {
-        dependencies: ['#reverseReferenceList'],
-        compute: ({
-          ['#reverseReferenceList']: reverseReferenceList,
-        }) =>
-          reverseReferenceList.filter(track => !track.originalReleaseTrack),
-      },
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/withAlbum.js b/src/data/composite/things/track/withAlbum.js
deleted file mode 100644
index 03b840d4..00000000
--- a/src/data/composite/things/track/withAlbum.js
+++ /dev/null
@@ -1,22 +0,0 @@
-// Gets the track's album. This will early exit if albumData is missing.
-// If there's no album whose list of tracks includes this track, the output
-// dependency will be null.
-
-import {input, templateCompositeFrom} from '#composite';
-
-import {withUniqueReferencingThing} from '#composite/wiki-data';
-
-export default templateCompositeFrom({
-  annotation: `withAlbum`,
-
-  outputs: ['#album'],
-
-  steps: () => [
-    withUniqueReferencingThing({
-      data: 'albumData',
-      list: input.value('tracks'),
-    }).outputs({
-      ['#uniqueReferencingThing']: '#album',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/track/withAllReleases.js b/src/data/composite/things/track/withAllReleases.js
new file mode 100644
index 00000000..b93bf753
--- /dev/null
+++ b/src/data/composite/things/track/withAllReleases.js
@@ -0,0 +1,47 @@
+// Gets all releases of the current track. All items of the outputs are
+// distinct Track objects; one track is the main release; all else are
+// secondary releases of that main release; and one item, which may be
+// the main release or one of the secondary releases, is the current
+// track. The results are sorted by date, and it is possible that the
+// main release is not actually the earliest/first.
+
+import {input, templateCompositeFrom} from '#composite';
+import {sortByDate} from '#sort';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withMainRelease from './withMainRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `withAllReleases`,
+
+  outputs: ['#allReleases'],
+
+  steps: () => [
+    withMainRelease({
+      selfIfMain: input.value(true),
+      notFoundValue: input.value([]),
+    }),
+
+    // We don't talk about bruno no no
+    // Yes, this can perform a normal access equivalent to
+    // `this.secondaryReleases` from within a data composition.
+    // Oooooooooooooooooooooooooooooooooooooooooooooooo
+    withPropertyFromObject({
+      object: '#mainRelease',
+      property: input.value('secondaryReleases'),
+    }),
+
+    {
+      dependencies: ['#mainRelease', '#mainRelease.secondaryReleases'],
+      compute: (continuation, {
+        ['#mainRelease']: mainRelease,
+        ['#mainRelease.secondaryReleases']: secondaryReleases,
+      }) => continuation({
+        ['#allReleases']:
+          sortByDate([mainRelease, ...secondaryReleases]),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
index fac8e213..60faeaf4 100644
--- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
+++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
@@ -7,10 +7,17 @@ import {input, templateCompositeFrom} from '#composite';
 import find from '#find';
 import {isBoolean} from '#validators';
 
-import {exitWithoutDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
 import {withResolvedReference} from '#composite/wiki-data';
+import {soupyFind} from '#composite/wiki-properties';
+
+import {
+  exitWithoutDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
 
 export default templateCompositeFrom({
   annotation: `withAlwaysReferenceByDirectory`,
@@ -22,9 +29,20 @@ export default templateCompositeFrom({
       validate: input.value(isBoolean),
     }),
 
+    withPropertyFromAlbum({
+      property: input.value('alwaysReferenceTracksByDirectory'),
+    }),
+
+    // Falsy mode means this exposes true if the album's property is true,
+    // but continues if the property is false (which is also the default).
+    exposeDependencyOrContinue({
+      dependency: '#album.alwaysReferenceTracksByDirectory',
+      mode: input.value('falsy'),
+    }),
+
     // Remaining code is for defaulting to true if this track is a rerelease of
     // another with the same name, so everything further depends on access to
-    // trackData as well as originalReleaseTrack.
+    // trackData as well as mainReleaseTrack.
 
     exitWithoutDependency({
       dependency: 'trackData',
@@ -33,45 +51,46 @@ export default templateCompositeFrom({
     }),
 
     exitWithoutDependency({
-      dependency: 'originalReleaseTrack',
+      dependency: 'mainReleaseTrack',
       value: input.value(false),
     }),
 
-    // It's necessary to use the custom trackOriginalReleasesOnly find function
+    // It's necessary to use the custom trackMainReleasesOnly find function
     // here, so as to avoid recursion issues - the find.track() function depends
     // on accessing each track's alwaysReferenceByDirectory, which means it'll
     // hit *this track* - and thus this step - and end up recursing infinitely.
-    // By definition, find.trackOriginalReleasesOnly excludes tracks which have
-    // an originalReleaseTrack update value set, which means even though it does
+    // By definition, find.trackMainReleasesOnly excludes tracks which have
+    // an mainReleaseTrack update value set, which means even though it does
     // still access each of tracks' `alwaysReferenceByDirectory` property, it
     // won't access that of *this* track - it will never proceed past the
     // `exitWithoutDependency` step directly above, so there's no opportunity
     // for recursion.
     withResolvedReference({
-      ref: 'originalReleaseTrack',
+      ref: 'mainReleaseTrack',
       data: 'trackData',
-      find: input.value(find.trackOriginalReleasesOnly),
+      find: input.value(find.trackMainReleasesOnly),
     }).outputs({
-      '#resolvedReference': '#originalRelease',
+      '#resolvedReference': '#mainRelease',
     }),
 
     exitWithoutDependency({
-      dependency: '#originalRelease',
+      dependency: '#mainRelease',
       value: input.value(false),
     }),
 
     withPropertyFromObject({
-      object: '#originalRelease',
+      object: '#mainRelease',
       property: input.value('name'),
     }),
 
     {
-      dependencies: ['name', '#originalRelease.name'],
+      dependencies: ['name', '#mainRelease.name'],
       compute: (continuation, {
         name,
-        ['#originalRelease.name']: originalName,
+        ['#mainRelease.name']: mainReleaseName,
       }) => continuation({
-        ['#alwaysReferenceByDirectory']: name === originalName,
+        ['#alwaysReferenceByDirectory']:
+          name === mainReleaseName,
       }),
     },
   ],
diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js
index eaac14de..3d4d081e 100644
--- a/src/data/composite/things/track/withContainingTrackSection.js
+++ b/src/data/composite/things/track/withContainingTrackSection.js
@@ -1,11 +1,9 @@
 // Gets the track section containing this track from its album's track list.
 
-import {input, templateCompositeFrom} from '#composite';
-import {is} from '#validators';
+import {templateCompositeFrom} from '#composite';
 
-import {raiseOutputWithoutDependency} from '#composite/control-flow';
-
-import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
 
 export default templateCompositeFrom({
   annotation: `withContainingTrackSection`,
@@ -13,30 +11,10 @@ export default templateCompositeFrom({
   outputs: ['#trackSection'],
 
   steps: () => [
-    withPropertyFromAlbum({
-      property: input.value('trackSections'),
-    }),
-
-    raiseOutputWithoutDependency({
-      dependency: '#album.trackSections',
-      output: input.value({'#trackSection': null}),
+    withUniqueReferencingThing({
+      reverse: soupyReverse.input('trackSectionsWhichInclude'),
+    }).outputs({
+      ['#uniqueReferencingThing']: '#trackSection',
     }),
-
-    {
-      dependencies: [
-        input.myself(),
-        '#album.trackSections',
-      ],
-
-      compute: (continuation, {
-        [input.myself()]: track,
-        [input('notFoundMode')]: notFoundMode,
-        ['#album.trackSections']: trackSections,
-      }) => continuation({
-        ['#trackSection']:
-          trackSections.find(({tracks}) => tracks.includes(track))
-            ?? null,
-      }),
-    },
   ],
 });
diff --git a/src/data/composite/things/track/withCoverArtistContribs.js b/src/data/composite/things/track/withCoverArtistContribs.js
new file mode 100644
index 00000000..9057cfeb
--- /dev/null
+++ b/src/data/composite/things/track/withCoverArtistContribs.js
@@ -0,0 +1,73 @@
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList} from '#validators';
+
+import {exposeDependencyOrContinue} from '#composite/control-flow';
+
+import {
+  withRecontextualizedContributionList,
+  withRedatedContributionList,
+  withResolvedContribs,
+} from '#composite/wiki-data';
+
+import exitWithoutUniqueCoverArt from './exitWithoutUniqueCoverArt.js';
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+import withTrackArtDate from './withTrackArtDate.js';
+
+export default templateCompositeFrom({
+  annotation: `withCoverArtistContribs`,
+
+  inputs: {
+    from: input({
+      defaultDependency: 'coverArtistContribs',
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#coverArtistContribs'],
+
+  steps: () => [
+    exitWithoutUniqueCoverArt({
+      value: input.value([]),
+    }),
+
+    withTrackArtDate(),
+
+    withResolvedContribs({
+      from: input('from'),
+      thingProperty: input.value('coverArtistContribs'),
+      artistProperty: input.value('trackCoverArtistContributions'),
+      date: '#trackArtDate',
+    }).outputs({
+      '#resolvedContribs': '#coverArtistContribs',
+    }),
+
+    exposeDependencyOrContinue({
+      dependency: '#coverArtistContribs',
+      mode: input.value('empty'),
+    }),
+
+    withPropertyFromAlbum({
+      property: input.value('trackCoverArtistContribs'),
+    }),
+
+    withRecontextualizedContributionList({
+      list: '#album.trackCoverArtistContribs',
+      artistProperty: input.value('trackCoverArtistContributions'),
+    }),
+
+    withRedatedContributionList({
+      list: '#album.trackCoverArtistContribs',
+      date: '#trackArtDate',
+    }),
+
+    {
+      dependencies: ['#album.trackCoverArtistContribs'],
+      compute: (continuation, {
+        ['#album.trackCoverArtistContribs']: coverArtistContribs,
+      }) => continuation({
+        ['#coverArtistContribs']: coverArtistContribs,
+      }),
+    },
+  ],
+});
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/withDirectorySuffix.js b/src/data/composite/things/track/withDirectorySuffix.js
new file mode 100644
index 00000000..c063e158
--- /dev/null
+++ b/src/data/composite/things/track/withDirectorySuffix.js
@@ -0,0 +1,36 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+import withSuffixDirectoryFromAlbum from './withSuffixDirectoryFromAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withDirectorySuffix`,
+
+  outputs: ['#directorySuffix'],
+
+  steps: () => [
+    withSuffixDirectoryFromAlbum(),
+
+    raiseOutputWithoutDependency({
+      dependency: '#suffixDirectoryFromAlbum',
+      mode: input.value('falsy'),
+      output: input.value({['#directorySuffix']: null}),
+    }),
+
+    withPropertyFromAlbum({
+      property: input.value('directorySuffix'),
+    }),
+
+    {
+      dependencies: ['#album.directorySuffix'],
+      compute: (continuation, {
+        ['#album.directorySuffix']: directorySuffix,
+      }) => continuation({
+        ['#directorySuffix']:
+          directorySuffix,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js
index 96078d5f..85d3b92a 100644
--- a/src/data/composite/things/track/withHasUniqueCoverArt.js
+++ b/src/data/composite/things/track/withHasUniqueCoverArt.js
@@ -5,11 +5,18 @@
 // or a placeholder. (This property is named hasUniqueCoverArt instead of
 // the usual hasCoverArt to emphasize that it does not inherit from the
 // album.)
+//
+// withHasUniqueCoverArt is based only around the presence of *specified*
+// cover artist contributions, not whether the references to artists on those
+// contributions actually resolve to anything. It completely evades interacting
+// with find/replace.
 
 import {input, templateCompositeFrom} from '#composite';
-import {empty} from '#sugar';
 
-import {withResolvedContribs} from '#composite/wiki-data';
+import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {fillMissingListItems, withFlattenedList, withPropertyFromList}
+  from '#composite/data';
 
 import withPropertyFromAlbum from './withPropertyFromAlbum.js';
 
@@ -29,33 +36,73 @@ export default templateCompositeFrom({
           : continuation()),
     },
 
-    withResolvedContribs({from: 'coverArtistContribs'}),
+    withResultOfAvailabilityCheck({
+      from: 'coverArtistContribs',
+      mode: input.value('empty'),
+    }),
 
     {
-      dependencies: ['#resolvedContribs'],
+      dependencies: ['#availability'],
       compute: (continuation, {
-        ['#resolvedContribs']: contribsFromTrack,
+        ['#availability']: availability,
       }) =>
-        (empty(contribsFromTrack)
-          ? continuation()
-          : continuation.raiseOutput({
+        (availability
+          ? continuation.raiseOutput({
               ['#hasUniqueCoverArt']: true,
-            })),
+            })
+          : continuation()),
     },
 
     withPropertyFromAlbum({
       property: input.value('trackCoverArtistContribs'),
+      internal: input.value(true),
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#album.trackCoverArtistContribs',
+      mode: input.value('empty'),
     }),
 
     {
-      dependencies: ['#album.trackCoverArtistContribs'],
+      dependencies: ['#availability'],
       compute: (continuation, {
-        ['#album.trackCoverArtistContribs']: contribsFromAlbum,
+        ['#availability']: availability,
       }) =>
-        continuation.raiseOutput({
-          ['#hasUniqueCoverArt']:
-            !empty(contribsFromAlbum),
-        }),
+        (availability
+          ? continuation.raiseOutput({
+              ['#hasUniqueCoverArt']: true,
+            })
+          : continuation()),
     },
+
+    raiseOutputWithoutDependency({
+      dependency: 'trackArtworks',
+      mode: input.value('empty'),
+      output: input.value({'#hasUniqueCoverArt': false}),
+    }),
+
+    withPropertyFromList({
+      list: 'trackArtworks',
+      property: input.value('artistContribs'),
+      internal: input.value(true),
+    }),
+
+    // Since we're getting the update value for each artwork's artistContribs,
+    // it may not be set at all, and in that case won't be exposing as [].
+    fillMissingListItems({
+      list: '#trackArtworks.artistContribs',
+      fill: input.value([]),
+    }),
+
+    withFlattenedList({
+      list: '#trackArtworks.artistContribs',
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#flattenedList',
+      mode: input.value('empty'),
+    }).outputs({
+      '#availability': '#hasUniqueCoverArt',
+    }),
   ],
 });
diff --git a/src/data/composite/things/track/withOriginalRelease.js b/src/data/composite/things/track/withMainRelease.js
index c7f49657..3a91edae 100644
--- a/src/data/composite/things/track/withOriginalRelease.js
+++ b/src/data/composite/things/track/withMainRelease.js
@@ -1,62 +1,54 @@
-// Just includes the original release of this track as a dependency.
-// If this track isn't a rerelease, then it'll provide null, unless the
-// {selfIfOriginal} option is set, in which case it'll provide this track
-// itself. This will early exit (with notFoundValue) if the original release
+// Just includes the main release of this track as a dependency.
+// If this track isn't a secondary release, then it'll provide null, unless
+// the {selfIfMain} option is set, in which case it'll provide this track
+// itself. This will early exit (with notFoundValue) if the main release
 // is specified by reference and that reference doesn't resolve to anything.
 
 import {input, templateCompositeFrom} from '#composite';
-import find from '#find';
-import {validateWikiData} from '#validators';
 
 import {exitWithoutDependency, withResultOfAvailabilityCheck}
   from '#composite/control-flow';
 import {withResolvedReference} from '#composite/wiki-data';
+import {soupyFind} from '#composite/wiki-properties';
 
 export default templateCompositeFrom({
-  annotation: `withOriginalRelease`,
+  annotation: `withMainRelease`,
 
   inputs: {
-    selfIfOriginal: input({type: 'boolean', defaultValue: false}),
-
-    data: input({
-      validate: validateWikiData({referenceType: 'track'}),
-      defaultDependency: 'trackData',
-    }),
-
+    selfIfMain: input({type: 'boolean', defaultValue: false}),
     notFoundValue: input({defaultValue: null}),
   },
 
-  outputs: ['#originalRelease'],
+  outputs: ['#mainRelease'],
 
   steps: () => [
     withResultOfAvailabilityCheck({
-      from: 'originalReleaseTrack',
+      from: 'mainReleaseTrack',
     }),
 
     {
       dependencies: [
         input.myself(),
-        input('selfIfOriginal'),
+        input('selfIfMain'),
         '#availability',
       ],
 
       compute: (continuation, {
         [input.myself()]: track,
-        [input('selfIfOriginal')]: selfIfOriginal,
+        [input('selfIfMain')]: selfIfMain,
         '#availability': availability,
       }) =>
         (availability
           ? continuation()
           : continuation.raiseOutput({
-              ['#originalRelease']:
-                (selfIfOriginal ? track : null),
+              ['#mainRelease']:
+                (selfIfMain ? track : null),
             })),
     },
 
     withResolvedReference({
-      ref: 'originalReleaseTrack',
-      data: input('data'),
-      find: input.value(find.track),
+      ref: 'mainReleaseTrack',
+      find: soupyFind.input('track'),
     }),
 
     exitWithoutDependency({
@@ -71,7 +63,7 @@ export default templateCompositeFrom({
         ['#resolvedReference']: resolvedReference,
       }) =>
         continuation({
-          ['#originalRelease']: resolvedReference,
+          ['#mainRelease']: resolvedReference,
         }),
     },
   ],
diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js
index f8c1c3f0..0639742f 100644
--- a/src/data/composite/things/track/withOtherReleases.js
+++ b/src/data/composite/things/track/withOtherReleases.js
@@ -1,8 +1,12 @@
+// Gets all releases of the current track *except* this track itself;
+// in other words, all other releases of the current track.
+
 import {input, templateCompositeFrom} from '#composite';
 
 import {exitWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
 
-import withOriginalRelease from './withOriginalRelease.js';
+import withAllReleases from './withAllReleases.js';
 
 export default templateCompositeFrom({
   annotation: `withOtherReleases`,
@@ -10,31 +14,16 @@ export default templateCompositeFrom({
   outputs: ['#otherReleases'],
 
   steps: () => [
-    exitWithoutDependency({
-      dependency: 'trackData',
-      mode: input.value('empty'),
-    }),
-
-    withOriginalRelease({
-      selfIfOriginal: input.value(true),
-      notFoundValue: input.value([]),
-    }),
+    withAllReleases(),
 
     {
-      dependencies: [input.myself(), '#originalRelease', 'trackData'],
+      dependencies: [input.myself(), '#allReleases'],
       compute: (continuation, {
         [input.myself()]: thisTrack,
-        ['#originalRelease']: originalRelease,
-        trackData,
+        ['#allReleases']: allReleases,
       }) => continuation({
         ['#otherReleases']:
-          (originalRelease === thisTrack
-            ? []
-            : [originalRelease])
-            .concat(trackData.filter(track =>
-              track !== originalRelease &&
-              track !== thisTrack &&
-              track.originalReleaseTrack === originalRelease)),
+          allReleases.filter(track => track !== thisTrack),
       }),
     },
   ],
diff --git a/src/data/composite/things/track/withPropertyFromAlbum.js b/src/data/composite/things/track/withPropertyFromAlbum.js
index d41390fa..a203c2e7 100644
--- a/src/data/composite/things/track/withPropertyFromAlbum.js
+++ b/src/data/composite/things/track/withPropertyFromAlbum.js
@@ -2,17 +2,15 @@
 // property name prefixed with '#album.' (by default).
 
 import {input, templateCompositeFrom} from '#composite';
-import {is} from '#validators';
 
 import {withPropertyFromObject} from '#composite/data';
 
-import withAlbum from './withAlbum.js';
-
 export default templateCompositeFrom({
   annotation: `withPropertyFromAlbum`,
 
   inputs: {
     property: input.staticValue({type: 'string'}),
+    internal: input({type: 'boolean', defaultValue: false}),
   },
 
   outputs: ({
@@ -20,11 +18,21 @@ export default templateCompositeFrom({
   }) => ['#album.' + property],
 
   steps: () => [
-    withAlbum(),
+    // XXX: This is a ridiculous hack considering `defaultValue` above.
+    // If we were certain what was up, we'd just get around to fixing it LOL
+    {
+      dependencies: [input('internal')],
+      compute: (continuation, {
+        [input('internal')]: internal,
+      }) => continuation({
+        ['#internal']: internal ?? false,
+      }),
+    },
 
     withPropertyFromObject({
-      object: '#album',
+      object: 'album',
       property: input('property'),
+      internal: '#internal',
     }),
 
     {
diff --git a/src/data/composite/things/track/withPropertyFromMainRelease.js b/src/data/composite/things/track/withPropertyFromMainRelease.js
new file mode 100644
index 00000000..393a4c63
--- /dev/null
+++ b/src/data/composite/things/track/withPropertyFromMainRelease.js
@@ -0,0 +1,86 @@
+// Provides a value inherited from the main release, if applicable, and a
+// flag indicating if this track is a secondary release or not.
+//
+// Like withMainRelease, this will early exit (with notFoundValue) if the
+// main release is specified by reference and that reference doesn't
+// resolve to anything.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+import withMainRelease from './withMainRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `inheritFromMainRelease`,
+
+  inputs: {
+    property: input({type: 'string'}),
+
+    notFoundValue: input({
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ({
+    [input.staticValue('property')]: property,
+  }) =>
+    ['#isSecondaryRelease'].concat(
+      (property
+        ? ['#mainRelease.' + property]
+        : ['#mainReleaseValue'])),
+
+  steps: () => [
+    withMainRelease({
+      notFoundValue: input('notFoundValue'),
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#mainRelease',
+    }),
+
+    {
+      dependencies: [
+        '#availability',
+        input.staticValue('property'),
+      ],
+
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input.staticValue('property')]: property,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutput(
+              Object.assign(
+                {'#isSecondaryRelease': false},
+                (property
+                  ? {['#mainRelease.' + property]: null}
+                  : {'#mainReleaseValue': null})))),
+    },
+
+    withPropertyFromObject({
+      object: '#mainRelease',
+      property: input('property'),
+    }),
+
+    {
+      dependencies: [
+        '#value',
+        input.staticValue('property'),
+      ],
+
+      compute: (continuation, {
+        ['#value']: value,
+        [input.staticValue('property')]: property,
+      }) =>
+        continuation.raiseOutput(
+          Object.assign(
+            {'#isSecondaryRelease': true},
+            (property
+              ? {['#mainRelease.' + property]: value}
+              : {'#mainReleaseValue': value}))),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js b/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js
new file mode 100644
index 00000000..7159a3f4
--- /dev/null
+++ b/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js
@@ -0,0 +1,53 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withSuffixDirectoryFromAlbum`,
+
+  inputs: {
+    flagValue: input({
+      defaultDependency: 'suffixDirectoryFromAlbum',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#suffixDirectoryFromAlbum'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: 'suffixDirectoryFromAlbum',
+    }),
+
+    {
+      dependencies: [
+        '#availability',
+        'suffixDirectoryFromAlbum'
+      ],
+
+      compute: (continuation, {
+        ['#availability']: availability,
+        ['suffixDirectoryFromAlbum']: flagValue,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({['#suffixDirectoryFromAlbum']: flagValue})
+          : continuation()),
+    },
+
+    withPropertyFromAlbum({
+      property: input.value('suffixTrackDirectories'),
+    }),
+
+    {
+      dependencies: ['#album.suffixTrackDirectories'],
+      compute: (continuation, {
+        ['#album.suffixTrackDirectories']: suffixTrackDirectories,
+      }) => continuation({
+        ['#suffixDirectoryFromAlbum']:
+          suffixTrackDirectories,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withTrackArtDate.js b/src/data/composite/things/track/withTrackArtDate.js
new file mode 100644
index 00000000..9b7b61c7
--- /dev/null
+++ b/src/data/composite/things/track/withTrackArtDate.js
@@ -0,0 +1,60 @@
+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,
+    }),
+  },
+
+  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'],
+      compute: (continuation, {
+        ['#album.trackArtDate']: albumTrackArtDate,
+      }) =>
+        (albumTrackArtDate
+          ? continuation.raiseOutput({'#trackArtDate': albumTrackArtDate})
+          : continuation()),
+    },
+
+    withDate().outputs({
+      '#date': '#trackArtDate',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/withTrackNumber.js b/src/data/composite/things/track/withTrackNumber.js
new file mode 100644
index 00000000..61428e8c
--- /dev/null
+++ b/src/data/composite/things/track/withTrackNumber.js
@@ -0,0 +1,50 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withIndexInList, withPropertiesFromObject} from '#composite/data';
+
+import withContainingTrackSection from './withContainingTrackSection.js';
+
+export default templateCompositeFrom({
+  annotation: `withTrackNumber`,
+
+  outputs: ['#trackNumber'],
+
+  steps: () => [
+    withContainingTrackSection(),
+
+    // Zero is the fallback, not one, but in most albums the first track
+    // (and its intended output by this composition) will be one.
+    raiseOutputWithoutDependency({
+      dependency: '#trackSection',
+      output: input.value({'#trackNumber': 0}),
+    }),
+
+    withPropertiesFromObject({
+      object: '#trackSection',
+      properties: input.value(['tracks', 'startCountingFrom']),
+    }),
+
+    withIndexInList({
+      list: '#trackSection.tracks',
+      item: input.myself(),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#index',
+      output: input.value({'#trackNumber': 0}),
+    }),
+
+    {
+      dependencies: ['#trackSection.startCountingFrom', '#index'],
+      compute: (continuation, {
+        ['#trackSection.startCountingFrom']: startCountingFrom,
+        ['#index']: index,
+      }) => continuation({
+        ['#trackNumber']:
+          startCountingFrom +
+          index,
+      }),
+    },
+  ],
+});
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/gobbleSoupyFind.js b/src/data/composite/wiki-data/gobbleSoupyFind.js
new file mode 100644
index 00000000..aec3f5b1
--- /dev/null
+++ b/src/data/composite/wiki-data/gobbleSoupyFind.js
@@ -0,0 +1,39 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import inputSoupyFind, {getSoupyFindInputKey} from './inputSoupyFind.js';
+
+export default templateCompositeFrom({
+  annotation: `gobbleSoupyFind`,
+
+  inputs: {
+    find: inputSoupyFind(),
+  },
+
+  outputs: ['#find'],
+
+  steps: () => [
+    {
+      dependencies: [input('find')],
+      compute: (continuation, {
+        [input('find')]: find,
+      }) =>
+        (typeof find === 'function'
+          ? continuation.raiseOutput({
+              ['#find']: find,
+            })
+          : continuation({
+              ['#key']:
+                getSoupyFindInputKey(find),
+            })),
+    },
+
+    withPropertyFromObject({
+      object: 'find',
+      property: '#key',
+    }).outputs({
+      '#value': '#find',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/gobbleSoupyReverse.js b/src/data/composite/wiki-data/gobbleSoupyReverse.js
new file mode 100644
index 00000000..86a1061c
--- /dev/null
+++ b/src/data/composite/wiki-data/gobbleSoupyReverse.js
@@ -0,0 +1,39 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import inputSoupyReverse, {getSoupyReverseInputKey} from './inputSoupyReverse.js';
+
+export default templateCompositeFrom({
+  annotation: `gobbleSoupyReverse`,
+
+  inputs: {
+    reverse: inputSoupyReverse(),
+  },
+
+  outputs: ['#reverse'],
+
+  steps: () => [
+    {
+      dependencies: [input('reverse')],
+      compute: (continuation, {
+        [input('reverse')]: reverse,
+      }) =>
+        (typeof reverse === 'function'
+          ? continuation.raiseOutput({
+              ['#reverse']: reverse,
+            })
+          : continuation({
+              ['#key']:
+                getSoupyReverseInputKey(reverse),
+            })),
+    },
+
+    withPropertyFromObject({
+      object: 'reverse',
+      property: '#key',
+    }).outputs({
+      '#value': '#reverse',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/helpers/withDirectoryFromName.js b/src/data/composite/wiki-data/helpers/withDirectoryFromName.js
new file mode 100644
index 00000000..f85dae16
--- /dev/null
+++ b/src/data/composite/wiki-data/helpers/withDirectoryFromName.js
@@ -0,0 +1,41 @@
+// Compute a directory from a name.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {isName} from '#validators';
+import {getKebabCase} from '#wiki-data';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `withDirectoryFromName`,
+
+  inputs: {
+    name: input({
+      validate: isName,
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#directory'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('name'),
+      mode: input.value('falsy'),
+      output: input.value({
+        ['#directory']: null,
+      }),
+    }),
+
+    {
+      dependencies: [input('name')],
+      compute: (continuation, {
+        [input('name')]: name,
+      }) => continuation({
+        ['#directory']:
+          getKebabCase(name),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/helpers/withResolvedReverse.js b/src/data/composite/wiki-data/helpers/withResolvedReverse.js
new file mode 100644
index 00000000..818f60b7
--- /dev/null
+++ b/src/data/composite/wiki-data/helpers/withResolvedReverse.js
@@ -0,0 +1,40 @@
+// Actually execute a reverse function.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputWikiData from '../inputWikiData.js';
+
+export default templateCompositeFrom({
+  annotation: `withReverseReferenceList`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: input({type: 'function'}),
+    options: input({type: 'object', defaultValue: null}),
+  },
+
+  outputs: ['#resolvedReverse'],
+
+  steps: () => [
+    {
+      dependencies: [
+        input.myself(),
+        input('data'),
+        input('reverse'),
+        input('options'),
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: myself,
+        [input('data')]: data,
+        [input('reverse')]: reverseFunction,
+        [input('options')]: opts,
+      }) => continuation({
+        ['#resolvedReverse']:
+          (data
+            ? reverseFunction(myself, data, opts)
+            : reverseFunction(myself, opts)),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/helpers/withSimpleDirectory.js b/src/data/composite/wiki-data/helpers/withSimpleDirectory.js
new file mode 100644
index 00000000..08ca3bfc
--- /dev/null
+++ b/src/data/composite/wiki-data/helpers/withSimpleDirectory.js
@@ -0,0 +1,52 @@
+// A "simple" directory, based only on the already-provided directory, if
+// available, or the provided name.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {isDirectory, isName} from '#validators';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+
+import withDirectoryFromName from './withDirectoryFromName.js';
+
+export default templateCompositeFrom({
+  annotation: `withSimpleDirectory`,
+
+  inputs: {
+    directory: input({
+      validate: isDirectory,
+      defaultDependency: 'directory',
+      acceptsNull: true,
+    }),
+
+    name: input({
+      validate: isName,
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#directory'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('directory'),
+    }),
+
+    {
+      dependencies: ['#availability', input('directory')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('directory')]: directory,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              ['#directory']: directory
+            })
+          : continuation()),
+    },
+
+    withDirectoryFromName({
+      name: input('name'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index b4cf6d13..1d94f74b 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -5,12 +5,28 @@
 //
 
 export {default as exitWithoutContribs} from './exitWithoutContribs.js';
+export {default as gobbleSoupyFind} from './gobbleSoupyFind.js';
+export {default as gobbleSoupyReverse} from './gobbleSoupyReverse.js';
+export {default as inputNotFoundMode} from './inputNotFoundMode.js';
+export {default as inputSoupyFind} from './inputSoupyFind.js';
+export {default as inputSoupyReverse} from './inputSoupyReverse.js';
 export {default as inputWikiData} from './inputWikiData.js';
+export {default as processContentEntryDates} from './processContentEntryDates.js';
+export {default as withClonedThings} from './withClonedThings.js';
+export {default as withConstitutedArtwork} from './withConstitutedArtwork.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 withParsedCommentaryEntries} from './withParsedCommentaryEntries.js';
+export {default as withParsedContentEntries} from './withParsedContentEntries.js';
+export {default as withParsedLyricsEntries} from './withParsedLyricsEntries.js';
+export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js';
+export {default as withRedatedContributionList} from './withRedatedContributionList.js';
+export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnotatedReferenceList.js';
 export {default as withResolvedContribs} from './withResolvedContribs.js';
 export {default as withResolvedReference} from './withResolvedReference.js';
 export {default as withResolvedReferenceList} from './withResolvedReferenceList.js';
-export {default as withReverseContributionList} from './withReverseContributionList.js';
+export {default as withResolvedSeriesList} from './withResolvedSeriesList.js';
 export {default as withReverseReferenceList} from './withReverseReferenceList.js';
 export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js';
 export {default as withUniqueReferencingThing} from './withUniqueReferencingThing.js';
diff --git a/src/data/composite/wiki-data/inputNotFoundMode.js b/src/data/composite/wiki-data/inputNotFoundMode.js
new file mode 100644
index 00000000..d16b2472
--- /dev/null
+++ b/src/data/composite/wiki-data/inputNotFoundMode.js
@@ -0,0 +1,9 @@
+import {input} from '#composite';
+import {is} from '#validators';
+
+export default function inputNotFoundMode() {
+  return input({
+    validate: is('exit', 'filter', 'null'),
+    defaultValue: 'filter',
+  });
+}
diff --git a/src/data/composite/wiki-data/inputSoupyFind.js b/src/data/composite/wiki-data/inputSoupyFind.js
new file mode 100644
index 00000000..020f4990
--- /dev/null
+++ b/src/data/composite/wiki-data/inputSoupyFind.js
@@ -0,0 +1,28 @@
+import {input} from '#composite';
+import {anyOf, isFunction, isString} from '#validators';
+
+function inputSoupyFind() {
+  return input({
+    validate:
+      anyOf(
+        isFunction,
+        val => {
+          isString(val);
+
+          if (!val.startsWith('_soupyFind:')) {
+            throw new Error(`Expected soupyFind.input() token`);
+          }
+
+          return true;
+        }),
+  });
+}
+
+inputSoupyFind.input = key =>
+  input.value('_soupyFind:' + key);
+
+export default inputSoupyFind;
+
+export function getSoupyFindInputKey(value) {
+  return value.slice('_soupyFind:'.length);
+}
diff --git a/src/data/composite/wiki-data/inputSoupyReverse.js b/src/data/composite/wiki-data/inputSoupyReverse.js
new file mode 100644
index 00000000..0b0a23fe
--- /dev/null
+++ b/src/data/composite/wiki-data/inputSoupyReverse.js
@@ -0,0 +1,32 @@
+import {input} from '#composite';
+import {anyOf, isFunction, isString} from '#validators';
+
+function inputSoupyReverse() {
+  return input({
+    validate:
+      anyOf(
+        isFunction,
+        val => {
+          isString(val);
+
+          if (!val.startsWith('_soupyReverse:')) {
+            throw new Error(`Expected soupyReverse.input() token`);
+          }
+
+          return true;
+        }),
+  });
+}
+
+inputSoupyReverse.input = key =>
+  input.value('_soupyReverse:' + key);
+
+export default inputSoupyReverse;
+
+export function getSoupyReverseInputKey(value) {
+  return value.slice('_soupyReverse:'.length).replace(/\.unique$/, '');
+}
+
+export function doesSoupyReverseInputWantUnique(value) {
+  return value.endsWith('.unique');
+}
diff --git a/src/data/composite/wiki-data/inputWikiData.js b/src/data/composite/wiki-data/inputWikiData.js
index cf7a7c2c..b9021986 100644
--- a/src/data/composite/wiki-data/inputWikiData.js
+++ b/src/data/composite/wiki-data/inputWikiData.js
@@ -12,6 +12,6 @@ export default function inputWikiData({
 } = {}) {
   return input({
     validate: validateWikiData({referenceType, allowMixedTypes}),
-    acceptsNull: true,
+    defaultValue: null,
   });
 }
diff --git a/src/data/composite/wiki-data/processContentEntryDates.js b/src/data/composite/wiki-data/processContentEntryDates.js
new file mode 100644
index 00000000..e418a121
--- /dev/null
+++ b/src/data/composite/wiki-data/processContentEntryDates.js
@@ -0,0 +1,181 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isContentString, isString, looseArrayOf} from '#validators';
+
+import {fillMissingListItems} from '#composite/data';
+
+// Important note: These two kinds of inputs have the exact same shape!!
+// This isn't on purpose (besides that they *are* both supposed to be strings).
+// They just don't have any more particular validation, yet.
+
+const inputDateList = defaultDependency =>
+  input({
+    validate: looseArrayOf(isString),
+    defaultDependency,
+  });
+
+const inputKindList = defaultDependency =>
+  input.staticDependency({
+    validate: looseArrayOf(isString),
+    defaultDependency: defaultDependency,
+  });
+
+export default templateCompositeFrom({
+  annotation: `processContentEntryDates`,
+
+  inputs: {
+    annotations: input({
+      validate: looseArrayOf(isContentString),
+      defaultDependency: '#entries.annotation',
+    }),
+
+    dates: inputDateList('#entries.date'),
+    secondDates: inputDateList('#entries.secondDate'),
+    accessDates: inputDateList('#entries.accessDate'),
+
+    dateKinds: inputKindList('#entries.dateKind'),
+    accessKinds: inputKindList('#entries.accessKind'),
+  },
+
+  outputs: ({
+    [input.staticDependency('dates')]: dates,
+    [input.staticDependency('secondDates')]: secondDates,
+    [input.staticDependency('accessDates')]: accessDates,
+    [input.staticDependency('dateKinds')]: dateKinds,
+    [input.staticDependency('accessKinds')]: accessKinds,
+  }) => [
+    dates ?? '#processedContentEntryDates',
+    secondDates ?? '#processedContentEntrySecondDates',
+    accessDates ?? '#processedContentEntryAccessDates',
+    dateKinds ?? '#processedContentEntryDateKinds',
+    accessKinds ?? '#processedContentEntryAccessKinds',
+  ],
+
+  steps: () => [
+    {
+      dependencies: [input('annotations')],
+      compute: (continuation, {
+        [input('annotations')]: annotations,
+      }) => continuation({
+        ['#webArchiveDates']:
+          annotations
+            .map(text => text?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//))
+            .map(match => match?.[1])
+            .map(dateText =>
+              (dateText
+                ? dateText.slice(0, 4) + '/' +
+                  dateText.slice(4, 6) + '/' +
+                  dateText.slice(6, 8)
+                : null)),
+      }),
+    },
+
+    {
+      dependencies: [input('dates')],
+      compute: (continuation, {
+        [input('dates')]: dates,
+      }) => continuation({
+        ['#processedContentEntryDates']:
+          dates
+            .map(date => date ? new Date(date) : null),
+      }),
+    },
+
+    {
+      dependencies: [input('secondDates')],
+      compute: (continuation, {
+        [input('secondDates')]: secondDates,
+      }) => continuation({
+        ['#processedContentEntrySecondDates']:
+          secondDates
+            .map(date => date ? new Date(date) : null),
+      }),
+    },
+
+    fillMissingListItems({
+      list: input('dateKinds'),
+      fill: input.value(null),
+    }).outputs({
+      '#list': '#processedContentEntryDateKinds',
+    }),
+
+    {
+      dependencies: [input('accessDates'), '#webArchiveDates'],
+      compute: (continuation, {
+        [input('accessDates')]: accessDates,
+        ['#webArchiveDates']: webArchiveDates,
+      }) => continuation({
+        ['#processedContentEntryAccessDates']:
+          stitchArrays({
+            accessDate: accessDates,
+            webArchiveDate: webArchiveDates
+          }).map(({accessDate, webArchiveDate}) =>
+              accessDate ??
+              webArchiveDate ??
+              null)
+            .map(date => date ? new Date(date) : date),
+      }),
+    },
+
+    {
+      dependencies: [input('accessKinds'), '#webArchiveDates'],
+      compute: (continuation, {
+        [input('accessKinds')]: accessKinds,
+        ['#webArchiveDates']: webArchiveDates,
+      }) => continuation({
+        ['#processedContentEntryAccessKinds']:
+          stitchArrays({
+            accessKind: accessKinds,
+            webArchiveDate: webArchiveDates,
+          }).map(({accessKind, webArchiveDate}) =>
+              accessKind ??
+              (webArchiveDate && 'captured') ??
+              null),
+      }),
+    },
+
+    // TODO: Annoying conversion step for outputs, would be nice to avoid.
+    {
+      dependencies: [
+        '#processedContentEntryDates',
+        '#processedContentEntrySecondDates',
+        '#processedContentEntryAccessDates',
+        '#processedContentEntryDateKinds',
+        '#processedContentEntryAccessKinds',
+        input.staticDependency('dates'),
+        input.staticDependency('secondDates'),
+        input.staticDependency('accessDates'),
+        input.staticDependency('dateKinds'),
+        input.staticDependency('accessKinds'),
+      ],
+
+      compute: (continuation, {
+        ['#processedContentEntryDates']: processedContentEntryDates,
+        ['#processedContentEntrySecondDates']: processedContentEntrySecondDates,
+        ['#processedContentEntryAccessDates']: processedContentEntryAccessDates,
+        ['#processedContentEntryDateKinds']: processedContentEntryDateKinds,
+        ['#processedContentEntryAccessKinds']: processedContentEntryAccessKinds,
+        [input.staticDependency('dates')]: dates,
+        [input.staticDependency('secondDates')]: secondDates,
+        [input.staticDependency('accessDates')]: accessDates,
+        [input.staticDependency('dateKinds')]: dateKinds,
+        [input.staticDependency('accessKinds')]: accessKinds,
+      }) => continuation({
+        [dates ?? '#processedContentEntryDates']:
+          processedContentEntryDates,
+
+        [secondDates ?? '#processedContentEntrySecondDates']:
+          processedContentEntrySecondDates,
+
+        [accessDates ?? '#processedContentEntryAccessDates']:
+          processedContentEntryAccessDates,
+
+        [dateKinds ?? '#processedContentEntryDateKinds']:
+          processedContentEntryDateKinds,
+
+        [accessKinds ?? '#processedContentEntryAccessKinds']:
+          processedContentEntryAccessKinds,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/raiseResolvedReferenceList.js b/src/data/composite/wiki-data/raiseResolvedReferenceList.js
new file mode 100644
index 00000000..613b002b
--- /dev/null
+++ b/src/data/composite/wiki-data/raiseResolvedReferenceList.js
@@ -0,0 +1,96 @@
+// Concludes compositions like withResolvedReferenceList, which share behavior
+// in processing the resolved results before continuing further.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {withFilteredList} from '#composite/data';
+
+import inputNotFoundMode from './inputNotFoundMode.js';
+
+export default templateCompositeFrom({
+  inputs: {
+    notFoundMode: inputNotFoundMode(),
+
+    results: input({type: 'array'}),
+    filter: input({type: 'array'}),
+
+    exitValue: input({defaultValue: []}),
+
+    outputs: input.staticValue({type: 'string'}),
+  },
+
+  outputs: ({
+    [input.staticValue('outputs')]: outputs,
+  }) => [outputs],
+
+  steps: () => [
+    {
+      dependencies: [
+        input('results'),
+        input('filter'),
+        input('outputs'),
+      ],
+
+      compute: (continuation, {
+        [input('results')]: results,
+        [input('filter')]: filter,
+        [input('outputs')]: outputs,
+      }) =>
+        (filter.every(keep => keep)
+          ? continuation.raiseOutput({[outputs]: results})
+          : continuation()),
+    },
+
+    {
+      dependencies: [
+        input('notFoundMode'),
+        input('exitValue'),
+      ],
+
+      compute: (continuation, {
+        [input('notFoundMode')]: notFoundMode,
+        [input('exitValue')]: exitValue,
+      }) =>
+        (notFoundMode === 'exit'
+          ? continuation.exit(exitValue)
+          : continuation()),
+    },
+
+    {
+      dependencies: [
+        input('results'),
+        input('notFoundMode'),
+        input('outputs'),
+      ],
+
+      compute: (continuation, {
+        [input('results')]: results,
+        [input('notFoundMode')]: notFoundMode,
+        [input('outputs')]: outputs,
+      }) =>
+        (notFoundMode === 'null'
+          ? continuation.raiseOutput({[outputs]: results})
+          : continuation()),
+    },
+
+    withFilteredList({
+      list: input('results'),
+      filter: input('filter'),
+    }),
+
+    {
+      dependencies: [
+        '#filteredList',
+        input('outputs'),
+      ],
+
+      compute: (continuation, {
+        ['#filteredList']: filteredList,
+        [input('outputs')]: outputs,
+      }) => continuation({
+        [outputs]:
+          filteredList,
+      }),
+    },
+  ],
+});
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/withConstitutedArtwork.js b/src/data/composite/wiki-data/withConstitutedArtwork.js
new file mode 100644
index 00000000..9e260abf
--- /dev/null
+++ b/src/data/composite/wiki-data/withConstitutedArtwork.js
@@ -0,0 +1,57 @@
+import {input, templateCompositeFrom} from '#composite';
+import thingConstructors from '#things';
+import {isContributionList} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withConstitutedArtwork`,
+
+  inputs: {
+    dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
+    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsArtistProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
+  },
+
+  outputs: ['#constitutedArtwork'],
+
+  steps: () => [
+    {
+      dependencies: [
+        input.myself(),
+        input('dimensionsFromThingProperty'),
+        input('fileExtensionFromThingProperty'),
+        input('dateFromThingProperty'),
+        input('artistContribsFromThingProperty'),
+        input('artistContribsArtistProperty'),
+        input('artTagsFromThingProperty'),
+        input('referencedArtworksFromThingProperty'),
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: myself,
+        [input('dimensionsFromThingProperty')]: dimensionsFromThingProperty,
+        [input('fileExtensionFromThingProperty')]: fileExtensionFromThingProperty,
+        [input('dateFromThingProperty')]: dateFromThingProperty,
+        [input('artistContribsFromThingProperty')]: artistContribsFromThingProperty,
+        [input('artistContribsArtistProperty')]: artistContribsArtistProperty,
+        [input('artTagsFromThingProperty')]: artTagsFromThingProperty,
+        [input('referencedArtworksFromThingProperty')]: referencedArtworksFromThingProperty,
+      }) => continuation({
+        ['#constitutedArtwork']:
+          Object.assign(new thingConstructors.Artwork, {
+            thing: myself,
+            dimensionsFromThingProperty,
+            fileExtensionFromThingProperty,
+            artistContribsFromThingProperty,
+            artistContribsArtistProperty,
+            artTagsFromThingProperty,
+            dateFromThingProperty,
+            referencedArtworksFromThingProperty,
+          }),
+      }),
+    },
+  ],
+});
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..a114d5ff
--- /dev/null
+++ b/src/data/composite/wiki-data/withCoverArtDate.js
@@ -0,0 +1,51 @@
+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,
+    }),
+  },
+
+  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: ['date'],
+      compute: (continuation, {date}) =>
+        (date
+          ? continuation({'#coverArtDate': date})
+          : continuation({'#coverArtDate': null})),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withDirectory.js b/src/data/composite/wiki-data/withDirectory.js
new file mode 100644
index 00000000..f3bedf2e
--- /dev/null
+++ b/src/data/composite/wiki-data/withDirectory.js
@@ -0,0 +1,62 @@
+// Select a directory, either using a manually specified directory, or
+// computing it from a name. By default these values are the current thing's
+// 'directory' and 'name' properties, so it can be used without any options
+// to get the current thing's effective directory (assuming no custom rules).
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {isDirectory, isName} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withSimpleDirectory from './helpers/withSimpleDirectory.js';
+
+export default templateCompositeFrom({
+  annotation: `withDirectory`,
+
+  inputs: {
+    directory: input({
+      validate: isDirectory,
+      defaultDependency: 'directory',
+      acceptsNull: true,
+    }),
+
+    name: input({
+      validate: isName,
+      defaultDependency: 'name',
+      acceptsNull: true,
+    }),
+
+    suffix: input({
+      validate: isDirectory,
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ['#directory'],
+
+  steps: () => [
+    withSimpleDirectory({
+      directory: input('directory'),
+      name: input('name'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#directory',
+      output: input.value({['#directory']: null}),
+    }),
+
+    {
+      dependencies: ['#directory', input('suffix')],
+      compute: (continuation, {
+        ['#directory']: directory,
+        [input('suffix')]: suffix,
+      }) => continuation({
+        ['#directory']:
+          (suffix
+            ? directory + '-' + suffix
+            : directory),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
index f0404a5d..6794c479 100644
--- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js
+++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -1,5 +1,4 @@
 import {input, templateCompositeFrom} from '#composite';
-import find from '#find';
 import {stitchArrays} from '#sugar';
 import {isCommentary} from '#validators';
 import {commentaryRegexCaseSensitive} from '#wiki-data';
@@ -11,6 +10,9 @@ import {
   withUnflattenedList,
 } from '#composite/data';
 
+import inputSoupyFind from './inputSoupyFind.js';
+import processContentEntryDates from './processContentEntryDates.js';
+import withParsedContentEntries from './withParsedContentEntries.js';
 import withResolvedReferenceList from './withResolvedReferenceList.js';
 
 export default templateCompositeFrom({
@@ -23,78 +25,23 @@ export default templateCompositeFrom({
   outputs: ['#parsedCommentaryEntries'],
 
   steps: () => [
-    {
-      dependencies: [input('from')],
-
-      compute: (continuation, {
-        [input('from')]: commentaryText,
-      }) => continuation({
-        ['#rawMatches']:
-          Array.from(commentaryText.matchAll(commentaryRegexCaseSensitive)),
-      }),
-    },
-
-    withPropertiesFromList({
-      list: '#rawMatches',
-      properties: input.value([
-        '0', // The entire match as a string.
-        'groups',
-        'index',
-      ]),
-    }).outputs({
-      '#rawMatches.0': '#rawMatches.text',
-      '#rawMatches.groups': '#rawMatches.groups',
-      '#rawMatches.index': '#rawMatches.startIndex',
+    withParsedContentEntries({
+      from: input('from'),
+      caseSensitiveRegex: input.value(commentaryRegexCaseSensitive),
     }),
 
-    {
-      dependencies: [
-        '#rawMatches.text',
-        '#rawMatches.startIndex',
-      ],
-
-      compute: (continuation, {
-        ['#rawMatches.text']: text,
-        ['#rawMatches.startIndex']: startIndex,
-      }) => continuation({
-        ['#rawMatches.endIndex']:
-          stitchArrays({text, startIndex})
-            .map(({text, startIndex}) => startIndex + text.length),
-      }),
-    },
-
-    {
-      dependencies: [
-        input('from'),
-        '#rawMatches.startIndex',
-        '#rawMatches.endIndex',
-      ],
-
-      compute: (continuation, {
-        [input('from')]: commentaryText,
-        ['#rawMatches.startIndex']: startIndex,
-        ['#rawMatches.endIndex']: endIndex,
-      }) => continuation({
-        ['#entries.body']:
-          stitchArrays({startIndex, endIndex})
-            .map(({endIndex}, index, stitched) =>
-              (index === stitched.length - 1
-                ? commentaryText.slice(endIndex)
-                : commentaryText.slice(
-                    endIndex,
-                    stitched[index + 1].startIndex)))
-            .map(body => body.trim()),
-      }),
-    },
-
     withPropertiesFromList({
-      list: '#rawMatches.groups',
+      list: '#parsedContentEntryHeadings',
       prefix: input.value('#entries'),
       properties: input.value([
         'artistReferences',
         'artistDisplayText',
         'annotation',
         'date',
+        'secondDate',
+        'dateKind',
+        'accessDate',
+        'accessKind',
       ]),
     }),
 
@@ -118,8 +65,7 @@ export default templateCompositeFrom({
 
     withResolvedReferenceList({
       list: '#flattenedList',
-      data: 'artistData',
-      find: input.value(find.artist),
+      find: inputSoupyFind.input('artist'),
       notFoundMode: input.value('null'),
     }),
 
@@ -139,15 +85,7 @@ export default templateCompositeFrom({
       fill: input.value(null),
     }),
 
-    {
-      dependencies: ['#entries.date'],
-      compute: (continuation, {
-        ['#entries.date']: date,
-      }) => continuation({
-        ['#entries.date']:
-          date.map(date => date ? new Date(date) : null),
-      }),
-    },
+    processContentEntryDates(),
 
     {
       dependencies: [
@@ -155,7 +93,11 @@ export default templateCompositeFrom({
         '#entries.artistDisplayText',
         '#entries.annotation',
         '#entries.date',
-        '#entries.body',
+        '#entries.secondDate',
+        '#entries.dateKind',
+        '#entries.accessDate',
+        '#entries.accessKind',
+        '#parsedContentEntryBodies',
       ],
 
       compute: (continuation, {
@@ -163,7 +105,11 @@ export default templateCompositeFrom({
         ['#entries.artistDisplayText']: artistDisplayText,
         ['#entries.annotation']: annotation,
         ['#entries.date']: date,
-        ['#entries.body']: body,
+        ['#entries.secondDate']: secondDate,
+        ['#entries.dateKind']: dateKind,
+        ['#entries.accessDate']: accessDate,
+        ['#entries.accessKind']: accessKind,
+        ['#parsedContentEntryBodies']: body,
       }) => continuation({
         ['#parsedCommentaryEntries']:
           stitchArrays({
@@ -171,6 +117,10 @@ export default templateCompositeFrom({
             artistDisplayText,
             annotation,
             date,
+            secondDate,
+            dateKind,
+            accessDate,
+            accessKind,
             body,
           }),
       }),
diff --git a/src/data/composite/wiki-data/withParsedContentEntries.js b/src/data/composite/wiki-data/withParsedContentEntries.js
new file mode 100644
index 00000000..2a9b3f6a
--- /dev/null
+++ b/src/data/composite/wiki-data/withParsedContentEntries.js
@@ -0,0 +1,111 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isContentString, validateInstanceOf} from '#validators';
+
+import {withPropertiesFromList} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withParsedContentEntries`,
+
+  inputs: {
+    // TODO: Is there any way to validate this input based on the *other*
+    // inputs proivded, i.e. regexes? This kind of just assumes the string
+    // has already been validated according to the form the regex expects,
+    // which *is* always the case (as used), but it seems a bit awkward.
+    from: input({validate: isContentString}),
+
+    caseSensitiveRegex: input({
+      validate: validateInstanceOf(RegExp),
+    }),
+  },
+
+  outputs: [
+    '#parsedContentEntryHeadings',
+    '#parsedContentEntryBodies',
+  ],
+
+  steps: () => [
+    {
+      dependencies: [
+        input('from'),
+        input('caseSensitiveRegex'),
+      ],
+
+      compute: (continuation, {
+        [input('from')]: commentaryText,
+        [input('caseSensitiveRegex')]: caseSensitiveRegex,
+      }) => continuation({
+        ['#rawMatches']:
+          Array.from(commentaryText.matchAll(caseSensitiveRegex)),
+      }),
+    },
+
+    withPropertiesFromList({
+      list: '#rawMatches',
+      properties: input.value([
+        '0', // The entire match as a string.
+        'groups',
+        'index',
+      ]),
+    }).outputs({
+      '#rawMatches.0': '#rawMatches.text',
+      '#rawMatches.groups': '#parsedContentEntryHeadings',
+      '#rawMatches.index': '#rawMatches.startIndex',
+    }),
+
+    {
+      dependencies: [
+        '#rawMatches.text',
+        '#rawMatches.startIndex',
+      ],
+
+      compute: (continuation, {
+        ['#rawMatches.text']: text,
+        ['#rawMatches.startIndex']: startIndex,
+      }) => continuation({
+        ['#rawMatches.endIndex']:
+          stitchArrays({text, startIndex})
+            .map(({text, startIndex}) => startIndex + text.length),
+      }),
+    },
+
+    {
+      dependencies: [
+        input('from'),
+        '#rawMatches.startIndex',
+        '#rawMatches.endIndex',
+      ],
+
+      compute: (continuation, {
+        [input('from')]: commentaryText,
+        ['#rawMatches.startIndex']: startIndex,
+        ['#rawMatches.endIndex']: endIndex,
+      }) => continuation({
+        ['#parsedContentEntryBodies']:
+          stitchArrays({startIndex, endIndex})
+            .map(({endIndex}, index, stitched) =>
+              (index === stitched.length - 1
+                ? commentaryText.slice(endIndex)
+                : commentaryText.slice(
+                    endIndex,
+                    stitched[index + 1].startIndex)))
+            .map(body => body.trim()),
+      }),
+    },
+
+    {
+      dependencies: [
+        '#parsedContentEntryHeadings',
+        '#parsedContentEntryBodies',
+      ],
+
+      compute: (continuation, {
+        ['#parsedContentEntryHeadings']: parsedContentEntryHeadings,
+        ['#parsedContentEntryBodies']: parsedContentEntryBodies,
+      }) => continuation({
+        ['#parsedContentEntryHeadings']: parsedContentEntryHeadings,
+        ['#parsedContentEntryBodies']: parsedContentEntryBodies,
+      })
+    }
+  ],
+});
diff --git a/src/data/composite/wiki-data/withParsedLyricsEntries.js b/src/data/composite/wiki-data/withParsedLyricsEntries.js
new file mode 100644
index 00000000..d13bfbaa
--- /dev/null
+++ b/src/data/composite/wiki-data/withParsedLyricsEntries.js
@@ -0,0 +1,157 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isLyrics} from '#validators';
+import {commentaryRegexCaseSensitive, oldStyleLyricsDetectionRegex}
+  from '#wiki-data';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withPropertiesFromList,
+  withUnflattenedList,
+} from '#composite/data';
+
+import inputSoupyFind from './inputSoupyFind.js';
+import processContentEntryDates from './processContentEntryDates.js';
+import withParsedContentEntries from './withParsedContentEntries.js';
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+function constituteLyricsEntry(text) {
+  return {
+    artists: [],
+    artistDisplayText: null,
+    annotation: null,
+    date: null,
+    secondDate: null,
+    dateKind: null,
+    accessDate: null,
+    accessKind: null,
+    body: text,
+  };
+}
+
+export default templateCompositeFrom({
+  annotation: `withParsedLyricsEntries`,
+
+  inputs: {
+    from: input({validate: isLyrics}),
+  },
+
+  outputs: ['#parsedLyricsEntries'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: lyrics,
+      }) =>
+        (oldStyleLyricsDetectionRegex.test(lyrics)
+          ? continuation()
+          : continuation.raiseOutput({
+              ['#parsedLyricsEntries']:
+                [constituteLyricsEntry(lyrics)],
+            })),
+    },
+
+    withParsedContentEntries({
+      from: input('from'),
+      caseSensitiveRegex: input.value(commentaryRegexCaseSensitive),
+    }),
+
+    withPropertiesFromList({
+      list: '#parsedContentEntryHeadings',
+      prefix: input.value('#entries'),
+      properties: input.value([
+        'artistReferences',
+        'artistDisplayText',
+        'annotation',
+        'date',
+        'secondDate',
+        'dateKind',
+        'accessDate',
+        'accessKind',
+      ]),
+    }),
+
+    // The artistReferences group will always have a value, since it's required
+    // for the line to match in the first place.
+
+    {
+      dependencies: ['#entries.artistReferences'],
+      compute: (continuation, {
+        ['#entries.artistReferences']: artistReferenceTexts,
+      }) => continuation({
+        ['#entries.artistReferences']:
+          artistReferenceTexts
+            .map(text => text.split(',').map(ref => ref.trim())),
+      }),
+    },
+
+    withFlattenedList({
+      list: '#entries.artistReferences',
+    }),
+
+    withResolvedReferenceList({
+      list: '#flattenedList',
+      find: inputSoupyFind.input('artist'),
+      notFoundMode: input.value('null'),
+    }),
+
+    withUnflattenedList({
+      list: '#resolvedReferenceList',
+    }).outputs({
+      '#unflattenedList': '#entries.artists',
+    }),
+
+    fillMissingListItems({
+      list: '#entries.artistDisplayText',
+      fill: input.value(null),
+    }),
+
+    fillMissingListItems({
+      list: '#entries.annotation',
+      fill: input.value(null),
+    }),
+
+    processContentEntryDates(),
+
+    {
+      dependencies: [
+        '#entries.artists',
+        '#entries.artistDisplayText',
+        '#entries.annotation',
+        '#entries.date',
+        '#entries.secondDate',
+        '#entries.dateKind',
+        '#entries.accessDate',
+        '#entries.accessKind',
+        '#parsedContentEntryBodies',
+      ],
+
+      compute: (continuation, {
+        ['#entries.artists']: artists,
+        ['#entries.artistDisplayText']: artistDisplayText,
+        ['#entries.annotation']: annotation,
+        ['#entries.date']: date,
+        ['#entries.secondDate']: secondDate,
+        ['#entries.dateKind']: dateKind,
+        ['#entries.accessDate']: accessDate,
+        ['#entries.accessKind']: accessKind,
+        ['#parsedContentEntryBodies']: body,
+      }) => continuation({
+        ['#parsedLyricsEntries']:
+          stitchArrays({
+            artists,
+            artistDisplayText,
+            annotation,
+            date,
+            secondDate,
+            dateKind,
+            accessDate,
+            accessKind,
+            body,
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withRecontextualizedContributionList.js b/src/data/composite/wiki-data/withRecontextualizedContributionList.js
new file mode 100644
index 00000000..bcc6e486
--- /dev/null
+++ b/src/data/composite/wiki-data/withRecontextualizedContributionList.js
@@ -0,0 +1,100 @@
+// 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 {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/withResolvedAnnotatedReferenceList.js b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
new file mode 100644
index 00000000..9cc52f29
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
@@ -0,0 +1,100 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isObject, validateArrayItems} from '#validators';
+
+import {withPropertyFromList} from '#composite/data';
+
+import {raiseOutputWithoutDependency, withAvailabilityFilter}
+  from '#composite/control-flow';
+
+import inputSoupyFind from './inputSoupyFind.js';
+import inputNotFoundMode from './inputNotFoundMode.js';
+import inputWikiData from './inputWikiData.js';
+import raiseResolvedReferenceList from './raiseResolvedReferenceList.js';
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedAnnotatedReferenceList`,
+
+  inputs: {
+    list: input({
+      validate: validateArrayItems(isObject),
+      acceptsNull: true,
+    }),
+
+    reference: input({type: 'string', defaultValue: 'reference'}),
+    annotation: input({type: 'string', defaultValue: 'annotation'}),
+    thing: input({type: 'string', defaultValue: 'thing'}),
+
+    data: inputWikiData({allowMixedTypes: true}),
+    find: inputSoupyFind(),
+
+    notFoundMode: inputNotFoundMode(),
+  },
+
+  outputs: ['#resolvedAnnotatedReferenceList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('list'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedAnnotatedReferenceList']: [],
+      }),
+    }),
+
+    withPropertyFromList({
+      list: input('list'),
+      property: input('reference'),
+    }).outputs({
+      ['#values']: '#references',
+    }),
+
+    withPropertyFromList({
+      list: input('list'),
+      property: input('annotation'),
+    }).outputs({
+      ['#values']: '#annotations',
+    }),
+
+    withResolvedReferenceList({
+      list: '#references',
+      data: input('data'),
+      find: input('find'),
+      notFoundMode: input.value('null'),
+    }),
+
+    {
+      dependencies: [
+        input('thing'),
+        input('annotation'),
+        '#resolvedReferenceList',
+        '#annotations',
+      ],
+
+      compute: (continuation, {
+        [input('thing')]: thingProperty,
+        [input('annotation')]: annotationProperty,
+        ['#resolvedReferenceList']: things,
+        ['#annotations']: annotations,
+      }) => continuation({
+        ['#matches']:
+          stitchArrays({
+            [thingProperty]: things,
+            [annotationProperty]: annotations,
+          }),
+      }),
+    },
+
+    withAvailabilityFilter({
+      from: '#resolvedReferenceList',
+    }),
+
+    raiseResolvedReferenceList({
+      notFoundMode: input('notFoundMode'),
+      results: '#matches',
+      filter: '#availabilityFilter',
+      outputs: input.value('#resolvedAnnotatedReferenceList'),
+    }),
+  ],
+})
diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js
index 77b0f96d..838c991f 100644
--- a/src/data/composite/wiki-data/withResolvedContribs.js
+++ b/src/data/composite/wiki-data/withResolvedContribs.js
@@ -1,22 +1,20 @@
 // Resolves the contribsByRef contained in the provided dependency,
 // providing (named by the second argument) the result. "Resolving"
-// means mapping the "who" reference of each contribution to an artist
-// object, and filtering out those whose "who" doesn't match any artist.
+// means mapping the artist reference of each contribution to an artist
+// object, and filtering out those whose artist reference doesn't match
+// any artist.
 
 import {input, templateCompositeFrom} from '#composite';
-import find from '#find';
 import {filterMultipleArrays, stitchArrays} from '#sugar';
-import {is, isContributionList} from '#validators';
+import thingConstructors from '#things';
+import {isContributionList, isDate, isStringNonEmpty} from '#validators';
 
-import {
-  raiseOutputWithoutDependency,
-} from '#composite/control-flow';
+import {raiseOutputWithoutDependency, withAvailabilityFilter}
+  from '#composite/control-flow';
+import {withPropertyFromList, withPropertiesFromList} from '#composite/data';
 
-import {
-  withPropertiesFromList,
-} from '#composite/data';
-
-import withResolvedReferenceList from './withResolvedReferenceList.js';
+import inputNotFoundMode from './inputNotFoundMode.js';
+import raiseResolvedReferenceList from './raiseResolvedReferenceList.js';
 
 export default templateCompositeFrom({
   annotation: `withResolvedContribs`,
@@ -27,9 +25,21 @@ export default templateCompositeFrom({
       acceptsNull: true,
     }),
 
-    notFoundMode: input({
-      validate: is('exit', 'filter', 'null'),
-      defaultValue: 'null',
+    date: input({
+      validate: isDate,
+      acceptsNull: true,
+    }),
+
+    notFoundMode: inputNotFoundMode(),
+
+    thingProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
     }),
   },
 
@@ -44,33 +54,103 @@ 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(['who', 'what']),
+      properties: input.value(['artist', 'annotation']),
       prefix: input.value('#contribs'),
     }),
 
-    withResolvedReferenceList({
-      list: '#contribs.who',
-      data: 'artistData',
-      find: input.value(find.artist),
-      notFoundMode: input('notFoundMode'),
-    }).outputs({
-      ['#resolvedReferenceList']: '#contribs.who',
-    }),
-
     {
-      dependencies: ['#contribs.who', '#contribs.what'],
+      dependencies: [
+        '#contribs.artist',
+        '#contribs.annotation',
+        input('date'),
+      ],
 
       compute(continuation, {
-        ['#contribs.who']: who,
-        ['#contribs.what']: what,
+        ['#contribs.artist']: artist,
+        ['#contribs.annotation']: annotation,
+        [input('date')]: date,
       }) {
-        filterMultipleArrays(who, what, (who, _what) => who);
+        filterMultipleArrays(artist, annotation, (artist, _annotation) => artist);
+
         return continuation({
-          ['#resolvedContribs']: stitchArrays({who, what}),
+          ['#details']:
+            stitchArrays({artist, annotation})
+              .map(details => ({
+                ...details,
+                date: date ?? null,
+              })),
         });
       },
     },
+
+    {
+      dependencies: [
+        '#details',
+        '#thingProperty',
+        input('artistProperty'),
+        input.myself(),
+        'find',
+      ],
+
+      compute: (continuation, {
+        ['#details']: details,
+        ['#thingProperty']: thingProperty,
+        [input('artistProperty')]: artistProperty,
+        [input.myself()]: myself,
+        ['find']: find,
+      }) => continuation({
+        ['#contributions']:
+          details.map(details => {
+            const contrib = new thingConstructors.Contribution();
+
+            Object.assign(contrib, {
+              ...details,
+              thing: myself,
+              thingProperty: thingProperty,
+              artistProperty: artistProperty,
+              find: find,
+            });
+
+            return contrib;
+          }),
+      }),
+    },
+
+    withPropertyFromList({
+      list: '#contributions',
+      property: input.value('artist'),
+    }),
+
+    withAvailabilityFilter({
+      from: '#contributions.artist',
+    }),
+
+    raiseResolvedReferenceList({
+      notFoundMode: input('notFoundMode'),
+      results: '#contributions',
+      filter: '#availabilityFilter',
+      outputs: input.value('#resolvedContribs'),
+    }),
   ],
 });
diff --git a/src/data/composite/wiki-data/withResolvedReference.js b/src/data/composite/wiki-data/withResolvedReference.js
index ea71707e..6f422194 100644
--- a/src/data/composite/wiki-data/withResolvedReference.js
+++ b/src/data/composite/wiki-data/withResolvedReference.js
@@ -1,16 +1,14 @@
 // Resolves a reference by using the provided find function to match it
-// within the provided thingData dependency. This will early exit if the
-// data dependency is null. Otherwise, the data object is provided on the
-// output dependency, or null, if the reference doesn't match anything or
+// within the provided thingData dependency. The data object is provided on
+// the output dependency, or null, if the reference doesn't match anything or
 // itself was null to begin with.
 
 import {input, templateCompositeFrom} from '#composite';
 
-import {
-  exitWithoutDependency,
-  raiseOutputWithoutDependency,
-} from '#composite/control-flow';
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
 
+import gobbleSoupyFind from './gobbleSoupyFind.js';
+import inputSoupyFind from './inputSoupyFind.js';
 import inputWikiData from './inputWikiData.js';
 
 export default templateCompositeFrom({
@@ -20,7 +18,7 @@ export default templateCompositeFrom({
     ref: input({type: 'string', acceptsNull: true}),
 
     data: inputWikiData({allowMixedTypes: false}),
-    find: input({type: 'function'}),
+    find: inputSoupyFind(),
   },
 
   outputs: ['#resolvedReference'],
@@ -33,24 +31,26 @@ export default templateCompositeFrom({
       }),
     }),
 
-    exitWithoutDependency({
-      dependency: input('data'),
+    gobbleSoupyFind({
+      find: input('find'),
     }),
 
     {
       dependencies: [
         input('ref'),
         input('data'),
-        input('find'),
+        '#find',
       ],
 
       compute: (continuation, {
         [input('ref')]: ref,
         [input('data')]: data,
-        [input('find')]: findFunction,
+        ['#find']: findFunction,
       }) => continuation({
         ['#resolvedReference']:
-          findFunction(ref, data, {mode: 'quiet'}) ?? null,
+          (data
+            ? findFunction(ref, data, {mode: 'quiet'}) ?? null
+            : findFunction(ref, {mode: 'quiet'}) ?? null),
       }),
     },
   ],
diff --git a/src/data/composite/wiki-data/withResolvedReferenceList.js b/src/data/composite/wiki-data/withResolvedReferenceList.js
index 1d39e5b2..9dc960dd 100644
--- a/src/data/composite/wiki-data/withResolvedReferenceList.js
+++ b/src/data/composite/wiki-data/withResolvedReferenceList.js
@@ -1,18 +1,20 @@
 // Resolves a list of references, with each reference matched with provided
-// data in the same way as withResolvedReference. This will early exit if the
-// data dependency is null (even if the reference list is empty). By default
-// it will filter out references which don't match, but this can be changed
-// to early exit ({notFoundMode: 'exit'}) or leave null in place ('null').
+// data in the same way as withResolvedReference. By default it will filter
+// out references which don't match, but this can be changed to early exit
+// ({notFoundMode: 'exit'}) or leave null in place ('null').
 
 import {input, templateCompositeFrom} from '#composite';
-import {is, isString, validateArrayItems} from '#validators';
+import {isString, validateArrayItems} from '#validators';
 
-import {
-  exitWithoutDependency,
-  raiseOutputWithoutDependency,
-} from '#composite/control-flow';
+import {raiseOutputWithoutDependency, withAvailabilityFilter}
+  from '#composite/control-flow';
+import {withMappedList} from '#composite/data';
 
+import gobbleSoupyFind from './gobbleSoupyFind.js';
+import inputNotFoundMode from './inputNotFoundMode.js';
+import inputSoupyFind from './inputSoupyFind.js';
 import inputWikiData from './inputWikiData.js';
+import raiseResolvedReferenceList from './raiseResolvedReferenceList.js';
 
 export default templateCompositeFrom({
   annotation: `withResolvedReferenceList`,
@@ -23,23 +25,15 @@ export default templateCompositeFrom({
       acceptsNull: true,
     }),
 
-    data: inputWikiData({allowMixedTypes: false}),
-    find: input({type: 'function'}),
+    data: inputWikiData({allowMixedTypes: true}),
+    find: inputSoupyFind(),
 
-    notFoundMode: input({
-      validate: is('exit', 'filter', 'null'),
-      defaultValue: 'filter',
-    }),
+    notFoundMode: inputNotFoundMode(),
   },
 
   outputs: ['#resolvedReferenceList'],
 
   steps: () => [
-    exitWithoutDependency({
-      dependency: input('data'),
-      value: input.value([]),
-    }),
-
     raiseOutputWithoutDependency({
       dependency: input('list'),
       mode: input.value('empty'),
@@ -48,54 +42,39 @@ export default templateCompositeFrom({
       }),
     }),
 
+    gobbleSoupyFind({
+      find: input('find'),
+    }),
+
     {
-      dependencies: [input('list'), input('data'), input('find')],
+      dependencies: [input('data'), '#find'],
       compute: (continuation, {
-        [input('list')]: list,
         [input('data')]: data,
-        [input('find')]: findFunction,
-      }) =>
-        continuation({
-          '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})),
-        }),
-    },
-
-    {
-      dependencies: ['#matches'],
-      compute: (continuation, {'#matches': matches}) =>
-        (matches.every(match => match)
-          ? continuation.raiseOutput({
-              ['#resolvedReferenceList']: matches,
-            })
-          : continuation()),
+        ['#find']: findFunction,
+      }) => continuation({
+        ['#map']:
+          (data
+            ? ref => findFunction(ref, data, {mode: 'quiet'})
+            : ref => findFunction(ref, {mode: 'quiet'})),
+      }),
     },
 
-    {
-      dependencies: ['#matches', input('notFoundMode')],
-      compute(continuation, {
-        ['#matches']: matches,
-        [input('notFoundMode')]: notFoundMode,
-      }) {
-        switch (notFoundMode) {
-          case 'exit':
-            return continuation.exit([]);
-
-          case 'filter':
-            return continuation.raiseOutput({
-              ['#resolvedReferenceList']:
-                matches.filter(match => match),
-            });
+    withMappedList({
+      list: input('list'),
+      map: '#map',
+    }).outputs({
+      '#mappedList': '#matches',
+    }),
 
-          case 'null':
-            return continuation.raiseOutput({
-              ['#resolvedReferenceList']:
-                matches.map(match => match ?? null),
-            });
+    withAvailabilityFilter({
+      from: '#matches',
+    }),
 
-          default:
-            throw new TypeError(`Expected notFoundMode to be exit, filter, or null`);
-        }
-      },
-    },
+    raiseResolvedReferenceList({
+      notFoundMode: input('notFoundMode'),
+      results: '#matches',
+      filter: '#availabilityFilter',
+      outputs: input.value('#resolvedReferenceList'),
+    }),
   ],
 });
diff --git a/src/data/composite/wiki-data/withResolvedSeriesList.js b/src/data/composite/wiki-data/withResolvedSeriesList.js
new file mode 100644
index 00000000..deaab466
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedSeriesList.js
@@ -0,0 +1,130 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isSeriesList, validateThing} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withUnflattenedList,
+  withPropertiesFromList,
+} from '#composite/data';
+
+import inputSoupyFind from './inputSoupyFind.js';
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedSeriesList`,
+
+  inputs: {
+    group: input({
+      validate: validateThing({referenceType: 'group'}),
+    }),
+
+    list: input({
+      validate: isSeriesList,
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#resolvedSeriesList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('list'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedSeriesList']: [],
+      }),
+    }),
+
+    withPropertiesFromList({
+      list: input('list'),
+      prefix: input.value('#serieses'),
+      properties: input.value([
+        'name',
+        'description',
+        'albums',
+
+        'showAlbumArtists',
+      ]),
+    }),
+
+    fillMissingListItems({
+      list: '#serieses.albums',
+      fill: input.value([]),
+    }),
+
+    withFlattenedList({
+      list: '#serieses.albums',
+    }),
+
+    withResolvedReferenceList({
+      list: '#flattenedList',
+      find: inputSoupyFind.input('album'),
+      notFoundMode: input.value('null'),
+    }),
+
+    withUnflattenedList({
+      list: '#resolvedReferenceList',
+    }).outputs({
+      '#unflattenedList': '#serieses.albums',
+    }),
+
+    fillMissingListItems({
+      list: '#serieses.description',
+      fill: input.value(null),
+    }),
+
+    fillMissingListItems({
+      list: '#serieses.showAlbumArtists',
+      fill: input.value(null),
+    }),
+
+    {
+      dependencies: [
+        '#serieses.name',
+        '#serieses.description',
+        '#serieses.albums',
+
+        '#serieses.showAlbumArtists',
+      ],
+
+      compute: (continuation, {
+        ['#serieses.name']: name,
+        ['#serieses.description']: description,
+        ['#serieses.albums']: albums,
+
+        ['#serieses.showAlbumArtists']: showAlbumArtists,
+      }) => continuation({
+        ['#seriesProperties']:
+          stitchArrays({
+            name,
+            description,
+            albums,
+
+            showAlbumArtists,
+          }).map(properties => ({
+              ...properties,
+              group: input
+            }))
+      }),
+    },
+
+    {
+      dependencies: ['#seriesProperties', input('group')],
+      compute: (continuation, {
+        ['#seriesProperties']: seriesProperties,
+        [input('group')]: group,
+      }) => continuation({
+        ['#resolvedSeriesList']:
+          seriesProperties
+            .map(properties => ({
+              ...properties,
+              group,
+            })),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withReverseContributionList.js b/src/data/composite/wiki-data/withReverseContributionList.js
deleted file mode 100644
index eccb58b7..00000000
--- a/src/data/composite/wiki-data/withReverseContributionList.js
+++ /dev/null
@@ -1,83 +0,0 @@
-// 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 implementation uses a global cache (via WeakMap) to attempt to speed
-// up subsequent similar accesses.
-//
-// This has absolutely not been rigorously tested with altering properties of
-// data objects in a wiki data array which is reused. If a new wiki data array
-// is used, a fresh cache will always be created.
-
-import {input, templateCompositeFrom} from '#composite';
-
-import {exitWithoutDependency} from '#composite/control-flow';
-
-import inputWikiData from './inputWikiData.js';
-
-// Mapping of reference list property to WeakMap.
-// Each WeakMap maps a wiki data array to another weak map,
-// which in turn maps each referenced thing to an array of
-// things referencing it.
-const caches = new Map();
-
-export default templateCompositeFrom({
-  annotation: `withReverseContributionList`,
-
-  inputs: {
-    data: inputWikiData({allowMixedTypes: false}),
-    list: input({type: 'string'}),
-  },
-
-  outputs: ['#reverseContributionList'],
-
-  steps: () => [
-    exitWithoutDependency({
-      dependency: input('data'),
-      value: input.value([]),
-      mode: input.value('empty'),
-    }),
-
-    {
-      dependencies: [input.myself(), input('data'), input('list')],
-
-      compute: (continuation, {
-        [input.myself()]: myself,
-        [input('data')]: data,
-        [input('list')]: list,
-      }) => {
-        if (!caches.has(list)) {
-          caches.set(list, new WeakMap());
-        }
-
-        const cache = caches.get(list);
-
-        if (!cache.has(data)) {
-          const cacheRecord = new WeakMap();
-
-          for (const referencingThing of data) {
-            const referenceList = referencingThing[list];
-
-            // Destructuring {who} is the only unique part of the
-            // withReverseContributionList implementation, compared to
-            // withReverseReferneceList.
-            for (const {who: referencedThing} of referenceList) {
-              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) ?? [],
-        });
-      },
-    },
-  ],
-});
diff --git a/src/data/composite/wiki-data/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js
index 2d7a421b..906f5bc5 100644
--- a/src/data/composite/wiki-data/withReverseReferenceList.js
+++ b/src/data/composite/wiki-data/withReverseReferenceList.js
@@ -1,81 +1,36 @@
 // Check out the info on reverseReferenceList!
 // This is its composable form.
-//
-// This implementation uses a global cache (via WeakMap) to attempt to speed
-// up subsequent similar accesses.
-//
-// This has absolutely not been rigorously tested with altering properties of
-// data objects in a wiki data array which is reused. If a new wiki data array
-// is used, a fresh cache will always be created.
-//
-// Note that this implementation is mirrored in withReverseContributionList,
-// so any changes should be reflected there (until these are combined).
 
 import {input, templateCompositeFrom} from '#composite';
 
-import {exitWithoutDependency} from '#composite/control-flow';
-
+import gobbleSoupyReverse from './gobbleSoupyReverse.js';
+import inputSoupyReverse from './inputSoupyReverse.js';
 import inputWikiData from './inputWikiData.js';
 
-// Mapping of reference list property to WeakMap.
-// Each WeakMap maps a wiki data array to another weak map,
-// which in turn maps each referenced thing to an array of
-// things referencing it.
-const caches = new Map();
+import withResolvedReverse from './helpers/withResolvedReverse.js';
 
 export default templateCompositeFrom({
   annotation: `withReverseReferenceList`,
 
   inputs: {
-    data: inputWikiData({allowMixedTypes: false}),
-    list: input({type: 'string'}),
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: inputSoupyReverse(),
   },
 
   outputs: ['#reverseReferenceList'],
 
   steps: () => [
-    exitWithoutDependency({
-      dependency: input('data'),
-      value: input.value([]),
-      mode: input.value('empty'),
+    gobbleSoupyReverse({
+      reverse: input('reverse'),
     }),
 
-    {
-      dependencies: [input.myself(), input('data'), input('list')],
-
-      compute: (continuation, {
-        [input.myself()]: myself,
-        [input('data')]: data,
-        [input('list')]: list,
-      }) => {
-        if (!caches.has(list)) {
-          caches.set(list, new WeakMap());
-        }
-
-        const cache = caches.get(list);
-
-        if (!cache.has(data)) {
-          const cacheRecord = new WeakMap();
+    // TODO: Check that the reverse spec returns a list.
 
-          for (const referencingThing of data) {
-            const referenceList = referencingThing[list];
-            for (const referencedThing of referenceList) {
-              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) ?? [],
-        });
-      },
-    },
+    withResolvedReverse({
+      data: input('data'),
+      reverse: '#reverse',
+    }).outputs({
+      '#resolvedReverse': '#reverseReferenceList',
+    }),
   ],
 });
diff --git a/src/data/composite/wiki-data/withUniqueReferencingThing.js b/src/data/composite/wiki-data/withUniqueReferencingThing.js
index ce04f838..7c267038 100644
--- a/src/data/composite/wiki-data/withUniqueReferencingThing.js
+++ b/src/data/composite/wiki-data/withUniqueReferencingThing.js
@@ -4,49 +4,33 @@
 
 import {input, templateCompositeFrom} from '#composite';
 
-import {exitWithoutDependency, raiseOutputWithoutDependency}
-  from '#composite/control-flow';
-
+import gobbleSoupyReverse from './gobbleSoupyReverse.js';
+import inputSoupyReverse from './inputSoupyReverse.js';
 import inputWikiData from './inputWikiData.js';
-import withReverseReferenceList from './withReverseReferenceList.js';
+
+import withResolvedReverse from './helpers/withResolvedReverse.js';
 
 export default templateCompositeFrom({
   annotation: `withUniqueReferencingThing`,
 
   inputs: {
-    data: inputWikiData({allowMixedTypes: false}),
-    list: input({type: 'string'}),
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: inputSoupyReverse(),
   },
 
   outputs: ['#uniqueReferencingThing'],
 
   steps: () => [
-    // withReverseRefernceList does this check too, but it early exits with
-    // an empty array. That's no good here!
-    exitWithoutDependency({
-      dependency: input('data'),
-      mode: input.value('empty'),
+    gobbleSoupyReverse({
+      reverse: input('reverse'),
     }),
 
-    withReverseReferenceList({
+    withResolvedReverse({
       data: input('data'),
-      list: input('list'),
+      reverse: '#reverse',
+      options: input.value({unique: true}),
+    }).outputs({
+      '#resolvedReverse': '#uniqueReferencingThing',
     }),
-
-    raiseOutputWithoutDependency({
-      dependency: '#reverseReferenceList',
-      mode: input.value('empty'),
-      output: input.value({'#uniqueReferencingThing': null}),
-    }),
-
-    {
-      dependencies: ['#reverseReferenceList'],
-      compute: (continuation, {
-        ['#reverseReferenceList']: reverseReferenceList,
-      }) => continuation({
-        ['#uniqueReferencingThing']:
-          reverseReferenceList[0],
-      }),
-    },
   ],
 });
diff --git a/src/data/composite/wiki-properties/annotatedReferenceList.js b/src/data/composite/wiki-properties/annotatedReferenceList.js
new file mode 100644
index 00000000..8e6c96a1
--- /dev/null
+++ b/src/data/composite/wiki-properties/annotatedReferenceList.js
@@ -0,0 +1,64 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {
+  isContentString,
+  optional,
+  validateArrayItems,
+  validateProperties,
+  validateReference,
+} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputSoupyFind, inputWikiData, withResolvedAnnotatedReferenceList}
+  from '#composite/wiki-data';
+
+import {referenceListInputDescriptions, referenceListUpdateDescription}
+  from './helpers/reference-list-helpers.js';
+
+export default templateCompositeFrom({
+  annotation: `annotatedReferenceList`,
+
+  compose: false,
+
+  inputs: {
+    ...referenceListInputDescriptions(),
+
+    data: inputWikiData({allowMixedTypes: true}),
+    find: inputSoupyFind(),
+
+    reference: input.staticValue({type: 'string', defaultValue: 'reference'}),
+    annotation: input.staticValue({type: 'string', defaultValue: 'annotation'}),
+    thing: input.staticValue({type: 'string', defaultValue: 'thing'}),
+  },
+
+  update(staticInputs) {
+    const {
+      [input.staticValue('reference')]: referenceProperty,
+      [input.staticValue('annotation')]: annotationProperty,
+    } = staticInputs;
+
+    return referenceListUpdateDescription({
+      validateReferenceList: type =>
+        validateArrayItems(
+          validateProperties({
+            [referenceProperty]: validateReference(type),
+            [annotationProperty]: optional(isContentString),
+          })),
+    })(staticInputs);
+  },
+
+  steps: () => [
+    withResolvedAnnotatedReferenceList({
+      list: input.updateValue(),
+
+      reference: input('reference'),
+      annotation: input('annotation'),
+      thing: input('thing'),
+
+      data: input('data'),
+      find: input('find'),
+    }),
+
+    exposeDependency({dependency: '#resolvedAnnotatedReferenceList'}),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js
index cd6b7ac4..928bbd1b 100644
--- a/src/data/composite/wiki-properties/commentary.js
+++ b/src/data/composite/wiki-properties/commentary.js
@@ -12,11 +12,15 @@ export default templateCompositeFrom({
 
   compose: false,
 
+  update: {
+    validate: isCommentary,
+  },
+
   steps: () => [
     exitWithoutDependency({
-      dependency: input.updateValue({validate: isCommentary}),
+      dependency: input.updateValue(),
       mode: input.value('falsy'),
-      value: input.value(null),
+      value: input.value([]),
     }),
 
     withParsedCommentaryEntries({
diff --git a/src/data/composite/wiki-properties/constitutibleArtwork.js b/src/data/composite/wiki-properties/constitutibleArtwork.js
new file mode 100644
index 00000000..0ee3bfcd
--- /dev/null
+++ b/src/data/composite/wiki-properties/constitutibleArtwork.js
@@ -0,0 +1,68 @@
+// This composition does not actually inspect the values of any properties
+// specified, so it's not responsible for determining whether a constituted
+// artwork should exist at all.
+
+import {input, templateCompositeFrom} from '#composite';
+import {withEntries} from '#sugar';
+import Thing from '#thing';
+import {validateThing} from '#validators';
+
+import {exposeDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+import {withConstitutedArtwork} from '#composite/wiki-data';
+
+const template = templateCompositeFrom({
+  annotation: `constitutibleArtwork`,
+
+  compose: false,
+
+  inputs: {
+    dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
+    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsArtistProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
+  },
+
+  steps: () => [
+    exposeUpdateValueOrContinue({
+      validate: input.value(
+        validateThing({
+          referenceType: 'artwork',
+        })),
+    }),
+
+    withConstitutedArtwork({
+      dimensionsFromThingProperty: input('dimensionsFromThingProperty'),
+      fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'),
+      dateFromThingProperty: input('dateFromThingProperty'),
+      artistContribsFromThingProperty: input('artistContribsFromThingProperty'),
+      artistContribsArtistProperty: input('artistContribsArtistProperty'),
+      artTagsFromThingProperty: input('artTagsFromThingProperty'),
+      referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'),
+    }),
+
+    exposeDependency({
+      dependency: '#constitutedArtwork',
+    }),
+  ],
+});
+
+template.fromYAMLFieldSpec = function(field) {
+  const {[Thing.yamlDocumentSpec]: documentSpec} = this;
+
+  const {provide} = documentSpec.fields[field].transform;
+
+  const inputs =
+    withEntries(provide, entries =>
+      entries.map(([property, value]) => [
+        property,
+        input.value(value),
+      ]));
+
+  return template(inputs);
+};
+
+export default template;
diff --git a/src/data/composite/wiki-properties/constitutibleArtworkList.js b/src/data/composite/wiki-properties/constitutibleArtworkList.js
new file mode 100644
index 00000000..246c08b5
--- /dev/null
+++ b/src/data/composite/wiki-properties/constitutibleArtworkList.js
@@ -0,0 +1,70 @@
+// This composition does not actually inspect the values of any properties
+// specified, so it's not responsible for determining whether a constituted
+// artwork should exist at all.
+
+import {input, templateCompositeFrom} from '#composite';
+import {withEntries} from '#sugar';
+import Thing from '#thing';
+import {validateWikiData} from '#validators';
+
+import {exposeUpdateValueOrContinue} from '#composite/control-flow';
+import {withConstitutedArtwork} from '#composite/wiki-data';
+
+const template = templateCompositeFrom({
+  annotation: `constitutibleArtworkList`,
+
+  compose: false,
+
+  inputs: {
+    dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
+    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsArtistProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
+  },
+
+  steps: () => [
+    exposeUpdateValueOrContinue({
+      validate: input.value(
+        validateWikiData({
+          referenceType: 'artwork',
+        })),
+    }),
+
+    withConstitutedArtwork({
+      dimensionsFromThingProperty: input('dimensionsFromThingProperty'),
+      fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'),
+      dateFromThingProperty: input('dateFromThingProperty'),
+      artistContribsFromThingProperty: input('artistContribsFromThingProperty'),
+      artistContribsArtistProperty: input('artistContribsArtistProperty'),
+      artTagsFromThingProperty: input('artTagsFromThingProperty'),
+      referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'),
+    }),
+
+    {
+      dependencies: ['#constitutedArtwork'],
+      compute: ({
+        ['#constitutedArtwork']: constitutedArtwork,
+      }) => [constitutedArtwork],
+    },
+  ],
+});
+
+template.fromYAMLFieldSpec = function(field) {
+  const {[Thing.yamlDocumentSpec]: documentSpec} = this;
+
+  const {provide} = documentSpec.fields[field].transform;
+
+  const inputs =
+    withEntries(provide, entries =>
+      entries.map(([property, value]) => [
+        property,
+        input.value(value),
+      ]));
+
+  return template(inputs);
+};
+
+export default template;
diff --git a/src/data/composite/wiki-properties/contributionList.js b/src/data/composite/wiki-properties/contributionList.js
index 8fde2caa..d9a6b417 100644
--- a/src/data/composite/wiki-properties/contributionList.js
+++ b/src/data/composite/wiki-properties/contributionList.js
@@ -3,19 +3,19 @@
 // into one property. Update value will look something like this:
 //
 //   [
-//     {who: 'Artist Name', what: 'Viola'},
-//     {who: 'artist:john-cena', what: null},
+//     {artist: 'Artist Name', annotation: 'Viola'},
+//     {artist: 'artist:john-cena', annotation: null},
 //     ...
 //   ]
 //
 // ...typically as processed from YAML, spreadsheet, or elsewhere.
-// Exposes as the same, but with the "who" replaced with matches found in
-// artistData - which means this always depends on an `artistData` property
-// also existing on this object!
+// Exposes as the same, but with the artist property replaced with matches
+// found in artistData - which means this always depends on an `artistData`
+// property also existing on this object!
 //
 
 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/directory.js b/src/data/composite/wiki-properties/directory.js
index 0b2181c9..1756a8e5 100644
--- a/src/data/composite/wiki-properties/directory.js
+++ b/src/data/composite/wiki-properties/directory.js
@@ -2,22 +2,40 @@
 // almost any data object. Also corresponds to a part of the URL which pages of
 // such objects are visited at.
 
-import {isDirectory} from '#validators';
-import {getKebabCase} from '#wiki-data';
-
-// TODO: Not templateCompositeFrom.
-
-export default function() {
-  return {
-    flags: {update: true, expose: true},
-    update: {validate: isDirectory},
-    expose: {
-      dependencies: ['name'],
-      transform(directory, {name}) {
-        if (directory === null && name === null) return null;
-        else if (directory === null) return getKebabCase(name);
-        else return directory;
-      },
-    },
-  };
-}
+import {input, templateCompositeFrom} from '#composite';
+
+import {isDirectory, isName} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withDirectory} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `directory`,
+
+  compose: false,
+
+  inputs: {
+    name: input({
+      validate: isName,
+      defaultDependency: 'name',
+      acceptsNull: true,
+    }),
+
+    suffix: input({
+      validate: isDirectory,
+      defaultValue: null,
+    }),
+  },
+
+  steps: () => [
+    withDirectory({
+      directory: input.updateValue({validate: isDirectory}),
+      name: input('name'),
+      suffix: input('suffix'),
+    }),
+
+    exposeDependency({
+      dependency: '#directory',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/helpers/reference-list-helpers.js b/src/data/composite/wiki-properties/helpers/reference-list-helpers.js
new file mode 100644
index 00000000..dfdc6b41
--- /dev/null
+++ b/src/data/composite/wiki-properties/helpers/reference-list-helpers.js
@@ -0,0 +1,44 @@
+import {input} from '#composite';
+import {anyOf, isString, isThingClass, validateArrayItems} from '#validators';
+
+export function referenceListInputDescriptions() {
+  return {
+    class: input.staticValue({
+      validate:
+        anyOf(
+          isThingClass,
+          validateArrayItems(isThingClass)),
+
+      acceptsNull: true,
+      defaultValue: null,
+    }),
+
+    referenceType: input.staticValue({
+      validate:
+        anyOf(
+          isString,
+          validateArrayItems(isString)),
+
+      acceptsNull: true,
+      defaultValue: null,
+    }),
+  };
+}
+
+export function referenceListUpdateDescription({
+  validateReferenceList,
+}) {
+  return ({
+    [input.staticValue('class')]: thingClass,
+    [input.staticValue('referenceType')]: referenceType,
+  }) => ({
+    validate:
+      validateReferenceList(
+        (Array.isArray(thingClass)
+          ? thingClass.map(thingClass =>
+              thingClass[Symbol.for('Thing.referenceType')])
+       : thingClass
+          ? thingClass[Symbol.for('Thing.referenceType')]
+          : referenceType)),
+  });
+}
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
index 89cb6838..892fc44a 100644
--- a/src/data/composite/wiki-properties/index.js
+++ b/src/data/composite/wiki-properties/index.js
@@ -5,9 +5,12 @@
 
 export {default as additionalFiles} from './additionalFiles.js';
 export {default as additionalNameList} from './additionalNameList.js';
+export {default as annotatedReferenceList} from './annotatedReferenceList.js';
 export {default as color} from './color.js';
 export {default as commentary} from './commentary.js';
 export {default as commentatorArtists} from './commentatorArtists.js';
+export {default as constitutibleArtwork} from './constitutibleArtwork.js';
+export {default as constitutibleArtworkList} from './constitutibleArtworkList.js';
 export {default as contentString} from './contentString.js';
 export {default as contribsPresent} from './contribsPresent.js';
 export {default as contributionList} from './contributionList.js';
@@ -17,12 +20,19 @@ export {default as duration} from './duration.js';
 export {default as externalFunction} from './externalFunction.js';
 export {default as fileExtension} from './fileExtension.js';
 export {default as flag} from './flag.js';
+export {default as lyrics} from './lyrics.js';
 export {default as name} from './name.js';
 export {default as referenceList} from './referenceList.js';
-export {default as reverseContributionList} from './reverseContributionList.js';
+export {default as referencedArtworkList} from './referencedArtworkList.js';
 export {default as reverseReferenceList} from './reverseReferenceList.js';
+export {default as seriesList} from './seriesList.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 soupyFind} from './soupyFind.js';
+export {default as soupyReverse} from './soupyReverse.js';
+export {default as thing} from './thing.js';
+export {default as thingList} from './thingList.js';
 export {default as urls} from './urls.js';
+export {default as wallpaperParts} from './wallpaperParts.js';
 export {default as wikiData} from './wikiData.js';
diff --git a/src/data/composite/wiki-properties/lyrics.js b/src/data/composite/wiki-properties/lyrics.js
new file mode 100644
index 00000000..eb5e524a
--- /dev/null
+++ b/src/data/composite/wiki-properties/lyrics.js
@@ -0,0 +1,36 @@
+// Lyrics! This comes in two styles - "old", where there's just one set of
+// lyrics, or the newer/standard one, with multiple sets that are each
+// annotated, credited, etc.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isLyrics} from '#validators';
+
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withParsedLyricsEntries} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `lyrics`,
+
+  compose: false,
+
+  update: {
+    validate: isLyrics,
+  },
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input.updateValue(),
+      mode: input.value('falsy'),
+      value: input.value([]),
+    }),
+
+    withParsedLyricsEntries({
+      from: input.updateValue(),
+    }),
+
+    exposeDependency({
+      dependency: '#parsedLyricsEntries',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/referenceList.js b/src/data/composite/wiki-properties/referenceList.js
index af634a68..4f8207b5 100644
--- a/src/data/composite/wiki-properties/referenceList.js
+++ b/src/data/composite/wiki-properties/referenceList.js
@@ -1,5 +1,6 @@
 // Stores and exposes a list of references to other data objects; all items
-// must be references to the same type, which is specified on the class input.
+// must be references to the same type, which is either implied from the class
+// input, or explicitly set on the referenceType input.
 //
 // See also:
 //  - singleReference
@@ -7,10 +8,14 @@
 //
 
 import {input, templateCompositeFrom} from '#composite';
-import {isThingClass, validateReferenceList} from '#validators';
+import {validateReferenceList} from '#validators';
 
 import {exposeDependency} from '#composite/control-flow';
-import {inputWikiData, withResolvedReferenceList} from '#composite/wiki-data';
+import {inputSoupyFind, inputWikiData, withResolvedReferenceList}
+  from '#composite/wiki-data';
+
+import {referenceListInputDescriptions, referenceListUpdateDescription}
+  from './helpers/reference-list-helpers.js';
 
 export default templateCompositeFrom({
   annotation: `referenceList`,
@@ -18,20 +23,16 @@ export default templateCompositeFrom({
   compose: false,
 
   inputs: {
-    class: input.staticValue({validate: isThingClass}),
-
-    data: inputWikiData({allowMixedTypes: false}),
+    ...referenceListInputDescriptions(),
 
-    find: input({type: 'function'}),
+    data: inputWikiData({allowMixedTypes: true}),
+    find: inputSoupyFind(),
   },
 
-  update: ({
-    [input.staticValue('class')]: thingClass,
-  }) => ({
-    validate:
-      validateReferenceList(
-        thingClass[Symbol.for('Thing.referenceType')]),
-  }),
+  update:
+    referenceListUpdateDescription({
+      validateReferenceList: validateReferenceList,
+    }),
 
   steps: () => [
     withResolvedReferenceList({
diff --git a/src/data/composite/wiki-properties/referencedArtworkList.js b/src/data/composite/wiki-properties/referencedArtworkList.js
new file mode 100644
index 00000000..9ba2e393
--- /dev/null
+++ b/src/data/composite/wiki-properties/referencedArtworkList.js
@@ -0,0 +1,32 @@
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {isDate} from '#validators';
+
+import annotatedReferenceList from './annotatedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `referencedArtworkList`,
+
+  compose: false,
+
+  steps: () => [
+    {
+      compute: (continuation) => continuation({
+        ['#find']:
+          find.mixed({
+            track: find.trackPrimaryArtwork,
+            album: find.albumPrimaryArtwork,
+          }),
+      }),
+    },
+
+    annotatedReferenceList({
+      referenceType: input.value(['album', 'track']),
+
+      data: 'artworkData',
+      find: '#find',
+
+      thing: input.value('artwork'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/reverseContributionList.js b/src/data/composite/wiki-properties/reverseContributionList.js
deleted file mode 100644
index 7f3f9c81..00000000
--- a/src/data/composite/wiki-properties/reverseContributionList.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {exposeDependency} from '#composite/control-flow';
-import {inputWikiData, withReverseContributionList} from '#composite/wiki-data';
-
-export default templateCompositeFrom({
-  annotation: `reverseContributionList`,
-
-  compose: false,
-
-  inputs: {
-    data: inputWikiData({allowMixedTypes: false}),
-    list: input({type: 'string'}),
-  },
-
-  steps: () => [
-    withReverseContributionList({
-      data: input('data'),
-      list: input('list'),
-    }),
-
-    exposeDependency({dependency: '#reverseContributionList'}),
-  ],
-});
diff --git a/src/data/composite/wiki-properties/reverseReferenceList.js b/src/data/composite/wiki-properties/reverseReferenceList.js
index 84ba67df..6d590a67 100644
--- a/src/data/composite/wiki-properties/reverseReferenceList.js
+++ b/src/data/composite/wiki-properties/reverseReferenceList.js
@@ -1,13 +1,13 @@
 // Neat little shortcut for "reversing" the reference lists stored on other
 // things - for example, tracks specify a "referenced tracks" property, and
 // you would use this to compute a corresponding "referenced *by* tracks"
-// property. Naturally, the passed ref list property is of the things in the
-// wiki data provided, not the requesting Thing itself.
+// property.
 
 import {input, templateCompositeFrom} from '#composite';
 
 import {exposeDependency} from '#composite/control-flow';
-import {inputWikiData, withReverseReferenceList} from '#composite/wiki-data';
+import {inputSoupyReverse, inputWikiData, withReverseReferenceList}
+  from '#composite/wiki-data';
 
 export default templateCompositeFrom({
   annotation: `reverseReferenceList`,
@@ -15,14 +15,14 @@ export default templateCompositeFrom({
   compose: false,
 
   inputs: {
-    data: inputWikiData({allowMixedTypes: false}),
-    list: input({type: 'string'}),
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: inputSoupyReverse(),
   },
 
   steps: () => [
     withReverseReferenceList({
       data: input('data'),
-      list: input('list'),
+      reverse: input('reverse'),
     }),
 
     exposeDependency({dependency: '#reverseReferenceList'}),
diff --git a/src/data/composite/wiki-properties/seriesList.js b/src/data/composite/wiki-properties/seriesList.js
new file mode 100644
index 00000000..2a101b45
--- /dev/null
+++ b/src/data/composite/wiki-properties/seriesList.js
@@ -0,0 +1,31 @@
+import {input, templateCompositeFrom} from '#composite';
+import {isSeriesList, validateThing} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withResolvedSeriesList} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `seriesList`,
+
+  compose: false,
+
+  inputs: {
+    group: input({
+      validate: validateThing({referenceType: 'group'}),
+    }),
+  },
+
+  steps: () => [
+    withResolvedSeriesList({
+      group: input('group'),
+
+      list: input.updateValue({
+        validate: isSeriesList,
+      }),
+    }),
+
+    exposeDependency({
+      dependency: '#resolvedSeriesList',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/singleReference.js b/src/data/composite/wiki-properties/singleReference.js
index db4fc9f9..f532ebbe 100644
--- a/src/data/composite/wiki-properties/singleReference.js
+++ b/src/data/composite/wiki-properties/singleReference.js
@@ -11,7 +11,8 @@ import {input, templateCompositeFrom} from '#composite';
 import {isThingClass, validateReference} from '#validators';
 
 import {exposeDependency} from '#composite/control-flow';
-import {inputWikiData, withResolvedReference} from '#composite/wiki-data';
+import {inputSoupyFind, inputWikiData, withResolvedReference}
+  from '#composite/wiki-data';
 
 export default templateCompositeFrom({
   annotation: `singleReference`,
@@ -21,8 +22,7 @@ export default templateCompositeFrom({
   inputs: {
     class: input.staticValue({validate: isThingClass}),
 
-    find: input({type: 'function'}),
-
+    find: inputSoupyFind(),
     data: inputWikiData({allowMixedTypes: false}),
   },
 
diff --git a/src/data/composite/wiki-properties/soupyFind.js b/src/data/composite/wiki-properties/soupyFind.js
new file mode 100644
index 00000000..0f9a17e3
--- /dev/null
+++ b/src/data/composite/wiki-properties/soupyFind.js
@@ -0,0 +1,14 @@
+import {isObject} from '#validators';
+
+import {inputSoupyFind} from '#composite/wiki-data';
+
+function soupyFind() {
+  return {
+    flags: {update: true},
+    update: {validate: isObject},
+  };
+}
+
+soupyFind.input = inputSoupyFind.input;
+
+export default soupyFind;
diff --git a/src/data/composite/wiki-properties/soupyReverse.js b/src/data/composite/wiki-properties/soupyReverse.js
new file mode 100644
index 00000000..784a66b4
--- /dev/null
+++ b/src/data/composite/wiki-properties/soupyReverse.js
@@ -0,0 +1,37 @@
+import {isObject} from '#validators';
+
+import {inputSoupyReverse} from '#composite/wiki-data';
+
+function soupyReverse() {
+  return {
+    flags: {update: true},
+    update: {validate: isObject},
+  };
+}
+
+soupyReverse.input = inputSoupyReverse.input;
+
+soupyReverse.contributionsBy =
+  (bindTo, contributionsProperty) => ({
+    bindTo,
+
+    referencing: thing => thing[contributionsProperty],
+    referenced: contrib => [contrib.artist],
+  });
+
+soupyReverse.artworkContributionsBy =
+  (bindTo, artworkProperty, {single = false} = {}) => ({
+    bindTo,
+
+    referencing: thing =>
+      (single
+        ? (thing[artworkProperty]
+            ? thing[artworkProperty].artistContribs
+            : [])
+        : thing[artworkProperty]
+            .flatMap(artwork => artwork.artistContribs)),
+
+    referenced: contrib => [contrib.artist],
+  });
+
+export default soupyReverse;
diff --git a/src/data/composite/wiki-properties/thing.js b/src/data/composite/wiki-properties/thing.js
new file mode 100644
index 00000000..1f97a362
--- /dev/null
+++ b/src/data/composite/wiki-properties/thing.js
@@ -0,0 +1,40 @@
+// An individual Thing, provided directly rather than by reference.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isThingClass, validateThing} from '#validators';
+
+import {exposeConstant, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+
+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: () => [
+    exposeUpdateValueOrContinue(),
+
+    exposeConstant({
+      value: input.value(null),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/thingList.js b/src/data/composite/wiki-properties/thingList.js
new file mode 100644
index 00000000..f4c00e06
--- /dev/null
+++ b/src/data/composite/wiki-properties/thingList.js
@@ -0,0 +1,44 @@
+// A list of Things, provided directly rather than by reference.
+//
+// Essentially the same as wikiData, but exposes the list of things,
+// instead of keeping it private.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isThingClass, validateWikiData} from '#validators';
+
+import {exposeConstant, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `wikiData`,
+
+  compose: false,
+
+  inputs: {
+    class: input.staticValue({
+      validate: isThingClass,
+      defaultValue: null,
+    }),
+  },
+
+  update: ({
+    [input.staticValue('class')]: thingClass,
+  }) => ({
+    validate:
+      validateWikiData({
+        referenceType:
+          (thingClass
+            ? thingClass[Symbol.for('Thing.referenceType')]
+            : ''),
+      }),
+  }),
+
+  steps: () => [
+    exposeUpdateValueOrContinue(),
+
+    exposeConstant({
+      value: input.value([]),
+    }),
+  ],
+});
+
diff --git a/src/data/composite/wiki-properties/wallpaperParts.js b/src/data/composite/wiki-properties/wallpaperParts.js
new file mode 100644
index 00000000..23049397
--- /dev/null
+++ b/src/data/composite/wiki-properties/wallpaperParts.js
@@ -0,0 +1,9 @@
+import {isWallpaperPartList} from '#validators';
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isWallpaperPartList},
+    expose: {transform: value => value ?? []},
+  };
+}
diff --git a/src/data/language.js b/src/data/language.js
index a149e19f..3edf7e51 100644
--- a/src/data/language.js
+++ b/src/data/language.js
@@ -11,7 +11,7 @@ import {annotateError, annotateErrorWithFile, showAggregate, withAggregate}
   from '#aggregate';
 import {externalLinkSpec} from '#external-links';
 import {colors, logWarn} from '#cli';
-import {splitKeys, withEntries} from '#sugar';
+import {empty, splitKeys, withEntries} from '#sugar';
 import T from '#things';
 
 const {Language} = T;
@@ -72,7 +72,11 @@ export function unflattenLanguageSpec(flat, reference) {
   const setNestedProp = (obj, key, value) => {
     const recursive = (o, k) => {
       if (k.length === 1) {
-        o[k[0]] = value;
+        if (typeof o[k[0]] === 'object') {
+          o[k[0]] = {...o[k[0]], _: value};
+        } else {
+          o[k[0]] = value;
+        }
         return;
       }
 
@@ -97,7 +101,7 @@ export function unflattenLanguageSpec(flat, reference) {
       }
 
       const result =
-        (refKeys.length === 1
+        (empty(restKeys)
           ? walkEntry(ownNode[firstKey], refNode)
           : recursive(restKeys, ownNode[firstKey], refNode));
 
@@ -106,7 +110,13 @@ export function unflattenLanguageSpec(flat, reference) {
       }
 
       if (typeof result === 'string') {
-        delete ownNode[firstKey];
+        // When an algorithm faces a corner case, don't rethink the algorithm;
+        // hard-code the right thing to do.
+        if (typeof ownNode[firstKey] === 'object' && empty(restKeys) && ownNode[firstKey]._) {
+          delete ownNode[firstKey]._;
+        } else {
+          delete ownNode[firstKey];
+        }
         return {[firstKey]: result};
       }
 
@@ -121,7 +131,7 @@ export function unflattenLanguageSpec(flat, reference) {
     let mapped;
 
     for (const [key, value] of Object.entries(refNode)) {
-      const result = recursive(splitKeys(key), ownNode, refNode[key]);
+      const result = recursive(splitKeys(key), ownNode, value);
       if (!result) continue;
       if (!mapped) mapped = {};
       Object.assign(mapped, result);
@@ -143,7 +153,7 @@ export function unflattenLanguageSpec(flat, reference) {
       typeof refNode === 'object' &&
       typeof refNode._ === 'string'
     ) {
-      return {_: ownNode};
+      return ownNode;
     }
 
     if (
@@ -170,7 +180,7 @@ export function unflattenLanguageSpec(flat, reference) {
     }
 
     const entries = Object.entries(node);
-    if (entries.length === 0) {
+    if (empty(entries)) {
       return undefined;
     }
 
diff --git a/src/data/serialize.js b/src/data/serialize.js
index 8cac3309..2ecbf76c 100644
--- a/src/data/serialize.js
+++ b/src/data/serialize.js
@@ -16,7 +16,10 @@ export function toRefs(things) {
 }
 
 export function toContribRefs(contribs) {
-  return contribs?.map(({who, what}) => ({who: toRef(who), what}));
+  return contribs?.map(({artist, annotation}) => ({
+    artist: toRef(artist),
+    annotation,
+  }));
 }
 
 export function toCommentaryRefs(entries) {
diff --git a/src/data/thing.js b/src/data/thing.js
index 706e893d..66f73de5 100644
--- a/src/data/thing.js
+++ b/src/data/thing.js
@@ -14,20 +14,68 @@ export default class Thing extends CacheableObject {
   static getSerializeDescriptors = Symbol.for('Thing.getSerializeDescriptors');
 
   static findSpecs = Symbol.for('Thing.findSpecs');
+  static findThisThingOnly = Symbol.for('Thing.findThisThingOnly');
+
+  static reverseSpecs = Symbol.for('Thing.reverseSpecs');
+
   static yamlDocumentSpec = Symbol.for('Thing.yamlDocumentSpec');
   static getYamlLoadingSpec = Symbol.for('Thing.getYamlLoadingSpec');
 
+  static yamlSourceFilename = Symbol.for('Thing.yamlSourceFilename');
+  static yamlSourceDocument = Symbol.for('Thing.yamlSourceDocument');
+  static yamlSourceDocumentPlacement = Symbol.for('Thing.yamlSourceDocumentPlacement');
+
+  [Symbol.for('Thing.yamlSourceFilename')] = null;
+  [Symbol.for('Thing.yamlSourceDocument')] = null;
+  [Symbol.for('Thing.yamlSourceDocumentPlacement')] = null;
+
+  static isThingConstructor = Symbol.for('Thing.isThingConstructor');
+  static isThing = Symbol.for('Thing.isThing');
+
+  // To detect:
+  // Symbol.for('Thing.isThingConstructor') in constructor
+  static [Symbol.for('Thing.isThingConstructor')] = NaN;
+
+  constructor() {
+    super({seal: false});
+
+    // To detect:
+    // Object.hasOwn(object, Symbol.for('Thing.isThing'))
+    this[Symbol.for('Thing.isThing')] = NaN;
+
+    Object.seal(this);
+  }
+
+  static [Symbol.for('Thing.selectAll')] = _wikiData => [];
+
   // Default custom inspect function, which may be overridden by Thing
   // subclasses. This will be used when displaying aggregate errors and other
   // command-line logging - it's the place to provide information useful in
   // identifying the Thing being presented.
   [inspect.custom]() {
-    const cname = this.constructor.name;
+    const constructorName = this.constructor.name;
+
+    let name;
+    try {
+      if (this.name) {
+        name = colors.green(`"${this.name}"`);
+      }
+    } catch (error) {
+      name = colors.yellow(`couldn't get name`);
+    }
+
+    let reference;
+    try {
+      if (this.directory) {
+        reference = colors.blue(Thing.getReference(this));
+      }
+    } catch (error) {
+      reference = colors.yellow(`couldn't get reference`);
+    }
 
     return (
-      (this.name ? `${cname} ${colors.green(`"${this.name}"`)}` : `${cname}`) +
-      (this.directory ? ` (${colors.blue(Thing.getReference(this))})` : '')
-    );
+      (name ? `${constructorName} ${name}` : `${constructorName}`) +
+      (reference ? ` (${reference})` : ''));
   }
 
   static getReference(thing) {
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 40cd4631..4c85ddfa 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -1,26 +1,43 @@
 export const DATA_ALBUM_DIRECTORY = 'album';
 
 import * as path from 'node:path';
+import {inspect} from 'node:util';
 
+import {colors} from '#cli';
 import {input} from '#composite';
-import find from '#find';
 import {traverse} from '#node-utils';
 import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
-import {empty} from '#sugar';
+import {accumulateSum, empty} from '#sugar';
 import Thing from '#thing';
-import {isDate} from '#validators';
-import {parseAdditionalFiles, parseContributors, parseDate, parseDimensions}
-  from '#yaml';
+import {isColor, isDate, isDirectory, isNumber} from '#validators';
 
-import {exposeDependency, exposeUpdateValueOrContinue}
+import {
+  parseAdditionalFiles,
+  parseAdditionalNames,
+  parseAnnotatedReferences,
+  parseArtwork,
+  parseContributors,
+  parseDate,
+  parseDimensions,
+  parseWallpaperParts,
+} from '#yaml';
+
+import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
-import {exitWithoutContribs} from '#composite/wiki-data';
+import {withPropertyFromObject} from '#composite/data';
+
+import {exitWithoutContribs, withDirectory, withCoverArtDate}
+  from '#composite/wiki-data';
 
 import {
   additionalFiles,
+  additionalNameList,
   commentary,
   color,
   commentatorArtists,
+  constitutibleArtwork,
+  constitutibleArtworkList,
+  contentString,
   contribsPresent,
   contributionList,
   dimensions,
@@ -28,26 +45,61 @@ import {
   fileExtension,
   flag,
   name,
+  referencedArtworkList,
   referenceList,
+  reverseReferenceList,
   simpleDate,
   simpleString,
+  soupyFind,
+  soupyReverse,
+  thing,
+  thingList,
   urls,
+  wallpaperParts,
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withTracks, withTrackSections} from '#composite/things/album';
+import {withHasCoverArt, withTracks} from '#composite/things/album';
+import {withAlbum, withContinueCountingFrom, withStartCountingFrom}
+  from '#composite/things/track-section';
 
 export class Album extends Thing {
   static [Thing.referenceType] = 'album';
 
-  static [Thing.getPropertyDescriptors] = ({ArtTag, Artist, Group, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({
+    ArtTag,
+    Artwork,
+    Group,
+    Track,
+    TrackSection,
+    WikiInfo,
+  }) => ({
     // Update & expose
 
     name: name('Unnamed Album'),
-    color: color(),
     directory: directory(),
+
+    directorySuffix: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDirectory),
+      }),
+
+      withDirectory(),
+
+      exposeDependency({
+        dependency: '#directory',
+      }),
+    ],
+
+    alwaysReferenceByDirectory: flag(false),
+    alwaysReferenceTracksByDirectory: flag(false),
+    suffixTrackDirectories: flag(false),
+
+    color: color(),
     urls: urls(),
 
+    additionalNames: additionalNameList(),
+
     bandcampAlbumIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
 
@@ -56,13 +108,13 @@ export class Album extends Thing {
     dateAddedToWiki: simpleDate(),
 
     coverArtDate: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-
-      exposeUpdateValueOrContinue({
-        validate: input.value(isDate),
+      withCoverArtDate({
+        from: input.updateValue({
+          validate: isDate,
+        }),
       }),
 
-      exposeDependency({dependency: 'date'}),
+      exposeDependency({dependency: '#coverArtDate'}),
     ],
 
     coverArtFileExtension: [
@@ -87,6 +139,15 @@ export class Album extends Thing {
       simpleString(),
     ],
 
+    wallpaperParts: [
+      exitWithoutContribs({
+        contribs: 'wallpaperArtistContribs',
+        value: input.value([]),
+      }),
+
+      wallpaperParts(),
+    ],
+
     bannerStyle: [
       exitWithoutContribs({contribs: 'bannerArtistContribs'}),
       simpleString(),
@@ -97,33 +158,105 @@ export class Album extends Thing {
       dimensions(),
     ],
 
+    trackDimensions: dimensions(),
+
     bannerDimensions: [
       exitWithoutContribs({contribs: 'bannerArtistContribs'}),
       dimensions(),
     ],
 
+    wallpaperArtwork: [
+      exitWithoutDependency({
+        dependency: 'wallpaperArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Wallpaper Artwork'),
+    ],
+
+    bannerArtwork: [
+      exitWithoutDependency({
+        dependency: 'bannerArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Banner Artwork'),
+    ],
+
+    coverArtworks: [
+      withHasCoverArt(),
+
+      exitWithoutDependency({
+        dependency: '#hasCoverArt',
+        mode: input.value('falsy'),
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+    ],
+
     hasTrackNumbers: flag(true),
     isListedOnHomepage: flag(true),
     isListedInGalleries: flag(true),
 
     commentary: commentary(),
+    creditSources: commentary(),
     additionalFiles: additionalFiles(),
 
-    trackSections: [
-      withTrackSections(),
-      exposeDependency({dependency: '#trackSections'}),
+    trackSections: thingList({
+      class: input.value(TrackSection),
+    }),
+
+    artistContribs: contributionList({
+      date: 'date',
+      artistProperty: input.value('albumArtistContributions'),
+    }),
+
+    coverArtistContribs: [
+      withCoverArtDate(),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumCoverArtistContributions'),
+      }),
     ],
 
-    artistContribs: contributionList(),
-    coverArtistContribs: contributionList(),
-    trackCoverArtistContribs: contributionList(),
-    wallpaperArtistContribs: contributionList(),
-    bannerArtistContribs: contributionList(),
+    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(),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumWallpaperArtistContributions'),
+      }),
+    ],
+
+    bannerArtistContribs: [
+      withCoverArtDate(),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumBannerArtistContributions'),
+      }),
+    ],
 
     groups: referenceList({
       class: input.value(Group),
-      find: input.value(find.group),
-      data: 'groupData',
+      find: soupyFind.input('group'),
     }),
 
     artTags: [
@@ -134,37 +267,43 @@ export class Album extends Thing {
 
       referenceList({
         class: input.value(ArtTag),
-        find: input.value(find.artTag),
-        data: 'artTagData',
+        find: soupyFind.input('artTag'),
       }),
     ],
 
-    // Update only
+    referencedArtworks: [
+      exitWithoutContribs({
+        contribs: 'coverArtistContribs',
+        value: input.value([]),
+      }),
 
-    artistData: wikiData({
-      class: input.value(Artist),
-    }),
+      referencedArtworkList(),
+    ],
 
-    artTagData: wikiData({
-      class: input.value(ArtTag),
-    }),
+    // Update only
 
-    groupData: wikiData({
-      class: input.value(Group),
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // used for referencedArtworkList (mixedFind)
+    artworkData: wikiData({
+      class: input.value(Artwork),
     }),
 
-    // Only the tracks which belong to this album.
-    // Necessary for computing the track list, so provide this statically
-    // or keep it updated.
-    ownTrackData: wikiData({
-      class: input.value(Track),
+    // used for withMatchingContributionPresets (indirectly by Contribution)
+    wikiInfo: thing({
+      class: input.value(WikiInfo),
     }),
 
     // Expose only
 
     commentatorArtists: commentatorArtists(),
 
-    hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}),
+    hasCoverArt: [
+      withHasCoverArt(),
+      exposeDependency({dependency: '#hasCoverArt'}),
+    ],
+
     hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
     hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
 
@@ -216,15 +355,130 @@ export class Album extends Thing {
 
   static [Thing.findSpecs] = {
     album: {
-      referenceTypes: ['album', 'album-commentary', 'album-gallery'],
+      referenceTypes: [
+        'album',
+        'album-commentary',
+        'album-gallery',
+      ],
+
+      bindTo: 'albumData',
+
+      getMatchableNames: album =>
+        (album.alwaysReferenceByDirectory 
+          ? [] 
+          : [album.name]),
+    },
+
+    albumWithArtwork: {
+      referenceTypes: [
+        'album',
+        'album-referencing-artworks',
+        'album-referenced-artworks',
+      ],
+
+      bindTo: 'albumData',
+
+      include: album =>
+        album.hasCoverArt,
+
+      getMatchableNames: album =>
+        (album.alwaysReferenceByDirectory 
+          ? [] 
+          : [album.name]),
+    },
+
+    albumPrimaryArtwork: {
+      [Thing.findThisThingOnly]: false,
+
+      referenceTypes: [
+        'album',
+        'album-referencing-artworks',
+        'album-referenced-artworks',
+      ],
+
+      bindTo: 'artworkData',
+
+      include: (artwork, {Artwork, Album}) =>
+        artwork instanceof Artwork &&
+        artwork.thing instanceof Album &&
+        artwork === artwork.thing.coverArtworks[0],
+
+      getMatchableNames: ({thing: album}) =>
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+
+      getMatchableDirectories: ({thing: album}) =>
+        [album.directory],
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    albumsWhoseTracksInclude: {
+      bindTo: 'albumData',
+
+      referencing: album => [album],
+      referenced: album => album.tracks,
+    },
+
+    albumsWhoseTrackSectionsInclude: {
+      bindTo: 'albumData',
+
+      referencing: album => [album],
+      referenced: album => album.trackSections,
+    },
+
+    albumsWhoseArtworksFeature: {
+      bindTo: 'albumData',
+
+      referencing: album => [album],
+      referenced: album => album.artTags,
+    },
+
+    albumsWhoseGroupsInclude: {
+      bindTo: 'albumData',
+
+      referencing: album => [album],
+      referenced: album => album.groups,
+    },
+
+    albumArtistContributionsBy:
+      soupyReverse.contributionsBy('albumData', 'artistContribs'),
+
+    albumCoverArtistContributionsBy:
+      soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'),
+
+    albumWallpaperArtistContributionsBy:
+      soupyReverse.artworkContributionsBy('albumData', 'wallpaperArtwork', {single: true}),
+
+    albumBannerArtistContributionsBy:
+      soupyReverse.artworkContributionsBy('albumData', 'bannerArtwork', {single: true}),
+
+    albumsWithCommentaryBy: {
       bindTo: 'albumData',
+
+      referencing: album => [album],
+      referenced: album => album.commentatorArtists,
     },
   };
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
       'Album': {property: 'name'},
+
       'Directory': {property: 'directory'},
+      'Directory Suffix': {property: 'directorySuffix'},
+      'Suffix Track Directories': {property: 'suffixTrackDirectories'},
+
+      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+      'Always Reference Tracks By Directory': {
+        property: 'alwaysReferenceTracksByDirectory',
+      },
+
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
 
       'Bandcamp Album ID': {
         property: 'bandcampAlbumIdentifier',
@@ -248,6 +502,46 @@ export class Album extends Thing {
       'Listed on Homepage': {property: 'isListedOnHomepage'},
       'Listed in Galleries': {property: 'isListedInGalleries'},
 
+      'Cover Artwork': {
+        property: 'coverArtworks',
+        transform:
+          parseArtwork({
+            dimensionsFromThingProperty: 'coverArtDimensions',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dateFromThingProperty: 'coverArtDate',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'albumCoverArtistContributions',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+          }),
+      },
+
+      'Banner Artwork': {
+        property: 'bannerArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            dimensionsFromThingProperty: 'bannerDimensions',
+            fileExtensionFromThingProperty: 'bannerFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'bannerArtistContribs',
+            artistContribsArtistProperty: 'albumBannerArtistContributions',
+          }),
+      },
+
+      'Wallpaper Artwork': {
+        property: 'wallpaperArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            dimensionsFromThingProperty: null,
+            fileExtensionFromThingProperty: 'wallpaperFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'wallpaperArtistContribs',
+            artistContribsArtistProperty: 'albumWallpaperArtistContributions',
+          }),
+      },
+
       'Cover Art Date': {
         property: 'coverArtDate',
         transform: parseDate,
@@ -271,6 +565,11 @@ export class Album extends Thing {
         transform: parseDimensions,
       },
 
+      'Default Track Dimensions': {
+        property: 'trackDimensions',
+        transform: parseDimensions,
+      },
+
       'Wallpaper Artists': {
         property: 'wallpaperArtistContribs',
         transform: parseContributors,
@@ -279,6 +578,11 @@ export class Album extends Thing {
       'Wallpaper Style': {property: 'wallpaperStyle'},
       'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
 
+      'Wallpaper Parts': {
+        property: 'wallpaperParts',
+        transform: parseWallpaperParts,
+      },
+
       'Banner Artists': {
         property: 'bannerArtistContribs',
         transform: parseContributors,
@@ -293,12 +597,18 @@ export class Album extends Thing {
       },
 
       'Commentary': {property: 'commentary'},
+      'Credit Sources': {property: 'creditSources'},
 
       'Additional Files': {
         property: 'additionalFiles',
         transform: parseAdditionalFiles,
       },
 
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
+      },
+
       'Franchises': {ignore: true},
 
       'Artists': {
@@ -321,11 +631,23 @@ export class Album extends Thing {
 
       'Review Points': {ignore: true},
     },
+
+    invalidFieldCombinations: [
+      {message: `Specify one wallpaper style or multiple wallpaper parts, not both`, fields: [
+        'Wallpaper Parts',
+        'Wallpaper Style',
+      ]},
+
+      {message: `Wallpaper file extensions are specified on asset, per part`, fields: [
+        'Wallpaper Parts',
+        'Wallpaper File Extension',
+      ]},
+    ],
   };
 
   static [Thing.getYamlLoadingSpec] = ({
     documentModes: {headerAndEntries},
-    thingConstructors: {Album, Track, TrackSectionHelper},
+    thingConstructors: {Album, Track},
   }) => ({
     title: `Process album files`,
 
@@ -339,68 +661,85 @@ export class Album extends Thing {
     headerDocumentThing: Album,
     entryDocumentThing: document =>
       ('Section' in document
-        ? TrackSectionHelper
+        ? TrackSection
         : Track),
 
     save(results) {
       const albumData = [];
+      const trackSectionData = [];
       const trackData = [];
+      const artworkData = [];
 
       for (const {header: album, entries} of results) {
-        // We can't mutate an array once it's set as a property value,
-        // so prepare the track sections that will show up in a track list
-        // all the way before actually applying them. (It's okay to mutate
-        // an individual section before applying it, since those are just
-        // generic objects; they aren't Things in and of themselves.)
         const trackSections = [];
-        const ownTrackData = [];
 
-        let currentTrackSection = {
+        let currentTrackSection = new TrackSection();
+        let currentTrackSectionTracks = [];
+
+        Object.assign(currentTrackSection, {
           name: `Default Track Section`,
           isDefaultTrackSection: true,
-          tracks: [],
-        };
+        });
 
         const albumRef = Thing.getReference(album);
 
         const closeCurrentTrackSection = () => {
-          if (!empty(currentTrackSection.tracks)) {
-            trackSections.push(currentTrackSection);
+          if (
+            currentTrackSection.isDefaultTrackSection &&
+            empty(currentTrackSectionTracks)
+          ) {
+            return;
           }
+
+          currentTrackSection.tracks =
+            currentTrackSectionTracks;
+
+          trackSections.push(currentTrackSection);
+          trackSectionData.push(currentTrackSection);
         };
 
         for (const entry of entries) {
-          if (entry instanceof TrackSectionHelper) {
+          if (entry instanceof TrackSection) {
             closeCurrentTrackSection();
-
-            currentTrackSection = {
-              name: entry.name,
-              color: entry.color,
-              dateOriginallyReleased: entry.dateOriginallyReleased,
-              isDefaultTrackSection: false,
-              tracks: [],
-            };
-
+            currentTrackSection = entry;
+            currentTrackSectionTracks = [];
             continue;
           }
 
+          currentTrackSectionTracks.push(entry);
           trackData.push(entry);
 
-          entry.dataSourceAlbum = albumRef;
+          // Set the track's album before accessing its list of artworks.
+          // The existence of its artwork objects may depend on access to
+          // its album's 'Default Track Cover Artists'.
+          entry.album = album;
 
-          ownTrackData.push(entry);
-          currentTrackSection.tracks.push(Thing.getReference(entry));
+          artworkData.push(...entry.trackArtworks);
         }
 
         closeCurrentTrackSection();
 
         albumData.push(album);
 
+        artworkData.push(...album.coverArtworks);
+
+        if (album.bannerArtwork) {
+          artworkData.push(album.bannerArtwork);
+        }
+
+        if (album.wallpaperArtwork) {
+          artworkData.push(album.wallpaperArtwork);
+        }
+
         album.trackSections = trackSections;
-        album.ownTrackData = ownTrackData;
       }
 
-      return {albumData, trackData};
+      return {
+        albumData,
+        trackSectionData,
+        trackData,
+        artworkData,
+      };
     },
 
     sort({albumData, trackData}) {
@@ -408,27 +747,213 @@ export class Album extends Thing {
       sortAlbumsTracksChronologically(trackData);
     },
   });
+
+  getOwnArtworkPath(artwork) {
+    if (artwork === this.bannerArtwork) {
+      return [
+        'media.albumBanner',
+        this.directory,
+        artwork.fileExtension,
+      ];
+    }
+
+    if (artwork === this.wallpaperArtwork) {
+      if (!empty(this.wallpaperParts)) {
+        return null;
+      }
+
+      return [
+        'media.albumWallpaper',
+        this.directory,
+        artwork.fileExtension,
+      ];
+    }
+
+    // TODO: using trackCover here is obviously, badly wrong
+    // but we ought to refactor banners and wallpapers similarly
+    // (i.e. depend on those intrinsic artwork paths rather than
+    // accessing media.{albumBanner,albumWallpaper} from content
+    // or other code directly)
+    return [
+      'media.trackCover',
+      this.directory,
+
+      (artwork.unqualifiedDirectory
+        ? 'cover-' + artwork.unqualifiedDirectory
+        : 'cover'),
+
+      artwork.fileExtension,
+    ];
+  }
 }
 
-export class TrackSectionHelper extends Thing {
+export class TrackSection extends Thing {
   static [Thing.friendlyName] = `Track Section`;
+  static [Thing.referenceType] = `track-section`;
+
+  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+    // Update & expose
 
-  static [Thing.getPropertyDescriptors] = () => ({
     name: name('Unnamed Track Section'),
-    color: color(),
+
+    unqualifiedDirectory: directory(),
+
+    color: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isColor),
+      }),
+
+      withAlbum(),
+
+      withPropertyFromObject({
+        object: '#album',
+        property: input.value('color'),
+      }),
+
+      exposeDependency({dependency: '#album.color'}),
+    ],
+
+    startCountingFrom: [
+      withStartCountingFrom({
+        from: input.updateValue({validate: isNumber}),
+      }),
+
+      exposeDependency({dependency: '#startCountingFrom'}),
+    ],
+
     dateOriginallyReleased: simpleDate(),
-    isDefaultTrackGroup: flag(false),
-  })
+
+    isDefaultTrackSection: flag(false),
+
+    description: contentString(),
+
+    album: [
+      withAlbum(),
+      exposeDependency({dependency: '#album'}),
+    ],
+
+    tracks: thingList({
+      class: input.value(Track),
+    }),
+
+    // Update only
+
+    reverse: soupyReverse(),
+
+    // Expose only
+
+    directory: [
+      withAlbum(),
+
+      exitWithoutDependency({
+        dependency: '#album',
+      }),
+
+      withPropertyFromObject({
+        object: '#album',
+        property: input.value('directory'),
+      }),
+
+      withDirectory({
+        directory: 'unqualifiedDirectory',
+      }).outputs({
+        '#directory': '#unqualifiedDirectory',
+      }),
+
+      {
+        dependencies: ['#album.directory', '#unqualifiedDirectory'],
+        compute: ({
+          ['#album.directory']: albumDirectory,
+          ['#unqualifiedDirectory']: unqualifiedDirectory,
+        }) =>
+          albumDirectory + '/' + unqualifiedDirectory,
+      },
+    ],
+
+    continueCountingFrom: [
+      withContinueCountingFrom(),
+
+      exposeDependency({dependency: '#continueCountingFrom'}),
+    ],
+  });
+
+  static [Thing.findSpecs] = {
+    trackSection: {
+      referenceTypes: ['track-section'],
+      bindTo: 'trackSectionData',
+    },
+
+    unqualifiedTrackSection: {
+      referenceTypes: ['unqualified-track-section'],
+
+      getMatchableDirectories: trackSection =>
+        [trackSection.unqualifiedDirectory],
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    trackSectionsWhichInclude: {
+      bindTo: 'trackSectionData',
+
+      referencing: trackSection => [trackSection],
+      referenced: trackSection => trackSection.tracks,
+    },
+  };
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
       'Section': {property: 'name'},
       'Color': {property: 'color'},
+      'Start Counting From': {property: 'startCountingFrom'},
 
       'Date Originally Released': {
         property: 'dateOriginallyReleased',
         transform: parseDate,
       },
+
+      'Description': {property: 'description'},
     },
   };
+
+  [inspect.custom](depth) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (depth >= 0) {
+      let album = null;
+      try {
+        album = this.album;
+      } catch {}
+
+      let first = null;
+      try {
+        first = this.tracks.at(0).trackNumber;
+      } catch {}
+
+      let last = null;
+      try {
+        last = this.tracks.at(-1).trackNumber;
+      } catch {}
+
+      if (album) {
+        const albumName = album.name;
+        const albumIndex = album.trackSections.indexOf(this);
+
+        const num =
+          (albumIndex === -1
+            ? 'indeterminate position'
+            : `#${albumIndex + 1}`);
+
+        const range =
+          (albumIndex >= 0 && first !== null && last !== null
+            ? `: ${first}-${last}`
+            : '');
+
+        parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`);
+      }
+    }
+
+    return parts.join('');
+  }
 }
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index 3149b310..57e156ee 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -1,20 +1,35 @@
 export const ART_TAG_DATA_FILE = 'tags.yaml';
 
 import {input} from '#composite';
+import find from '#find';
 import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort';
 import Thing from '#thing';
+import {unique} from '#sugar';
 import {isName} from '#validators';
+import {parseAdditionalNames, parseAnnotatedReferences} from '#yaml';
 
-import {exposeUpdateValueOrContinue} from '#composite/control-flow';
+import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
 
 import {
+  additionalNameList,
+  annotatedReferenceList,
   color,
+  contentString,
   directory,
   flag,
+  referenceList,
+  reverseReferenceList,
   name,
+  soupyFind,
+  soupyReverse,
+  urls,
   wikiData,
 } from '#composite/wiki-properties';
 
+import {withAllDescendantArtTags, withAncestorArtTagBaobabTree}
+  from '#composite/things/art-tag';
+
 export class ArtTag extends Thing {
   static [Thing.referenceType] = 'tag';
   static [Thing.friendlyName] = `Art Tag`;
@@ -26,6 +41,7 @@ export class ArtTag extends Thing {
     directory: directory(),
     color: color(),
     isContentWarning: flag(false),
+    extraReadingURLs: urls(),
 
     nameShort: [
       exposeUpdateValueOrContinue({
@@ -39,30 +55,72 @@ export class ArtTag extends Thing {
       },
     ],
 
-    // Update only
+    additionalNames: additionalNameList(),
+
+    description: contentString(),
 
-    albumData: wikiData({
-      class: input.value(Album),
+    directDescendantArtTags: referenceList({
+      class: input.value(ArtTag),
+      find: soupyFind.input('artTag'),
     }),
 
-    trackData: wikiData({
-      class: input.value(Track),
+    relatedArtTags: annotatedReferenceList({
+      class: input.value(ArtTag),
+      find: soupyFind.input('artTag'),
+
+      reference: input.value('artTag'),
+      thing: input.value('artTag'),
     }),
 
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
     // Expose only
 
-    taggedInThings: {
-      flags: {expose: true},
+    descriptionShort: [
+      exitWithoutDependency({
+        dependency: 'description',
+        mode: input.value('falsy'),
+      }),
 
-      expose: {
-        dependencies: ['this', 'albumData', 'trackData'],
-        compute: ({this: artTag, albumData, trackData}) =>
-          sortAlbumsTracksChronologically(
-            [...albumData, ...trackData]
-              .filter(({artTags}) => artTags.includes(artTag)),
-            {getDate: thing => thing.coverArtDate ?? thing.date}),
+      {
+        dependencies: ['description'],
+        compute: ({description}) =>
+          description.split('<hr class="split">')[0],
       },
-    },
+    ],
+
+    directlyFeaturedInArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichFeature'),
+    }),
+
+    indirectlyFeaturedInArtworks: [
+      withAllDescendantArtTags(),
+
+      {
+        dependencies: ['#allDescendantArtTags'],
+        compute: ({'#allDescendantArtTags': allDescendantArtTags}) =>
+          unique(
+            allDescendantArtTags
+              .flatMap(artTag => artTag.directlyFeaturedInArtworks)),
+      },
+    ],
+
+    allDescendantArtTags: [
+      withAllDescendantArtTags(),
+      exposeDependency({dependency: '#allDescendantArtTags'}),
+    ],
+
+    directAncestorArtTags: reverseReferenceList({
+      reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'),
+    }),
+
+    ancestorArtTagBaobabTree: [
+      withAncestorArtTagBaobabTree(),
+      exposeDependency({dependency: '#ancestorArtTagBaobabTree'}),
+    ],
   });
 
   static [Thing.findSpecs] = {
@@ -70,10 +128,19 @@ export class ArtTag extends Thing {
       referenceTypes: ['tag'],
       bindTo: 'artTagData',
 
-      getMatchableNames: tag =>
-        (tag.isContentWarning
-          ? [`cw: ${tag.name}`]
-          : [tag.name]),
+      getMatchableNames: artTag =>
+        (artTag.isContentWarning
+          ? [`cw: ${artTag.name}`]
+          : [artTag.name]),
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    artTagsWhichDirectlyAncestor: {
+      bindTo: 'artTagData',
+
+      referencing: artTag => [artTag],
+      referenced: artTag => artTag.directDescendantArtTags,
     },
   };
 
@@ -82,9 +149,27 @@ export class ArtTag extends Thing {
       'Tag': {property: 'name'},
       'Short Name': {property: 'nameShort'},
       'Directory': {property: 'directory'},
+      'Description': {property: 'description'},
+      'Extra Reading URLs': {property: 'extraReadingURLs'},
+
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
 
       'Color': {property: 'color'},
       'Is CW': {property: 'isContentWarning'},
+
+      'Direct Descendant Tags': {property: 'directDescendantArtTags'},
+
+      'Related Tags': {
+        property: 'relatedArtTags',
+        transform: entries =>
+          parseAnnotatedReferences(entries, {
+            referenceField: 'Tag',
+            referenceProperty: 'artTag',
+          }),
+      },
     },
   };
 
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 841d652f..87e1c563 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -5,33 +5,37 @@ import {inspect} from 'node:util';
 import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
 import {input} from '#composite';
-import find from '#find';
 import {sortAlphabetically} from '#sort';
-import {stitchArrays, unique} from '#sugar';
+import {stitchArrays} from '#sugar';
 import Thing from '#thing';
 import {isName, validateArrayItems} from '#validators';
 import {getKebabCase} from '#wiki-data';
+import {parseArtwork} from '#yaml';
 
-import {withReverseContributionList} from '#composite/wiki-data';
+import {exitWithoutDependency} from '#composite/control-flow';
 
 import {
+  constitutibleArtwork,
   contentString,
   directory,
   fileExtension,
   flag,
   name,
-  reverseContributionList,
   reverseReferenceList,
   singleReference,
+  soupyFind,
+  soupyReverse,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
 
+import {artistTotalDuration} from '#composite/things/artist';
+
 export class Artist extends Thing {
   static [Thing.referenceType] = 'artist';
   static [Thing.wikiDataArray] = 'artistData';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Flash, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({Album, Flash, Group, Track}) => ({
     // Update & expose
 
     name: name('Unnamed Artist'),
@@ -43,6 +47,16 @@ export class Artist extends Thing {
     hasAvatar: flag(false),
     avatarFileExtension: fileExtension('jpg'),
 
+    avatarArtwork: [
+      exitWithoutDependency({
+        dependency: 'hasAvatar',
+        value: input.value(null),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Avatar Artwork'),
+    ],
+
     aliasNames: {
       flags: {update: true, expose: true},
       update: {validate: validateArrayItems(isName)},
@@ -53,178 +67,65 @@ export class Artist extends Thing {
 
     aliasedArtist: singleReference({
       class: input.value(Artist),
-      find: input.value(find.artist),
-      data: 'artistData',
+      find: soupyFind.input('artist'),
     }),
 
     // Update only
 
-    albumData: wikiData({
-      class: input.value(Album),
-    }),
-
-    artistData: wikiData({
-      class: input.value(Artist),
-    }),
-
-    flashData: wikiData({
-      class: input.value(Flash),
-    }),
-
-    trackData: wikiData({
-      class: input.value(Track),
-    }),
+    find: soupyFind(),
+    reverse: soupyReverse(),
 
     // Expose only
 
-    tracksAsArtist: reverseContributionList({
-      data: 'trackData',
-      list: input.value('artistContribs'),
+    trackArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('trackArtistContributionsBy'),
     }),
 
-    tracksAsContributor: reverseContributionList({
-      data: 'trackData',
-      list: input.value('contributorContribs'),
+    trackContributorContributions: reverseReferenceList({
+      reverse: soupyReverse.input('trackContributorContributionsBy'),
     }),
 
-    tracksAsCoverArtist: reverseContributionList({
-      data: 'trackData',
-      list: input.value('coverArtistContribs'),
+    trackCoverArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('trackCoverArtistContributionsBy'),
     }),
 
-    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'),
+      reverse: soupyReverse.input('tracksWithCommentaryBy'),
     }),
 
-    albumsAsAlbumArtist: reverseContributionList({
-      data: 'albumData',
-      list: input.value('artistContribs'),
+    albumArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumArtistContributionsBy'),
     }),
 
-    albumsAsCoverArtist: reverseContributionList({
-      data: 'albumData',
-      list: input.value('coverArtistContribs'),
+    albumCoverArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumCoverArtistContributionsBy'),
     }),
 
-    albumsAsWallpaperArtist: reverseContributionList({
-      data: 'albumData',
-      list: input.value('wallpaperArtistContribs'),
+    albumWallpaperArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumWallpaperArtistContributionsBy'),
     }),
 
-    albumsAsBannerArtist: reverseContributionList({
-      data: 'albumData',
-      list: input.value('bannerArtistContribs'),
+    albumBannerArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumBannerArtistContributionsBy'),
     }),
 
-    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'),
+      reverse: soupyReverse.input('albumsWithCommentaryBy'),
     }),
 
-    flashesAsContributor: reverseContributionList({
-      data: 'flashData',
-      list: input.value('contributorContribs'),
+    flashContributorContributions: reverseReferenceList({
+      reverse: soupyReverse.input('flashContributorContributionsBy'),
     }),
 
     flashesAsCommentator: reverseReferenceList({
-      data: 'flashData',
-      list: input.value('commentatorArtists'),
+      reverse: soupyReverse.input('flashesWithCommentaryBy'),
     }),
+
+    closelyLinkedGroups: reverseReferenceList({
+      reverse: soupyReverse.input('groupsCloselyLinkedTo'),
+    }),
+
+    totalDuration: artistTotalDuration(),
   });
 
   static [Thing.getSerializeDescriptors] = ({
@@ -240,18 +141,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] = {
@@ -316,6 +207,16 @@ export class Artist extends Thing {
       'URLs': {property: 'urls'},
       'Context Notes': {property: 'contextNotes'},
 
+      // note: doesn't really work as an independent field yet
+      'Avatar Artwork': {
+        property: 'avatarArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            fileExtensionFromThingProperty: 'avatarFileExtension',
+          }),
+      },
+
       'Has Avatar': {property: 'hasAvatar'},
       'Avatar File Extension': {property: 'avatarFileExtension'},
 
@@ -361,7 +262,12 @@ export class Artist extends Thing {
 
       const artistData = [...artists, ...artistAliases];
 
-      return {artistData};
+      const artworkData =
+        artistData
+          .filter(artist => artist.hasAvatar)
+          .map(artist => artist.avatarArtwork);
+
+      return {artistData, artworkData};
     },
 
     sort({artistData}) {
@@ -389,4 +295,12 @@ export class Artist extends Thing {
 
     return parts.join('');
   }
+
+  getOwnArtworkPath(artwork) {
+    return [
+      'media.artistAvatar',
+      this.directory,
+      artwork.fileExtension,
+    ];
+  }
 }
diff --git a/src/data/things/artwork.js b/src/data/things/artwork.js
new file mode 100644
index 00000000..2a97fd6d
--- /dev/null
+++ b/src/data/things/artwork.js
@@ -0,0 +1,399 @@
+import {inspect} from 'node:util';
+
+import {input} from '#composite';
+import find from '#find';
+import Thing from '#thing';
+
+import {
+  isContentString,
+  isContributionList,
+  isDate,
+  isDimensions,
+  isFileExtension,
+  optional,
+  validateArrayItems,
+  validateProperties,
+  validateReference,
+  validateReferenceList,
+} from '#validators';
+
+import {
+  parseAnnotatedReferences,
+  parseContributors,
+  parseDate,
+  parseDimensions,
+} from '#yaml';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  withRecontextualizedContributionList,
+  withResolvedAnnotatedReferenceList,
+  withResolvedContribs,
+  withResolvedReferenceList,
+} from '#composite/wiki-data';
+
+import {
+  contentString,
+  directory,
+  reverseReferenceList,
+  simpleString,
+  soupyFind,
+  soupyReverse,
+  thing,
+  wikiData,
+} from '#composite/wiki-properties';
+
+import {withDate} from '#composite/things/artwork';
+
+export class Artwork extends Thing {
+  static [Thing.referenceType] = 'artwork';
+
+  static [Thing.getPropertyDescriptors] = ({
+    ArtTag,
+    Contribution,
+  }) => ({
+    // Update & expose
+
+    unqualifiedDirectory: directory({
+      name: input.value(null),
+    }),
+
+    thing: thing(),
+
+    label: simpleString(),
+    source: contentString(),
+
+    dateFromThingProperty: simpleString(),
+
+    date: [
+      withDate({
+        from: input.updateValue({validate: isDate}),
+      }),
+
+      exposeDependency({dependency: '#date'}),
+    ],
+
+    fileExtensionFromThingProperty: simpleString(),
+
+    fileExtension: [
+      {
+        compute: (continuation) => continuation({
+          ['#default']: 'jpg',
+        }),
+      },
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isFileExtension),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'thing',
+        value: '#default',
+      }),
+
+      exitWithoutDependency({
+        dependency: 'fileExtensionFromThingProperty',
+        value: '#default',
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'fileExtensionFromThingProperty',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#value',
+      }),
+
+      exposeDependency({
+        dependency: '#default',
+      }),
+    ],
+
+    dimensionsFromThingProperty: simpleString(),
+
+    dimensions: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDimensions),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'artistContribsFromThingProperty',
+        value: input.value(null),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'dimensionsFromThingProperty',
+      }).outputs({
+        ['#value']: '#dimensionsFromThing',
+      }),
+
+      exitWithoutDependency({
+        dependency: 'dimensionsFromThingProperty',
+        value: input.value(null),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#dimensionsFromThing',
+      }),
+
+      exposeConstant({
+        value: input.value(null),
+      }),
+    ],
+
+    artistContribsFromThingProperty: simpleString(),
+    artistContribsArtistProperty: simpleString(),
+
+    artistContribs: [
+      withDate(),
+
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        date: '#date',
+        artistProperty: 'artistContribsArtistProperty',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#resolvedContribs',
+        mode: input.value('empty'),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'artistContribsFromThingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'artistContribsFromThingProperty',
+      }).outputs({
+        ['#value']: '#artistContribs',
+      }),
+
+      withRecontextualizedContributionList({
+        list: '#artistContribs',
+      }),
+
+      exposeDependency({
+        dependency: '#artistContribs',
+      }),
+    ],
+
+    artTagsFromThingProperty: simpleString(),
+
+    artTags: [
+      withResolvedReferenceList({
+        list: input.updateValue({
+          validate:
+            validateReferenceList(ArtTag[Thing.referenceType]),
+        }),
+
+        find: soupyFind.input('artTag'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#resolvedReferenceList',
+        mode: input.value('empty'),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'artTagsFromThingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'artTagsFromThingProperty',
+      }).outputs({
+        ['#value']: '#artTags',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#artTags',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+
+    referencedArtworksFromThingProperty: simpleString(),
+
+    referencedArtworks: [
+      {
+        compute: (continuation) => continuation({
+          ['#find']:
+            find.mixed({
+              track: find.trackPrimaryArtwork,
+              album: find.albumPrimaryArtwork,
+            }),
+        }),
+      },
+
+      withResolvedAnnotatedReferenceList({
+        list: input.updateValue({
+          validate:
+            // TODO: It's annoying to hardcode this when it's really the
+            // same behavior as through annotatedReferenceList and through
+            // referenceListUpdateDescription, the latter of which isn't
+            // available outside of #composite/wiki-data internals.
+            validateArrayItems(
+              validateProperties({
+                reference: validateReference(['album', 'track']),
+                annotation: optional(isContentString),
+              })),
+        }),
+
+        data: 'artworkData',
+        find: '#find',
+
+        thing: input.value('artwork'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#resolvedAnnotatedReferenceList',
+        mode: input.value('empty'),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'referencedArtworksFromThingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'referencedArtworksFromThingProperty',
+      }).outputs({
+        ['#value']: '#referencedArtworks',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#referencedArtworks',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // used for referencedArtworks (mixedFind)
+    artworkData: wikiData({
+      class: input.value(Artwork),
+    }),
+
+    // Expose only
+
+    referencedByArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichReference'),
+    }),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Directory': {property: 'unqualifiedDirectory'},
+      'File Extension': {property: 'fileExtension'},
+
+      'Dimensions': {
+        property: 'dimensions',
+        transform: parseDimensions,
+      },
+
+      'Label': {property: 'label'},
+      'Source': {property: 'source'},
+
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Tags': {property: 'artTags'},
+
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
+      },
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    artworksWhichReference: {
+      bindTo: 'artworkData',
+
+      referencing: referencingArtwork =>
+        referencingArtwork.referencedArtworks
+          .map(({artwork: referencedArtwork, ...referenceDetails}) => ({
+            referencingArtwork,
+            referencedArtwork,
+            referenceDetails,
+          })),
+
+      referenced: ({referencedArtwork}) => [referencedArtwork],
+
+      tidy: ({referencingArtwork, referenceDetails}) => ({
+        artwork: referencingArtwork,
+        ...referenceDetails,
+      }),
+
+      date: ({artwork}) => artwork.date,
+    },
+
+    artworksWhichFeature: {
+      bindTo: 'artworkData',
+
+      referencing: artwork => [artwork],
+      referenced: artwork => artwork.artTags,
+    },
+  };
+
+  get path() {
+    if (!this.thing) return null;
+    if (!this.thing.getOwnArtworkPath) return null;
+
+    return this.thing.getOwnArtworkPath(this);
+  }
+
+  [inspect.custom](depth, options, inspect) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (this.thing) {
+      if (depth >= 0) {
+        const newOptions = {
+          ...options,
+          depth:
+            (options.depth === null
+              ? null
+              : options.depth - 1),
+        };
+
+        parts.push(` for ${inspect(this.thing, newOptions)}`);
+      } else {
+        parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`);
+      }
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js
new file mode 100644
index 00000000..c92fafb4
--- /dev/null
+++ b/src/data/things/contribution.js
@@ -0,0 +1,302 @@
+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 {flag, simpleDate, soupyFind} from '#composite/wiki-properties';
+
+import {
+  withFilteredList,
+  withNearbyItemFromList,
+  withPropertyFromList,
+  withPropertyFromObject,
+} from '#composite/data';
+
+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),
+    ],
+
+    // Update only
+
+    find: soupyFind(),
+
+    // 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',
+      }).outputs({
+        '#value': '#contributions',
+      }),
+
+      withPropertyFromList({
+        list: '#contributions',
+        property: input.value('annotation'),
+      }),
+
+      {
+        dependencies: ['#contributions.annotation', 'annotation'],
+        compute: (continuation, {
+          ['#contributions.annotation']: contributionAnnotations,
+          ['annotation']: annotation,
+        }) => continuation({
+          ['#likeContributionsFilter']:
+            contributionAnnotations.map(mappingAnnotation =>
+              (annotation?.startsWith(`edits for wiki`)
+                ? mappingAnnotation?.startsWith(`edits for wiki`)
+                : !mappingAnnotation?.startsWith(`edits for wiki`))),
+        }),
+      },
+
+      withFilteredList({
+        list: '#contributions',
+        filter: '#likeContributionsFilter',
+      }).outputs({
+        '#filteredList': '#contributions',
+      }),
+
+      exposeDependency({
+        dependency: '#contributions',
+      }),
+    ],
+
+    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..ace18af9 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -1,13 +1,19 @@
 export const FLASH_DATA_FILE = 'flashes.yaml';
 
 import {input} from '#composite';
-import find from '#find';
 import {empty} from '#sugar';
 import {sortFlashesChronologically} from '#sort';
 import Thing from '#thing';
 import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
   from '#validators';
-import {parseDate, parseContributors} from '#yaml';
+
+import {
+  parseArtwork,
+  parseAdditionalNames,
+  parseContributors,
+  parseDate,
+  parseDimensions,
+} from '#yaml';
 
 import {withPropertyFromObject} from '#composite/data';
 
@@ -19,16 +25,22 @@ import {
 } from '#composite/control-flow';
 
 import {
+  additionalNameList,
   color,
   commentary,
   commentatorArtists,
+  constitutibleArtwork,
   contentString,
   contributionList,
+  dimensions,
   directory,
   fileExtension,
   name,
   referenceList,
   simpleDate,
+  soupyFind,
+  soupyReverse,
+  thing,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
@@ -39,7 +51,11 @@ 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] = ({
+    Track,
+    FlashAct,
+    WikiInfo,
+  }) => ({
     // Update & expose
 
     name: name('Unnamed Flash'),
@@ -89,30 +105,37 @@ export class Flash extends Thing {
 
     coverArtFileExtension: fileExtension('jpg'),
 
-    contributorContribs: contributionList(),
+    coverArtDimensions: dimensions(),
+
+    coverArtwork:
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+
+    contributorContribs: contributionList({
+      date: 'date',
+      artistProperty: input.value('flashContributorContributions'),
+    }),
 
     featuredTracks: referenceList({
       class: input.value(Track),
-      find: input.value(find.track),
-      data: 'trackData',
+      find: soupyFind.input('track'),
     }),
 
     urls: urls(),
 
+    additionalNames: additionalNameList(),
+
     commentary: commentary(),
+    creditSources: commentary(),
 
     // Update only
 
-    artistData: wikiData({
-      class: input.value(Artist),
-    }),
-
-    trackData: wikiData({
-      class: input.value(Track),
-    }),
+    find: soupyFind(),
+    reverse: soupyReverse(),
 
-    flashActData: wikiData({
-      class: input.value(FlashAct),
+    // used for withMatchingContributionPresets (indirectly by Contribution)
+    wikiInfo: thing({
+      class: input.value(WikiInfo),
     }),
 
     // Expose only
@@ -156,6 +179,25 @@ export class Flash extends Thing {
     },
   };
 
+  static [Thing.reverseSpecs] = {
+    flashesWhichFeature: {
+      bindTo: 'flashData',
+
+      referencing: flash => [flash],
+      referenced: flash => flash.featuredTracks,
+    },
+
+    flashContributorContributionsBy:
+      soupyReverse.contributionsBy('flashData', 'contributorContribs'),
+
+    flashesWithCommentaryBy: {
+      bindTo: 'flashData',
+
+      referencing: flash => [flash],
+      referenced: flash => flash.commentatorArtists,
+    },
+  };
+
   static [Thing.yamlDocumentSpec] = {
     fields: {
       'Flash': {property: 'name'},
@@ -169,8 +211,28 @@ export class Flash extends Thing {
         transform: parseDate,
       },
 
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
+      'Cover Artwork': {
+        property: 'coverArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dimensionsFromThingProperty: 'coverArtDimensions',
+          }),
+      },
+
       'Cover Art File Extension': {property: 'coverArtFileExtension'},
 
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
       'Featured Tracks': {property: 'featuredTracks'},
 
       'Contributors': {
@@ -179,10 +241,19 @@ export class Flash extends Thing {
       },
 
       'Commentary': {property: 'commentary'},
+      'Credit Sources': {property: 'creditSources'},
 
       'Review Points': {ignore: true},
     },
   };
+
+  getOwnArtworkPath(artwork) {
+    return [
+      'media.flashArt',
+      this.directory,
+      artwork.fileExtension,
+    ];
+  }
 }
 
 export class FlashAct extends Thing {
@@ -219,19 +290,13 @@ export class FlashAct extends Thing {
 
     flashes: referenceList({
       class: input.value(Flash),
-      find: input.value(find.flash),
-      data: 'flashData',
+      find: soupyFind.input('flash'),
     }),
 
     // Update only
 
-    flashData: wikiData({
-      class: input.value(Flash),
-    }),
-
-    flashSideData: wikiData({
-      class: input.value(FlashSide),
-    }),
+    find: soupyFind(),
+    reverse: soupyReverse(),
 
     // Expose only
 
@@ -248,6 +313,15 @@ export class FlashAct extends Thing {
     },
   };
 
+  static [Thing.reverseSpecs] = {
+    flashActsWhoseFlashesInclude: {
+      bindTo: 'flashActData',
+
+      referencing: flashAct => [flashAct],
+      referenced: flashAct => flashAct.flashes,
+    },
+  };
+
   static [Thing.yamlDocumentSpec] = {
     fields: {
       'Act': {property: 'name'},
@@ -275,15 +349,12 @@ export class FlashSide extends Thing {
 
     acts: referenceList({
       class: input.value(FlashAct),
-      find: input.value(find.flashAct),
-      data: 'flashActData',
+      find: soupyFind.input('flashAct'),
     }),
 
     // Update only
 
-    flashActData: wikiData({
-      class: input.value(FlashAct),
-    }),
+    find: soupyFind(),
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -302,6 +373,15 @@ export class FlashSide extends Thing {
     },
   };
 
+  static [Thing.reverseSpecs] = {
+    flashSidesWhoseActsInclude: {
+      bindTo: 'flashSideData',
+
+      referencing: flashSide => [flashSide],
+      referenced: flashSide => flashSide.acts,
+    },
+  };
+
   static [Thing.getYamlLoadingSpec] = ({
     documentModes: {allInOne},
     thingConstructors: {Flash, FlashAct},
@@ -360,7 +440,9 @@ export class FlashSide extends Thing {
       const flashActData = results.filter(x => x instanceof FlashAct);
       const flashSideData = results.filter(x => x instanceof FlashSide);
 
-      return {flashData, flashActData, flashSideData};
+      const artworkData = flashData.map(flash => flash.coverArtwork);
+
+      return {flashData, flashActData, flashSideData, artworkData};
     },
 
     sort({flashData}) {
diff --git a/src/data/things/group.js b/src/data/things/group.js
index 0dbbbb7f..b40d15b4 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -1,15 +1,18 @@
 export const GROUP_DATA_FILE = 'groups.yaml';
 
 import {input} from '#composite';
-import find from '#find';
 import Thing from '#thing';
+import {parseAnnotatedReferences, parseSerieses} from '#yaml';
 
 import {
+  annotatedReferenceList,
   color,
   contentString,
   directory,
   name,
   referenceList,
+  seriesList,
+  soupyFind,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
@@ -17,7 +20,7 @@ import {
 export class Group extends Thing {
   static [Thing.referenceType] = 'group';
 
-  static [Thing.getPropertyDescriptors] = ({Album}) => ({
+  static [Thing.getPropertyDescriptors] = ({Album, Artist}) => ({
     // Update & expose
 
     name: name('Unnamed Group'),
@@ -27,22 +30,28 @@ export class Group extends Thing {
 
     urls: urls(),
 
-    featuredAlbums: referenceList({
-      class: input.value(Album),
-      find: input.value(find.album),
-      data: 'albumData',
-    }),
+    closelyLinkedArtists: annotatedReferenceList({
+      class: input.value(Artist),
+      find: soupyFind.input('artist'),
 
-    // Update only
+      reference: input.value('artist'),
+      thing: input.value('artist'),
+    }),
 
-    albumData: wikiData({
+    featuredAlbums: referenceList({
       class: input.value(Album),
+      find: soupyFind.input('album'),
     }),
 
-    groupCategoryData: wikiData({
-      class: input.value(GroupCategory),
+    serieses: seriesList({
+      group: input.myself(),
     }),
 
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyFind(),
+
     // Expose only
 
     descriptionShort: {
@@ -61,9 +70,9 @@ export class Group extends Thing {
       flags: {expose: true},
 
       expose: {
-        dependencies: ['this', 'albumData'],
-        compute: ({this: group, albumData}) =>
-          albumData?.filter((album) => album.groups.includes(group)) ?? [],
+        dependencies: ['this', 'reverse'],
+        compute: ({this: group, reverse}) =>
+          reverse.albumsWhoseGroupsInclude(group),
       },
     },
 
@@ -71,9 +80,9 @@ export class Group extends Thing {
       flags: {expose: true},
 
       expose: {
-        dependencies: ['this', 'groupCategoryData'],
-        compute: ({this: group, groupCategoryData}) =>
-          groupCategoryData.find((category) => category.groups.includes(group))
+        dependencies: ['this', 'reverse'],
+        compute: ({this: group, reverse}) =>
+          reverse.groupCategoriesWhichInclude(group, {unique: true})
             ?.color,
       },
     },
@@ -82,9 +91,9 @@ export class Group extends Thing {
       flags: {expose: true},
 
       expose: {
-        dependencies: ['this', 'groupCategoryData'],
-        compute: ({this: group, groupCategoryData}) =>
-          groupCategoryData.find((category) => category.groups.includes(group)) ??
+        dependencies: ['this', 'reverse'],
+        compute: ({this: group, reverse}) =>
+          reverse.groupCategoriesWhichInclude(group, {unique: true}) ??
           null,
       },
     },
@@ -97,6 +106,25 @@ export class Group extends Thing {
     },
   };
 
+  static [Thing.reverseSpecs] = {
+    groupsCloselyLinkedTo: {
+      bindTo: 'groupData',
+
+      referencing: group =>
+        group.closelyLinkedArtists
+          .map(({artist, ...referenceDetails}) => ({
+            group,
+            artist,
+            referenceDetails,
+          })),
+
+      referenced: ({artist}) => [artist],
+
+      tidy: ({group, referenceDetails}) =>
+        ({group, ...referenceDetails}),
+    },
+  };
+
   static [Thing.yamlDocumentSpec] = {
     fields: {
       'Group': {property: 'name'},
@@ -104,8 +132,22 @@ export class Group extends Thing {
       'Description': {property: 'description'},
       'URLs': {property: 'urls'},
 
+      'Closely Linked Artists': {
+        property: 'closelyLinkedArtists',
+        transform: value =>
+          parseAnnotatedReferences(value, {
+            referenceField: 'Artist',
+            referenceProperty: 'artist',
+          }),
+      },
+
       'Featured Albums': {property: 'featuredAlbums'},
 
+      'Series': {
+        property: 'serieses',
+        transform: parseSerieses,
+      },
+
       'Review Points': {ignore: true},
     },
   };
@@ -174,17 +216,23 @@ export class GroupCategory extends Thing {
 
     groups: referenceList({
       class: input.value(Group),
-      find: input.value(find.group),
-      data: 'groupData',
+      find: soupyFind.input('group'),
     }),
 
     // Update only
 
-    groupData: wikiData({
-      class: input.value(Group),
-    }),
+    find: soupyFind(),
   });
 
+  static [Thing.reverseSpecs] = {
+    groupCategoriesWhichInclude: {
+      bindTo: 'groupCategoryData',
+
+      referencing: groupCategory => [groupCategory],
+      referenced: groupCategory => groupCategory.groups,
+    },
+  };
+
   static [Thing.yamlDocumentSpec] = {
     fields: {
       'Category': {property: 'name'},
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index 00d6aef5..82bad2d3 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -1,8 +1,11 @@
 export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml';
 
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
 import {input} from '#composite';
-import find from '#find';
 import Thing from '#thing';
+import {empty} from '#sugar';
 
 import {
   anyOf,
@@ -11,19 +14,26 @@ import {
   isString,
   isStringNonEmpty,
   validateArrayItems,
-  validateInstanceOf,
   validateReference,
 } from '#validators';
 
 import {exposeDependency} from '#composite/control-flow';
 import {withResolvedReference} from '#composite/wiki-data';
-import {color, contentString, name, referenceList, wikiData}
-  from '#composite/wiki-properties';
+
+import {
+  color,
+  contentString,
+  name,
+  referenceList,
+  soupyFind,
+  thing,
+  thingList,
+} from '#composite/wiki-properties';
 
 export class HomepageLayout extends Thing {
   static [Thing.friendlyName] = `Homepage Layout`;
 
-  static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({
+  static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({
     // Update & expose
 
     sidebarContent: contentString(),
@@ -31,15 +41,12 @@ export class HomepageLayout extends Thing {
     navbarLinks: {
       flags: {update: true, expose: true},
       update: {validate: validateArrayItems(isStringNonEmpty)},
+      expose: {transform: value => value ?? []},
     },
 
-    rows: {
-      flags: {update: true, expose: true},
-
-      update: {
-        validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)),
-      },
-    },
+    sections: thingList({
+      class: input.value(HomepageLayoutSection),
+    }),
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -50,85 +57,231 @@ export class HomepageLayout extends Thing {
       'Navbar Links': {property: 'navbarLinks'},
     },
   };
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {allInOne},
+    thingConstructors: {
+      HomepageLayout,
+      HomepageLayoutSection,
+      HomepageLayoutAlbumsRow,
+    },
+  }) => ({
+    title: `Process homepage layout file`,
+    file: HOMEPAGE_LAYOUT_DATA_FILE,
+
+    documentMode: allInOne,
+    documentThing: document => {
+      if (document['Homepage']) {
+        return HomepageLayout;
+      }
+
+      if (document['Section']) {
+        return HomepageLayoutSection;
+      }
+
+      if (document['Row']) {
+        switch (document['Row']) {
+          case 'actions':
+            return HomepageLayoutActionsRow;
+          case 'album carousel':
+            return HomepageLayoutAlbumCarouselRow;
+          case 'album grid':
+            return HomepageLayoutAlbumGridRow;
+          default:
+            throw new TypeError(`Unrecognized row type ${document['Row']}`);
+        }
+      }
+
+      return null;
+    },
+
+    save(results) {
+      if (!empty(results) && !(results[0] instanceof HomepageLayout)) {
+        throw new Error(`Expected 'Homepage' document at top of homepage layout file`);
+      }
+
+      const homepageLayout = results[0];
+      const sections = [];
+
+      let currentSection = null;
+      let currentSectionRows = [];
+
+      const closeCurrentSection = () => {
+        if (currentSection) {
+          for (const row of currentSectionRows) {
+            row.section = currentSection;
+          }
+
+          currentSection.rows = currentSectionRows;
+          sections.push(currentSection);
+
+          currentSection = null;
+          currentSectionRows = [];
+        }
+      };
+
+      for (const entry of results.slice(1)) {
+        if (entry instanceof HomepageLayout) {
+          throw new Error(`Expected only one 'Homepage' document in total`);
+        } else if (entry instanceof HomepageLayoutSection) {
+          closeCurrentSection();
+          currentSection = entry;
+        } else if (entry instanceof HomepageLayoutRow) {
+          if (currentSection) {
+            currentSectionRows.push(entry);
+          } else {
+            throw new Error(`Expected a 'Section' document to add following rows into`);
+          }
+        }
+      }
+
+      closeCurrentSection();
+
+      homepageLayout.sections = sections;
+
+      return {homepageLayout};
+    },
+  });
+}
+
+export class HomepageLayoutSection extends Thing {
+  static [Thing.friendlyName] = `Homepage Section`;
+
+  static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({
+    // Update & expose
+
+    name: name(`Unnamed Homepage Section`),
+
+    color: color(),
+
+    rows: thingList({
+      class: input.value(HomepageLayoutRow),
+    }),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Section': {property: 'name'},
+      'Color': {property: 'color'},
+    },
+  };
 }
 
 export class HomepageLayoutRow extends Thing {
   static [Thing.friendlyName] = `Homepage Row`;
 
-  static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({
+  static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({
     // Update & expose
 
-    name: name('Unnamed Homepage Row'),
+    section: thing({
+      class: input.value(HomepageLayoutSection),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+
+    // Expose only
 
     type: {
-      flags: {update: true, expose: true},
+      flags: {expose: true},
 
-      update: {
-        validate() {
+      expose: {
+        compute() {
           throw new Error(`'type' property validator must be overridden`);
         },
       },
     },
+  });
 
-    color: color(),
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Row': {ignore: true},
+    },
+  };
 
-    // Update only
+  [inspect.custom](depth) {
+    const parts = [];
 
-    // These wiki data arrays aren't necessarily used by every subclass, but
-    // to the convenience of providing these, the superclass accepts all wiki
-    // data arrays depended upon by any subclass.
+    parts.push(Thing.prototype[inspect.custom].apply(this));
 
-    albumData: wikiData({
-      class: input.value(Album),
-    }),
+    if (depth >= 0 && this.section) {
+      const sectionName = this.section.name;
+      const index = this.section.rows.indexOf(this);
+      const rowNum =
+        (index === -1
+          ? 'indeterminate position'
+          : `#${index + 1}`);
+      parts.push(` (${colors.yellow(rowNum)} in ${colors.green(sectionName)})`);
+    }
 
-    groupData: wikiData({
-      class: input.value(Group),
-    }),
+    return parts.join('');
+  }
+}
+
+export class HomepageLayoutActionsRow extends HomepageLayoutRow {
+  static [Thing.friendlyName] = `Homepage Actions Row`;
+
+  static [Thing.getPropertyDescriptors] = (opts) => ({
+    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
+
+    // Update & expose
+
+    actionLinks: {
+      flags: {update: true, expose: true},
+      update: {validate: validateArrayItems(isString)},
+    },
+
+    // Expose only
+
+    type: {
+      flags: {expose: true},
+      expose: {compute: () => 'actions'},
+    },
   });
 
-  static [Thing.yamlDocumentSpec] = {
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
     fields: {
-      'Row': {property: 'name'},
-      'Color': {property: 'color'},
-      'Type': {property: 'type'},
+      'Actions': {property: 'actionLinks'},
     },
-  };
+  });
 }
 
-export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
-  static [Thing.friendlyName] = `Homepage Albums Row`;
+export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow {
+  static [Thing.friendlyName] = `Homepage Album Carousel Row`;
 
   static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
     ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
 
     // Update & expose
 
+    albums: referenceList({
+      class: input.value(Album),
+      find: soupyFind.input('album'),
+    }),
+
+    // Expose only
+
     type: {
-      flags: {update: true, expose: true},
-      update: {
-        validate(value) {
-          if (value !== 'albums') {
-            throw new TypeError(`Expected 'albums'`);
-          }
+      flags: {expose: true},
+      expose: {compute: () => 'album carousel'},
+    },
+  });
 
-          return true;
-        },
-      },
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+    fields: {
+      'Albums': {property: 'albums'},
     },
+  });
+}
 
-    displayStyle: {
-      flags: {update: true, expose: true},
+export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow {
+  static [Thing.friendlyName] = `Homepage Album Grid Row`;
 
-      update: {
-        validate: is('grid', 'carousel'),
-      },
+  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
+    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
 
-      expose: {
-        transform: (displayStyle) =>
-          displayStyle ?? 'grid',
-      },
-    },
+    // Update & expose
 
     sourceGroup: [
       {
@@ -151,8 +304,7 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
 
       withResolvedReference({
         ref: input.updateValue(),
-        data: 'groupData',
-        find: input.value(find.group),
+        find: soupyFind.input('group'),
       }),
 
       exposeDependency({dependency: '#resolvedReference'}),
@@ -160,8 +312,7 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
 
     sourceAlbums: referenceList({
       class: input.value(Album),
-      find: input.value(find.album),
-      data: 'albumData',
+      find: soupyFind.input('album'),
     }),
 
     countAlbumsFromGroup: {
@@ -169,55 +320,19 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
       update: {validate: isCountingNumber},
     },
 
-    actionLinks: {
-      flags: {update: true, expose: true},
-      update: {validate: validateArrayItems(isString)},
+    // Expose only
+
+    type: {
+      flags: {expose: true},
+      expose: {compute: () => 'album grid'},
     },
   });
 
   static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
     fields: {
-      'Display Style': {property: 'displayStyle'},
       'Group': {property: 'sourceGroup'},
       'Count': {property: 'countAlbumsFromGroup'},
       'Albums': {property: 'sourceAlbums'},
-      'Actions': {property: 'actionLinks'},
-    },
-  });
-
-  static [Thing.getYamlLoadingSpec] = ({
-    documentModes: {headerAndEntries}, // Kludge, see below
-    thingConstructors: {
-      HomepageLayout,
-      HomepageLayoutAlbumsRow,
-    },
-  }) => ({
-    title: `Process homepage layout file`,
-
-    // Kludge: This benefits from the same headerAndEntries style messaging as
-    // albums and tracks (for example), but that document mode is designed to
-    // support multiple files, and only one is actually getting processed here.
-    files: [HOMEPAGE_LAYOUT_DATA_FILE],
-
-    documentMode: headerAndEntries,
-    headerDocumentThing: HomepageLayout,
-    entryDocumentThing: document => {
-      switch (document['Type']) {
-        case 'albums':
-          return HomepageLayoutAlbumsRow;
-        default:
-          throw new TypeError(`No processDocument function for row type ${document['Type']}!`);
-      }
-    },
-
-    save(results) {
-      if (!results[0]) {
-        return;
-      }
-
-      const {header: homepageLayout, entries: rows} = results[0];
-      Object.assign(homepageLayout, {rows});
-      return {homepageLayout};
     },
   });
 }
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 3bf84091..96cec88e 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -2,20 +2,24 @@ import * as path from 'node:path';
 import {fileURLToPath} from 'node:url';
 
 import {openAggregate, showAggregate} from '#aggregate';
+import CacheableObject from '#cacheable-object';
 import {logError} from '#cli';
 import {compositeFrom} from '#composite';
 import * as serialize from '#serialize';
-
+import {withEntries} from '#sugar';
 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 artworkClasses from './artwork.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';
 import * as languageClasses from './language.js';
 import * as newsEntryClasses from './news-entry.js';
+import * as sortingRuleClasses from './sorting-rule.js';
 import * as staticPageClasses from './static-page.js';
 import * as trackClasses from './track.js';
 import * as wikiInfoClasses from './wiki-info.js';
@@ -24,11 +28,14 @@ const allClassLists = {
   'album.js': albumClasses,
   'art-tag.js': artTagClasses,
   'artist.js': artistClasses,
+  'artwork.js': artworkClasses,
+  'contribution.js': contributionClasses,
   'flash.js': flashClasses,
   'group.js': groupClasses,
   'homepage-layout.js': homepageLayoutClasses,
   'language.js': languageClasses,
   'news-entry.js': newsEntryClasses,
+  'sorting-rule.js': sortingRuleClasses,
   'static-page.js': staticPageClasses,
   'track.js': trackClasses,
   'wiki-info.js': wikiInfoClasses,
@@ -77,13 +84,25 @@ function errorDuplicateClassNames() {
 }
 
 function flattenClassLists() {
+  let allClassesUnsorted = Object.create(null);
+
   for (const classes of Object.values(allClassLists)) {
     for (const [name, constructor] of Object.entries(classes)) {
       if (typeof constructor !== 'function') continue;
       if (!(constructor.prototype instanceof Thing)) continue;
-      allClasses[name] = constructor;
+      allClassesUnsorted[name] = constructor;
     }
   }
+
+  // Sort subclasses after their superclasses.
+  Object.assign(allClasses,
+    withEntries(allClassesUnsorted, entries =>
+      entries.sort(({[1]: A}, {[1]: B}) =>
+        (A.prototype instanceof B
+          ? +1
+       : B.prototype instanceof A
+          ? -1
+          :  0))));
 }
 
 function descriptorAggregateHelper({
@@ -142,7 +161,10 @@ function evaluatePropertyDescriptors() {
         }
       }
 
-      constructor.propertyDescriptors = results;
+      constructor[CacheableObject.propertyDescriptors] = {
+        ...constructor[CacheableObject.propertyDescriptors] ?? {},
+        ...results,
+      };
     },
 
     showFailedClasses(failedClasses) {
@@ -172,6 +194,20 @@ function evaluateSerializeDescriptors() {
   });
 }
 
+function finalizeCacheableObjectPrototypes() {
+  return descriptorAggregateHelper({
+    message: `Errors finalizing Thing class prototypes`,
+
+    op(constructor) {
+      constructor.finalizeCacheableObjectPrototype();
+    },
+
+    showFailedClasses(failedClasses) {
+      logError`Failed to finalize cacheable object prototypes for classes: ${failedClasses.join(', ')}`;
+    },
+  });
+}
+
 if (!errorDuplicateClassNames())
   process.exit(1);
 
@@ -183,6 +219,9 @@ if (!evaluatePropertyDescriptors())
 if (!evaluateSerializeDescriptors())
   process.exit(1);
 
+if (!finalizeCacheableObjectPrototypes())
+  process.exit(1);
+
 Object.assign(allClasses, {Thing});
 
 export default allClasses;
diff --git a/src/data/things/language.js b/src/data/things/language.js
index dbe1ff3d..a3f861bd 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -115,7 +115,7 @@ export class Language extends Thing {
     },
 
     // List of descriptors for providing to external link utilities when using
-    // language.formatExternalLink - refer to util/external-links.js for info.
+    // language.formatExternalLink - refer to #external-links for info.
     externalLinkSpec: {
       flags: {update: true, expose: true},
       update: {validate: isExternalLinkSpec},
@@ -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,50 @@ 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} = {}) {
+  function(value, {
+    unit = false,
+    blankIfZero = false,
+  } = {}) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
+    // Zero is blank content, if that option is set.
+    if (value === 0 && blankIfZero) {
+      return html.blank();
+    }
+
     return this.formatString(
       unit
         ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
@@ -715,6 +896,7 @@ const countHelper = (stringKey, optionName = stringKey) =>
 Object.assign(Language.prototype, {
   countAdditionalFiles: countHelper('additionalFiles', 'files'),
   countAlbums: countHelper('albums'),
+  countArtTags: countHelper('artTags', 'tags'),
   countArtworks: countHelper('artworks'),
   countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
   countContributions: countHelper('contributions'),
@@ -722,6 +904,7 @@ Object.assign(Language.prototype, {
   countDays: countHelper('days'),
   countFlashes: countHelper('flashes'),
   countMonths: countHelper('months'),
+  countTimesFeatured: countHelper('timesFeatured'),
   countTimesReferenced: countHelper('timesReferenced'),
   countTimesUsed: countHelper('timesUsed'),
   countTracks: countHelper('tracks'),
diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js
new file mode 100644
index 00000000..b169a541
--- /dev/null
+++ b/src/data/things/sorting-rule.js
@@ -0,0 +1,386 @@
+export const SORTING_RULE_DATA_FILE = 'sorting-rules.yaml';
+
+import {readFile, writeFile} from 'node:fs/promises';
+import * as path from 'node:path';
+
+import {input} from '#composite';
+import {chunkByProperties, compareArrays, unique} from '#sugar';
+import Thing from '#thing';
+import {isObject, isStringNonEmpty, anyOf, strictArrayOf} from '#validators';
+
+import {
+  compareCaseLessSensitive,
+  sortByDate,
+  sortByDirectory,
+  sortByName,
+} from '#sort';
+
+import {
+  documentModes,
+  flattenThingLayoutToDocumentOrder,
+  getThingLayoutForFilename,
+  reorderDocumentsInYAMLSourceText,
+} from '#yaml';
+
+import {flag} from '#composite/wiki-properties';
+
+function isSelectFollowingEntry(value) {
+  isObject(value);
+
+  const {length} = Object.keys(value);
+  if (length !== 1) {
+    throw new Error(`Expected object with 1 key, got ${length}`);
+  }
+
+  return true;
+}
+
+export class SortingRule extends Thing {
+  static [Thing.friendlyName] = `Sorting Rule`;
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    active: flag(true),
+
+    message: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Message': {property: 'message'},
+      'Active': {property: 'active'},
+    },
+  };
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {allInOne},
+    thingConstructors: {DocumentSortingRule},
+  }) => ({
+    title: `Process sorting rules file`,
+    file: SORTING_RULE_DATA_FILE,
+
+    documentMode: allInOne,
+    documentThing: document =>
+      (document['Sort Documents']
+        ? DocumentSortingRule
+        : null),
+
+    save: (results) => ({sortingRules: results}),
+  });
+
+  check(opts) {
+    return this.constructor.check(this, opts);
+  }
+
+  apply(opts) {
+    return this.constructor.apply(this, opts);
+  }
+
+  static check(rule, opts) {
+    const result = this.apply(rule, {...opts, dry: true});
+    if (!result) return true;
+    if (!result.changed) return true;
+    return false;
+  }
+
+  static async apply(_rule, _opts) {
+    throw new Error(`Not implemented`);
+  }
+
+  static async* applyAll(_rules, _opts) {
+    throw new Error(`Not implemented`);
+  }
+
+  static async* go({dataPath, wikiData, dry}) {
+    const rules = wikiData.sortingRules;
+    const constructors = unique(rules.map(rule => rule.constructor));
+
+    for (const constructor of constructors) {
+      yield* constructor.applyAll(
+        rules
+          .filter(rule => rule.active)
+          .filter(rule => rule.constructor === constructor),
+        {dataPath, wikiData, dry});
+    }
+  }
+}
+
+export class ThingSortingRule extends SortingRule {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    properties: {
+      flags: {update: true, expose: true},
+      update: {
+        validate: strictArrayOf(isStringNonEmpty),
+      },
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(SortingRule, {
+    fields: {
+      'By Properties': {property: 'properties'},
+    },
+  });
+
+  sort(sortable) {
+    if (this.properties) {
+      for (const property of this.properties.slice().reverse()) {
+        const get = thing => thing[property];
+        const lc = property.toLowerCase();
+
+        if (lc.endsWith('date')) {
+          sortByDate(sortable, {getDate: get});
+          continue;
+        }
+
+        if (lc.endsWith('directory')) {
+          sortByDirectory(sortable, {getDirectory: get});
+          continue;
+        }
+
+        if (lc.endsWith('name')) {
+          sortByName(sortable, {getName: get});
+          continue;
+        }
+
+        const values = sortable.map(get);
+
+        if (values.every(v => typeof v === 'string')) {
+          sortable.sort((a, b) =>
+            compareCaseLessSensitive(get(a), get(b)));
+          continue;
+        }
+
+        if (values.every(v => typeof v === 'number')) {
+          sortable.sort((a, b) => get(a) - get(b));
+          continue;
+        }
+
+        sortable.sort((a, b) =>
+          (get(a).toString() < get(b).toString()
+            ? -1
+         : get(a).toString() > get(b).toString()
+            ? +1
+            :  0));
+      }
+    }
+
+    return sortable;
+  }
+}
+
+export class DocumentSortingRule extends ThingSortingRule {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    // TODO: glob :plead:
+    filename: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    message: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+
+      expose: {
+        dependencies: ['filename'],
+        transform: (value, {filename}) =>
+          value ??
+          `Sort ${filename}`,
+      },
+    },
+
+    selectDocumentsFollowing: {
+      flags: {update: true, expose: true},
+
+      update: {
+        validate:
+          anyOf(
+            isSelectFollowingEntry,
+            strictArrayOf(isSelectFollowingEntry)),
+      },
+
+      compute: {
+        transform: value =>
+          (Array.isArray(value)
+            ? value
+            : [value]),
+      },
+    },
+
+    selectDocumentsUnder: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ThingSortingRule, {
+    fields: {
+      'Sort Documents': {property: 'filename'},
+      'Select Documents Following': {property: 'selectDocumentsFollowing'},
+      'Select Documents Under': {property: 'selectDocumentsUnder'},
+    },
+
+    invalidFieldCombinations: [
+      {message: `Specify only one of these`, fields: [
+        'Select Documents Following',
+        'Select Documents Under',
+      ]},
+    ],
+  });
+
+  static async apply(rule, {wikiData, dataPath, dry}) {
+    const oldLayout = getThingLayoutForFilename(rule.filename, wikiData);
+    if (!oldLayout) return null;
+
+    const newLayout = rule.#processLayout(oldLayout);
+
+    const oldOrder = flattenThingLayoutToDocumentOrder(oldLayout);
+    const newOrder = flattenThingLayoutToDocumentOrder(newLayout);
+    const changed = compareArrays(oldOrder, newOrder);
+
+    if (dry) return {changed};
+
+    const realPath =
+      path.join(
+        dataPath,
+        rule.filename.split(path.posix.sep).join(path.sep));
+
+    const oldSourceText = await readFile(realPath, 'utf8');
+    const newSourceText = reorderDocumentsInYAMLSourceText(oldSourceText, newOrder);
+
+    await writeFile(realPath, newSourceText);
+
+    return {changed};
+  }
+
+  static async* applyAll(rules, {wikiData, dataPath, dry}) {
+    rules =
+      rules
+        .slice()
+        .sort((a, b) => a.filename.localeCompare(b.filename, 'en'));
+
+    for (const {chunk, filename} of chunkByProperties(rules, ['filename'])) {
+      const initialLayout = getThingLayoutForFilename(filename, wikiData);
+      if (!initialLayout) continue;
+
+      let currLayout = initialLayout;
+      let prevLayout = initialLayout;
+      let anyChanged = false;
+
+      for (const rule of chunk) {
+        currLayout = rule.#processLayout(currLayout);
+
+        const prevOrder = flattenThingLayoutToDocumentOrder(prevLayout);
+        const currOrder = flattenThingLayoutToDocumentOrder(currLayout);
+
+        if (compareArrays(currOrder, prevOrder)) {
+          yield {rule, changed: false};
+        } else {
+          anyChanged = true;
+          yield {rule, changed: true};
+        }
+
+        prevLayout = currLayout;
+      }
+
+      if (!anyChanged) continue;
+      if (dry) continue;
+
+      const newLayout = currLayout;
+      const newOrder = flattenThingLayoutToDocumentOrder(newLayout);
+
+      const realPath =
+        path.join(
+          dataPath,
+          filename.split(path.posix.sep).join(path.sep));
+
+      const oldSourceText = await readFile(realPath, 'utf8');
+      const newSourceText = reorderDocumentsInYAMLSourceText(oldSourceText, newOrder);
+
+      await writeFile(realPath, newSourceText);
+    }
+  }
+
+  #processLayout(layout) {
+    const fresh = {...layout};
+
+    let sortable = null;
+    switch (fresh.documentMode) {
+      case documentModes.headerAndEntries:
+        sortable = fresh.entryThings =
+          fresh.entryThings.slice();
+        break;
+
+      case documentModes.allInOne:
+        sortable = fresh.things =
+          fresh.things.slice();
+        break;
+
+      default:
+        throw new Error(`Invalid document type for sorting`);
+    }
+
+    if (this.selectDocumentsFollowing) {
+      for (const entry of this.selectDocumentsFollowing) {
+        const [field, value] = Object.entries(entry)[0];
+
+        const after =
+          sortable.findIndex(thing =>
+            thing[Thing.yamlSourceDocument][field] === value);
+
+        const different =
+          after +
+          sortable
+            .slice(after)
+            .findIndex(thing =>
+              Object.hasOwn(thing[Thing.yamlSourceDocument], field) &&
+              thing[Thing.yamlSourceDocument][field] !== value);
+
+        const before =
+          (different === -1
+            ? sortable.length
+            : different);
+
+        const subsortable =
+          sortable.slice(after + 1, before);
+
+        this.sort(subsortable);
+
+        sortable.splice(after + 1, before - after - 1, ...subsortable);
+      }
+    } else if (this.selectDocumentsUnder) {
+      const field = this.selectDocumentsUnder;
+
+      const indices =
+        Array.from(sortable.entries())
+          .filter(([_index, thing]) =>
+            Object.hasOwn(thing[Thing.yamlSourceDocument], field))
+          .map(([index, _thing]) => index);
+
+      for (const [indicesIndex, after] of indices.entries()) {
+        const before =
+          (indicesIndex === indices.length - 1
+            ? sortable.length
+            : indices[indicesIndex + 1]);
+
+        const subsortable =
+          sortable.slice(after + 1, before);
+
+        this.sort(subsortable);
+
+        sortable.splice(after + 1, before - after - 1, ...subsortable);
+      }
+    } else {
+      this.sort(sortable);
+    }
+
+    return fresh;
+  }
+}
diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js
index 03274979..52a09c31 100644
--- a/src/data/things/static-page.js
+++ b/src/data/things/static-page.js
@@ -7,7 +7,7 @@ import {sortAlphabetically} from '#sort';
 import Thing from '#thing';
 import {isName} from '#validators';
 
-import {contentString, directory, name, simpleString}
+import {contentString, directory, flag, name, simpleString}
   from '#composite/wiki-properties';
 
 export class StaticPage extends Thing {
@@ -30,9 +30,12 @@ export class StaticPage extends Thing {
     },
 
     directory: directory(),
-    content: contentString(),
+
     stylesheet: simpleString(),
     script: simpleString(),
+    content: contentString(),
+
+    absoluteLinks: flag(),
   });
 
   static [Thing.findSpecs] = {
@@ -48,6 +51,8 @@ export class StaticPage extends Thing {
       'Short Name': {property: 'nameShort'},
       'Directory': {property: 'directory'},
 
+      'Absolute Links': {property: 'absoluteLinks'},
+
       'Style': {property: 'stylesheet'},
       'Script': {property: 'script'},
       'Content': {property: 'content'},
diff --git a/src/data/things/track.js b/src/data/things/track.js
index cc49fc24..bcf84aa8 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -3,14 +3,15 @@ import {inspect} from 'node:util';
 import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
 import {input} from '#composite';
-import find from '#find';
 import Thing from '#thing';
-import {isColor, isContributionList, isDate, isFileExtension}
+import {isBoolean, isColor, isContributionList, isDate, isFileExtension}
   from '#validators';
 
 import {
   parseAdditionalFiles,
   parseAdditionalNames,
+  parseAnnotatedReferences,
+  parseArtwork,
   parseContributors,
   parseDate,
   parseDimensions,
@@ -18,63 +19,117 @@ import {
 } from '#yaml';
 
 import {withPropertyFromObject} from '#composite/data';
-import {withResolvedContribs} from '#composite/wiki-data';
 
 import {
-  exitWithoutDependency,
   exposeConstant,
   exposeDependency,
   exposeDependencyOrContinue,
   exposeUpdateValueOrContinue,
+  exposeWhetherDependencyAvailable,
 } from '#composite/control-flow';
 
 import {
+  withRecontextualizedContributionList,
+  withRedatedContributionList,
+  withResolvedContribs,
+} from '#composite/wiki-data';
+
+import {
   additionalFiles,
   additionalNameList,
   commentary,
   commentatorArtists,
+  constitutibleArtworkList,
   contentString,
   contributionList,
   dimensions,
   directory,
   duration,
   flag,
+  lyrics,
   name,
   referenceList,
+  referencedArtworkList,
   reverseReferenceList,
   simpleDate,
-  singleReference,
   simpleString,
+  singleReference,
+  soupyFind,
+  soupyReverse,
+  thing,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
 
 import {
   exitWithoutUniqueCoverArt,
-  inferredAdditionalNameList,
-  inheritFromOriginalRelease,
-  sharedAdditionalNameList,
-  trackReverseReferenceList,
-  withAlbum,
+  inheritContributionListFromMainRelease,
+  inheritFromMainRelease,
+  withAllReleases,
   withAlwaysReferenceByDirectory,
   withContainingTrackSection,
+  withCoverArtistContribs,
+  withDate,
+  withDirectorySuffix,
   withHasUniqueCoverArt,
+  withMainRelease,
   withOtherReleases,
   withPropertyFromAlbum,
+  withSuffixDirectoryFromAlbum,
+  withTrackArtDate,
+  withTrackNumber,
 } 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,
+    Artwork,
+    Flash,
+    TrackSection,
+    WikiInfo,
+  }) => ({
     // Update & expose
 
     name: name('Unnamed Track'),
-    directory: directory(),
+
+    directory: [
+      withDirectorySuffix(),
+
+      directory({
+        suffix: '#directorySuffix',
+      }),
+    ],
+
+    suffixDirectoryFromAlbum: [
+      {
+        dependencies: [
+          input.updateValue({validate: isBoolean}),
+        ],
+
+        compute: (continuation, {
+          [input.updateValue()]: value,
+        }) => continuation({
+          ['#flagValue']: value ?? false,
+        }),
+      },
+
+      withSuffixDirectoryFromAlbum({
+        flagValue: '#flagValue',
+      }),
+
+      exposeDependency({
+        dependency: '#suffixDirectoryFromAlbum',
+      })
+    ],
+
+    album: thing({
+      class: input.value(Album),
+    }),
 
     additionalNames: additionalNameList(),
-    sharedAdditionalNames: sharedAdditionalNameList(),
-    inferredAdditionalNames: inferredAdditionalNameList(),
 
     bandcampTrackIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
@@ -137,71 +192,57 @@ 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'),
+      withTrackArtDate({
+        from: input.updateValue({
+          validate: isDate,
+        }),
       }),
 
-      exposeUpdateValueOrContinue({
-        validate: input.value(isDate),
-      }),
+      exposeDependency({dependency: '#trackArtDate'}),
+    ],
+
+    coverArtDimensions: [
+      exitWithoutUniqueCoverArt(),
+
+      exposeUpdateValueOrContinue(),
 
       withPropertyFromAlbum({
-        property: input.value('trackArtDate'),
+        property: input.value('trackDimensions'),
       }),
 
-      exposeDependency({dependency: '#album.trackArtDate'}),
-    ],
+      exposeDependencyOrContinue({dependency: '#album.trackDimensions'}),
 
-    coverArtDimensions: [
-      exitWithoutUniqueCoverArt(),
       dimensions(),
     ],
 
     commentary: commentary(),
+    creditSources: commentary(),
 
     lyrics: [
-      inheritFromOriginalRelease({
-        property: input.value('lyrics'),
-      }),
-
-      contentString(),
+      inheritFromMainRelease(),
+      lyrics(),
     ],
 
     additionalFiles: additionalFiles(),
     sheetMusicFiles: additionalFiles(),
     midiProjectFiles: additionalFiles(),
 
-    originalReleaseTrack: singleReference({
+    mainReleaseTrack: singleReference({
       class: input.value(Track),
-      find: input.value(find.track),
-      data: 'trackData',
-    }),
-
-    // Internal use only - for directly identifying an album inside a track's
-    // util.inspect display, if it isn't indirectly available (by way of being
-    // included in an album's track list).
-    dataSourceAlbum: singleReference({
-      class: input.value(Album),
-      find: input.value(find.album),
-      data: 'albumData',
+      find: soupyFind.input('track'),
     }),
 
     artistContribs: [
-      inheritFromOriginalRelease({
-        property: input.value('artistContribs'),
-        notFoundValue: input.value([]),
-      }),
+      inheritContributionListFromMainRelease(),
+
+      withDate(),
 
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackArtistContributions'),
+        date: '#date',
       }).outputs({
         '#resolvedContribs': '#artistContribs',
       }),
@@ -215,68 +256,69 @@ export class Track extends Thing {
         property: input.value('artistContribs'),
       }),
 
-      exposeDependency({dependency: '#album.artistContribs'}),
-    ],
+      withRecontextualizedContributionList({
+        list: '#album.artistContribs',
+        artistProperty: input.value('trackArtistContributions'),
+      }),
 
-    contributorContribs: [
-      inheritFromOriginalRelease({
-        property: input.value('contributorContribs'),
-        notFoundValue: input.value([]),
+      withRedatedContributionList({
+        list: '#album.artistContribs',
+        date: '#date',
       }),
 
-      contributionList(),
+      exposeDependency({dependency: '#album.artistContribs'}),
     ],
 
-    // Cover artists aren't inherited from the original release, since it
-    // typically varies by release and isn't defined by the musical qualities
-    // of the track.
-    coverArtistContribs: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
+    contributorContribs: [
+      inheritContributionListFromMainRelease(),
 
-      withResolvedContribs({
-        from: input.updateValue({validate: isContributionList}),
-      }).outputs({
-        '#resolvedContribs': '#coverArtistContribs',
-      }),
+      withDate(),
 
-      exposeDependencyOrContinue({
-        dependency: '#coverArtistContribs',
-        mode: input.value('empty'),
+      contributionList({
+        date: '#date',
+        artistProperty: input.value('trackContributorContributions'),
       }),
+    ],
 
-      withPropertyFromAlbum({
-        property: input.value('trackCoverArtistContribs'),
+    coverArtistContribs: [
+      withCoverArtistContribs({
+        from: input.updateValue({
+          validate: isContributionList,
+        }),
       }),
 
-      exposeDependency({dependency: '#album.trackCoverArtistContribs'}),
+      exposeDependency({dependency: '#coverArtistContribs'}),
     ],
 
     referencedTracks: [
-      inheritFromOriginalRelease({
-        property: input.value('referencedTracks'),
+      inheritFromMainRelease({
         notFoundValue: input.value([]),
       }),
 
       referenceList({
         class: input.value(Track),
-        find: input.value(find.track),
-        data: 'trackData',
+        find: soupyFind.input('track'),
       }),
     ],
 
     sampledTracks: [
-      inheritFromOriginalRelease({
-        property: input.value('sampledTracks'),
+      inheritFromMainRelease({
         notFoundValue: input.value([]),
       }),
 
       referenceList({
         class: input.value(Track),
-        find: input.value(find.track),
-        data: 'trackData',
+        find: soupyFind.input('track'),
+      }),
+    ],
+
+    trackArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
       }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Track Artwork'),
     ],
 
     artTags: [
@@ -286,50 +328,50 @@ export class Track extends Thing {
 
       referenceList({
         class: input.value(ArtTag),
-        find: input.value(find.artTag),
-        data: 'artTagData',
+        find: soupyFind.input('artTag'),
       }),
     ],
 
-    // Update only
+    referencedArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
 
-    albumData: wikiData({
-      class: input.value(Album),
-    }),
+      referencedArtworkList(),
+    ],
 
-    artistData: wikiData({
-      class: input.value(Artist),
-    }),
+    // Update only
 
-    artTagData: wikiData({
-      class: input.value(ArtTag),
-    }),
+    find: soupyFind(),
+    reverse: soupyReverse(),
 
-    flashData: wikiData({
-      class: input.value(Flash),
+    // used for referencedArtworkList (mixedFind)
+    artworkData: wikiData({
+      class: input.value(Artwork),
     }),
 
+    // used for withAlwaysReferenceByDirectory (for some reason)
     trackData: wikiData({
       class: input.value(Track),
     }),
 
+    // used for withMatchingContributionPresets (indirectly by Contribution)
+    wikiInfo: thing({
+      class: input.value(WikiInfo),
+    }),
+
     // Expose only
 
     commentatorArtists: commentatorArtists(),
 
-    album: [
-      withAlbum(),
-      exposeDependency({dependency: '#album'}),
-    ],
-
     date: [
-      exposeDependencyOrContinue({dependency: 'dateFirstReleased'}),
-
-      withPropertyFromAlbum({
-        property: input.value('date'),
-      }),
+      withDate(),
+      exposeDependency({dependency: '#date'}),
+    ],
 
-      exposeDependency({dependency: '#album.date'}),
+    trackNumber: [
+      withTrackNumber(),
+      exposeDependency({dependency: '#trackNumber'}),
     ],
 
     hasUniqueCoverArt: [
@@ -337,22 +379,49 @@ export class Track extends Thing {
       exposeDependency({dependency: '#hasUniqueCoverArt'}),
     ],
 
+    isMainRelease: [
+      withMainRelease(),
+
+      exposeWhetherDependencyAvailable({
+        dependency: '#mainRelease',
+        negate: input.value(true),
+      }),
+    ],
+
+    isSecondaryRelease: [
+      withMainRelease(),
+
+      exposeWhetherDependencyAvailable({
+        dependency: '#mainRelease',
+      }),
+    ],
+
+    // Only has any value for main releases, because secondary releases
+    // are never secondary to *another* secondary release.
+    secondaryReleases: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichAreSecondaryReleasesOf'),
+    }),
+
+    allReleases: [
+      withAllReleases(),
+      exposeDependency({dependency: '#allReleases'}),
+    ],
+
     otherReleases: [
       withOtherReleases(),
       exposeDependency({dependency: '#otherReleases'}),
     ],
 
-    referencedByTracks: trackReverseReferenceList({
-      list: input.value('referencedTracks'),
+    referencedByTracks: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichReference'),
     }),
 
-    sampledByTracks: trackReverseReferenceList({
-      list: input.value('sampledTracks'),
+    sampledByTracks: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichSample'),
     }),
 
     featuredInFlashes: reverseReferenceList({
-      data: 'flashData',
-      list: input.value('featuredTracks'),
+      reverse: soupyReverse.input('flashesWhichFeature'),
     }),
   });
 
@@ -360,6 +429,7 @@ export class Track extends Thing {
     fields: {
       'Track': {property: 'name'},
       'Directory': {property: 'directory'},
+      'Suffix Directory': {property: 'suffixDirectoryFromAlbum'},
 
       'Additional Names': {
         property: 'additionalNames',
@@ -413,6 +483,7 @@ export class Track extends Thing {
 
       'Lyrics': {property: 'lyrics'},
       'Commentary': {property: 'commentary'},
+      'Credit Sources': {property: 'creditSources'},
 
       'Additional Files': {
         property: 'additionalFiles',
@@ -429,10 +500,15 @@ export class Track extends Thing {
         transform: parseAdditionalFiles,
       },
 
-      'Originally Released As': {property: 'originalReleaseTrack'},
+      'Main Release': {property: 'mainReleaseTrack'},
       'Referenced Tracks': {property: 'referencedTracks'},
       'Sampled Tracks': {property: 'sampledTracks'},
 
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
+      },
+
       'Franchises': {ignore: true},
       'Inherit Franchises': {ignore: true},
 
@@ -451,34 +527,48 @@ export class Track extends Thing {
         transform: parseContributors,
       },
 
+      'Track Artwork': {
+        property: 'trackArtworks',
+        transform:
+          parseArtwork({
+            dimensionsFromThingProperty: 'coverArtDimensions',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dateFromThingProperty: 'coverArtDate',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'trackCoverArtistContributions',
+          }),
+      },
+
       'Art Tags': {property: 'artTags'},
 
       'Review Points': {ignore: true},
     },
 
     invalidFieldCombinations: [
-      {message: `Rereleases inherit references from the original`, fields: [
-        'Originally Released As',
+      {message: `Secondary releases inherit references from the main one`, fields: [
+        'Main Release',
         'Referenced Tracks',
       ]},
 
-      {message: `Rereleases inherit samples from the original`, fields: [
-        'Originally Released As',
+      {message: `Secondary releases inherit samples from the main one`, fields: [
+        'Main Release',
         'Sampled Tracks',
       ]},
 
-      {message: `Rereleases inherit artists from the original`, fields: [
-        'Originally Released As',
+      {message: `Secondary releases inherit artists from the main one`, fields: [
+        'Main Release',
         'Artists',
       ]},
 
-      {message: `Rereleases inherit contributors from the original`, fields: [
-        'Originally Released As',
+      {message: `Secondary releases inherit contributors from the main one`, fields: [
+        'Main Release',
         'Contributors',
       ]},
 
-      {message: `Rereleases inherit lyrics from the original`, fields: [
-        'Originally Released As',
+      {message: `Secondary releases inherit lyrics from the main one`, fields: [
+        'Main Release',
         'Lyrics',
       ]},
 
@@ -499,6 +589,7 @@ export class Track extends Thing {
   static [Thing.findSpecs] = {
     track: {
       referenceTypes: ['track'],
+
       bindTo: 'trackData',
 
       getMatchableNames: track =>
@@ -507,12 +598,12 @@ export class Track extends Thing {
           : [track.name]),
     },
 
-    trackOriginalReleasesOnly: {
+    trackMainReleasesOnly: {
       referenceTypes: ['track'],
       bindTo: 'trackData',
 
       include: track =>
-        !CacheableObject.getUpdateValue(track, 'originalReleaseTrack'),
+        !CacheableObject.getUpdateValue(track, 'mainReleaseTrack'),
 
       // It's still necessary to check alwaysReferenceByDirectory here, since
       // it may be set manually (with `Always Reference By Directory: true`),
@@ -523,32 +614,128 @@ export class Track extends Thing {
           ? []
           : [track.name]),
     },
+
+    trackWithArtwork: {
+      referenceTypes: [
+        'track',
+        'track-referencing-artworks',
+        'track-referenced-artworks',
+      ],
+
+      bindTo: 'trackData',
+
+      include: track =>
+        track.hasUniqueCoverArt,
+
+      getMatchableNames: track =>
+        (track.alwaysReferenceByDirectory
+          ? []
+          : [track.name]),
+    },
+
+    trackPrimaryArtwork: {
+      [Thing.findThisThingOnly]: false,
+
+      referenceTypes: [
+        'track',
+        'track-referencing-artworks',
+        'track-referenced-artworks',
+      ],
+
+      bindTo: 'artworkData',
+
+      include: (artwork, {Artwork, Track}) =>
+        artwork instanceof Artwork &&
+        artwork.thing instanceof Track &&
+        artwork === artwork.thing.trackArtworks[0],
+
+      getMatchableNames: ({thing: track}) =>
+        (track.alwaysReferenceByDirectory
+          ? []
+          : [track.name]),
+
+      getMatchableDirectories: ({thing: track}) =>
+        [track.directory],
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    tracksWhichReference: {
+      bindTo: 'trackData',
+
+      referencing: track => track.isMainRelease ? [track] : [],
+      referenced: track => track.referencedTracks,
+    },
+
+    tracksWhichSample: {
+      bindTo: 'trackData',
+
+      referencing: track => track.isMainRelease ? [track] : [],
+      referenced: track => track.sampledTracks,
+    },
+
+    tracksWhoseArtworksFeature: {
+      bindTo: 'trackData',
+
+      referencing: track => [track],
+      referenced: track => track.artTags,
+    },
+
+    trackArtistContributionsBy:
+      soupyReverse.contributionsBy('trackData', 'artistContribs'),
+
+    trackContributorContributionsBy:
+      soupyReverse.contributionsBy('trackData', 'contributorContribs'),
+
+    trackCoverArtistContributionsBy:
+      soupyReverse.artworkContributionsBy('trackData', 'trackArtworks'),
+
+    tracksWithCommentaryBy: {
+      bindTo: 'trackData',
+
+      referencing: track => [track],
+      referenced: track => track.commentatorArtists,
+    },
+
+    tracksWhichAreSecondaryReleasesOf: {
+      bindTo: 'trackData',
+
+      referencing: track => track.isSecondaryRelease ? [track] : [],
+      referenced: track => [track.mainReleaseTrack],
+    },
   };
 
   // Track YAML loading is handled in album.js.
   static [Thing.getYamlLoadingSpec] = null;
 
+  getOwnArtworkPath(artwork) {
+    if (!this.album) return null;
+
+    return [
+      'media.trackCover',
+      this.album.directory,
+
+      (artwork.unqualifiedDirectory
+        ? this.directory + '-' + artwork.unqualifiedDirectory
+        : this.directory),
+
+      artwork.fileExtension,
+    ];
+  }
+
   [inspect.custom](depth) {
     const parts = [];
 
     parts.push(Thing.prototype[inspect.custom].apply(this));
 
-    if (CacheableObject.getUpdateValue(this, 'originalReleaseTrack')) {
-      parts.unshift(`${colors.yellow('[rerelease]')} `);
+    if (CacheableObject.getUpdateValue(this, 'mainReleaseTrack')) {
+      parts.unshift(`${colors.yellow('[secrelease]')} `);
     }
 
     let album;
 
     if (depth >= 0) {
-      try {
-        album = this.album;
-      } catch (_error) {
-        // Computing album might crash for any reason, which we don't want to
-        // distract from another error we might be trying to work out at the
-        // moment (for which debugging might involve inspecting this track!).
-      }
-
-      album ??= this.dataSourceAlbum;
+      album = this.album;
     }
 
     if (album) {
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 316bd3bb..590598be 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -1,11 +1,20 @@
 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 {contentString, flag, name, referenceList, wikiData}
+import {parseContributionPresets} from '#yaml';
+
+import {
+  isBoolean,
+  isColor,
+  isContributionPresetList,
+  isLanguageCode,
+  isName,
+  isURL,
+} from '#validators';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+import {contentString, flag, name, referenceList, soupyFind}
   from '#composite/wiki-properties';
 
 export class WikiInfo extends Thing {
@@ -49,14 +58,26 @@ export class WikiInfo extends Thing {
     canonicalBase: {
       flags: {update: true, expose: true},
       update: {validate: isURL},
+      expose: {
+        transform: (value) =>
+          (value === null
+            ? null
+         : value.endsWith('/')
+            ? value
+            : value + '/'),
+      },
     },
 
     divideTrackListsByGroups: referenceList({
       class: input.value(Group),
-      find: input.value(find.group),
-      data: 'groupData',
+      find: soupyFind.input('group'),
     }),
 
+    contributionPresets: {
+      flags: {update: true, expose: true},
+      update: {validate: isContributionPresetList},
+    },
+
     // Feature toggles
     enableFlashesAndGames: flag(false),
     enableListings: flag(false),
@@ -64,11 +85,27 @@ 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
 
-    groupData: wikiData({
-      class: input.value(Group),
-    }),
+    find: soupyFind(),
+
+    searchDataAvailable: {
+      flags: {update: true},
+      update: {
+        validate: isBoolean,
+        default: false,
+      },
+    },
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -86,6 +123,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/yaml.js b/src/data/yaml.js
index 86f30143..af1d5740 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -9,26 +9,35 @@ import yaml from 'js-yaml';
 
 import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
 import {sortByName} from '#sort';
-import {atOffset, empty, filterProperties, typeAppearance, withEntries}
-  from '#sugar';
 import Thing from '#thing';
 import thingConstructors from '#things';
 
 import {
-  filterReferenceErrors,
-  reportContentTextErrors,
-  reportDuplicateDirectories,
-} from '#data-checks';
-
-import {
+  aggregateThrows,
   annotateErrorWithFile,
   decorateErrorWithIndex,
   decorateErrorWithAnnotation,
   openAggregate,
   showAggregate,
-  withAggregate,
 } from '#aggregate';
 
+import {
+  filterReferenceErrors,
+  reportContentTextErrors,
+  reportDirectoryErrors,
+} from '#data-checks';
+
+import {
+  atOffset,
+  empty,
+  filterProperties,
+  getNestedProp,
+  stitchArrays,
+  typeAppearance,
+  unique,
+  withEntries,
+} from '#sugar';
+
 function inspect(value, opts = {}) {
   return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
@@ -80,6 +89,10 @@ function makeProcessDocument(thingConstructor, {
   // A or B.
   //
   invalidFieldCombinations = [],
+
+  // Bouncing function used to process subdocuments: this is a function which
+  // in turn calls the appropriate *result of* makeProcessDocument.
+  processDocument: bouncer,
 }) {
   if (!thingConstructor) {
     throw new Error(`Missing Thing class`);
@@ -89,6 +102,10 @@ function makeProcessDocument(thingConstructor, {
     throw new Error(`Expected fields to be provided`);
   }
 
+  if (!bouncer) {
+    throw new Error(`Missing processDocument bouncer`);
+  }
+
   const knownFields = Object.keys(fieldSpecs);
 
   const ignoredFields =
@@ -136,9 +153,12 @@ function makeProcessDocument(thingConstructor, {
         : `document`);
 
     const aggregate = openAggregate({
+      ...aggregateThrows(ProcessDocumentError),
       message: `Errors processing ${constructorPart}` + namePart,
     });
 
+    const thing = Reflect.construct(thingConstructor, []);
+
     const documentEntries = Object.entries(document)
       .filter(([field]) => !ignoredFields.includes(field));
 
@@ -186,13 +206,50 @@ function makeProcessDocument(thingConstructor, {
 
     const fieldValues = {};
 
+    const subdocSymbol = Symbol('subdoc');
+    const subdocLayouts = {};
+
+    const isSubdocToken = value =>
+      typeof value === 'object' &&
+      value !== null &&
+      Object.hasOwn(value, subdocSymbol);
+
+    const transformUtilities = {
+      ...thingConstructors,
+
+      subdoc(documentType, data, {
+        bindInto = null,
+        provide = null,
+      } = {}) {
+        if (!documentType)
+          throw new Error(`Expected document type, got ${typeAppearance(documentType)}`);
+        if (!data)
+          throw new Error(`Expected data, got ${typeAppearance(data)}`);
+        if (typeof data !== 'object' || data === null)
+          throw new Error(`Expected data to be an object, got ${typeAppearance(data)}`);
+        if (typeof bindInto !== 'string' && bindInto !== null)
+          throw new Error(`Expected bindInto to be a string, got ${typeAppearance(bindInto)}`);
+        if (typeof provide !== 'object' && provide !== null)
+          throw new Error(`Expected provide to be an object, got ${typeAppearance(provide)}`);
+
+        return {
+          [subdocSymbol]: {
+            documentType,
+            data,
+            bindInto,
+            provide,
+          },
+        };
+      },
+    };
+
     for (const [field, documentValue] of documentEntries) {
       if (skippedFields.has(field)) continue;
 
       // This variable would like to certify itself as "not into capitalism".
       let propertyValue =
         (fieldSpecs[field].transform
-          ? fieldSpecs[field].transform(documentValue)
+          ? fieldSpecs[field].transform(documentValue, transformUtilities)
           : documentValue);
 
       // Completely blank items in a YAML list are read as null.
@@ -215,10 +272,99 @@ function makeProcessDocument(thingConstructor, {
         }
       }
 
+      if (isSubdocToken(propertyValue)) {
+        subdocLayouts[field] = propertyValue[subdocSymbol];
+        continue;
+      }
+
+      if (Array.isArray(propertyValue) && propertyValue.every(isSubdocToken)) {
+        subdocLayouts[field] =
+          propertyValue
+            .map(token => token[subdocSymbol]);
+        continue;
+      }
+
       fieldValues[field] = propertyValue;
     }
 
-    const thing = Reflect.construct(thingConstructor, []);
+    const subdocErrors = [];
+
+    const followSubdocSetup = setup => {
+      let error = null;
+
+      let subthing;
+      try {
+        const result = bouncer(setup.data, setup.documentType);
+        subthing = result.thing;
+        result.aggregate.close();
+      } catch (caughtError) {
+        error = caughtError;
+      }
+
+      if (subthing) {
+        if (setup.bindInto) {
+          subthing[setup.bindInto] = thing;
+        }
+
+        if (setup.provide) {
+          Object.assign(subthing, setup.provide);
+        }
+      }
+
+      return {error, subthing};
+    };
+
+    for (const [field, layout] of Object.entries(subdocLayouts)) {
+      if (Array.isArray(layout)) {
+        const subthings = [];
+        let anySucceeded = false;
+        let anyFailed = false;
+
+        for (const [index, setup] of layout.entries()) {
+          const {subthing, error} = followSubdocSetup(setup);
+          if (error) {
+            subdocErrors.push(new SubdocError(
+              {field, index},
+              setup,
+              {cause: error}));
+          }
+
+          if (subthing) {
+            subthings.push(subthing);
+            anySucceeded = true;
+          } else {
+            anyFailed = true;
+          }
+        }
+
+        if (anySucceeded) {
+          fieldValues[field] = subthings;
+        } else if (anyFailed) {
+          skippedFields.add(field);
+        }
+      } else {
+        const setup = layout;
+        const {subthing, error} = followSubdocSetup(setup);
+
+        if (error) {
+          subdocErrors.push(new SubdocError(
+            {field},
+            setup,
+            {cause: error}));
+        }
+
+        if (subthing) {
+          fieldValues[field] = subthing;
+        } else {
+          skippedFields.add(field);
+        }
+      }
+    }
+
+    if (!empty(subdocErrors)) {
+      aggregate.push(new SubdocAggregateError(
+        subdocErrors, thingConstructor));
+    }
 
     const fieldValueErrors = [];
 
@@ -252,6 +398,8 @@ function makeProcessDocument(thingConstructor, {
   });
 }
 
+export class ProcessDocumentError extends AggregateError {}
+
 export class UnknownFieldsError extends Error {
   constructor(fields) {
     super(`Unknown fields ignored: ${fields.map(field => colors.red(field)).join(', ')}`);
@@ -339,12 +487,46 @@ export class SkippedFieldsSummaryError extends Error {
         : `${entries.length} fields`);
 
     super(
-      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) +
+      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:`)) + '\n' +
       lines.join('\n') + '\n' +
       colors.bright(colors.yellow(`See above errors for details.`)));
   }
 }
 
+export class SubdocError extends Error {
+  constructor({field, index = null}, setup, options) {
+    const fieldText =
+      (index === null
+        ? colors.green(`"${field}"`)
+        : colors.yellow(`#${index + 1}`) + ' in ' +
+          colors.green(`"${field}"`));
+
+    const constructorText =
+      setup.documentType.name;
+
+    if (options.cause instanceof ProcessDocumentError) {
+      options.cause[Symbol.for('hsmusic.aggregate.translucent')] = true;
+    }
+
+    super(
+      `Errors processing ${constructorText} for ${fieldText} field`,
+      options);
+  }
+}
+
+export class SubdocAggregateError extends AggregateError {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+  constructor(errors, thingConstructor) {
+    const constructorText =
+      colors.green(thingConstructor.name);
+
+    super(
+      errors,
+      `Errors processing subdocuments for ${constructorText}`);
+  }
+}
+
 export function parseDate(date) {
   return new Date(date);
 }
@@ -364,36 +546,56 @@ 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 {who: item['Who'], what: item['What'] ?? null};
+      return {
+        artist: item['Who'],
+        annotation: item['What'] ?? null,
+      };
+
+    if (typeof item === 'object' && item['Artist'])
+      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;
 
@@ -401,20 +603,31 @@ export function parseContributors(contributionStrings) {
     if (!match) return item;
 
     return {
-      who: match.groups.main,
-      what: match.groups.accent ?? null,
+      artist: match.groups.main,
+      annotation: match.groups.accent ?? null,
     };
   });
 }
 
-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 {
+      title: item['Title'],
+      description: item['Description'] ?? null,
+      files: item['Files'],
+    };
+  });
+}
 
-  return additionalNameStrings.map(item => {
-    if (typeof item === 'object' && item['Name'])
-      return {name: item['Name'], annotation: item['Annotation'] ?? null};
+export function parseAdditionalNames(entries) {
+  return parseArrayEntries(entries, item => {
+    if (typeof item === 'object' && typeof item['Name'] === 'string')
+      return {
+        name: item['Name'],
+        annotation: item['Annotation'] ?? null,
+      };
 
     if (typeof item !== 'string') return item;
 
@@ -428,6 +641,35 @@ export function parseAdditionalNames(additionalNameStrings) {
   });
 }
 
+export function parseSerieses(entries) {
+  return parseArrayEntries(entries, item => {
+    if (typeof item !== 'object') return item;
+
+    return {
+      name: item['Name'],
+      description: item['Description'] ?? null,
+      albums: item['Albums'] ?? null,
+
+      showAlbumArtists: item['Show Album Artists'] ?? null,
+    };
+  });
+}
+
+export function parseWallpaperParts(entries) {
+  return parseArrayEntries(entries, item => {
+    if (typeof item !== 'object') return item;
+
+    return {
+      asset:
+        (item['Asset'] === 'none'
+          ? null
+          : item['Asset'] ?? null),
+
+      style: item['Style'] ?? null,
+    };
+  });
+}
+
 export function parseDimensions(string) {
   // It's technically possible to pass an array like [30, 40] through here.
   // That's not really an issue because if it isn't of the appropriate shape,
@@ -451,6 +693,137 @@ 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,
+    };
+  });
+}
+
+export function parseAnnotatedReferences(entries, {
+  referenceField = 'References',
+  annotationField = 'Annotation',
+  referenceProperty = 'reference',
+  annotationProperty = 'annotation',
+} = {}) {
+  return parseArrayEntries(entries, item => {
+    if (typeof item === 'object' && item[referenceField])
+      return {
+        [referenceProperty]: item[referenceField],
+        [annotationProperty]: item[annotationField] ?? null,
+      };
+
+    if (typeof item !== 'string') return item;
+
+    const match = item.match(extractAccentRegex);
+    if (!match)
+      return {
+        [referenceProperty]: item,
+        [annotationProperty]: null,
+      };
+
+    return {
+      [referenceProperty]: match.groups.main,
+      [annotationProperty]: match.groups.accent ?? null,
+    };
+  });
+}
+
+export function parseArtwork({
+  single = false,
+  dimensionsFromThingProperty = null,
+  fileExtensionFromThingProperty = null,
+  dateFromThingProperty = null,
+  artistContribsFromThingProperty = null,
+  artistContribsArtistProperty = null,
+  artTagsFromThingProperty = null,
+  referencedArtworksFromThingProperty = null,
+}) {
+  const provide = {
+    dimensionsFromThingProperty,
+    fileExtensionFromThingProperty,
+    dateFromThingProperty,
+    artistContribsFromThingProperty,
+    artistContribsArtistProperty,
+    artTagsFromThingProperty,
+    referencedArtworksFromThingProperty,
+  };
+
+  const parseSingleEntry = (entry, {subdoc, Artwork}) =>
+    subdoc(Artwork, entry, {bindInto: 'thing', provide});
+
+  const transform = (value, ...args) =>
+    (Array.isArray(value)
+      ? value.map(entry => parseSingleEntry(entry, ...args))
+   : single
+      ? parseSingleEntry(value, ...args)
+      : [parseSingleEntry(value, ...args)]);
+
+  transform.provide = provide;
+
+  return transform;
+}
+
 // documentModes: Symbols indicating sets of behavior for loading and processing
 // data files.
 export const documentModes = {
@@ -523,13 +896,26 @@ export const documentModes = {
 //   them to each other, setting additional properties, etc). Input argument
 //   format depends on documentMode.
 //
-export const getDataSteps = () => {
+export function getAllDataSteps() {
+  try {
+    thingConstructors;
+  } catch (error) {
+    throw new Error(`Thing constructors aren't ready yet, can't get all data steps`);
+  }
+
   const steps = [];
 
+  const seenLoadingFns = new Set();
+
   for (const thingConstructor of Object.values(thingConstructors)) {
     const getSpecFn = thingConstructor[Thing.getYamlLoadingSpec];
     if (!getSpecFn) continue;
 
+    // Subclasses can expose literally the same static properties
+    // by inheritence. We don't want to double-count those!
+    if (seenLoadingFns.has(getSpecFn)) continue;
+    seenLoadingFns.add(getSpecFn);
+
     steps.push(getSpecFn({
       documentModes,
       thingConstructors,
@@ -539,464 +925,661 @@ export const getDataSteps = () => {
   sortByName(steps, {getName: step => step.title});
 
   return steps;
-};
+}
 
-export async function loadAndProcessDataDocuments({dataPath}) {
-  const processDataAggregate = openAggregate({
-    message: `Errors processing data files`,
-  });
-  const wikiDataResult = {};
-
-  function decorateErrorWithFile(fn) {
-    return decorateErrorWithAnnotation(fn,
-      (caughtError, firstArg) =>
-        annotateErrorWithFile(
-          caughtError,
-          path.relative(
-            dataPath,
-            (typeof firstArg === 'object'
-              ? firstArg.file
-              : firstArg))));
-  }
+export async function getFilesFromDataStep(dataStep, {dataPath}) {
+  const {documentMode} = dataStep;
 
-  function asyncDecorateErrorWithFile(fn) {
-    return decorateErrorWithFile(fn).async;
-  }
+  switch (documentMode) {
+    case documentModes.allInOne:
+    case documentModes.oneDocumentTotal: {
+      if (!dataStep.file) {
+        throw new Error(`Expected 'file' property for ${documentMode.toString()}`);
+      }
 
-  for (const dataStep of getDataSteps()) {
-    await processDataAggregate.nestAsync(
-      {
-        message: `Errors during data step: ${colors.bright(dataStep.title)}`,
-        translucent: true,
-      },
-      async ({call, callAsync, map, mapAsync, push}) => {
-        const {documentMode} = dataStep;
+      const localFile =
+        (typeof dataStep.file === 'function'
+          ? await dataStep.file(dataPath)
+          : dataStep.file);
+
+      const fileUnderDataPath =
+        path.join(dataPath, localFile);
+
+      const statResult =
+        await stat(fileUnderDataPath).then(
+          () => true,
+          error => {
+            if (error.code === 'ENOENT') {
+              return false;
+            } else {
+              throw error;
+            }
+          });
 
-        if (!Object.values(documentModes).includes(documentMode)) {
-          throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
-        }
+      if (statResult) {
+        return [fileUnderDataPath];
+      } else {
+        return [];
+      }
+    }
 
-        // Hear me out, it's been like 1200 years since I wrote the rest of
-        // this beautifully error-containing code and I don't know how to
-        // integrate this nicely. So I'm just returning the result and the
-        // error that should be thrown. Yes, we're back in callback hell,
-        // just without the callbacks. Thank you.
-        const filterBlankDocuments = documents => {
-          const aggregate = openAggregate({
-            message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`,
-          });
+    case documentModes.headerAndEntries:
+    case documentModes.onePerFile: {
+      if (!dataStep.files) {
+        throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
+      }
 
-          const filteredDocuments =
-            documents
-              .filter(doc => doc !== null);
-
-          if (filteredDocuments.length !== documents.length) {
-            const blankIndexRangeInfo =
-              documents
-                .map((doc, index) => [doc, index])
-                .filter(([doc]) => doc === null)
-                .map(([doc, index]) => index)
-                .reduce((accumulator, index) => {
-                  if (accumulator.length === 0) {
-                    return [[index, index]];
-                  }
-                  const current = accumulator.at(-1);
-                  const rest = accumulator.slice(0, -1);
-                  if (current[1] === index - 1) {
-                    return rest.concat([[current[0], index]]);
-                  } else {
-                    return accumulator.concat([[index, index]]);
-                  }
-                }, [])
-                .map(([start, end]) => ({
-                  start,
-                  end,
-                  count: end - start + 1,
-                  previous: atOffset(documents, start, -1),
-                  next: atOffset(documents, end, +1),
-                }));
-
-            for (const {start, end, count, previous, next} of blankIndexRangeInfo) {
-              const parts = [];
-
-              if (count === 1) {
-                const range = `#${start + 1}`;
-                parts.push(`${count} document (${colors.yellow(range)}), `);
-              } else {
-                const range = `#${start + 1}-${end + 1}`;
-                parts.push(`${count} documents (${colors.yellow(range)}), `);
-              }
-
-              if (previous === null) {
-                parts.push(`at start of file`);
-              } else if (next === null) {
-                parts.push(`at end of file`);
-              } else {
-                const previousDescription = Object.entries(previous).at(0).join(': ');
-                const nextDescription = Object.entries(next).at(0).join(': ');
-                parts.push(`between "${colors.cyan(previousDescription)}" and "${colors.cyan(nextDescription)}"`);
-              }
-
-              aggregate.push(new Error(parts.join('')));
-            }
-          }
+      const localFiles =
+        (typeof dataStep.files === 'function'
+          ? await dataStep.files(dataPath).then(
+              files => files,
+              error => {
+                if (error.code === 'ENOENT') {
+                  return [];
+                } else {
+                  throw error;
+                }
+              })
+          : dataStep.files);
 
-          return {documents: filteredDocuments, aggregate};
-        };
+      const filesUnderDataPath =
+        localFiles
+          .map(file => path.join(dataPath, file));
 
-        const processDocument = (document, thingClassOrFn) => {
-          const thingClass =
-            (thingClassOrFn.prototype instanceof Thing
-              ? thingClassOrFn
-              : thingClassOrFn(document));
+      return filesUnderDataPath;
+    }
 
-          if (typeof thingClass !== 'function') {
-            throw new Error(`Expected a thing class, got ${typeAppearance(thingClass)}`);
-          }
+    default:
+      throw new Error(`Unknown document mode ${documentMode.toString()}`);
+  }
+}
 
-          if (!(thingClass.prototype instanceof Thing)) {
-            throw new Error(`Expected a thing class, got ${thingClass.name}`);
-          }
+export async function loadYAMLDocumentsFromFile(file) {
+  let contents;
+  try {
+    contents = await readFile(file, 'utf-8');
+  } catch (caughtError) {
+    throw new Error(`Failed to read data file`, {cause: caughtError});
+  }
+
+  let documents;
+  try {
+    documents = yaml.loadAll(contents);
+  } catch (caughtError) {
+    throw new Error(`Failed to parse valid YAML`, {cause: caughtError});
+  }
 
-          const spec = thingClass[Thing.yamlDocumentSpec];
+  const aggregate = openAggregate({
+    message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`,
+  });
 
-          if (!spec) {
-            throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
+  const filteredDocuments =
+    documents
+      .filter(doc => doc !== null);
+
+  if (filteredDocuments.length !== documents.length) {
+    const blankIndexRangeInfo =
+      documents
+        .map((doc, index) => [doc, index])
+        .filter(([doc]) => doc === null)
+        .map(([doc, index]) => index)
+        .reduce((accumulator, index) => {
+          if (accumulator.length === 0) {
+            return [[index, index]];
+          }
+          const current = accumulator.at(-1);
+          const rest = accumulator.slice(0, -1);
+          if (current[1] === index - 1) {
+            return rest.concat([[current[0], index]]);
+          } else {
+            return accumulator.concat([[index, index]]);
           }
+        }, [])
+        .map(([start, end]) => ({
+          start,
+          end,
+          count: end - start + 1,
+          previous: atOffset(documents, start, -1),
+          next: atOffset(documents, end, +1),
+        }));
+
+    for (const {start, end, count, previous, next} of blankIndexRangeInfo) {
+      const parts = [];
+
+      if (count === 1) {
+        const range = `#${start + 1}`;
+        parts.push(`${count} document (${colors.yellow(range)}), `);
+      } else {
+        const range = `#${start + 1}-${end + 1}`;
+        parts.push(`${count} documents (${colors.yellow(range)}), `);
+      }
 
-          // TODO: Making a function to only call it just like that is
-          // obviously pretty jank! It should be created once per data step.
-          const fn = makeProcessDocument(thingClass, spec);
-          return fn(document);
-        };
+      if (previous === null) {
+        parts.push(`at start of file`);
+      } else if (next === null) {
+        parts.push(`at end of file`);
+      } else {
+        const previousDescription = Object.entries(previous).at(0).join(': ');
+        const nextDescription = Object.entries(next).at(0).join(': ');
+        parts.push(`between "${colors.cyan(previousDescription)}" and "${colors.cyan(nextDescription)}"`);
+      }
 
-        if (
-          documentMode === documentModes.allInOne ||
-          documentMode === documentModes.oneDocumentTotal
-        ) {
-          if (!dataStep.file) {
-            throw new Error(`Expected 'file' property for ${documentMode.toString()}`);
-          }
+      aggregate.push(new Error(parts.join('')));
+    }
+  }
 
-          const file = path.join(
-            dataPath,
-            typeof dataStep.file === 'function'
-              ? await callAsync(dataStep.file, dataPath)
-              : dataStep.file);
+  return {result: filteredDocuments, aggregate};
+}
 
-          const statResult = await callAsync(() =>
-            stat(file).then(
-              () => true,
-              error => {
-                if (error.code === 'ENOENT') {
-                  return false;
-                } else {
-                  throw error;
-                }
-              }));
+// Mapping from dataStep (spec) object each to a sub-map, from thing class to
+// processDocument function.
+const processDocumentFns = new WeakMap();
 
-          if (statResult === false) {
-            const saveResult = call(dataStep.save, {
-              [documentModes.allInOne]: [],
-              [documentModes.oneDocumentTotal]: {},
-            }[documentMode]);
+export function processThingsFromDataStep(documents, dataStep) {
+  let submap;
+  if (processDocumentFns.has(dataStep)) {
+    submap = processDocumentFns.get(dataStep);
+  } else {
+    submap = new Map();
+    processDocumentFns.set(dataStep, submap);
+  }
 
-            if (!saveResult) return;
+  function processDocument(document, thingClassOrFn) {
+    const thingClass =
+      (thingClassOrFn.prototype instanceof Thing
+        ? thingClassOrFn
+        : thingClassOrFn(document));
+
+    let fn;
+    if (submap.has(thingClass)) {
+      fn = submap.get(thingClass);
+    } else {
+      if (typeof thingClass !== 'function') {
+        throw new Error(`Expected a thing class, got ${typeAppearance(thingClass)}`);
+      }
 
-            Object.assign(wikiDataResult, saveResult);
+      if (!(thingClass.prototype instanceof Thing)) {
+        throw new Error(`Expected a thing class, got ${thingClass.name}`);
+      }
 
-            return;
-          }
+      const spec = thingClass[Thing.yamlDocumentSpec];
 
-          const readResult = await callAsync(readFile, file, 'utf-8');
+      if (!spec) {
+        throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
+      }
 
-          if (!readResult) {
-            return;
-          }
+      fn = makeProcessDocument(thingClass, {...spec, processDocument});
+      submap.set(thingClass, fn);
+    }
 
-          let processResults;
+    return fn(document);
+  }
 
-          switch (documentMode) {
-            case documentModes.oneDocumentTotal: {
-              const yamlResult = call(yaml.load, readResult);
+  const {documentMode} = dataStep;
 
-              if (!yamlResult) {
-                processResults = null;
-                break;
-              }
+  switch (documentMode) {
+    case documentModes.allInOne: {
+      const result = [];
+      const aggregate = openAggregate({message: `Errors processing documents`});
 
-              const {thing, aggregate} =
-                processDocument(yamlResult, dataStep.documentThing);
+      documents.forEach(
+        decorateErrorWithIndex((document, index) => {
+          const {thing, aggregate: subAggregate} =
+            processDocument(document, dataStep.documentThing);
 
-              processResults = thing;
+          thing[Thing.yamlSourceDocument] = document;
+          thing[Thing.yamlSourceDocumentPlacement] =
+            [documentModes.allInOne, index];
 
-              call(() => aggregate.close());
+          result.push(thing);
+          aggregate.call(subAggregate.close);
+        }));
 
-              break;
-            }
+      return {
+        aggregate,
+        result,
+        things: result,
+      };
+    }
 
-            case documentModes.allInOne: {
-              const yamlResults = call(yaml.loadAll, readResult);
+    case documentModes.oneDocumentTotal: {
+      if (documents.length > 1)
+        throw new Error(`Only expected one document to be present, got ${documents.length}`);
 
-              if (!yamlResults) {
-                processResults = [];
-                return;
-              }
+      const {thing, aggregate} =
+        processDocument(documents[0], dataStep.documentThing);
 
-              const {documents, aggregate: filterAggregate} =
-                filterBlankDocuments(yamlResults);
+      thing[Thing.yamlSourceDocument] = documents[0];
+      thing[Thing.yamlSourceDocumentPlacement] =
+        [documentModes.oneDocumentTotal];
 
-              call(filterAggregate.close);
+      return {
+        aggregate,
+        result: thing,
+        things: [thing],
+      };
+    }
 
-              processResults = [];
+    case documentModes.headerAndEntries: {
+      const headerDocument = documents[0];
+      const entryDocuments = documents.slice(1).filter(Boolean);
 
-              map(documents, decorateErrorWithIndex(document => {
-                const {thing, aggregate} =
-                  processDocument(document, dataStep.documentThing);
+      if (!headerDocument)
+        throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`);
 
-                processResults.push(thing);
-                aggregate.close();
-              }), {message: `Errors processing documents`});
+      const aggregate = openAggregate({message: `Errors processing documents`});
 
-              break;
-            }
-          }
+      const {thing: headerThing, aggregate: headerAggregate} =
+        processDocument(headerDocument, dataStep.headerDocumentThing);
 
-          if (!processResults) return;
+      headerThing[Thing.yamlSourceDocument] = headerDocument;
+      headerThing[Thing.yamlSourceDocumentPlacement] =
+        [documentModes.headerAndEntries, 'header'];
 
-          const saveResult = call(dataStep.save, processResults);
+      try {
+        headerAggregate.close();
+      } catch (caughtError) {
+        caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`;
+        aggregate.push(caughtError);
+      }
 
-          if (!saveResult) return;
+      const entryThings = [];
 
-          Object.assign(wikiDataResult, saveResult);
+      for (const [index, entryDocument] of entryDocuments.entries()) {
+        const {thing: entryThing, aggregate: entryAggregate} =
+          processDocument(entryDocument, dataStep.entryDocumentThing);
 
-          return;
-        }
+        entryThing[Thing.yamlSourceDocument] = entryDocument;
+        entryThing[Thing.yamlSourceDocumentPlacement] =
+          [documentModes.headerAndEntries, 'entry', index];
 
-        if (!dataStep.files) {
-          throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
+        entryThings.push(entryThing);
+
+        try {
+          entryAggregate.close();
+        } catch (caughtError) {
+          caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`;
+          aggregate.push(caughtError);
         }
+      }
 
-        const filesFromDataStep =
-          (typeof dataStep.files === 'function'
-            ? await callAsync(() =>
-                dataStep.files(dataPath).then(
-                  files => files,
-                  error => {
-                    if (error.code === 'ENOENT') {
-                      return [];
-                    } else {
-                      throw error;
-                    }
-                  }))
-            : dataStep.files);
-
-        const filesUnderDataPath =
-          filesFromDataStep
-            .map(file => path.join(dataPath, file));
-
-        const yamlResults = [];
-
-        await mapAsync(filesUnderDataPath, {message: `Errors loading data files`},
-          asyncDecorateErrorWithFile(async file => {
-            let contents;
-            try {
-              contents = await readFile(file, 'utf-8');
-            } catch (caughtError) {
-              throw new Error(`Failed to read data file`, {cause: caughtError});
-            }
+      return {
+        aggregate,
+        result: {
+          header: headerThing,
+          entries: entryThings,
+        },
+        things: [headerThing, ...entryThings],
+      };
+    }
 
-            let documents;
-            try {
-              documents = yaml.loadAll(contents);
-            } catch (caughtError) {
-              throw new Error(`Failed to parse valid YAML`, {cause: caughtError});
-            }
+    case documentModes.onePerFile: {
+      if (documents.length > 1)
+        throw new Error(`Only expected one document to be present per file, got ${documents.length} here`);
+
+      if (empty(documents) || !documents[0])
+        throw new Error(`Expected a document, this file is empty`);
+
+      const {thing, aggregate} =
+        processDocument(documents[0], dataStep.documentThing);
+
+      thing[Thing.yamlSourceDocument] = documents[0];
+      thing[Thing.yamlSourceDocumentPlacement] =
+        [documentModes.onePerFile];
+
+      return {
+        aggregate,
+        result: thing,
+        things: [thing],
+      };
+    }
+
+    default:
+      throw new Error(`Unknown document mode ${documentMode.toString()}`);
+  }
+}
+
+export function decorateErrorWithFileFromDataPath(fn, {dataPath}) {
+  return decorateErrorWithAnnotation(fn,
+    (caughtError, firstArg) =>
+      annotateErrorWithFile(
+        caughtError,
+        path.relative(
+          dataPath,
+          (typeof firstArg === 'object'
+            ? firstArg.file
+            : firstArg))));
+}
+
+// Loads a list of files for each data step, and a list of documents
+// for each file.
+export async function loadYAMLDocumentsFromDataSteps(dataSteps, {dataPath}) {
+  const aggregate =
+    openAggregate({
+      message: `Errors loading data files`,
+      translucent: true,
+    });
 
-            const {documents: filteredDocuments, aggregate: filterAggregate} =
-              filterBlankDocuments(documents);
-
-            try {
-              filterAggregate.close();
-            } catch (caughtError) {
-              // Blank documents aren't a critical error, they're just something
-              // that should be noted - the (filtered) documents still get pushed.
-              const pathToFile = path.relative(dataPath, file);
-              annotateErrorWithFile(caughtError, pathToFile);
-              push(caughtError);
+  const fileLists =
+    await Promise.all(
+      dataSteps.map(dataStep =>
+        getFilesFromDataStep(dataStep, {dataPath})));
+
+  const filePromises =
+    fileLists
+      .map(files => files
+        .map(file =>
+          loadYAMLDocumentsFromFile(file).then(
+            ({result, aggregate}) => {
+              const close =
+                decorateErrorWithFileFromDataPath(aggregate.close, {dataPath});
+
+              aggregate.close = () =>
+                close({file});
+
+              return {result, aggregate};
+            },
+            (error) => {
+              const aggregate = {};
+
+              annotateErrorWithFile(error, path.relative(dataPath, file));
+
+              aggregate.close = () => {
+                throw error;
+              };
+
+              return {result: [], aggregate};
+            })));
+
+  const fileListPromises =
+    filePromises
+      .map(filePromises => Promise.all(filePromises));
+
+  const dataStepPromises =
+    stitchArrays({
+      dataStep: dataSteps,
+      fileListPromise: fileListPromises,
+    }).map(async ({dataStep, fileListPromise}) =>
+        openAggregate({
+          message: `Errors loading data files for data step: ${colors.bright(dataStep.title)}`,
+          translucent: true,
+        }).contain(await fileListPromise));
+
+  const documentLists =
+    aggregate
+      .receive(await Promise.all(dataStepPromises));
+
+  return {aggregate, result: {documentLists, fileLists}};
+}
+
+// Loads a list of things from a list of documents for each file
+// for each data step. Nesting!
+export async function processThingsFromDataSteps(documentLists, fileLists, dataSteps, {dataPath}) {
+  const aggregate =
+    openAggregate({
+      message: `Errors processing documents in data files`,
+      translucent: true,
+    });
+
+  const filePromises =
+    stitchArrays({
+      dataStep: dataSteps,
+      files: fileLists,
+      documentLists: documentLists,
+    }).map(({dataStep, files, documentLists}) =>
+        stitchArrays({
+          file: files,
+          documents: documentLists,
+        }).map(({file, documents}) => {
+            const {result, aggregate, things} =
+              processThingsFromDataStep(documents, dataStep);
+
+            for (const thing of things) {
+              thing[Thing.yamlSourceFilename] =
+                path.relative(dataPath, file)
+                  .split(path.sep)
+                  .join(path.posix.sep);
             }
 
-            yamlResults.push({file, documents: filteredDocuments});
+            const close = decorateErrorWithFileFromDataPath(aggregate.close, {dataPath});
+            aggregate.close = () => close({file});
+
+            return {result, aggregate};
           }));
 
-        const processResults = [];
-
-        switch (documentMode) {
-          case documentModes.headerAndEntries:
-            map(yamlResults, {message: `Errors processing documents in data files`, translucent: true},
-              decorateErrorWithFile(({documents}) => {
-                const headerDocument = documents[0];
-                const entryDocuments = documents.slice(1).filter(Boolean);
-
-                if (!headerDocument)
-                  throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`);
-
-                withAggregate({message: `Errors processing documents`}, ({push}) => {
-                  const {thing: headerObject, aggregate: headerAggregate} =
-                    processDocument(headerDocument, dataStep.headerDocumentThing);
-
-                  try {
-                    headerAggregate.close();
-                  } catch (caughtError) {
-                    caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`;
-                    push(caughtError);
-                  }
-
-                  const entryObjects = [];
-
-                  for (let index = 0; index < entryDocuments.length; index++) {
-                    const entryDocument = entryDocuments[index];
-
-                    const {thing: entryObject, aggregate: entryAggregate} =
-                      processDocument(entryDocument, dataStep.entryDocumentThing);
-
-                    entryObjects.push(entryObject);
-
-                    try {
-                      entryAggregate.close();
-                    } catch (caughtError) {
-                      caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`;
-                      push(caughtError);
-                    }
-                  }
-
-                  processResults.push({
-                    header: headerObject,
-                    entries: entryObjects,
-                  });
-                });
-              }));
-            break;
-
-          case documentModes.onePerFile:
-            map(yamlResults, {message: `Errors processing data files as valid documents`},
-              decorateErrorWithFile(({documents}) => {
-                if (documents.length > 1)
-                  throw new Error(`Only expected one document to be present per file, got ${documents.length} here`);
-
-                if (empty(documents) || !documents[0])
-                  throw new Error(`Expected a document, this file is empty`);
-
-                const {thing, aggregate} =
-                  processDocument(documents[0], dataStep.documentThing);
-
-                processResults.push(thing);
-                aggregate.close();
-              }));
-            break;
-        }
+  const fileListPromises =
+    filePromises
+      .map(filePromises => Promise.all(filePromises));
+
+  const dataStepPromises =
+    stitchArrays({
+      dataStep: dataSteps,
+      fileListPromise: fileListPromises,
+    }).map(async ({dataStep, fileListPromise}) =>
+        openAggregate({
+          message: `Errors loading data files for data step: ${colors.bright(dataStep.title)}`,
+          translucent: true,
+        }).contain(await fileListPromise));
+
+  const thingLists =
+    aggregate
+      .receive(await Promise.all(dataStepPromises));
+
+  return {aggregate, result: thingLists};
+}
 
-        const saveResult = call(dataStep.save, processResults);
+// Flattens a list of *lists* of things for a given data step (each list
+// corresponding to one YAML file) into results to be saved on the final
+// wikiData object, routing thing lists into the step's save() function.
+export function saveThingsFromDataStep(thingLists, dataStep) {
+  const {documentMode} = dataStep;
 
-        if (!saveResult) return;
+  switch (documentMode) {
+    case documentModes.allInOne: {
+      const things =
+        (empty(thingLists)
+          ? []
+          : thingLists[0]);
 
-        Object.assign(wikiDataResult, saveResult);
-      }
-    );
+      return dataStep.save(things);
+    }
+
+    case documentModes.oneDocumentTotal: {
+      const thing =
+        (empty(thingLists)
+          ? {}
+          : thingLists[0]);
+
+      return dataStep.save(thing);
+    }
+
+    case documentModes.headerAndEntries:
+    case documentModes.onePerFile: {
+      return dataStep.save(thingLists);
+    }
+
+    default:
+      throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
   }
+}
 
-  return {
-    aggregate: processDataAggregate,
-    result: wikiDataResult,
-  };
+// Flattens a list of *lists* of things for each data step (each list
+// corresponding to one YAML file) into the final wikiData object,
+// routing thing lists into each step's save() function.
+export function saveThingsFromDataSteps(thingLists, dataSteps) {
+  const aggregate =
+    openAggregate({
+      message: `Errors finalizing things from data files`,
+      translucent: true,
+    });
+
+  const wikiData = {};
+
+  stitchArrays({
+    dataStep: dataSteps,
+    thingLists: thingLists,
+  }).map(({dataStep, thingLists}) => {
+      try {
+        return saveThingsFromDataStep(thingLists, dataStep);
+      } catch (caughtError) {
+        const error = new Error(
+          `Error finalizing things for data step: ${colors.bright(dataStep.title)}`,
+          {cause: caughtError});
+
+        error[Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+        aggregate.push(error);
+
+        return null;
+      }
+    })
+    .filter(Boolean)
+    .forEach(saveResult => {
+      for (const [saveKey, saveValue] of Object.entries(saveResult)) {
+        if (Object.hasOwn(wikiData, saveKey)) {
+          if (Array.isArray(wikiData[saveKey])) {
+            if (Array.isArray(saveValue)) {
+              wikiData[saveKey].push(...saveValue);
+            } else {
+              throw new Error(`${saveKey} already present, expected array of items to push`);
+            }
+          } else {
+            if (Array.isArray(saveValue)) {
+              throw new Error(`${saveKey} already present and not an array, refusing to overwrite`);
+            } else {
+              throw new Error(`${saveKey} already present, refusing to overwrite`);
+            }
+          }
+        } else {
+          wikiData[saveKey] = saveValue;
+        }
+      }
+    });
+
+  return {aggregate, result: wikiData};
+}
+
+export async function loadAndProcessDataDocuments(dataSteps, {dataPath}) {
+  const aggregate =
+    openAggregate({
+      message: `Errors processing data files`,
+    });
+
+  const {documentLists, fileLists} =
+    aggregate.receive(
+      await loadYAMLDocumentsFromDataSteps(dataSteps, {dataPath}));
+
+  const thingLists =
+    aggregate.receive(
+      await processThingsFromDataSteps(documentLists, fileLists, dataSteps, {dataPath}));
+
+  const wikiData =
+    aggregate.receive(
+      saveThingsFromDataSteps(thingLists, dataSteps));
+
+  return {aggregate, result: wikiData};
 }
 
 // Data linking! Basically, provide (portions of) wikiData to the Things which
 // require it - they'll expose dynamically computed properties as a result (many
 // of which are required for page HTML generation and other expected behavior).
-export function linkWikiDataArrays(wikiData) {
+export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
   const linkWikiDataSpec = new Map([
-    [wikiData.albumData, [
-      'artTagData',
-      'artistData',
-      'groupData',
-    ]],
+    // entries must be present here even without any properties to explicitly
+    // link if the 'find' or 'reverse' properties will be implicitly linked
 
-    [wikiData.artTagData, [
-      'albumData',
-      'trackData',
+    ['albumData', [
+      'artworkData',
+      'wikiInfo',
     ]],
 
-    [wikiData.artistData, [
-      'albumData',
-      'artistData',
-      'flashData',
-      'trackData',
-    ]],
+    ['artTagData', [/* reverse */]],
 
-    [wikiData.flashData, [
-      'artistData',
-      'flashActData',
-      'trackData',
-    ]],
+    ['artistData', [/* find, reverse */]],
 
-    [wikiData.flashActData, [
-      'flashData',
-      'flashSideData',
-    ]],
+    ['artworkData', ['artworkData']],
 
-    [wikiData.flashSideData, [
-      'flashActData',
+    ['flashData', [
+      'wikiInfo',
     ]],
 
-    [wikiData.groupData, [
-      'albumData',
-      'groupCategoryData',
-    ]],
+    ['flashActData', [/* find, reverse */]],
 
-    [wikiData.groupCategoryData, [
-      'groupData',
-    ]],
+    ['flashSideData', [/* find */]],
 
-    [wikiData.homepageLayout?.rows, [
-      'albumData',
-      'groupData',
-    ]],
+    ['groupData', [/* find, reverse */]],
 
-    [wikiData.trackData, [
-      'albumData',
-      'artTagData',
-      'artistData',
-      'flashData',
+    ['groupCategoryData', [/* find */]],
+
+    ['homepageLayout.sections.rows', [/* find */]],
+
+    ['trackData', [
+      'artworkData',
       'trackData',
+      'wikiInfo',
     ]],
 
-    [[wikiData.wikiInfo], [
-      'groupData',
-    ]],
+    ['trackSectionData', [/* reverse */]],
+
+    ['wikiInfo', [/* find */]],
   ]);
 
-  for (const [things, keys] of linkWikiDataSpec.entries()) {
-    if (things === undefined) continue;
+  const constructorHasFindMap = new Map();
+  const constructorHasReverseMap = new Map();
+
+  const boundFind = bindFind(wikiData);
+  const boundReverse = bindReverse(wikiData);
+
+  for (const [thingDataProp, keys] of linkWikiDataSpec.entries()) {
+    const thingData = getNestedProp(wikiData, thingDataProp);
+    const things =
+      (Array.isArray(thingData)
+        ? thingData.flat(Infinity)
+        : [thingData]);
+
     for (const thing of things) {
       if (thing === undefined) continue;
+
+      let hasFind;
+      if (constructorHasFindMap.has(thing.constructor)) {
+        hasFind = constructorHasFindMap.get(thing.constructor);
+      } else {
+        hasFind = 'find' in thing;
+        constructorHasFindMap.set(thing.constructor, hasFind);
+      }
+
+      if (hasFind) {
+        thing.find = boundFind;
+      }
+
+      let hasReverse;
+      if (constructorHasReverseMap.has(thing.constructor)) {
+        hasReverse = constructorHasReverseMap.get(thing.constructor);
+      } else {
+        hasReverse = 'reverse' in thing;
+        constructorHasReverseMap.set(thing.constructor, hasReverse);
+      }
+
+      if (hasReverse) {
+        thing.reverse = boundReverse;
+      }
+
       for (const key of keys) {
         if (!(key in wikiData)) continue;
+
         thing[key] = wikiData[key];
       }
     }
   }
 }
 
-export function sortWikiDataArrays(wikiData) {
+export function sortWikiDataArrays(dataSteps, wikiData, {bindFind, bindReverse}) {
   for (const [key, value] of Object.entries(wikiData)) {
     if (!Array.isArray(value)) continue;
     wikiData[key] = value.slice();
   }
 
-  const steps = getDataSteps();
-
-  for (const step of steps) {
+  for (const step of dataSteps) {
     if (!step.sort) continue;
     step.sort(wikiData);
   }
@@ -1006,7 +1589,7 @@ export function sortWikiDataArrays(wikiData) {
   // slices instead of the original arrays) - this is so that the object
   // caching system understands that it's working with a new ordering.
   // We still need to actually provide those updated arrays over again!
-  linkWikiDataArrays(wikiData);
+  linkWikiDataArrays(wikiData, {bindFind, bindReverse});
 }
 
 // Utility function for loading all wiki data from the provided YAML data
@@ -1016,17 +1599,21 @@ export function sortWikiDataArrays(wikiData) {
 // where reporting info about data loading isn't as relevant as during the
 // main wiki build process.
 export async function quickLoadAllFromYAML(dataPath, {
+  find,
   bindFind,
+  bindReverse,
   getAllFindSpecs,
 
   showAggregate: customShowAggregate = showAggregate,
 }) {
   const showAggregate = customShowAggregate;
 
+  const dataSteps = getAllDataSteps();
+
   let wikiData;
 
   {
-    const {aggregate, result} = await loadAndProcessDataDocuments({dataPath});
+    const {aggregate, result} = await loadAndProcessDataDocuments(dataSteps, {dataPath});
 
     wikiData = result;
 
@@ -1039,10 +1626,10 @@ export async function quickLoadAllFromYAML(dataPath, {
     }
   }
 
-  linkWikiDataArrays(wikiData);
+  linkWikiDataArrays(wikiData, {bindFind, bindReverse});
 
   try {
-    reportDuplicateDirectories(wikiData, {getAllFindSpecs});
+    reportDirectoryErrors(wikiData, {getAllFindSpecs});
     logInfo`No duplicate directories found. (complete data)`;
   } catch (error) {
     showAggregate(error);
@@ -1050,7 +1637,7 @@ export async function quickLoadAllFromYAML(dataPath, {
   }
 
   try {
-    filterReferenceErrors(wikiData, {bindFind}).close();
+    filterReferenceErrors(wikiData, {find, bindFind}).close();
     logInfo`No reference errors found. (complete data)`;
   } catch (error) {
     showAggregate(error);
@@ -1065,7 +1652,203 @@ export async function quickLoadAllFromYAML(dataPath, {
     logWarn`Content text errors found.`;
   }
 
-  sortWikiDataArrays(wikiData);
+  sortWikiDataArrays(dataSteps, wikiData, {bindFind, bindReverse});
 
   return wikiData;
 }
+
+export function cruddilyGetAllThings(wikiData) {
+  const allThings = [];
+
+  for (const v of Object.values(wikiData)) {
+    if (Array.isArray(v)) {
+      allThings.push(...v);
+    } else {
+      allThings.push(v);
+    }
+  }
+
+  return allThings;
+}
+
+export function getThingLayoutForFilename(filename, wikiData) {
+  const things =
+    cruddilyGetAllThings(wikiData)
+      .filter(thing =>
+        thing[Thing.yamlSourceFilename] === filename);
+
+  if (empty(things)) {
+    return null;
+  }
+
+  const allDocumentModes =
+    unique(things.map(thing =>
+      thing[Thing.yamlSourceDocumentPlacement][0]));
+
+  if (allDocumentModes.length > 1) {
+    throw new Error(`More than one document mode for documents from ${filename}`);
+  }
+
+  const documentMode = allDocumentModes[0];
+
+  switch (documentMode) {
+    case documentModes.allInOne: {
+      return {
+        documentMode,
+        things:
+          things.sort((a, b) =>
+            a[Thing.yamlSourceDocumentPlacement][1] -
+            b[Thing.yamlSourceDocumentPlacement][1]),
+      };
+    }
+
+    case documentModes.oneDocumentTotal:
+    case documentModes.onePerFile: {
+      if (things.length > 1) {
+        throw new Error(`More than one document for ${filename}`);
+      }
+
+      return {
+        documentMode,
+        thing: things[0],
+      };
+    }
+
+    case documentModes.headerAndEntries: {
+      const headerThings =
+        things.filter(thing =>
+          thing[Thing.yamlSourceDocumentPlacement][1] === 'header');
+
+      if (headerThings.length > 1) {
+        throw new Error(`More than one header document for ${filename}`);
+      }
+
+      return {
+        documentMode,
+        headerThing: headerThings[0] ?? null,
+        entryThings:
+          things
+            .filter(thing =>
+              thing[Thing.yamlSourceDocumentPlacement][1] === 'entry')
+            .sort((a, b) =>
+              a[Thing.yamlSourceDocumentPlacement][2] -
+              b[Thing.yamlSourceDocumentPlacement][2]),
+      };
+    }
+
+    default: {
+      return {documentMode};
+    }
+  }
+}
+
+export function flattenThingLayoutToDocumentOrder(layout) {
+  switch (layout.documentMode) {
+    case documentModes.oneDocumentTotal:
+    case documentModes.onePerFile: {
+      if (layout.thing) {
+        return [0];
+      } else {
+        return [];
+      }
+    }
+
+    case documentModes.allInOne: {
+      const indices =
+        layout.things
+          .map(thing => thing[Thing.yamlSourceDocumentPlacement][1]);
+
+      return indices;
+    }
+
+    case documentModes.headerAndEntries: {
+      const entryIndices =
+        layout.entryThings
+          .map(thing => thing[Thing.yamlSourceDocumentPlacement][2])
+          .map(index => index + 1);
+
+      if (layout.headerThing) {
+        return [0, ...entryIndices];
+      } else {
+        return entryIndices;
+      }
+    }
+
+    default: {
+      throw new Error(`Unknown document mode`);
+    }
+  }
+}
+
+export function* splitDocumentsInYAMLSourceText(sourceText) {
+  // Not multiline!
+  const dividerRegex = /(?:\r\n|\n|^)-{3,}(?:\r\n|\n|$)/g;
+
+  let previousDivider = '';
+
+  while (true) {
+    const {lastIndex} = dividerRegex;
+    const match = dividerRegex.exec(sourceText);
+    if (match) {
+      const nextDivider = match[0];
+
+      yield {
+        previousDivider,
+        nextDivider,
+        text: sourceText.slice(lastIndex, match.index),
+      };
+
+      previousDivider = nextDivider;
+    } else {
+      const nextDivider = '';
+      const lineBreak = previousDivider.match(/\r?\n/)?.[0] ?? '';
+
+      yield {
+        previousDivider,
+        nextDivider,
+        text: sourceText.slice(lastIndex).replace(/(?<!\n)$/, lineBreak),
+      };
+
+      return;
+    }
+  }
+}
+
+export function recombineDocumentsIntoYAMLSourceText(documents) {
+  const dividers =
+    unique(
+      documents
+        .flatMap(d => [d.previousDivider, d.nextDivider])
+        .filter(Boolean));
+
+  const divider = dividers[0];
+
+  if (dividers.length > 1) {
+    // TODO: Accommodate mixed dividers as best as we can lol
+    logWarn`Found multiple dividers in this file, using only ${divider}`;
+  }
+
+  let sourceText = '';
+
+  for (const document of documents) {
+    if (sourceText) {
+      sourceText += divider;
+    }
+
+    sourceText += document.text;
+  }
+
+  return sourceText;
+}
+
+export function reorderDocumentsInYAMLSourceText(sourceText, order) {
+  const sourceDocuments =
+    Array.from(splitDocumentsInYAMLSourceText(sourceText));
+
+  const sortedDocuments =
+    Array.from(
+      order,
+      sourceIndex => sourceDocuments[sourceIndex]);
+
+  return recombineDocumentsIntoYAMLSourceText(sortedDocuments);
+}
diff --git a/src/util/external-links.js b/src/external-links.js
index 3b779afc..1055a391 100644
--- a/src/util/external-links.js
+++ b/src/external-links.js
@@ -211,6 +211,18 @@ export const externalLinkSpec = [
   // Generic domains, sorted alphabetically (by string)
 
   {
+    match: {
+      domains: [
+        'music.amazon.co.jp',
+        'music.amazon.com',
+      ],
+    },
+
+    platform: 'amazonMusic',
+    icon: 'globe',
+  },
+
+  {
     match: {domain: 'music.apple.com'},
     platform: 'appleMusic',
     icon: 'appleMusic',
@@ -341,6 +353,14 @@ export const externalLinkSpec = [
   },
 
   {
+    match: {domain: 'm.nintendo.com'},
+
+    platform: 'nintendoMusic',
+
+    icon: 'nintendoMusic',
+  },
+
+  {
     match: {domain: 'mspaintadventures.fandom.com'},
 
     platform: 'fandom.mspaintadventures',
@@ -397,6 +417,17 @@ export const externalLinkSpec = [
   },
 
   {
+    match: {
+      domain: 'media.hsmusic.wiki',
+      pathname: /^misc\/archive/,
+    },
+
+    platform: 'hsmusic.archive',
+
+    icon: 'globe',
+  },
+
+  {
     match: {domain: 'hsmusic.wiki'},
     platform: 'hsmusic',
     icon: 'globe',
@@ -547,6 +578,12 @@ export const externalLinkSpec = [
   },
 
   {
+    match: {domains: ['store.steampowered.com', 'steamcommunity.com']},
+    platform: 'steam',
+    icon: 'steam',
+  },
+
+  {
     match: {domain: 'tiktok.com'},
 
     platform: 'tiktok',
diff --git a/src/file-size-preloader.js b/src/file-size-preloader.js
index 4eadde7b..b2a55407 100644
--- a/src/file-size-preloader.js
+++ b/src/file-size-preloader.js
@@ -18,8 +18,10 @@
 // are very, very fast.
 
 import {stat} from 'node:fs/promises';
+import {relative, resolve, sep} from 'node:path';
 
 import {logWarn} from '#cli';
+import {filterMultipleArrays, transposeArrays} from '#sugar';
 
 export default class FileSizePreloader {
   #paths = [];
@@ -31,6 +33,10 @@ export default class FileSizePreloader {
 
   hadErrored = false;
 
+  constructor({prefix = ''} = {}) {
+    this.prefix = prefix;
+  }
+
   loadPaths(...paths) {
     this.#paths.push(...paths.filter((p) => !this.#paths.includes(p)));
     return this.#startLoadingPaths();
@@ -45,9 +51,9 @@ export default class FileSizePreloader {
       return this.#loadingPromise;
     }
 
-    this.#loadingPromise = new Promise((resolve) => {
-      this.#resolveLoadingPromise = resolve;
-    });
+    ({promise: this.#loadingPromise,
+      resolve: this.#resolveLoadingPromise} =
+        Promise.withResolvers());
 
     this.#loadNextPath();
 
@@ -96,9 +102,54 @@ export default class FileSizePreloader {
   }
 
   getSizeOfPath(path) {
+    let size = this.#getSizeOfPath(path);
+    if (size || !this.prefix) return size;
+    const path2 = resolve(this.prefix, path);
+    if (path2 === path) return null;
+    return this.#getSizeOfPath(path2);
+  }
+
+  #getSizeOfPath(path) {
     const index = this.#paths.indexOf(path);
     if (index === -1) return null;
     if (index > this.#loadedPathIndex) return null;
     return this.#sizes[index];
   }
+
+  saveAsCache() {
+    const entries =
+      transposeArrays([
+        this.#paths.slice(0, this.#loadedPathIndex)
+          .map(path => relative(this.prefix, path)),
+
+        this.#sizes.slice(0, this.#loadedPathIndex),
+      ]);
+
+    // Do not be alarmed: This cannot be meaningfully moved to
+    // the top because stringifyCache sorts alphabetically lol
+    entries.push(['_separator', sep]);
+
+    return Object.fromEntries(entries);
+  }
+
+  loadFromCache(cache) {
+    const {_separator: cacheSep, ...rest} = cache;
+    const entries = Object.entries(rest);
+    let [newPaths, newSizes] = transposeArrays(entries);
+
+    if (sep !== cacheSep) {
+      newPaths = newPaths.map(p => p.split(cacheSep).join(sep));
+    }
+
+    newPaths = newPaths.map(p => resolve(this.prefix, p));
+
+    filterMultipleArrays(
+      newPaths,
+      newSizes,
+      path => !this.#paths.includes(path));
+
+    this.#paths.splice(this.#loadedPathIndex + 1, 0, ...newPaths);
+    this.#sizes.splice(this.#loadedPathIndex + 1, 0, ...newSizes);
+    this.#loadedPathIndex += entries.length;
+  }
 }
diff --git a/src/find-reverse.js b/src/find-reverse.js
new file mode 100644
index 00000000..6a67ac0f
--- /dev/null
+++ b/src/find-reverse.js
@@ -0,0 +1,144 @@
+// Helpers common to #find and #reverse logic.
+
+import thingConstructors from '#things';
+
+export function getAllSpecs({
+  word,
+  constructorKey,
+
+  hardcodedSpecs,
+  postprocessSpec,
+}) {
+  try {
+    thingConstructors;
+  } catch (error) {
+    throw new Error(`Thing constructors aren't ready yet, can't get all ${word} specs`);
+  }
+
+  const specs = {...hardcodedSpecs};
+
+  const seenSpecs = new Set();
+
+  for (const thingConstructor of Object.values(thingConstructors)) {
+    const thingSpecs = thingConstructor[constructorKey];
+    if (!thingSpecs) continue;
+
+    // Subclasses can expose literally the same static properties
+    // by inheritence. We don't want to double-count those!
+    if (seenSpecs.has(thingSpecs)) continue;
+    seenSpecs.add(thingSpecs);
+
+    for (const [key, spec] of Object.entries(thingSpecs)) {
+      specs[key] =
+        postprocessSpec(spec, {
+          thingConstructor,
+        });
+    }
+  }
+
+  return specs;
+}
+
+export function findSpec(key, {
+  word,
+  constructorKey,
+
+  hardcodedSpecs,
+  postprocessSpec,
+}) {
+  if (Object.hasOwn(hardcodedSpecs, key)) {
+    return hardcodedSpecs[key];
+  }
+
+  try {
+    thingConstructors;
+  } catch (error) {
+    throw new Error(`Thing constructors aren't ready yet, can't check if "${word}.${key}" available`);
+  }
+
+  for (const thingConstructor of Object.values(thingConstructors)) {
+    const thingSpecs = thingConstructor[constructorKey];
+    if (!thingSpecs) continue;
+
+    if (Object.hasOwn(thingSpecs, key)) {
+      return postprocessSpec(thingSpecs[key], {
+        thingConstructor,
+      });
+    }
+  }
+
+  throw new Error(`"${word}.${key}" isn't available`);
+}
+
+export function tokenProxy({
+  findSpec,
+  prepareBehavior,
+
+  handle: customHandle =
+    (_key) => undefined,
+}) {
+  return new Proxy({}, {
+    get: (store, key) => {
+      const custom = customHandle(key);
+      if (custom !== undefined) {
+        return custom;
+      }
+
+      if (!Object.hasOwn(store, key)) {
+        let behavior = (...args) => {
+          // This will error if the spec isn't available...
+          const spec = findSpec(key);
+
+          // ...or, if it is available, replace this function with the
+          // ready-for-use find function made out of that spec.
+          return (behavior = prepareBehavior(spec))(...args);
+        };
+
+        store[key] = (...args) => behavior(...args);
+        store[key][tokenKey] = key;
+      }
+
+      return store[key];
+    },
+  });
+}
+
+export function bind(wikiData, opts1, {
+  getAllSpecs,
+  prepareBehavior,
+}) {
+  const specs = getAllSpecs();
+
+  const bound = {};
+
+  for (const [key, spec] of Object.entries(specs)) {
+    if (!spec.bindTo) continue;
+
+    const behavior = prepareBehavior(spec);
+
+    const data =
+      (spec.bindTo === 'wikiData'
+        ? wikiData
+        : wikiData[spec.bindTo]);
+
+    bound[key] =
+      (opts1
+        ? (ref, opts2) =>
+            (opts2
+              ? behavior(ref, data, {...opts1, ...opts2})
+              : behavior(ref, data, opts1))
+        : (ref, opts2) =>
+            (opts2
+              ? behavior(ref, data, opts2)
+              : behavior(ref, data)));
+
+    bound[key][boundData] = data;
+    bound[key][boundOptions] = opts1 ?? {};
+  }
+
+  return bound;
+}
+
+export const tokenKey = Symbol.for('find.tokenKey');
+export const boundData = Symbol.for('find.boundData');
+export const boundOptions = Symbol.for('find.boundOptions');
diff --git a/src/find.js b/src/find.js
index afe34dd9..e7f5cda1 100644
--- a/src/find.js
+++ b/src/find.js
@@ -1,8 +1,19 @@
 import {inspect} from 'node:util';
 
 import {colors, logWarn} from '#cli';
+import {compareObjects, stitchArrays, typeAppearance} from '#sugar';
 import thingConstructors from '#things';
-import {typeAppearance} from '#sugar';
+import {isFunction, validateArrayItems} from '#validators';
+
+import * as fr from './find-reverse.js';
+
+import {
+  tokenKey as findTokenKey,
+  boundData as boundFindData,
+  boundOptions as boundFindOptions,
+} from './find-reverse.js';
+
+export {findTokenKey, boundFindData, boundFindOptions};
 
 function warnOrThrow(mode, message) {
   if (mode === 'error') {
@@ -16,34 +27,22 @@ function warnOrThrow(mode, message) {
   return null;
 }
 
-export function processAllAvailableMatches(data, {
+export const keyRefRegex =
+  new RegExp(String.raw`^(?:(?<key>[a-z-]*):(?=\S))?(?<ref>.*)$`);
+
+export function processAvailableMatchesByName(data, {
   include = _thing => true,
 
   getMatchableNames = thing =>
-    (Object.hasOwn(thing, 'name')
+    (thing.constructor.hasPropertyDescriptor('name')
       ? [thing.name]
       : []),
 
-  getMatchableDirectories = thing =>
-    (Object.hasOwn(thing, 'directory')
-      ? [thing.directory]
-      : [null]),
-} = {}) {
-  const byName = Object.create(null);
-  const byDirectory = Object.create(null);
-  const multipleNameMatches = Object.create(null);
-
+  results = Object.create(null),
+  multipleNameMatches = Object.create(null),
+}) {
   for (const thing of data) {
-    if (!include(thing)) continue;
-
-    for (const directory of getMatchableDirectories(thing)) {
-      if (typeof directory !== 'string') {
-        logWarn`Unexpected ${typeAppearance(directory)} returned in directories for ${inspect(thing)}`;
-        continue;
-      }
-
-      byDirectory[directory] = thing;
-    }
+    if (!include(thing, thingConstructors)) continue;
 
     for (const name of getMatchableNames(thing)) {
       if (typeof name !== 'string') {
@@ -53,23 +52,137 @@ export function processAllAvailableMatches(data, {
 
       const normalizedName = name.toLowerCase();
 
-      if (normalizedName in byName) {
-        const alreadyMatchesByName = byName[normalizedName];
-        byName[normalizedName] = null;
+      if (normalizedName in results) {
         if (normalizedName in multipleNameMatches) {
           multipleNameMatches[normalizedName].push(thing);
         } else {
-          multipleNameMatches[normalizedName] = [alreadyMatchesByName, thing];
+          multipleNameMatches[normalizedName] = [results[normalizedName], thing];
+          results[normalizedName] = null;
         }
       } else {
-        byName[normalizedName] = thing;
+        results[normalizedName] = thing;
       }
     }
   }
 
+  return {results, multipleNameMatches};
+}
+
+export function processAvailableMatchesByDirectory(data, {
+  include = _thing => true,
+
+  getMatchableDirectories = thing =>
+    (thing.constructor.hasPropertyDescriptor('directory')
+      ? [thing.directory]
+      : [null]),
+
+  results = Object.create(null),
+}) {
+  for (const thing of data) {
+    if (!include(thing, thingConstructors)) continue;
+
+    for (const directory of getMatchableDirectories(thing)) {
+      if (typeof directory !== 'string') {
+        logWarn`Unexpected ${typeAppearance(directory)} returned in directories for ${inspect(thing)}`;
+        continue;
+      }
+
+      results[directory] = thing;
+    }
+  }
+
+  return {results};
+}
+
+export function processAllAvailableMatches(data, spec) {
+  const {results: byName, multipleNameMatches} =
+    processAvailableMatchesByName(data, spec);
+
+  const {results: byDirectory} =
+    processAvailableMatchesByDirectory(data, spec);
+
   return {byName, byDirectory, multipleNameMatches};
 }
 
+function oopsMultipleNameMatches(mode, {
+  name,
+  normalizedName,
+  multipleNameMatches,
+}) {
+  return warnOrThrow(mode,
+    `Multiple matches for reference "${name}". Please resolve:\n` +
+    multipleNameMatches[normalizedName]
+      .map(match => `- ${inspect(match)}\n`)
+      .join('') +
+    `Returning null for this reference.`);
+}
+
+export function prepareMatchByName(mode, {byName, multipleNameMatches}) {
+  return (name) => {
+    const normalizedName = name.toLowerCase();
+    const match = byName[normalizedName];
+
+    if (match) {
+      return match;
+    } else if (multipleNameMatches[normalizedName]) {
+      return oopsMultipleNameMatches(mode, {
+        name,
+        normalizedName,
+        multipleNameMatches,
+      });
+    } else {
+      return null;
+    }
+  };
+}
+
+function oopsWrongReferenceType(mode, {
+  referenceType,
+  referenceTypes,
+}) {
+  return warnOrThrow(mode,
+    `Reference starts with "${referenceType}:", expected ` +
+    referenceTypes.map(type => `"${type}:"`).join(', '));
+}
+
+export function prepareMatchByDirectory(mode, {referenceTypes, byDirectory}) {
+  return (referenceType, directory) => {
+    if (!referenceTypes.includes(referenceType)) {
+      return oopsWrongReferenceType(mode, {
+        referenceType,
+        referenceTypes,
+      });
+    }
+
+    return byDirectory[directory];
+  };
+}
+
+function matchHelper(fullRef, mode, {
+  matchByDirectory = (_referenceType, _directory) => null,
+  matchByName = (_name) => null,
+}) {
+  const regexMatch = fullRef.match(keyRefRegex);
+  if (!regexMatch) {
+    return warnOrThrow(mode,
+      `Malformed link reference: "${fullRef}"`);
+  }
+
+  const {key: keyPart, ref: refPart} = regexMatch.groups;
+
+  const match =
+    (keyPart
+      ? matchByDirectory(keyPart, refPart)
+      : matchByName(refPart));
+
+  if (match) {
+    return match;
+  } else {
+    return warnOrThrow(mode,
+      `Didn't match anything for ${colors.bright(fullRef)}`);
+  }
+}
+
 function findHelper({
   referenceTypes,
 
@@ -77,9 +190,6 @@ function findHelper({
   getMatchableNames = undefined,
   getMatchableDirectories = undefined,
 }) {
-  const keyRefRegex =
-    new RegExp(String.raw`^(?:(${referenceTypes.join('|')}):(?=\S))?(.*)$`);
-
   // Note: This cache explicitly *doesn't* support mutable data arrays. If the
   // data array is modified, make sure it's actually a new array object, not
   // the original, or the cache here will break and act as though the data
@@ -92,6 +202,7 @@ function findHelper({
   // console.
   return (fullRef, data, {mode = 'warn'} = {}) => {
     if (!fullRef) return null;
+
     if (typeof fullRef !== 'string') {
       throw new TypeError(`Expected a string, got ${typeAppearance(fullRef)}`);
     }
@@ -112,42 +223,21 @@ function findHelper({
       cache.set(data, subcache);
     }
 
-    const regexMatch = fullRef.match(keyRefRegex);
-    if (!regexMatch) {
-      return warnOrThrow(mode,
-        `Malformed link reference: "${fullRef}"`);
-    }
-
-    const typePart = regexMatch[1];
-    const refPart = regexMatch[2];
-
-    const normalizedName =
-      (typePart
-        ? null
-        : refPart.toLowerCase());
-
-    const match =
-      (typePart
-        ? subcache.byDirectory[refPart]
-        : subcache.byName[normalizedName]);
-
-    if (!match && !typePart) {
-      if (subcache.multipleNameMatches[normalizedName]) {
-        return warnOrThrow(mode,
-          `Multiple matches for reference "${fullRef}". Please resolve:\n` +
-          subcache.multipleNameMatches[normalizedName]
-            .map(match => `- ${inspect(match)}\n`)
-            .join('') +
-          `Returning null for this reference.`);
-      }
-    }
-
-    if (!match) {
-      return warnOrThrow(mode,
-        `Didn't match anything for ${colors.bright(fullRef)}`);
-    }
-
-    return match;
+    const {byDirectory, byName, multipleNameMatches} = subcache;
+
+    return matchHelper(fullRef, mode, {
+      matchByDirectory:
+        prepareMatchByDirectory(mode, {
+          referenceTypes,
+          byDirectory,
+        }),
+
+      matchByName:
+        prepareMatchByName(mode, {
+          byName,
+          multipleNameMatches,
+        }),
+    });
   };
 }
 
@@ -160,64 +250,162 @@ const hardcodedFindSpecs = {
   },
 };
 
-export function getAllFindSpecs() {
-  try {
-    thingConstructors;
-  } catch (error) {
-    throw new Error(`Thing constructors aren't ready yet, can't get all find specs`);
-  }
-
-  const findSpecs = {...hardcodedFindSpecs};
+const findReverseHelperConfig = {
+  word: `find`,
+  constructorKey: Symbol.for('Thing.findSpecs'),
 
-  for (const thingConstructor of Object.values(thingConstructors)) {
-    const thingFindSpecs = thingConstructor[Symbol.for('Thing.findSpecs')];
-    if (!thingFindSpecs) continue;
+  hardcodedSpecs: hardcodedFindSpecs,
+  postprocessSpec: postprocessFindSpec,
+};
 
-    Object.assign(findSpecs, thingFindSpecs);
+export function postprocessFindSpec(spec, {thingConstructor}) {
+  const newSpec = {...spec};
+
+  // Default behavior is to find only instances of the constructor.
+  // This symbol field lets a spec opt out.
+  if (spec[Symbol.for('Thing.findThisThingOnly')] !== false) {
+    if (spec.include) {
+      const oldInclude = spec.include;
+      newSpec.include = (thing, ...args) =>
+        thing instanceof thingConstructor &&
+        oldInclude(thing, ...args);
+    } else {
+      newSpec.include = thing =>
+        thing instanceof thingConstructor;
+    }
   }
 
-  return findSpecs;
+  return newSpec;
+}
+
+export function getAllFindSpecs() {
+  return fr.getAllSpecs(findReverseHelperConfig);
 }
 
 export function findFindSpec(key) {
-  if (Object.hasOwn(hardcodedFindSpecs, key)) {
-    return hardcodedFindSpecs[key];
+  return fr.findSpec(key, findReverseHelperConfig);
+}
+
+function findMixedHelper(config) {
+  const
+    keys = Object.keys(config),
+    tokens = Object.values(config),
+    specKeys = tokens.map(token => token[findTokenKey]),
+    specs = specKeys.map(specKey => findFindSpec(specKey));
+
+  const cache = new WeakMap();
+
+  return (fullRef, data, {mode = 'warn'} = {}) => {
+    if (!fullRef) return null;
+
+    if (typeof fullRef !== 'string') {
+      throw new TypeError(`Expected a string, got ${typeAppearance(fullRef)}`);
+    }
+
+    if (!data) {
+      throw new TypeError(`Expected data to be present`);
+    }
+
+    let subcache = cache.get(data);
+    if (!subcache) {
+      const byName = Object.create(null);
+      const multipleNameMatches = Object.create(null);
+
+      for (const spec of specs) {
+        processAvailableMatchesByName(data, {
+          ...spec,
+
+          results: byName,
+          multipleNameMatches,
+        });
+      }
+
+      const byDirectory =
+        Object.fromEntries(
+          stitchArrays({
+            referenceType: keys,
+            spec: specs,
+          }).map(({referenceType, spec}) => [
+              referenceType,
+              processAvailableMatchesByDirectory(data, spec).results,
+            ]));
+
+      subcache = {byName, multipleNameMatches, byDirectory};
+      cache.set(data, subcache);
+    }
+
+    const {byName, multipleNameMatches, byDirectory} = subcache;
+
+    return matchHelper(fullRef, mode, {
+      matchByDirectory: (referenceType, directory) => {
+        if (!keys.includes(referenceType)) {
+          return oopsWrongReferenceType(mode, {
+            referenceType,
+            referenceTypes: keys,
+          });
+        }
+
+        return byDirectory[referenceType][directory];
+      },
+
+      matchByName:
+        prepareMatchByName(mode, {
+          byName,
+          multipleNameMatches,
+        }),
+    });
+  };
+}
+
+const findMixedStore = new Map();
+
+export function findMixed(config) {
+  for (const key of findMixedStore.keys()) {
+    if (compareObjects(key, config)) {
+      return findMixedStore.get(key);
+    }
   }
 
+  // Validate that this is a valid config to begin with - we can do this
+  // before find specs are actually available.
+  const tokens = Object.values(config);
+
   try {
-    thingConstructors;
-  } catch (error) {
-    throw new Error(`Thing constructors aren't ready yet, can't check if "find.${key}" available`);
-  }
+    validateArrayItems(token => {
+      isFunction(token);
 
-  for (const thingConstructor of Object.values(thingConstructors)) {
-    const thingFindSpecs = thingConstructor[Symbol.for('Thing.findSpecs')];
-    if (!thingFindSpecs) continue;
+      if (token[boundFindData])
+        throw new Error(`find.mixed doesn't work with bindFind yet`);
 
-    if (Object.hasOwn(thingFindSpecs, key)) {
-      return thingFindSpecs[key];
-    }
+      if (!token[findTokenKey])
+        throw new Error(`missing findTokenKey, is this actually a find.thing token?`);
+
+      return true;
+    })(tokens);
+  } catch (caughtError) {
+    throw new Error(
+      `Expected find.mixed mapping to include valid find.thing tokens only`,
+      {cause: caughtError});
   }
 
-  throw new Error(`"find.${key}" isn't available`);
-}
+  let behavior = (...args) => {
+    // findMixedHelper will error if find specs aren't available yet,
+    // canceling overwriting `behavior` here.
+    return (behavior = findMixedHelper(config))(...args);
+  };
 
-export default new Proxy({}, {
-  get: (store, key) => {
-    if (!Object.hasOwn(store, key)) {
-      let behavior = (...args) => {
-        // This will error if the find spec isn't available...
-        const findSpec = findFindSpec(key);
+  findMixedStore.set(config, (...args) => behavior(...args));
+  return findMixedStore.get(config);
+}
 
-        // ...or, if it is available, replace this function with the
-        // ready-for-use find function made out of that find spec.
-        return (behavior = findHelper(findSpec))(...args);
-      };
+export default fr.tokenProxy({
+  findSpec: findFindSpec,
+  prepareBehavior: findHelper,
 
-      store[key] = (...args) => behavior(...args);
+  handle(key) {
+    if (key === 'mixed') {
+      return findMixed;
     }
-
-    return store[key];
   },
 });
 
@@ -226,28 +414,13 @@ export default new Proxy({}, {
 // function. Note that this caches the arrays read from wikiData right when it's
 // called, so if their values change, you'll have to continue with a fresh call
 // to bindFind.
-export function bindFind(wikiData, opts1) {
-  const findSpecs = getAllFindSpecs();
-
-  const boundFindFns = {};
-
-  for (const [key, spec] of Object.entries(findSpecs)) {
-    if (!spec.bindTo) continue;
-
-    const findFn = findHelper(spec);
-    const thingData = wikiData[spec.bindTo];
-
-    boundFindFns[key] =
-      (opts1
-        ? (ref, opts2) =>
-            (opts2
-              ? findFn(ref, thingData, {...opts1, ...opts2})
-              : findFn(ref, thingData, opts1))
-        : (ref, opts2) =>
-            (opts2
-              ? findFn(ref, thingData, opts2)
-              : findFn(ref, thingData)));
-  }
+export function bindFind(wikiData, opts) {
+  const boundFind = fr.bind(wikiData, opts, {
+    getAllSpecs: getAllFindSpecs,
+    prepareBehavior: findHelper,
+  });
+
+  boundFind.mixed = findMixed;
 
-  return boundFindFns;
+  return boundFind;
 }
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index 8a582693..97cf74a9 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,
@@ -156,6 +163,7 @@ import {
 import dimensionsOf from 'image-size';
 
 import CacheableObject from '#cacheable-object';
+import {stringifyCache} from '#cli';
 import {commandExists, isMain, promisifyProcess, traverse} from '#node-utils';
 import {sortByName} from '#sort';
 
@@ -339,28 +347,6 @@ export function getThumbnailsAvailableForDimensions([width, height]) {
   ];
 }
 
-function stringifyCache(cache) {
-  if (Object.keys(cache).length === 0) {
-    return `{}`;
-  }
-
-  const entries = Object.entries(cache);
-  sortByName(entries, {getName: entry => entry[0]});
-
-  return [
-    `{`,
-    entries
-      .map(([key, value]) => [JSON.stringify(key), JSON.stringify(value)])
-      .map(([key, value]) => `${key}: ${value}`)
-      .map((line, index, array) =>
-        (index < array.length - 1
-          ? `${line},`
-          : line))
-      .map(line => `  ${line}`),
-    `}`,
-  ].flat().join('\n');
-}
-
 getThumbnailsAvailableForDimensions.all =
   Object.entries(thumbnailSpec)
     .map(([name, {size}]) => [name, size])
@@ -610,8 +596,11 @@ async function generateImageThumbnail(imagePath, thumbtack, {
 
 export async function determineMediaCachePath({
   mediaPath,
+  wikiCachePath,
   providedMediaCachePath,
+
   disallowDoubling = false,
+  regenerateMissingThumbnailCache = false,
 }) {
   if (!mediaPath) {
     return {
@@ -627,6 +616,13 @@ export async function determineMediaCachePath({
     };
   }
 
+  if (!wikiCachePath) {
+    return {
+      annotation: 'wiki cache path not provided',
+      mediaCachePath: null,
+    };
+  }
+
   let mediaIncludesThumbnailCache;
 
   try {
@@ -643,45 +639,127 @@ export async function determineMediaCachePath({
     };
   }
 
-  const inferredPath =
+  // Two inferred paths are possible - "adjacent" and "contained".
+  // "Contained" is the preferred format and we'll create it if
+  // 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);
+
+  const adjacentInferredPath =
     path.join(
       path.dirname(mediaPath),
       path.basename(mediaPath) + '-cache');
 
-  let inferredIncludesThumbnailCache;
+  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(inferredPath);
-    inferredIncludesThumbnailCache = files.includes(CACHE_FILE);
+    const files = await readdir(adjacentInferredPath);
+    adjacentIncludesThumbnailCache = files.includes(CACHE_FILE);
   } catch (error) {
     if (error.code === 'ENOENT') {
-      inferredIncludesThumbnailCache = null;
+      adjacentIncludesThumbnailCache = null;
     } else {
-      inferredIncludesThumbnailCache = undefined;
+      adjacentIncludesThumbnailCache = undefined;
     }
   }
 
-  if (inferredIncludesThumbnailCache === true) {
+  // Go ahead with the contained path if it exists and contains a cache -
+  // no other conditions matter.
+  if (containedIncludesThumbnailCache === true) {
     return {
-      annotation: 'inferred path has cache',
-      mediaCachePath: inferredPath,
+      annotation: `contained path has cache`,
+      mediaCachePath: containedInferredPath,
     };
-  } else if (inferredIncludesThumbnailCache === false) {
+  }
+
+  // Reuse an existing adjacent cache before figuring out what to do
+  // if there's no extant cache at all.
+  if (adjacentIncludesThumbnailCache === true) {
     return {
-      annotation: 'inferred path does not have cache',
-      mediaCachePath: null,
+      annotation: `adjacent path has cache`,
+      mediaCachePath: adjacentInferredPath,
     };
-  } else if (inferredIncludesThumbnailCache === null) {
+  }
+
+  // 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 (containedIncludesThumbnailCache === undefined) {
     return {
-      annotation: 'inferred path will be created',
-      mediaCachePath: inferredPath,
+      annotation: `contained path not readable`,
+      mediaCachePath: null,
     };
-  } else {
+  }
+
+  // Throw a secondary tantrum if the adjacent cache exists but
+  // isn't readable. This is just as big of a problem, but if for
+  // some reason both the contained and adjacent caches exist,
+  // the contained one is the one we'd rather have addressed.
+  if (adjacentIncludesThumbnailCache === undefined) {
     return {
-      annotation: 'inferred path not readable',
+      annotation: `adjacent path not readable`,
       mediaCachePath: null,
     };
   }
+
+  // Throw a high-priority tantrum if the contained cache exists but is
+  // missing its cache file, again because it's the more preferred cache.
+  // Unless we're indicated to regenerate such a missing cache file!
+  if (containedIncludesThumbnailCache === false) {
+    if (regenerateMissingThumbnailCache) {
+      return {
+        annotation: `contained path will regenerate missing cache`,
+        mediaCachePath: containedInferredPath,
+      };
+    } else {
+      return {
+        annotation: `contained path does not have cache`,
+        mediaCachePath: null,
+      };
+    }
+  }
+
+  // Throw a secondary tantrum if the adjacent cache exists but is
+  // missing its cache file, because it's the less preferred cache.
+  // Unless we're indicated to regenerate a missing cache file!
+  if (adjacentIncludesThumbnailCache === false) {
+    if (regenerateMissingThumbnailCache) {
+      return {
+        annotation: `adjacent path will regenerate missing cache`,
+        mediaCachePath: adjacentInferredPath,
+      };
+    } else {
+      return {
+        annotation: `adjacent path does not have cache`,
+        mediaCachePath: null,
+      };
+    }
+  }
+
+  // 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: `contained path will be created`,
+    mediaCachePath: containedInferredPath,
+  };
 }
 
 export async function migrateThumbsIntoDedicatedCacheDirectory({
@@ -1164,24 +1242,15 @@ export function getExpectedImagePaths(mediaPath, {urls, wikiData}) {
   const fromRoot = urls.from('media.root');
 
   const paths = [
+    wikiData.artworkData
+      .filter(artwork => artwork.path)
+      .map(artwork => fromRoot.to(...artwork.path)),
+
     wikiData.albumData
-      .flatMap(album => [
-        album.hasCoverArt && fromRoot.to('media.albumCover', album.directory, album.coverArtFileExtension),
-        !empty(CacheableObject.getUpdateValue(album, 'bannerArtistContribs')) && fromRoot.to('media.albumBanner', album.directory, album.bannerFileExtension),
-        !empty(CacheableObject.getUpdateValue(album, 'wallpaperArtistContribs')) && fromRoot.to('media.albumWallpaper', album.directory, album.wallpaperFileExtension),
-      ])
-      .filter(Boolean),
-
-    wikiData.artistData
-      .filter(artist => artist.hasAvatar)
-      .map(artist => fromRoot.to('media.artistAvatar', artist.directory, artist.avatarFileExtension)),
-
-    wikiData.flashData
-      .map(flash => fromRoot.to('media.flashArt', flash.directory, flash.coverArtFileExtension)),
-
-    wikiData.trackData
-      .filter(track => track.hasUniqueCoverArt)
-      .map(track => fromRoot.to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension)),
+      .flatMap(album => album.wallpaperParts
+        .filter(part => part.asset)
+        .map(part =>
+          fromRoot.to('media.albumWallpaperPart', album.directory, part.asset))),
   ].flat();
 
   sortByName(paths, {getName: path => path});
diff --git a/src/util/html.js b/src/html.js
index d1d509e2..0fe424df 100644
--- a/src/util/html.js
+++ b/src/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
@@ -98,6 +105,12 @@ export const blockwrap = Symbol();
 // considered wrappable units, not the entire element!
 export const chunkwrap = Symbol();
 
+// Don't pass this directly, use html.metatag('imaginary-sibling') instead.
+// A tag without any content, which is completely ignored when serializing,
+// but makes siblings with [onlyIfSiblings] feel less shy and show up on
+// their own, even without a non-blank (and non-onlyIfSiblings) sibling.
+export const imaginarySibling = Symbol();
+
 // Recursive helper function for isBlank, which basically flattens an array
 // and returns as soon as it finds any content - a non-blank case - and doesn't
 // traverse templates of its own accord. If it doesn't find directly non-blank
@@ -124,13 +137,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 +223,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,
@@ -302,6 +328,9 @@ export function metatag(identifier, ...args) {
     case 'chunkwrap':
       return new Tag(null, {[chunkwrap]: true, ...opts}, content);
 
+    case 'imaginary-sibling':
+      return new Tag(null, {[imaginarySibling]: true}, content);
+
     default:
       throw new Error(`Unknown metatag "${identifier}"`);
   }
@@ -372,16 +401,22 @@ export class Tag {
   }
 
   set content(value) {
-    if (
-      this.selfClosing &&
-      !(value === null ||
-        value === undefined ||
-        !value ||
-        Array.isArray(value) && value.filter(Boolean).length === 0)
-    ) {
+    const contentful =
+      value !== null &&
+      value !== undefined &&
+      value &&
+      (Array.isArray(value)
+        ? !empty(value.filter(Boolean))
+        : true);
+
+    if (this.selfClosing && contentful) {
       throw new Error(`Tag <${this.tagName}> is self-closing but got content`);
     }
 
+    if (this.imaginarySibling && contentful) {
+      throw new Error(`html.metatag('imaginary-sibling') can't have content`);
+    }
+
     const contentArray =
       (Array.isArray(value)
         ? value.flat(Infinity).filter(Boolean)
@@ -416,6 +451,14 @@ 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.imaginarySibling) {
+      return true;
+    }
+
     if (this.onlyIfContent && isBlank(this.content)) {
       return true;
     }
@@ -477,6 +520,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);
   }
@@ -512,7 +563,7 @@ export class Tag {
     this.#setAttributeFlag(chunkwrap, value);
 
     try {
-      this.content = content;
+      this.content = this.content;
     } catch (error) {
       this.#setAttributeFlag(chunkwrap, false);
       throw error;
@@ -523,6 +574,20 @@ export class Tag {
     return this.#getAttributeFlag(chunkwrap);
   }
 
+  set imaginarySibling(value) {
+    this.#setAttributeFlag(imaginarySibling, value);
+
+    try {
+      this.content = this.content;
+    } catch (error) {
+      this.#setAttributeFlag(imaginarySibling, false);
+    }
+  }
+
+  get imaginarySibling() {
+    return this.#getAttributeFlag(imaginarySibling);
+  }
+
   toString() {
     if (this.onlyIfContent && isBlank(this.content)) {
       return '';
@@ -593,6 +658,8 @@ export class Tag {
     let content = '';
     let blockwrapClosers = '';
 
+    let seenSiblingIndependentContent = false;
+
     const chunkwrapSplitter =
       (this.chunkwrap
         ? this.#getAttributeString('split')
@@ -615,10 +682,17 @@ export class Tag {
     }
 
     for (const [index, item] of contentItems.entries()) {
-      let itemContent;
+      const nonTemplateItem =
+        Template.resolve(item);
+
+      if (nonTemplateItem instanceof Tag && nonTemplateItem.imaginarySibling) {
+        seenSiblingIndependentContent = true;
+        continue;
+      }
 
+      let itemContent;
       try {
-        itemContent = item.toString();
+        itemContent = nonTemplateItem.toString();
       } catch (caughtError) {
         const indexPart = colors.yellow(`child #${index + 1}`);
 
@@ -647,8 +721,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 +736,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 +766,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 +775,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 +797,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>';
@@ -1101,8 +1186,17 @@ export class Attributes {
     return this.#attributes[attribute];
   }
 
-  has(attribute) {
-    return attribute in this.#attributes;
+  has(attribute, pattern) {
+    if (typeof pattern === 'undefined') {
+      return attribute in this.#attributes;
+    } else if (this.has(attribute)) {
+      const value = this.get(attribute);
+      if (Array.isArray(value)) {
+        return value.includes(pattern);
+      } else {
+        return value === pattern;
+      }
+    }
   }
 
   remove(attribute) {
@@ -1301,8 +1395,13 @@ export class Attributes {
   }
 }
 
-export function resolve(tagOrTemplate, {normalize = null} = {}) {
-  if (normalize === 'tag') {
+export function resolve(tagOrTemplate, {
+  normalize = null,
+  slots = null,
+} = {}) {
+  if (slots) {
+    return Template.resolveForSlots(tagOrTemplate, slots);
+  } else if (normalize === 'tag') {
     return Tag.normalize(tagOrTemplate);
   } else if (normalize === 'string') {
     return Tag.normalize(tagOrTemplate).toString();
@@ -1338,6 +1437,22 @@ export function smush(smushee) {
   return smush(Tag.normalize(smushee));
 }
 
+// Much gentler version of smush - this only flattens nested html.tags(), and
+// guarantees the result is itself an html.tags(). It doesn't manipulate text
+// content, and it doesn't resolve templates.
+export function smooth(smoothie) {
+  // Helper function to avoid intermediate html.tags() calls.
+  function helper(tag) {
+    if (tag instanceof Tag && tag.contentOnly) {
+      return tag.content.flatMap(helper);
+    } else {
+      return tag;
+    }
+  }
+
+  return tags(helper(smoothie));
+}
+
 export function template(description) {
   return new Template(description);
 }
@@ -1359,7 +1474,13 @@ export class Template {
       this.#description,
     ]);
 
-    clone.setSlots(this.#slotValues);
+    // getSlotValue(), called via #getReadySlotValues(), is responsible for
+    // preparing slot values for consumption, which includes cloning mutable
+    // html/attributes. We reuse that behavior here, in a recursive manner,
+    // so that clone() is effectively "deep" - slots that may be mutated are
+    // cloned, so that this template and its clones will never mutate the same
+    // identities.
+    clone.setSlots(this.#getReadySlotValues());
 
     return clone;
   }
@@ -1641,6 +1762,10 @@ export class Template {
       if (providedValue instanceof Tag || providedValue instanceof Template) {
         return providedValue.toString();
       }
+
+      if (isBlank(providedValue)) {
+        return null;
+      }
     }
 
     if (providedValue !== null) {
@@ -1681,17 +1806,23 @@ export class Template {
     return description;
   }
 
-  set content(_value) {
-    throw new Error(`Template content can't be changed after constructed`);
-  }
-
-  get content() {
+  #getReadySlotValues() {
     const slots = {};
 
     for (const slotName of Object.keys(this.description.slots ?? {})) {
       slots[slotName] = this.getSlotValue(slotName);
     }
 
+    return slots;
+  }
+
+  set content(_value) {
+    throw new Error(`Template content can't be changed after constructed`);
+  }
+
+  get content() {
+    const slots = this.#getReadySlotValues();
+
     try {
       return this.description.content(slots);
     } catch (caughtError) {
@@ -1735,6 +1866,34 @@ export class Template {
     return content;
   }
 
+  static resolveForSlots(tagOrTemplate, slots) {
+    if (!slots || typeof slots !== 'object') {
+      throw new Error(
+        `Expected slots to be an object or array, ` +
+        `got ${typeAppearance(slots)}`);
+    }
+
+    if (!Array.isArray(slots)) {
+      return Template.resolveForSlots(tagOrTemplate, Object.keys(slots)).slots(slots);
+    }
+
+    while (tagOrTemplate && tagOrTemplate instanceof Template) {
+      try {
+        for (const slot of slots) {
+          tagOrTemplate.getSlotDescription(slot);
+        }
+
+        return tagOrTemplate;
+      } catch {
+        tagOrTemplate = tagOrTemplate.content;
+      }
+    }
+
+    throw new Error(
+      `Didn't find slots ${inspect(slots, {compact: true})} ` +
+      `resolving ${inspect(tagOrTemplate, {compact: true})}`);
+  }
+
   [inspect.custom]() {
     const {annotation} = this.description;
 
diff --git a/src/import-heck.js b/src/import-heck.js
new file mode 100644
index 00000000..3470fbb5
--- /dev/null
+++ b/src/import-heck.js
@@ -0,0 +1,9 @@
+// Due to import time shenanigans, these imports have to come in the specified
+// order. This obviously needs fixing up.
+
+/* precede #find */
+import '#data-checks';
+
+import '#find';
+
+// End of import time shenanigans (hopefully)
diff --git a/src/listing-spec.js b/src/listing-spec.js
index 73fbee6d..142c5976 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',
@@ -196,15 +196,22 @@ listingSpec.push({
 
 listingSpec.push({
   directory: 'tags/by-name',
-  stringsKey: 'listTags.byName',
-  contentFunction: 'listTagsByName',
+  stringsKey: 'listArtTags.byName',
+  contentFunction: 'listArtTagsByName',
   featureFlag: 'enableArtTagUI',
 });
 
 listingSpec.push({
   directory: 'tags/by-uses',
-  stringsKey: 'listTags.byUses',
-  contentFunction: 'listTagsByUses',
+  stringsKey: 'listArtTags.byUses',
+  contentFunction: 'listArtTagsByUses',
+  featureFlag: 'enableArtTagUI',
+});
+
+listingSpec.push({
+  directory: 'tags/network',
+  stringsKey: 'listArtTags.network',
+  contentFunction: 'listArtTagNetwork',
   featureFlag: 'enableArtTagUI',
 });
 
@@ -238,6 +245,27 @@ listingSpec.push({
   groupUnderOther: true,
 });
 
+// Dunkass mock. Listings should be Things! In the fuuuuture!
+class Listing {
+  static properties = {};
+
+  constructor() {
+    Object.assign(this, this.constructor.properties);
+  }
+
+  static hasPropertyDescriptor(key) {
+    return Object.hasOwn(this.properties, key);
+  }
+}
+
+for (const [index, listing] of listingSpec.entries()) {
+  class ListingSubclass extends Listing {
+    static properties = listing;
+  }
+
+  listingSpec.splice(index, 1, new ListingSubclass);
+}
+
 {
   const errors = [];
 
diff --git a/src/util/node-utils.js b/src/node-utils.js
index 345d10aa..345d10aa 100644
--- a/src/util/node-utils.js
+++ b/src/node-utils.js
diff --git a/src/page/album.js b/src/page/album.js
index c7327cc8..696e2854 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -1,3 +1,5 @@
+import {empty} from '#sugar';
+
 export const description = `per-album info, artwork gallery & commentary pages`;
 
 export function targets({wikiData}) {
@@ -5,8 +7,6 @@ export function targets({wikiData}) {
 }
 
 export function pathsForTarget(album) {
-  const hasCommentaryPage = !!album.commentary || album.tracks.some(t => t.commentary);
-
   return [
     {
       type: 'page',
@@ -28,7 +28,7 @@ export function pathsForTarget(album) {
       },
     },
 
-    hasCommentaryPage && {
+    {
       type: 'page',
       path: ['albumCommentary', album.directory],
 
@@ -38,6 +38,34 @@ export function pathsForTarget(album) {
       },
     },
 
+    {
+      type: 'page',
+      path: ['albumReferencedArtworks', album.directory],
+
+      condition: () =>
+        album.hasCoverArt &&
+        !empty(album.coverArtworks[0].referencedArtworks),
+
+      contentFunction: {
+        name: 'generateAlbumReferencedArtworksPage',
+        args: [album],
+      },
+    },
+
+    {
+      type: 'page',
+      path: ['albumReferencingArtworks', album.directory],
+
+      condition: () =>
+        album.hasCoverArt &&
+        !empty(album.coverArtworks[0].referencedByArtworks),
+
+      contentFunction: {
+        name: 'generateAlbumReferencingArtworksPage',
+        args: [album],
+      },
+    },
+
     /*
     {
       type: 'data',
@@ -60,13 +88,15 @@ export function pathsTargetless({wikiData: {wikiInfo}}) {
       contentFunction: {name: 'generateCommentaryIndexPage'},
     },
 
-    wikiInfo.canonicalBase === 'https://hsmusic.wiki/' &&
-      {
-        type: 'redirect',
-        fromPath: ['page', 'list/all-commentary'],
-        toPath: ['commentaryIndex'],
-        title: 'Album Commentary',
-      },
+    {
+      type: 'redirect',
+      fromPath: ['page', 'list/all-commentary'],
+      toPath: ['commentaryIndex'],
+      title: 'Album Commentary',
+
+      condition: () =>
+        wikiInfo.canonicalBase === 'https://hsmusic.wiki/',
+    },
   ];
 }
 
diff --git a/src/page/tag.js b/src/page/art-tag.js
index 8942aea9..5b61229d 100644
--- a/src/page/tag.js
+++ b/src/page/art-tag.js
@@ -1,6 +1,6 @@
 // Art tag page specification.
 
-export const description = `per-artwork-tag gallery pages`;
+export const description = `per-art-tag info & gallery pages`;
 
 export function condition({wikiData}) {
   return wikiData.wikiInfo.enableArtTagUI;
@@ -14,7 +14,17 @@ export function pathsForTarget(tag) {
   return [
     {
       type: 'page',
-      path: ['tag', tag.directory],
+      path: ['artTagInfo', tag.directory],
+
+      contentFunction: {
+        name: 'generateArtTagInfoPage',
+        args: [tag],
+      },
+    },
+
+    {
+      type: 'page',
+      path: ['artTagGallery', tag.directory],
 
       contentFunction: {
         name: 'generateArtTagGalleryPage',
diff --git a/src/page/artist-alias.js b/src/page/artist-alias.js
index c1177798..6af5ef8a 100644
--- a/src/page/artist-alias.js
+++ b/src/page/artist-alias.js
@@ -1,18 +1,32 @@
 export const description = `redirects for aliased artist names`;
 
 export function targets({wikiData}) {
-  return wikiData.artistData.filter(artist => artist.isAlias);
+  const normalArtistDirectories =
+    wikiData.artistData
+      .filter(artist => !artist.isAlias)
+      .map(artist => artist.directory);
+
+  return (
+    wikiData.artistData
+      .filter(artist => artist.isAlias)
+
+      // Don't generate a redirect page if this aliased name resolves to the
+      // same directory as the original artist! See issue #280.
+      .filter(aliasArtist =>
+        aliasArtist.directory !==
+        aliasArtist.aliasedArtist.directory)
+
+      // And don't generate a redirect page if this aliased name resolves to the
+      // same directory as any *other, non-alias* artist. In that case we really
+      // just need the page (at this directory) to lead to the actual artist with
+      // this directory - not be a redirect. See issue #543.
+      .filter(aliasArtist =>
+        !normalArtistDirectories.includes(aliasArtist.directory)));
 }
 
 export function pathsForTarget(aliasArtist) {
   const {aliasedArtist} = aliasArtist;
 
-  // Don't generate a redirect page if this aliased name resolves to the same
-  // directory as the original artist! See issue #280.
-  if (aliasArtist.directory === aliasedArtist.directory) {
-    return [];
-  }
-
   return [
     {
       type: 'redirect',
diff --git a/src/page/artist.js b/src/page/artist.js
index f80bd906..257e060d 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -8,10 +8,6 @@ export function targets({wikiData}) {
 }
 
 export function pathsForTarget(artist) {
-  const hasGalleryPage =
-    !empty(artist.tracksAsCoverArtist) ||
-    !empty(artist.albumsAsCoverArtist);
-
   return [
     {
       type: 'page',
@@ -23,10 +19,14 @@ export function pathsForTarget(artist) {
       },
     },
 
-    hasGalleryPage && {
+    {
       type: 'page',
       path: ['artistGallery', artist.directory],
 
+      condition: () =>
+        !empty(artist.albumCoverArtistContributions) ||
+        !empty(artist.trackCoverArtistContributions),
+
       contentFunction: {
         name: 'generateArtistGalleryPage',
         args: [artist],
diff --git a/src/page/group.js b/src/page/group.js
index b0ed5baf..87590eaf 100644
--- a/src/page/group.js
+++ b/src/page/group.js
@@ -7,8 +7,6 @@ export function targets({wikiData}) {
 }
 
 export function pathsForTarget(group) {
-  const hasGalleryPage = !empty(group.albums);
-
   return [
     {
       type: 'page',
@@ -20,10 +18,13 @@ export function pathsForTarget(group) {
       },
     },
 
-    hasGalleryPage && {
+    {
       type: 'page',
       path: ['groupGallery', group.directory],
 
+      condition: () =>
+        !empty(group.albums),
+
       contentFunction: {
         name: 'generateGroupGalleryPage',
         args: [group],
@@ -34,20 +35,24 @@ export function pathsForTarget(group) {
 
 export function pathsTargetless({wikiData: {wikiInfo}}) {
   return [
-    wikiInfo.canonicalBase === 'https://hsmusic.wiki/' &&
-      {
-        type: 'redirect',
-        fromPath: ['page', 'albums/fandom'],
-        toPath: ['groupGallery', 'fandom'],
-        title: 'Fandom - Gallery',
-      },
+    {
+      type: 'redirect',
+      fromPath: ['page', 'albums/fandom'],
+      toPath: ['groupGallery', 'fandom'],
+      title: 'Fandom - Gallery',
 
-    wikiInfo.canonicalBase === 'https://hsmusic.wiki/' &&
-      {
-        type: 'redirect',
-        fromPath: ['page', 'albums/official'],
-        toPath: ['groupGallery', 'official'],
-        title: 'Official - Gallery',
-      },
+      condition: () =>
+        wikiInfo.canonicalBase === 'https://hsmusic.wiki/',
+    },
+
+    {
+      type: 'redirect',
+      fromPath: ['page', 'albums/official'],
+      toPath: ['groupGallery', 'official'],
+      title: 'Official - Gallery',
+
+      condition: () =>
+        wikiInfo.canonicalBase === 'https://hsmusic.wiki/',
+    },
   ];
 }
diff --git a/src/page/homepage.js b/src/page/homepage.js
index 53ee6e46..cfcdd6e1 100644
--- a/src/page/homepage.js
+++ b/src/page/homepage.js
@@ -7,7 +7,7 @@ export function pathsTargetless({wikiData}) {
       path: ['home'],
 
       contentFunction: {
-        name: 'generateWikiHomePage',
+        name: 'generateWikiHomepagePage',
         args: [wikiData.homepageLayout],
       },
     },
diff --git a/src/page/index.js b/src/page/index.js
index 21d93c8f..ae480136 100644
--- a/src/page/index.js
+++ b/src/page/index.js
@@ -1,6 +1,7 @@
 export * as album from './album.js';
 export * as artist from './artist.js';
 export * as artistAlias from './artist-alias.js';
+export * as artTag from './art-tag.js';
 export * as flash from './flash.js';
 export * as flashAct from './flash-act.js';
 export * as group from './group.js';
@@ -8,5 +9,4 @@ export * as homepage from './homepage.js';
 export * as listing from './listing.js';
 export * as news from './news.js';
 export * as static from './static.js';
-export * as tag from './tag.js';
 export * as track from './track.js';
diff --git a/src/page/static.js b/src/page/static.js
index c9d806ff..733844de 100644
--- a/src/page/static.js
+++ b/src/page/static.js
@@ -12,6 +12,7 @@ export function pathsForTarget(staticPage) {
     {
       type: 'page',
       path: ['staticPage', staticPage.directory],
+      absoluteLinks: staticPage.absoluteLinks,
 
       contentFunction: {
         name: 'generateStaticPage',
diff --git a/src/page/track.js b/src/page/track.js
index e75b6958..95647334 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -1,5 +1,7 @@
 // Track page specification.
 
+import {empty} from '#sugar';
+
 export const description = `per-track info pages`;
 
 export function targets({wikiData}) {
@@ -17,5 +19,33 @@ export function pathsForTarget(track) {
         args: [track],
       },
     },
+
+    {
+      type: 'page',
+      path: ['trackReferencedArtworks', track.directory],
+
+      condition: () =>
+        track.hasUniqueCoverArt &&
+        !empty(track.trackArtworks[0].referencedArtworks),
+
+      contentFunction: {
+        name: 'generateTrackReferencedArtworksPage',
+        args: [track],
+      },
+    },
+
+    {
+      type: 'page',
+      path: ['trackReferencingArtworks', track.directory],
+
+      condition: () =>
+        track.hasUniqueCoverArt &&
+        !empty(track.trackArtworks[0].referencedByArtworks),
+
+      contentFunction: {
+        name: 'generateTrackReferencingArtworksPage',
+        args: [track],
+      },
+    },
   ];
 }
diff --git a/src/util/replacer.js b/src/replacer.js
index d1b0a269..32657a5a 100644
--- a/src/util/replacer.js
+++ b/src/replacer.js
@@ -13,7 +13,7 @@ import {escapeRegex, typeAppearance} from '#sugar';
 export const replacerSpec = {
   'album': {
     find: 'album',
-    link: 'linkAlbum',
+    link: 'linkAlbumDynamically',
   },
 
   'album-commentary': {
@@ -26,6 +26,16 @@ export const replacerSpec = {
     link: 'linkAlbumGallery',
   },
 
+  'album-referenced-artworks': {
+    find: 'albumWithArtwork',
+    link: 'linkAlbumReferencedArtworks',
+  },
+
+  'album-referencing-artworks': {
+    find: 'albumWithArtwork',
+    link: 'linkAlbumReferencingArtworks',
+  },
+
   'artist': {
     find: 'artist',
     link: 'linkArtist',
@@ -74,6 +84,11 @@ export const replacerSpec = {
     link: 'linkFlashAct',
   },
 
+  'flash-side': {
+    find: 'flashSide',
+    link: 'linkFlashSide',
+  },
+
   'group': {
     find: 'group',
     link: 'linkGroup',
@@ -86,7 +101,7 @@ export const replacerSpec = {
 
   'home': {
     find: null,
-    link: 'linkWikiHome',
+    link: 'linkWikiHomepage',
   },
 
   'listing-index': {
@@ -137,13 +152,28 @@ export const replacerSpec = {
 
   'tag': {
     find: 'artTag',
-    link: 'linkArtTag',
+    link: 'linkArtTagDynamically',
+  },
+
+  'tag-info': {
+    find: 'artTag',
+    link: 'linkArtTagInfo',
   },
 
   'track': {
     find: 'track',
     link: 'linkTrackDynamically',
   },
+
+  'track-referenced-artworks': {
+    find: 'trackWithArtwork',
+    link: 'linkTrackReferencedArtworks',
+  },
+
+  'track-referencing-artworks': {
+    find: 'trackWithArtwork',
+    link: 'linkTrackReferencingArtworks',
+  },
 };
 
 // Syntax literals.
@@ -428,7 +458,7 @@ export function squashBackslashes(text) {
   // a set of characters where the backslash carries meaning
   // into later formatting (i.e. markdown). Note that we do
   // NOT compress double backslashes into single backslashes.
-  return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_-])/g, '$1');
+  return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>-])/g, '$1');
 }
 
 export function restoreRawHTMLTags(text) {
@@ -444,23 +474,73 @@ export function cleanRawText(text) {
   return text;
 }
 
-export function postprocessImages(inputNodes) {
+export function postprocessComments(inputNodes) {
   const outputNodes = [];
 
-  let atStartOfLine = true;
+  for (const node of inputNodes) {
+    if (node.type !== 'text') {
+      outputNodes.push(node);
+      continue;
+    }
+
+    const commentRegexp =
+      new RegExp(
+        (// Remove comments which occupy entire lines, trimming the line break
+         // leading into them. These comments never include the ending of a
+         // comment which does not end a line, which is a regex way of saying
+         // "please fail early if we hit a --> that doesn't happen at the end
+         // of the line".
+         String.raw`\n<!--(?:(?!-->(?!$))[\s\S])*?-->(?=$)`
+       + '|' +
+
+         // Remove comments which appear at the start of a line, and any
+         // following spaces.
+         String.raw`^<!--[\s\S]*?--> *` +
+       + '|' +
+
+         // Remove comments which appear anywhere else, including in the
+         // middle of a line or at the end of a line, and any leading spaces.
+         String.raw` *<!--[\s\S]*?-->`),
+
+        'gm');
+
+    outputNodes.push({
+      type: 'text',
+
+      data:
+        node.data.replace(commentRegexp, ''),
+
+      i: node.i,
+      iEnd: node.iEnd,
+    });
+  }
+
+  return outputNodes;
+}
+
+function postprocessHTMLTags(inputNodes, tagName, callback) {
+  const outputNodes = [];
 
   const lastNode = inputNodes.at(-1);
 
+  const regexp =
+    new RegExp(
+      `<${tagName} (.*?)>` +
+      (html.selfClosingTags.includes(tagName)
+        ? ''
+        : `(?:</${tagName}>)?`),
+      'g');
+
+  let atStartOfLine = true;
+
   for (const node of inputNodes) {
     if (node.type === 'tag') {
       atStartOfLine = false;
     }
 
     if (node.type === 'text') {
-      const imageRegexp = /<img (.*?)>/g;
-
       let match = null, parseFrom = 0;
-      while (match = imageRegexp.exec(node.data)) {
+      while (match = regexp.exec(node.data)) {
         const previousText = node.data.slice(parseFrom, match.index);
 
         outputNodes.push({
@@ -472,23 +552,19 @@ export function postprocessImages(inputNodes) {
 
         parseFrom = match.index + match[0].length;
 
-        const imageNode = {type: 'image'};
-        const attributes = html.parseAttributes(match[1]);
-
-        imageNode.src = attributes.get('src');
-
         if (previousText.endsWith('\n')) {
           atStartOfLine = true;
         } else if (previousText.length) {
           atStartOfLine = false;
         }
 
-        imageNode.inline = (() => {
-          // Images can force themselves to be rendered inline using a custom
-          // attribute - this style just works better for certain embeds,
-          // usually jokes or small images.
-          if (attributes.get('inline')) return true;
+        const attributes =
+          html.parseAttributes(match[1]);
+
+        const remainingTextInNode =
+          node.data.slice(parseFrom);
 
+        const inline = (() => {
           // If we've already determined we're in the middle of a line,
           // we're inline. (Of course!)
           if (!atStartOfLine) {
@@ -497,42 +573,27 @@ export function postprocessImages(inputNodes) {
 
           // If there's more text to go in this text node, and what's
           // remaining doesn't start with a line break, we're inline.
-          if (
-            parseFrom !== node.data.length &&
-            node.data[parseFrom] !== '\n'
-          ) {
+          if (remainingTextInNode && remainingTextInNode[0] !== '\n') {
             return true;
           }
 
           // If we're at the end of this text node, but this text node
           // isn't the last node overall, we're inline.
-          if (
-            parseFrom === node.data.length &&
-            node !== lastNode
-          ) {
+          if (!remainingTextInNode && node !== lastNode) {
             return true;
           }
 
-          // If no other condition matches, this image is on its own line.
+          // If no other condition matches, this tag is on its own line.
           return false;
         })();
 
-        if (attributes.get('link')) imageNode.link = attributes.get('link');
-        if (attributes.get('style')) imageNode.style = attributes.get('style');
-        if (attributes.get('width')) imageNode.width = parseInt(attributes.get('width'));
-        if (attributes.get('height')) imageNode.height = parseInt(attributes.get('height'));
-        if (attributes.get('align')) imageNode.align = attributes.get('align');
-        if (attributes.get('pixelate')) imageNode.pixelate = true;
-
-        if (attributes.get('warning')) {
-          imageNode.warnings =
-            attributes.get('warning').split(', ');
-        }
-
-        outputNodes.push(imageNode);
+        outputNodes.push(
+          callback(attributes, {
+            inline,
+          }));
 
-        // No longer at the start of a line after an image - there will at
-        // least be a text node with only '\n' before the next image that's
+        // No longer at the start of a line after the tag - there will at
+        // least be text with only '\n' before the next of this tag that's
         // on its own line.
         atStartOfLine = false;
       }
@@ -555,6 +616,57 @@ export function postprocessImages(inputNodes) {
   return outputNodes;
 }
 
+export function postprocessImages(inputNodes) {
+  return postprocessHTMLTags(inputNodes, 'img',
+    (attributes, {inline}) => {
+      const node = {type: 'image'};
+
+      node.src = attributes.get('src');
+      node.inline = attributes.get('inline') ?? inline;
+
+      if (attributes.get('link')) node.link = attributes.get('link');
+      if (attributes.get('style')) node.style = attributes.get('style');
+      if (attributes.get('width')) node.width = parseInt(attributes.get('width'));
+      if (attributes.get('height')) node.height = parseInt(attributes.get('height'));
+      if (attributes.get('align')) node.align = attributes.get('align');
+      if (attributes.get('pixelate')) node.pixelate = true;
+
+      if (attributes.get('warning')) {
+        node.warnings =
+          attributes.get('warning').split(', ');
+      }
+
+      return node;
+    });
+}
+
+export function postprocessVideos(inputNodes) {
+  return postprocessHTMLTags(inputNodes, 'video',
+    attributes => {
+      const node = {type: 'video'};
+
+      node.src = attributes.get('src');
+
+      if (attributes.get('width')) node.width = parseInt(attributes.get('width'));
+      if (attributes.get('height')) node.height = parseInt(attributes.get('height'));
+      if (attributes.get('pixelate')) node.pixelate = true;
+
+      return node;
+    });
+}
+
+export function postprocessAudios(inputNodes) {
+  return postprocessHTMLTags(inputNodes, 'audio',
+    (attributes, {inline}) => {
+      const node = {type: 'audio'};
+
+      node.src = attributes.get('src');
+      node.inline = attributes.get('inline') ?? inline;
+
+      return node;
+    });
+}
+
 export function postprocessHeadings(inputNodes) {
   const outputNodes = [];
 
@@ -596,6 +708,51 @@ export function postprocessHeadings(inputNodes) {
   return outputNodes;
 }
 
+export function postprocessSummaries(inputNodes) {
+  const outputNodes = [];
+
+  for (const node of inputNodes) {
+    if (node.type !== 'text') {
+      outputNodes.push(node);
+      continue;
+    }
+
+    const summaryRegexp = /<summary>(.*)<\/summary>/g;
+
+    let textContent = '';
+
+    let match = null, parseFrom = 0;
+    while (match = summaryRegexp.exec(node.data)) {
+      textContent += node.data.slice(parseFrom, match.index);
+      parseFrom = match.index + match[0].length;
+
+      const colorizeWholeSummary = !match[1].includes('<b>');
+
+      // We're wrapping the contents of the <summary> with a <span>, and
+      // possibly with a <b>, too. This means we have to add the closing tags
+      // where the summary ends.
+      textContent += `<summary><span>`;
+      textContent += (colorizeWholeSummary ? `<b>` : ``);
+      textContent += match[1];
+      textContent += (colorizeWholeSummary ? `</b>` : ``);
+      textContent += `</span></summary>`;
+    }
+
+    if (parseFrom !== node.data.length) {
+      textContent += node.data.slice(parseFrom);
+    }
+
+    outputNodes.push({
+      type: 'text',
+      data: textContent,
+      i: node.i,
+      iEnd: node.iEnd,
+    });
+  }
+
+  return outputNodes;
+}
+
 export function postprocessExternalLinks(inputNodes) {
   const outputNodes = [];
 
@@ -669,8 +826,12 @@ export function parseInput(input) {
 
   try {
     let output = parseNodes(input, 0);
+    output = postprocessComments(output);
     output = postprocessImages(output);
+    output = postprocessVideos(output);
+    output = postprocessAudios(output);
     output = postprocessHeadings(output);
+    output = postprocessSummaries(output);
     output = postprocessExternalLinks(output);
     return output;
   } catch (errorNode) {
diff --git a/src/reverse.js b/src/reverse.js
new file mode 100644
index 00000000..b4b225f0
--- /dev/null
+++ b/src/reverse.js
@@ -0,0 +1,141 @@
+import * as fr from './find-reverse.js';
+
+import {sortByDate} from '#sort';
+import {stitchArrays} from '#sugar';
+
+function checkUnique(value) {
+  if (value.length === 0) {
+    return null;
+  } else if (value.length === 1) {
+    return value[0];
+  } else {
+    throw new Error(
+      `Requested unique referencing thing, ` +
+      `but ${value.length} reference this`);
+  }
+}
+
+function reverseHelper(spec) {
+  const cache = new WeakMap();
+
+  return (thing, data, {
+    unique = false,
+  } = {}) => {
+    // Check for an existing cache record which corresponds to this data.
+    // If it exists, query it for the requested thing, and return that;
+    // if it doesn't, create it and put it where it needs to be.
+
+    if (cache.has(data)) {
+      const value = cache.get(data).get(thing) ?? [];
+
+      if (unique) {
+        return checkUnique(value);
+      } else {
+        return value;
+      }
+    }
+
+    const cacheRecord = new WeakMap();
+    cache.set(data, cacheRecord);
+
+    // Get the referencing and referenced things. This is the meat of how
+    // one reverse spec is different from another. If the spec includes a
+    // 'tidy' step, use that to finalize the referencing things, the way
+    // they'll be recorded as results.
+
+    const interstitialReferencingThings =
+      (spec.bindTo === 'wikiData'
+        ? spec.referencing(data)
+        : data.flatMap(thing => spec.referencing(thing)));
+
+    const referencedThings =
+      interstitialReferencingThings.map(thing => spec.referenced(thing));
+
+    const referencingThings =
+      (spec.tidy
+        ? interstitialReferencingThings.map(thing => spec.tidy(thing))
+        : interstitialReferencingThings);
+
+    // Actually fill in the cache record. Since we're building up a *reverse*
+    // reference list, track connections in terms of the referenced thing.
+    // Also gather all referenced things into a set, for sorting purposes.
+
+    const allReferencedThings = new Set();
+
+    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]);
+            allReferencedThings.add(referencedThing);
+          }
+        }
+      });
+
+    // Sort the entries in the cache records, too, just by date. The rest of
+    // sorting should be handled externally - either preceding the reverse
+    // call (changing the data input) or following (sorting the output).
+
+    for (const referencedThing of allReferencedThings) {
+      if (cacheRecord.has(referencedThing)) {
+        const referencingThings = cacheRecord.get(referencedThing);
+        sortByDate(referencingThings, {
+          getDate: spec.date ?? (thing => thing.date),
+        });
+      }
+    }
+
+    // Then just pluck out the requested thing from the now-filled
+    // cache record!
+
+    const value = cacheRecord.get(thing) ?? [];
+
+    if (unique) {
+      return checkUnique(value);
+    } else {
+      return value;
+    }
+  };
+}
+
+const hardcodedReverseSpecs = {};
+
+const findReverseHelperConfig = {
+  word: `reverse`,
+  constructorKey: Symbol.for('Thing.reverseSpecs'),
+
+  hardcodedSpecs: hardcodedReverseSpecs,
+  postprocessSpec: postprocessReverseSpec,
+};
+
+export function postprocessReverseSpec(spec, {thingConstructor}) {
+  const newSpec = {...spec};
+
+  void thingConstructor;
+
+  return newSpec;
+}
+
+export function getAllReverseSpecs() {
+  return fr.getAllSpecs(findReverseHelperConfig);
+}
+
+export function findReverseSpec(key) {
+  return fr.findSpec(key, findReverseHelperConfig);
+}
+
+export default fr.tokenProxy({
+  findSpec: findReverseSpec,
+  prepareBehavior: reverseHelper,
+});
+
+export function bindReverse(wikiData, opts) {
+  return fr.bind(wikiData, opts, {
+    getAllSpecs: getAllReverseSpecs,
+    prepareBehavior: reverseHelper,
+  });
+}
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/client3.js b/src/static/client3.js
deleted file mode 100644
index 7d6544a0..00000000
--- a/src/static/client3.js
+++ /dev/null
@@ -1,3483 +0,0 @@
-/* eslint-env browser */
-
-// This is the JS file that gets loaded on the client! It's only really used for
-// the random track feature right now - the idea is we only use it for stuff
-// that cannot 8e done at static-site compile time, 8y its fundamentally
-// ephemeral nature.
-
-import {accumulateSum, empty, filterMultipleArrays, stitchArrays}
-  from '../util/sugar.js';
-
-const clientInfo = window.hsmusicClientInfo = Object.create(null);
-
-const clientSteps = {
-  getPageReferences: [],
-  addInternalListeners: [],
-  mutatePageContent: [],
-  initializeState: [],
-  addPageListeners: [],
-};
-
-function initInfo(key, description) {
-  const object = {...description};
-
-  for (const obj of [
-    object,
-    object.state,
-    object.setting,
-    object.event,
-  ]) {
-    if (!obj) continue;
-    Object.preventExtensions(obj);
-  }
-
-  clientInfo[key] = object;
-
-  return object;
-}
-
-// Localiz8tion nonsense ----------------------------------
-
-/*
-const language = document.documentElement.getAttribute('lang');
-
-let list;
-if (typeof Intl === 'object' && typeof Intl.ListFormat === 'function') {
-  const getFormat = (type) => {
-    const formatter = new Intl.ListFormat(language, {type});
-    return formatter.format.bind(formatter);
-  };
-
-  list = {
-    conjunction: getFormat('conjunction'),
-    disjunction: getFormat('disjunction'),
-    unit: getFormat('unit'),
-  };
-} else {
-  // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free.
-  // We use the same mock for every list 'cuz we don't have any of the
-  // necessary CLDR info to appropri8tely distinguish 8etween them.
-  const arbitraryMock = (array) => array.join(', ');
-
-  list = {
-    conjunction: arbitraryMock,
-    disjunction: arbitraryMock,
-    unit: arbitraryMock,
-  };
-}
-*/
-
-// Miscellaneous helpers ----------------------------------
-
-function rebase(href, rebaseKey = 'rebaseLocalized') {
-  const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/';
-  if (relative) {
-    return relative + href;
-  } else {
-    return href;
-  }
-}
-
-function pick(array) {
-  return array[Math.floor(Math.random() * array.length)];
-}
-
-function cssProp(el, ...args) {
-  if (typeof args[0] === 'string' && args.length === 1) {
-    return getComputedStyle(el).getPropertyValue(args[0]).trim();
-  }
-
-  if (typeof args[0] === 'string' && args.length === 2) {
-    if (args[1] === null) {
-      el.style.removeProperty(args[0]);
-    } else {
-      el.style.setProperty(args[0], args[1]);
-    }
-    return;
-  }
-
-  if (typeof args[0] === 'object') {
-    for (const [property, value] of Object.entries(args[0])) {
-      cssProp(el, property, value);
-    }
-  }
-}
-
-// Curry-style, so multiple points can more conveniently be tested at once.
-function pointIsOverAnyOf(elements) {
-  return (clientX, clientY) => {
-    const element = document.elementFromPoint(clientX, clientY);
-    return elements.some(el => el.contains(element));
-  };
-}
-
-function getVisuallyContainingElement(child) {
-  let parent = child.parentElement;
-
-  while (parent) {
-    if (
-      cssProp(parent, 'overflow') === 'hidden' ||
-      cssProp(parent, 'contain') === 'paint'
-    ) {
-      return parent;
-    }
-
-    parent = parent.parentElement;
-  }
-
-  return null;
-}
-
-// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to
-// separ8te the tooling around that into common-shared code too.
-
-/*
-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}`);
-
-// TODO: This should also use urlSpec.
-
-/*
-function fetchData(type, directory) {
-  return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')).then(
-    (res) => res.json()
-  );
-}
-*/
-
-function dispatchInternalEvent(event, eventName, ...args) {
-  const [infoName] =
-    Object.entries(clientInfo)
-      .find(pair => pair[1].event === event);
-
-  if (!infoName) {
-    throw new Error(`Expected event to be stored on clientInfo`);
-  }
-
-  const {[eventName]: listeners} = event;
-
-  if (!listeners) {
-    throw new Error(`Event name "${eventName}" isn't stored on ${infoName}.event`);
-  }
-
-  let results = [];
-  for (const listener of listeners) {
-    try {
-      results.push(listener(...args));
-    } catch (error) {
-      console.warn(`Uncaught error in listener for ${infoName}.${eventName}`);
-      console.debug(error);
-      results.push(undefined);
-    }
-  }
-
-  return results;
-}
-
-// Rectangle math -----------------------------------------
-
-class WikiRect extends DOMRect {
-  // Useful constructors
-
-  static fromWindow() {
-    const {clientWidth: width, clientHeight: height} =
-      document.documentElement;
-
-    return Reflect.construct(this, [0, 0, width, height]);
-  }
-
-  static fromElement(element) {
-    return this.fromRect(element.getBoundingClientRect());
-  }
-
-  static fromMouse() {
-    const {clientX, clientY} = liveMousePositionInfo.state;
-
-    return WikiRect.fromRect({
-      x: clientX,
-      y: clientY,
-      width: 0,
-      height: 0,
-    });
-  }
-
-  static fromElementUnderMouse(element) {
-    const mouseRect = WikiRect.fromMouse();
-
-    const rects =
-      Array.from(element.getClientRects())
-        .map(rect => WikiRect.fromRect(rect));
-
-    const rectUnderMouse =
-      rects.find(rect => rect.contains(mouseRect));
-
-    if (rectUnderMouse) {
-      return rectUnderMouse;
-    } else {
-      return rects[0];
-    }
-  }
-
-  static leftOf(origin, offset = 0) {
-    // Returns a rectangle representing everywhere to the left of the provided
-    // point or rectangle (with no top or bottom bounds), towards negative x.
-    // If an offset is provided, this is added onto the origin.
-
-    return this.#past(origin, offset, {
-      origin: 'x',
-      extent: 'width',
-      edge: 'left',
-      direction: -Infinity,
-      construct: from =>
-        [from, -Infinity, -Infinity, Infinity],
-    });
-  }
-
-  static rightOf(origin, offset = 0) {
-    // Returns a rectangle representing everywhere to the right of the
-    // provided point or rectangle (with no top or bottom bounds), towards
-    // positive x. If an offset is provided, this is added onto the origin.
-
-    return this.#past(origin, offset, {
-      origin: 'x',
-      extent: 'width',
-      edge: 'right',
-      direction: Infinity,
-      construct: from =>
-        [from, -Infinity, Infinity, Infinity],
-    });
-  }
-
-  static above(origin, offset = 0) {
-    // Returns a rectangle representing everywhere above the provided point
-    // or rectangle (with no left or right bounds), towards negative y.
-    // If an offset is provided, this is added onto the origin.
-
-    return this.#past(origin, offset, {
-      origin: 'y',
-      extent: 'height',
-      edge: 'top',
-      direction: -Infinity,
-      construct: from =>
-        [-Infinity, from, Infinity, -Infinity],
-    });
-  }
-
-  static beneath(origin, offset = 0) {
-    // Returns a rectangle representing everywhere beneath the provided point
-    // or rectangle (with no left or right bounds), towards positive y.
-    // If an offset is provided, this is added onto the origin.
-
-    return this.#past(origin, offset, {
-      origin: 'y',
-      extent: 'height',
-      edge: 'bottom',
-      direction: Infinity,
-      construct: from =>
-        [-Infinity, from, Infinity, Infinity],
-    });
-  }
-
-  // Constructor helpers
-
-  static #past(origin, offset, opts) {
-    if (!isFinite(offset)) {
-      throw new TypeError(`Didn't expect infinite offset`);
-    }
-
-    const {direction, edge} = opts;
-
-    if (typeof origin === 'object') {
-      const {origin: originProperty, extent: extentProperty} = opts;
-
-      const normalized =
-        WikiRect.fromRect(origin).toNormalized();
-
-      if (normalized[extentProperty] === direction) {
-        throw new TypeError(`Provided rectangle already extends to ${edge} edge`);
-      }
-
-      if (normalized[extentProperty] === -direction) {
-        return this.#past(normalized[originProperty], offset, opts);
-      }
-
-      if (normalized.y === direction) {
-        throw new TypeError(`Provided rectangle already starts at ${edge} edge`);
-      }
-
-      return this.#past(normalized[edge], offset, opts);
-    }
-
-    const {construct} = opts;
-
-    if (origin === direction) {
-      throw new TypeError(`Provided point is already at ${edge} edge`);
-    }
-
-    return Reflect.construct(this, construct(origin + offset)).toNormalized();
-  }
-
-  // Predicates
-
-  static rejectInfiniteOriginNonZeroFiniteExtent({origin, extent}) {
-    // Indicate that, in this context, it's meaningless to provide
-    // a finite extent starting at an infinite origin and going towards
-    // or away from zero (i.e. a rectangle along a cardinal edge).
-
-    if (!isFinite(origin) && isFinite(extent) && extent !== 0) {
-      throw new TypeError(`Didn't expect infinite origin paired with finite extent`);
-    }
-  }
-
-  static rejectInfiniteOriginZeroExtent({origin, extent}) {
-    // Indicate that, in this context, it's meaningless to provide
-    // a zero extent at an infinite origin (i.e. a cardinal edge).
-
-    if (!isFinite(origin) && extent === 0) {
-      throw new TypeError(`Didn't expect infinite origin paired with zero extent`);
-    }
-  }
-
-  static rejectNonOpposingInfiniteOriginInfiniteExtent({origin, extent}) {
-    // Indicate that, in this context, it's meaningless to provide
-    // an infinite extent going in the same direction as its infinite
-    // origin (an area "infinitely past" a cardinal edge).
-
-    if (!isFinite(origin) && origin === extent) {
-      throw new TypeError(`Didn't expect non-opposing infinite origin and extent`);
-    }
-  }
-
-  // Transformations
-
-  static normalizeOriginExtent({origin, extent}) {
-    // Varying behavior based on inputs:
-    //
-    //  - For finite origin and finite extent, flip the orientation
-    //    (if necessary) so that extent is positive.
-    //  - For finite origin and infinite extent (i.e. an origin up to
-    //    a cardinal edge), leave as-is.
-    //  - For infinite origin and infinite extent, flip the orientation
-    //    (if necessary) so origin is negative and extent is positive.
-    //  - For infinite origin and zero extent (i.e. a cardinal edge),
-    //    leave as-is.
-    //  - For all other cases, error.
-    //
-
-    this.rejectInfiniteOriginNonZeroFiniteExtent({origin, extent});
-    this.rejectNonOpposingInfiniteOriginInfiniteExtent({origin, extent});
-
-    if (isFinite(origin) && isFinite(extent) && extent < 0) {
-      return {origin: origin + extent, extent: -extent};
-    }
-
-    if (!isFinite(origin) && !isFinite(extent)) {
-      return {origin: -Infinity, extent: Infinity};
-    }
-
-    return {origin, extent};
-  }
-
-  toNormalized() {
-    const {origin: newX, extent: newWidth} =
-      WikiRect.normalizeOriginExtent({
-        origin: this.x,
-        extent: this.width,
-      });
-
-    const {origin: newY, extent: newHeight} =
-      WikiRect.normalizeOriginExtent({
-        origin: this.y,
-        extent: this.height,
-      });
-
-    return Reflect.construct(this.constructor, [newX, newY, newWidth, newHeight]);
-  }
-
-  static intersectionFromOriginsExtents(...entries) {
-    // An intersection is the common subsection across two or more regions.
-
-    const [first, second, ...rest] = entries;
-
-    if (entries.length >= 3) {
-      return this.intersection(first, this.intersection(second, ...rest));
-    }
-
-    if (entries.length === 2) {
-      if (first === null || second === null) {
-        return null;
-      }
-
-      this.rejectInfiniteOriginZeroExtent(first);
-      this.rejectInfiniteOriginZeroExtent(second);
-
-      const {origin: origin1, extent: extent1} = this.normalizeOriginExtent(first);
-      const {origin: origin2, extent: extent2} = this.normalizeOriginExtent(second);
-
-      // After normalizing, *each* region will be one of these:
-      //
-      //  - Finite origin, finite extent
-      //    (a standard region, bounded on both sides)
-      //  - Finite origin, infinite extent
-      //    (everything to one direction of a given origin)
-      //  - Infinite origin, infinite extent
-      //    (everything everywhere)
-      //
-      // So we need to handle any *combination* of these kinds of regions.
-
-      // If either origin is infinite, that region represents everywhere,
-      // so it'll never limit the region of the other.
-
-      if (!isFinite(origin1)) {
-        return {origin: origin2, extent: extent2};
-      }
-
-      if (!isFinite(origin2)) {
-        return {origin: origin1, extent: extent1};
-      }
-
-      // If neither origin is infinite, both regions are bounded on at least
-      // one side, and may limit the other accordingly. Find the minimum and
-      // maximum points in each region, letting Infinity propagate through,
-      // which represents no boundary in that direction.
-
-      const minimum1 = Math.min(origin1, origin1 + extent1);
-      const minimum2 = Math.min(origin2, origin2 + extent2);
-      const maximum1 = Math.max(origin1, origin1 + extent1);
-      const maximum2 = Math.max(origin2, origin2 + extent2);
-
-      // Now get the maximum of the regions' minimums, and the minimum of the
-      // regions' maximums. These are the limits of the new region; computing
-      // with minimums and maximums in this way "polarizes" the limits, so we
-      // can perform specific polarized math in the following steps.
-      //
-      // Infinity will also propagate here, but with some important
-      // restricitons: only maxOfMinimums can be positive Infinity, and only
-      // minOfMaximums can be negative Infinity; and if either is Infinity,
-      // the other is not, since otherwise we'd be working with two everywhere
-      // regions, and would've just returned an everywhere region above.
-
-      const maxOfMinimums = Math.max(minimum1, minimum2);
-      const minOfMaximums = Math.min(maximum1, maximum2);
-
-      // Now check if the maximum of minimums is greater than the minimum of
-      // maximums. If so, the regions don't have any overlap - one region
-      // limits the overlap to end before the other region starts. This works
-      // because we've polarized the limits above!
-
-      if (maxOfMinimums > minOfMaximums) {
-        return null;
-      }
-
-      // Otherwise there's at least some overlap, even if it's just one point
-      // (i.e. one ends exactly where the other begins). We have to take care
-      // of infinities in particular, now. As mentioned above, only one of the
-      // points will be infinity (at most). So the origin is the non-infinite
-      // point, and the extent is in the direction of the infinite point.
-
-      if (minOfMaximums === -Infinity) {
-        return {origin: maxOfMinimums, extent: -Infinity};
-      }
-
-      if (maxOfMinimums === Infinity) {
-        return {origin: minOfMaximums, extent: Infinity};
-      }
-
-      // If neither point is infinity, we're working with two regions that are
-      // both bounded on both sides, so the overlapping region is just the
-      // region constrained by the limits above. Since these are polarized,
-      // start from maxOfMinimums and extend to minOfMaximums, resulting in
-      // a standard, already-normalized region.
-
-      return {
-        origin: maxOfMinimums,
-        extent: minOfMaximums - maxOfMinimums,
-      };
-    }
-
-    if (entries.length === 1) {
-      return first;
-    }
-
-    throw new TypeError(`Expected at least one {origin, extent} entry`);
-  }
-
-  intersectionWith(rect) {
-    const horizontalIntersection =
-      WikiRect.intersectionFromOriginsExtents(
-        {origin: this.x, extent: this.width},
-        {origin: rect.x, extent: rect.width});
-
-    const verticalIntersection =
-      WikiRect.intersectionFromOriginsExtents(
-        {origin: this.y, extent: this.height},
-        {origin: rect.y, extent: rect.height});
-
-    if (!horizontalIntersection) return null;
-    if (!verticalIntersection) return null;
-
-    const {origin: x, extent: width} = horizontalIntersection;
-    const {origin: y, extent: height} = verticalIntersection;
-
-    return Reflect.construct(this.constructor, [x, y, width, height]);
-  }
-
-  chopExtendingOutside(rect) {
-    this.intersectionWith(rect).writeOnto(this);
-  }
-
-  static insetOriginExtent({origin, extent, start = 0, end = 0}) {
-    const normalized =
-      this.normalizeOriginExtent({origin, extent});
-
-    // If this would crush the bounds past each other, just return
-    // the halfway point.
-    if (extent < start + end) {
-      return {origin: origin + (start + end) / 2, extent: 0};
-    }
-
-    return {
-      origin: normalized.origin + start,
-      extent: normalized.extent - start - end,
-    };
-  }
-
-  toInset(arg1, arg2) {
-    if (typeof arg1 === 'number' && typeof arg2 === 'number') {
-      return this.toInset({
-        left: arg2,
-        right: arg2,
-        top: arg1,
-        bottom: arg1,
-      });
-    } else if (typeof arg1 === 'number') {
-      return this.toInset({
-        left: arg1,
-        right: arg1,
-        top: arg1,
-        bottom: arg1,
-      });
-    }
-
-    const {top, left, bottom, right} = arg1;
-
-    const {origin: x, extent: width} =
-      WikiRect.insetOriginExtent({
-        origin: this.x,
-        extent: this.width,
-        start: left,
-        end: right,
-      });
-
-    const {origin: y, extent: height} =
-      WikiRect.insetOriginExtent({
-        origin: this.y,
-        extent: this.height,
-        start: top,
-        end: bottom,
-      });
-
-    return Reflect.construct(this.constructor, [x, y, width, height]);
-  }
-
-  static extendOriginExtent({origin, extent, start = 0, end = 0}) {
-    const normalized =
-      this.normalizeOriginExtent({origin, extent});
-
-    return {
-      origin: normalized.origin - start,
-      extent: normalized.extent + start + end,
-    };
-  }
-
-  toExtended(arg1, arg2) {
-    if (typeof arg1 === 'number' && typeof arg2 === 'number') {
-      return this.toExtended({
-        left: arg2,
-        right: arg2,
-        top: arg1,
-        bottom: arg1,
-      });
-    } else if (typeof arg1 === 'number') {
-      return this.toExtended({
-        left: arg1,
-        right: arg1,
-        top: arg1,
-        bottom: arg1,
-      });
-    }
-
-    const {top, left, bottom, right} = arg1;
-
-    const {origin: x, extent: width} =
-      WikiRect.extendOriginExtent({
-        origin: this.x,
-        extent: this.width,
-        start: left,
-        end: right,
-      });
-
-    const {origin: y, extent: height} =
-      WikiRect.extendOriginExtent({
-        origin: this.y,
-        extent: this.height,
-        start: top,
-        end: bottom,
-      });
-
-    return Reflect.construct(this.constructor, [x, y, width, height]);
-  }
-
-  // Comparisons
-
-  equals(rect) {
-    const rectNormalized = WikiRect.fromRect(rect).toNormalized();
-    const thisNormalized = this.toNormalized();
-
-    return (
-      rectNormalized.x === thisNormalized.x &&
-      rectNormalized.y === thisNormalized.y &&
-      rectNormalized.width === thisNormalized.width &&
-      rectNormalized.height === thisNormalized.height
-    );
-  }
-
-  contains(rect) {
-    return !!this.intersectionWith(rect)?.equals(rect);
-  }
-
-  containedWithin(rect) {
-    return !!this.intersectionWith(rect)?.equals(this);
-  }
-
-  fits(rect) {
-    const rectNormalized = WikiRect.fromRect(rect).toNormalized();
-    const thisNormalized = this.toNormalized();
-
-    return (
-      (!isFinite(this.width) || rectNormalized.width <= thisNormalized.width) &&
-      (!isFinite(this.height) || rectNormalized.height <= thisNormalized.height)
-    );
-  }
-
-  fitsWithin(rect) {
-    const rectNormalized = WikiRect.fromRect(rect).toNormalized();
-    const thisNormalized = this.toNormalized();
-
-    return (
-      (!isFinite(rect.width) || thisNormalized.width <= rectNormalized.width) &&
-      (!isFinite(rect.height) || thisNormalized.height <= rectNormalized.height)
-    );
-  }
-
-  // Interfacing utilities
-
-  static fromRect(rect) {
-    return Reflect.construct(this, [rect.x, rect.y, rect.width, rect.height]);
-  }
-
-  writeOnto(destination) {
-    Object.assign(destination, {
-      x: this.x,
-      y: this.y,
-      width: this.width,
-      height: this.height,
-    });
-  }
-}
-
-// CSS compatibility-assistant ----------------------------
-
-const cssCompatibilityAssistantInfo = clientInfo.cssCompatibilityAssistantInfo = {
-  coverArtContainer: null,
-  coverArtImageDetails: null,
-};
-
-function getCSSCompatibilityAssistantInfoReferences() {
-  const info = cssCompatibilityAssistantInfo;
-
-  info.coverArtContainer =
-    document.getElementById('cover-art-container');
-
-  info.coverArtImageDetails =
-    info.coverArtContainer?.querySelector('.image-details');
-}
-
-function mutateCSSCompatibilityContent() {
-  const info = cssCompatibilityAssistantInfo;
-
-  if (info.coverArtImageDetails) {
-    info.coverArtContainer.classList.add('has-image-details');
-  }
-}
-
-clientSteps.getPageReferences.push(getCSSCompatibilityAssistantInfoReferences);
-clientSteps.mutatePageContent.push(mutateCSSCompatibilityContent);
-
-// Ever-updating mouse position helper --------------------
-
-const liveMousePositionInfo = initInfo('liveMousePositionInfo', {
-  state: {
-    clientX: null,
-    clientY: null,
-  },
-});
-
-function addLiveMousePositionPageListeners() {
-  const info = liveMousePositionInfo;
-  const {state} = info;
-
-  document.body.addEventListener('mousemove', domEvent => {
-    Object.assign(state, {
-      clientX: domEvent.clientX,
-      clientY: domEvent.clientY,
-    });
-  });
-}
-
-clientSteps.addPageListeners.push(addLiveMousePositionPageListeners);
-
-// JS-based links -----------------------------------------
-
-const scriptedLinkInfo = initInfo('scriptedLinkInfo', {
-  randomLinks: null,
-  revealLinks: null,
-  revealContainers: null,
-
-  nextNavLink: null,
-  previousNavLink: null,
-  randomNavLink: null,
-
-  state: {
-    albumDirectories: null,
-    albumTrackDirectories: null,
-    artistDirectories: null,
-    artistNumContributions: null,
-  },
-});
-
-function getScriptedLinkReferences() {
-  scriptedLinkInfo.randomLinks =
-    document.querySelectorAll('[data-random]');
-
-  scriptedLinkInfo.revealLinks =
-    document.querySelectorAll('.reveal .image-outer-area > *');
-
-  scriptedLinkInfo.revealContainers =
-    Array.from(scriptedLinkInfo.revealLinks)
-      .map(link => link.closest('.reveal'));
-
-  scriptedLinkInfo.nextNavLink =
-    document.getElementById('next-button');
-
-  scriptedLinkInfo.previousNavLink =
-    document.getElementById('previous-button');
-
-  scriptedLinkInfo.randomNavLink =
-    document.getElementById('random-button');
-}
-
-function addRandomLinkListeners() {
-  for (const a of scriptedLinkInfo.randomLinks ?? []) {
-    a.addEventListener('click', domEvent => {
-      handleRandomLinkClicked(a, domEvent);
-    });
-  }
-}
-
-function handleRandomLinkClicked(a, domEvent) {
-  const href = determineRandomLinkHref(a);
-
-  if (!href) {
-    domEvent.preventDefault();
-    return;
-  }
-
-  setTimeout(() => {
-    a.href = '#'
-  });
-
-  a.href = href;
-}
-
-function determineRandomLinkHref(a) {
-  const {state} = scriptedLinkInfo;
-
-  const trackDirectoriesFromAlbumDirectories = albumDirectories =>
-    albumDirectories
-      .map(directory => state.albumDirectories.indexOf(directory))
-      .map(index => state.albumTrackDirectories[index])
-      .reduce((acc, trackDirectories) => acc.concat(trackDirectories, []));
-
-  switch (a.dataset.random) {
-    case 'album': {
-      const {albumDirectories} = state;
-      if (!albumDirectories) return null;
-
-      return openAlbum(pick(albumDirectories));
-    }
-
-    case 'track': {
-      const {albumDirectories} = state;
-      if (!albumDirectories) return null;
-
-      const trackDirectories =
-        trackDirectoriesFromAlbumDirectories(
-          albumDirectories);
-
-      return openTrack(pick(trackDirectories));
-    }
-
-    case 'album-in-group-dl': {
-      const albumLinks =
-        Array.from(a
-          .closest('dt')
-          .nextElementSibling
-          .querySelectorAll('li a'))
-
-      const listAlbumDirectories =
-        albumLinks
-          .map(a => cssProp(a, '--album-directory'));
-
-      return openAlbum(pick(listAlbumDirectories));
-    }
-
-    case 'track-in-group-dl': {
-      const {albumDirectories} = state;
-      if (!albumDirectories) return null;
-
-      const albumLinks =
-        Array.from(a
-          .closest('dt')
-          .nextElementSibling
-          .querySelectorAll('li a'))
-
-      const listAlbumDirectories =
-        albumLinks
-          .map(a => cssProp(a, '--album-directory'));
-
-      const trackDirectories =
-        trackDirectoriesFromAlbumDirectories(
-          listAlbumDirectories);
-
-      return openTrack(pick(trackDirectories));
-    }
-
-    case 'track-in-sidebar': {
-      // Note that the container for track links may be <ol> or <ul>, and
-      // they can't be identified by href, since links from one track to
-      // another don't include "track" in the href.
-      const trackLinks =
-        Array.from(document
-          .querySelector('.track-list-sidebar-box')
-          .querySelectorAll('li a'));
-
-      return pick(trackLinks).href;
-    }
-
-    case 'track-in-album': {
-      const {albumDirectories, albumTrackDirectories} = state;
-      if (!albumDirectories || !albumTrackDirectories) return null;
-
-      const albumDirectory = cssProp(a, '--album-directory');
-      const albumIndex = albumDirectories.indexOf(albumDirectory);
-      const trackDirectories = albumTrackDirectories[albumIndex];
-
-      return openTrack(pick(trackDirectories));
-    }
-
-    case 'artist': {
-      const {artistDirectories} = state;
-      if (!artistDirectories) return null;
-
-      return openArtist(pick(artistDirectories));
-    }
-
-    case 'artist-more-than-one-contrib': {
-      const {artistDirectories, artistNumContributions} = state;
-      if (!artistDirectories || !artistNumContributions) return null;
-
-      const filteredArtistDirectories =
-        artistDirectories
-          .filter((_artist, index) => artistNumContributions[index] > 1);
-
-      return openArtist(pick(filteredArtistDirectories));
-    }
-  }
-}
-
-function mutateNavigationLinkContent() {
-  const prependTitle = (el, prepend) =>
-    el?.setAttribute('title',
-      (el.hasAttribute('title')
-        ? prepend + ' ' + el.getAttribute('title')
-        : prepend));
-
-  prependTitle(scriptedLinkInfo.nextNavLink, '(Shift+N)');
-  prependTitle(scriptedLinkInfo.previousNavLink, '(Shift+P)');
-  prependTitle(scriptedLinkInfo.randomNavLink, '(Shift+R)');
-}
-
-function addNavigationKeyPressListeners() {
-  document.addEventListener('keypress', (event) => {
-    if (event.shiftKey) {
-      if (event.charCode === 'N'.charCodeAt(0)) {
-        scriptedLinkInfo.nextNavLink?.click();
-      } else if (event.charCode === 'P'.charCodeAt(0)) {
-        scriptedLinkInfo.previousNavLink?.click();
-      } else if (event.charCode === 'R'.charCodeAt(0)) {
-        scriptedLinkInfo.randomNavLink?.click();
-      }
-    }
-  });
-}
-
-function addRevealLinkClickListeners() {
-  const info = scriptedLinkInfo;
-
-  for (const {revealLink, revealContainer} of stitchArrays({
-    revealLink: Array.from(info.revealLinks ?? []),
-    revealContainer: Array.from(info.revealContainers ?? []),
-  })) {
-    revealLink.addEventListener('click', (event) => {
-      handleRevealLinkClicked(event, revealLink, revealContainer);
-    });
-  }
-}
-
-function handleRevealLinkClicked(domEvent, _revealLink, revealContainer) {
-  if (revealContainer.classList.contains('revealed')) {
-    return;
-  }
-
-  domEvent.preventDefault();
-  revealContainer.classList.add('revealed');
-  revealContainer.dispatchEvent(new CustomEvent('hsmusic-reveal'));
-}
-
-clientSteps.getPageReferences.push(getScriptedLinkReferences);
-clientSteps.addPageListeners.push(addRandomLinkListeners);
-clientSteps.addPageListeners.push(addNavigationKeyPressListeners);
-clientSteps.addPageListeners.push(addRevealLinkClickListeners);
-clientSteps.mutatePageContent.push(mutateNavigationLinkContent);
-
-if (
-  document.documentElement.dataset.urlKey === 'localized.listing' &&
-  document.documentElement.dataset.urlValue0 === 'random'
-) {
-  const dataLoadingLine = document.getElementById('data-loading-line');
-  const dataLoadedLine = document.getElementById('data-loaded-line');
-  const dataErrorLine = document.getElementById('data-error-line');
-
-  dataLoadingLine.style.display = 'block';
-
-  fetch(rebase('random-link-data.json', 'rebaseShared'))
-    .then(data => data.json())
-    .then(data => {
-      const {state} = scriptedLinkInfo;
-
-      Object.assign(state, {
-        albumDirectories: data.albumDirectories,
-        albumTrackDirectories: data.albumTrackDirectories,
-        artistDirectories: data.artistDirectories,
-        artistNumContributions: data.artistNumContributions,
-      });
-
-      dataLoadingLine.style.display = 'none';
-      dataLoadedLine.style.display = 'block';
-    }, () => {
-      dataLoadingLine.style.display = 'none';
-      dataErrorLine.style.display = 'block';
-    })
-    .then(() => {
-      const {randomLinks} = scriptedLinkInfo;
-      for (const a of randomLinks) {
-        const href = determineRandomLinkHref(a);
-        if (!href) {
-          a.removeAttribute('href');
-        }
-      }
-    });
-}
-
-// Tooltip-style hover (infrastructure) -------------------
-
-const hoverableTooltipInfo = initInfo('hoverableTooltipInfo', {
-  settings: {
-    // Hovering has two speed settings. The normal setting is used by default,
-    // and once a tooltip is displayed as a result of hover, the entire tooltip
-    // system will enter a "fast hover mode" - hovering will activate tooltips
-    // sooner. "Fast hover mode" is disabled after a sustained duration of not
-    // hovering over any hoverables; it's meant only to accelerate switching
-    // tooltips while still deciding, or getting a quick overview across more
-    // than one tooltip.
-    normalHoverInfoDelay: 400,
-    fastHoveringInfoDelay: 150,
-    endFastHoveringDelay: 500,
-
-    // Focusing has a single speed setting, which is how long it will take to
-    // enter a functional "focus mode" (though it's not actually implemented
-    // in terms of this state). As soon as "focus mode" is entered, the tooltip
-    // for the current hoverable is displayed, and focusing another hoverable
-    // will cause the current tooltip to be swapped for that one immediately.
-    // "Focus mode" ends as soon as anything apart from a tooltip or hoverable
-    // is focused, and it will be necessary to wait on this delay again.
-    focusInfoDelay: 750,
-
-    hideTooltipDelay: 500,
-
-    // If a tooltip that's transitioning to hidden is hovered during the grace
-    // period (or the corresponding hoverable is hovered at any point in the
-    // transition), it'll cancel out of this animation immediately.
-    transitionHiddenDuration: 300,
-    inertGracePeriod: 100,
-  },
-
-  state: {
-    // These maps store a record for each registered element and related state
-    // and registration info, if applicable.
-    registeredTooltips: new Map(),
-    registeredHoverables: new Map(),
-
-    // These are common across all tooltips, rather than stored individually,
-    // based on the principles that 1) only a single tooltip can be displayed
-    // at once, and 2) likewise, only a single hoverable can be hovered,
-    // focused, or otherwise active at once.
-    hoverTimeout: null,
-    focusTimeout: null,
-    touchTimeout: null,
-    hideTimeout: null,
-    transitionHiddenTimeout: null,
-    inertGracePeriodTimeout: null,
-    currentlyShownTooltip: null,
-    currentlyActiveHoverable: null,
-    currentlyTransitioningHiddenTooltip: null,
-    previouslyActiveHoverable: null,
-    tooltipWasJustHidden: false,
-    hoverableWasRecentlyTouched: false,
-
-    // Fast hovering is a global mode which is activated as soon as any tooltip
-    // is displayed and turns off after a delay of no hoverables being hovered.
-    // Note that fast hovering may be turned off while hovering a tooltip, but
-    // it will never be turned off while idling over a hoverable.
-    fastHovering: false,
-    endFastHoveringTimeout: false,
-
-    // These track the identifiers of current touches and a record of current
-    // identifiers that are "banished" by scrolling - that is, touches which
-    // existed while the page scrolled and were probably responsible for that
-    // scrolling. This is a bit loose (we can't actually tell which touches
-    // caused the page to scroll) but it's intended to keep scrolling the page
-    // from causing the current tooltip to be hidden.
-    currentTouchIdentifiers: new Set(),
-    touchIdentifiersBanishedByScrolling: new Set(),
-  },
-
-  event: {
-    whenTooltipShows: [],
-    whenTooltipHides: [],
-  },
-});
-
-// Adds DOM event listeners, so must be called during addPageListeners step.
-function registerTooltipElement(tooltip) {
-  const {state} = hoverableTooltipInfo;
-
-  if (!tooltip)
-    throw new Error(`Expected tooltip`);
-
-  if (state.registeredTooltips.has(tooltip))
-    throw new Error(`This tooltip is already registered`);
-
-  // No state or registration info here.
-  state.registeredTooltips.set(tooltip, {});
-
-  tooltip.addEventListener('mouseenter', () => {
-    handleTooltipMouseEntered(tooltip);
-  });
-
-  tooltip.addEventListener('mouseleave', () => {
-    handleTooltipMouseLeft(tooltip);
-  });
-
-  tooltip.addEventListener('focusin', event => {
-    handleTooltipReceivedFocus(tooltip, event.relatedTarget);
-  });
-
-  tooltip.addEventListener('focusout', event => {
-    // This event gets activated for tabbing *between* links inside the
-    // tooltip, which is no good and certainly doesn't represent the focus
-    // leaving the tooltip.
-    if (currentlyShownTooltipHasFocus(event.relatedTarget)) return;
-
-    handleTooltipLostFocus(tooltip, event.relatedTarget);
-  });
-}
-
-// Adds DOM event listeners, so must be called during addPageListeners step.
-function registerTooltipHoverableElement(hoverable, tooltip) {
-  const {state} = hoverableTooltipInfo;
-
-  if (!hoverable || !tooltip)
-    if (hoverable)
-      throw new Error(`Expected hoverable and tooltip, got only hoverable`);
-    else
-      throw new Error(`Expected hoverable and tooltip, got neither`);
-
-  if (!state.registeredTooltips.has(tooltip))
-    throw new Error(`Register tooltip before registering hoverable`);
-
-  if (state.registeredHoverables.has(hoverable))
-    throw new Error(`This hoverable is already registered`);
-
-  state.registeredHoverables.set(hoverable, {tooltip});
-
-  hoverable.addEventListener('mouseenter', () => {
-    handleTooltipHoverableMouseEntered(hoverable);
-  });
-
-  hoverable.addEventListener('mouseleave', () => {
-    handleTooltipHoverableMouseLeft(hoverable);
-  });
-
-  hoverable.addEventListener('focusin', event => {
-    handleTooltipHoverableReceivedFocus(hoverable, event);
-  });
-
-  hoverable.addEventListener('focusout', event => {
-    handleTooltipHoverableLostFocus(hoverable, event);
-  });
-
-  hoverable.addEventListener('touchend', event => {
-    handleTooltipHoverableTouchEnded(hoverable, event);
-  });
-
-  hoverable.addEventListener('click', event => {
-    handleTooltipHoverableClicked(hoverable, event);
-  });
-}
-
-function handleTooltipMouseEntered(tooltip) {
-  const {state} = hoverableTooltipInfo;
-
-  if (state.currentlyTransitioningHiddenTooltip) {
-    cancelTransitioningTooltipHidden(true);
-    return;
-  }
-
-  if (state.currentlyShownTooltip !== tooltip) return;
-
-  // Don't time out the current tooltip while hovering it.
-
-  if (state.hideTimeout) {
-    clearTimeout(state.hideTimeout);
-    state.hideTimeout = null;
-  }
-}
-
-function handleTooltipMouseLeft(tooltip) {
-  const {settings, state} = hoverableTooltipInfo;
-
-  if (state.currentlyShownTooltip !== tooltip) return;
-
-  // Start timing out the current tooltip when it's left. This could be
-  // canceled by mousing over a hoverable, or back over the tooltip again.
-  if (!state.hideTimeout) {
-    state.hideTimeout =
-      setTimeout(() => {
-        state.hideTimeout = null;
-        hideCurrentlyShownTooltip();
-      }, settings.hideTooltipDelay);
-  }
-}
-
-function handleTooltipReceivedFocus(_tooltip) {
-  const {state} = hoverableTooltipInfo;
-
-  // Cancel the tooltip-hiding timeout if it exists. The tooltip will never
-  // be hidden while it contains the focus anyway, but this ensures the timeout
-  // will be suitably reset when the tooltip loses focus.
-  if (state.hideTimeout) {
-    clearTimeout(state.hideTimeout);
-    state.hideTimeout = null;
-  }
-}
-
-function handleTooltipLostFocus(_tooltip) {
-  // Hide the current tooltip right away when it loses focus. Specify intent
-  // to replace - while we don't strictly know if another tooltip is going to
-  // immediately replace it, the mode of navigating with tab focus (once one
-  // tooltip has been activated) is a "switch focus immediately" kind of
-  // interaction in its nature.
-  hideCurrentlyShownTooltip(true);
-}
-
-function handleTooltipHoverableMouseEntered(hoverable) {
-  const {settings, state} = hoverableTooltipInfo;
-  const {tooltip} = state.registeredHoverables.get(hoverable);
-
-  // If this tooltip was transitioning to hidden, hovering should cancel that
-  // animation and show it immediately.
-
-  if (tooltip === state.currentlyTransitioningHiddenTooltip) {
-    cancelTransitioningTooltipHidden(true);
-    return;
-  }
-
-  // Start a timer to show the corresponding tooltip, with the delay depending
-  // on whether fast hovering or not. This could be canceled by mousing out of
-  // the hoverable.
-
-  const hoverTimeoutDelay =
-    (state.fastHovering
-      ? settings.fastHoveringInfoDelay
-      : settings.normalHoverInfoDelay);
-
-  state.hoverTimeout =
-    setTimeout(() => {
-      state.hoverTimeout = null;
-      state.fastHovering = true;
-      showTooltipFromHoverable(hoverable);
-    }, hoverTimeoutDelay);
-
-  // Don't stop fast hovering while over any hoverable.
-  if (state.endFastHoveringTimeout) {
-    clearTimeout(state.endFastHoveringTimeout);
-    state.endFastHoveringTimeout = null;
-  }
-
-  // Don't time out the current tooltip while over any hoverable.
-  if (state.hideTimeout) {
-    clearTimeout(state.hideTimeout);
-    state.hideTimeout = null;
-  }
-}
-
-function handleTooltipHoverableMouseLeft(_hoverable) {
-  const {settings, state} = hoverableTooltipInfo;
-
-  // Don't show a tooltip when not over a hoverable!
-  if (state.hoverTimeout) {
-    clearTimeout(state.hoverTimeout);
-    state.hoverTimeout = null;
-  }
-
-  // Start timing out fast hovering (if active) when not over a hoverable.
-  // This will only be canceled by mousing over another hoverable.
-  if (state.fastHovering && !state.endFastHoveringTimeout) {
-    state.endFastHoveringTimeout =
-      setTimeout(() => {
-        state.endFastHoveringTimeout = null;
-        state.fastHovering = false;
-      }, settings.endFastHoveringDelay);
-  }
-
-  // Start timing out the current tooltip when mousing not over a hoverable.
-  // This could be canceled by mousing over another hoverable, or over the
-  // currently shown tooltip.
-  if (state.currentlyShownTooltip && !state.hideTimeout) {
-    state.hideTimeout =
-      setTimeout(() => {
-        state.hideTimeout = null;
-        hideCurrentlyShownTooltip();
-      }, settings.hideTooltipDelay);
-  }
-}
-
-function handleTooltipHoverableReceivedFocus(hoverable) {
-  const {settings, state} = hoverableTooltipInfo;
-
-  // By default, display the corresponding tooltip after a delay.
-
-  state.focusTimeout =
-    setTimeout(() => {
-      state.focusTimeout = null;
-      showTooltipFromHoverable(hoverable);
-    }, settings.focusInfoDelay);
-
-  // If a tooltip was just hidden - which is almost certainly a result of the
-  // focus changing - then display this tooltip immediately, canceling the
-  // above timeout.
-
-  if (state.tooltipWasJustHidden) {
-    clearTimeout(state.focusTimeout);
-    state.focusTimeout = null;
-
-    showTooltipFromHoverable(hoverable);
-  }
-}
-
-function handleTooltipHoverableLostFocus(hoverable, domEvent) {
-  const {state} = hoverableTooltipInfo;
-
-  // Don't show a tooltip from focusing a hoverable if it isn't focused
-  // anymore! If another hoverable is receiving focus, that will be evaluated
-  // and set its own focus timeout after we clear the previous one here.
-  if (state.focusTimeout) {
-    clearTimeout(state.focusTimeout);
-    state.focusTimeout = null;
-  }
-
-  // Unless focus is entering the tooltip itself, hide the tooltip immediately.
-  // This will set the tooltipWasJustHidden flag, which is detected by a newly
-  // focused hoverable, if applicable. Always specify intent to replace when
-  // navigating via tab focus. (Check `handleTooltipLostFocus` for details.)
-  if (!currentlyShownTooltipHasFocus(domEvent.relatedTarget)) {
-    hideCurrentlyShownTooltip(true);
-  }
-}
-
-function handleTooltipHoverableTouchEnded(hoverable, domEvent) {
-  const {state} = hoverableTooltipInfo;
-  const {tooltip} = state.registeredHoverables.get(hoverable);
-
-  // Don't proceed if this hoverable's tooltip is already visible - in that
-  // case touching the hoverable again should behave just like a normal click.
-  if (state.currentlyShownTooltip === tooltip) {
-    // If the hoverable was *recently* touched - meaning that this is a second
-    // touchend in short succession - then just letting the click come through
-    // naturally would (depending on timing) not actually navigate anywhere,
-    // because we've deliberately banished the *first* touch from navigation.
-    // We do want the second touch to navigate, so clear that recently-touched
-    // state, allowing this touch's click to behave as normal.
-    if (state.hoverableWasRecentlyTouched) {
-      clearTimeout(state.touchTimeout);
-      state.touchTimeout = null;
-      state.hoverableWasRecentlyTouched = false;
-    }
-
-    // Otherwise, this is just a second touch after enough time has passed
-    // that the one which showed the tooltip is no longer "recent", and we're
-    // not in any special state. The link will navigate to its page just like
-    // normal.
-    return;
-  }
-
-  const touches = Array.from(domEvent.changedTouches);
-  const identifiers = touches.map(touch => touch.identifier);
-
-  // Don't process touch events that were "banished" because the page was
-  // scrolled while those touches were active, and most likely as a result of
-  // them.
-  filterMultipleArrays(touches, identifiers,
-    (_touch, identifier) =>
-      !state.touchIdentifiersBanishedByScrolling.has(identifier));
-
-  if (empty(touches)) return;
-
-  // Don't proceed if none of the (just-ended) touches ended over the
-  // hoverable.
-
-  const pointIsOverThisHoverable = pointIsOverAnyOf([hoverable]);
-
-  const anyTouchEndedOverHoverable =
-    touches.some(({clientX, clientY}) =>
-      pointIsOverThisHoverable(clientX, clientY));
-
-  if (!anyTouchEndedOverHoverable) {
-    return;
-  }
-
-  if (state.touchTimeout) {
-    clearTimeout(state.touchTimeout);
-    state.touchTimeout = null;
-  }
-
-  // Show the tooltip right away.
-  showTooltipFromHoverable(hoverable);
-
-  // Set a state, for a brief but not instantaneous period, indicating that a
-  // hoverable was recently touched. The touchend event may precede the click
-  // event by some time, and we don't want to navigate away from the page as
-  // a result of the click event which this touch precipitated.
-  state.hoverableWasRecentlyTouched = true;
-  state.touchTimeout =
-    setTimeout(() => {
-      state.touchTimeout = null;
-      state.hoverableWasRecentlyTouched = false;
-    }, 1200);
-}
-
-function handleTooltipHoverableClicked(hoverable) {
-  const {state} = hoverableTooltipInfo;
-
-  // Don't navigate away from the page if the this hoverable was recently
-  // touched (and had its tooltip activated). That flag won't be set if its
-  // tooltip was already open before the touch.
-  if (
-    state.currentlyActiveHoverable === hoverable &&
-    state.hoverableWasRecentlyTouched
-  ) {
-    event.preventDefault();
-  }
-}
-
-function currentlyShownTooltipHasFocus(focusElement = document.activeElement) {
-  const {state} = hoverableTooltipInfo;
-
-  const {
-    currentlyShownTooltip: tooltip,
-    currentlyActiveHoverable: hoverable,
-  } = state;
-
-  // If there's no tooltip, it can't possibly have focus.
-  if (!tooltip) return false;
-
-  // If the tooltip literally contains (or is) the focused element, then that's
-  // the principle condition we're looking for.
-  if (tooltip.contains(focusElement)) return true;
-
-  // If the hoverable *which opened the tooltip* is focused, then that also
-  // represents the tooltip being focused (in its currently shown state).
-  if (hoverable.contains(focusElement)) return true;
-
-  return false;
-}
-
-function beginTransitioningTooltipHidden(tooltip) {
-  const {settings, state} = hoverableTooltipInfo;
-
-  if (state.currentlyTransitioningHiddenTooltip) {
-    cancelTransitioningTooltipHidden();
-  }
-
-  cssProp(tooltip, {
-    'display': 'block',
-    'opacity': '0',
-
-    'transition-property': 'opacity',
-    'transition-timing-function':
-      `steps(${Math.ceil(settings.transitionHiddenDuration / 60)}, end)`,
-    'transition-duration':
-      `${settings.transitionHiddenDuration / 1000}s`,
-  });
-
-  state.currentlyTransitioningHiddenTooltip = tooltip;
-  state.transitionHiddenTimeout =
-    setTimeout(() => {
-      endTransitioningTooltipHidden();
-    }, settings.transitionHiddenDuration);
-}
-
-function cancelTransitioningTooltipHidden(andShow = false) {
-  const {state} = hoverableTooltipInfo;
-
-  endTransitioningTooltipHidden();
-
-  if (andShow) {
-    showTooltipFromHoverable(state.previouslyActiveHoverable);
-  }
-}
-
-function endTransitioningTooltipHidden() {
-  const {state} = hoverableTooltipInfo;
-  const {currentlyTransitioningHiddenTooltip: tooltip} = state;
-
-  if (!tooltip) return;
-
-  cssProp(tooltip, {
-    'display': null,
-    'opacity': null,
-    'transition-property': null,
-    'transition-timing-function': null,
-    'transition-duration': null,
-  });
-
-  state.currentlyTransitioningHiddenTooltip = null;
-
-  if (state.inertGracePeriodTimeout) {
-    clearTimeout(state.inertGracePeriodTimeout);
-    state.inertGracePeriodTimeout = null;
-  }
-
-  if (state.transitionHiddenTimeout) {
-    clearTimeout(state.transitionHiddenTimeout);
-    state.transitionHiddenTimeout = null;
-  }
-}
-
-function hideCurrentlyShownTooltip(intendingToReplace = false) {
-  const {settings, state, event} = hoverableTooltipInfo;
-  const {currentlyShownTooltip: tooltip} = state;
-
-  // If there was no tooltip to begin with, we're functionally in the desired
-  // state already, so return true.
-  if (!tooltip) return true;
-
-  // Never hide the tooltip if it's focused.
-  if (currentlyShownTooltipHasFocus()) return false;
-
-  state.currentlyActiveHoverable.classList.remove('has-visible-tooltip');
-
-  // If there's no intent to replace this tooltip, it's the last one currently
-  // apparent in the interaction, and should be hidden with a transition.
-  if (intendingToReplace) {
-    cssProp(tooltip, 'display', 'none');
-  } else {
-    beginTransitioningTooltipHidden(state.currentlyShownTooltip);
-  }
-
-  // Wait just a moment before making the tooltip inert. You might react
-  // (to the ghosting, or just to time passing) and realize you wanted
-  // to look at the tooltip after all - this delay gives a little buffer
-  // to second guess letting it disappear.
-  state.inertGracePeriodTimeout =
-    setTimeout(() => {
-      tooltip.inert = true;
-    }, settings.inertGracePeriod);
-
-  state.previouslyActiveHoverable = state.currentlyActiveHoverable;
-
-  state.currentlyShownTooltip = null;
-  state.currentlyActiveHoverable = null;
-
-  // Set this for one tick of the event cycle.
-  state.tooltipWasJustHidden = true;
-  setTimeout(() => {
-    state.tooltipWasJustHidden = false;
-  });
-
-  dispatchInternalEvent(event, 'whenTooltipHides', {
-    tooltip,
-  });
-
-  return true;
-}
-
-function showTooltipFromHoverable(hoverable) {
-  const {state, event} = hoverableTooltipInfo;
-  const {tooltip} = state.registeredHoverables.get(hoverable);
-
-  if (!hideCurrentlyShownTooltip(true)) return false;
-
-  // Cancel out another tooltip that's transitioning hidden, if that's going
-  // on - it's a distraction that this tooltip is now replacing.
-  cancelTransitioningTooltipHidden();
-
-  hoverable.classList.add('has-visible-tooltip');
-
-  positionTooltipFromHoverableWithBrains(hoverable);
-
-  cssProp(tooltip, 'display', 'block');
-  tooltip.inert = false;
-
-  state.currentlyShownTooltip = tooltip;
-  state.currentlyActiveHoverable = hoverable;
-
-  state.tooltipWasJustHidden = false;
-
-  dispatchInternalEvent(event, 'whenTooltipShows', {
-    tooltip,
-  });
-
-  return true;
-}
-
-function peekTooltipClientRect(tooltip) {
-  const oldDisplayStyle = cssProp(tooltip, 'display');
-  cssProp(tooltip, 'display', 'block');
-
-  // Tooltips have a bit of padding that makes the interactive
-  // area wider, so that you're less likely to accidentally let
-  // the tooltip disappear (by hovering outside it). But this
-  // isn't visual at all, so for placement we only care about
-  // the content element.
-  const content =
-    tooltip.querySelector('.tooltip-content');
-
-  try {
-    return WikiRect.fromElement(content);
-  } finally {
-    cssProp(tooltip, 'display', oldDisplayStyle);
-  }
-}
-
-function positionTooltipFromHoverableWithBrains(hoverable) {
-  const {state} = hoverableTooltipInfo;
-  const {tooltip} = state.registeredHoverables.get(hoverable);
-
-  // 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.
-  resetDynamicTooltipPositioning(tooltip);
-
-  const opportunities =
-    getTooltipFromHoverablePlacementOpportunityAreas(hoverable);
-
-  const tooltipRect =
-    peekTooltipClientRect(tooltip);
-
-  // If the tooltip is already in the baseline containing area,
-  // prefer to keep it positioned naturally, adjusted by CSS
-  // instead of JavaScript.
-
-  const {numBaselineRects, idealBaseline: baselineRect} = opportunities;
-
-  if (baselineRect.contains(tooltipRect)) {
-    return;
-  }
-
-  let selectedRect = null;
-  for (let i = 0; i < numBaselineRects; i++) {
-    selectedRect = opportunities.right.down[i];
-    if (selectedRect) break;
-
-    selectedRect = opportunities.left.down[i];
-    if (selectedRect) break;
-
-    selectedRect = opportunities.right.up[i];
-    if (selectedRect) break;
-
-    selectedRect = opportunities.left.up[i];
-    if (selectedRect) break;
-  }
-
-  selectedRect ??= baselineRect;
-
-  positionTooltip(tooltip, selectedRect.x, selectedRect.y);
-}
-
-function positionTooltip(tooltip, x, y) {
-  // Imagine what it'd be like if the tooltip were positioned
-  // with zero left/top offset, and calculate its actual offsets
-  // based on that.
-
-  cssProp(tooltip, {
-    left: `0`,
-    top: `0`,
-  });
-
-  const tooltipRect =
-    peekTooltipClientRect(tooltip);
-
-  cssProp(tooltip, {
-    left: `${x - tooltipRect.x}px`,
-    top: `${y - tooltipRect.y}px`,
-  });
-}
-
-function resetDynamicTooltipPositioning(tooltip) {
-  cssProp(tooltip, {
-    left: null,
-    top: null,
-  });
-}
-
-function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) {
-  const {state} = hoverableTooltipInfo;
-  const {tooltip} = state.registeredHoverables.get(hoverable);
-
-  const baselineRects =
-    getTooltipBaselineOpportunityAreas(tooltip);
-
-  const hoverableRect =
-    WikiRect.fromElementUnderMouse(hoverable).toExtended(5, 10);
-
-  const tooltipRect =
-    peekTooltipClientRect(tooltip);
-
-  // Get placements relative to the hoverable. Make these available by key,
-  // allowing the caller to choose by preferred orientation. Each value is
-  // an array which corresponds to the baseline areas - placement closer to
-  // front of the array indicates stronger preference. Since not all relative
-  // placements cooperate with all baseline areas, any of these arrays may
-  // include (or be entirely made of) null.
-
-  const keepIfFits = (rect) =>
-    (rect?.fits(tooltipRect)
-      ? rect
-      : null);
-
-  const prepareRegionRects = (relationalRect, direct) =>
-    baselineRects
-      .map(rect => rect.intersectionWith(relationalRect))
-      .map(direct)
-      .map(keepIfFits);
-
-  const regionRects = {
-    left:
-      prepareRegionRects(
-        WikiRect.leftOf(hoverableRect),
-        rect => WikiRect.fromRect({
-          x: rect.right,
-          y: rect.y,
-          width: -rect.width,
-          height: rect.height,
-        })),
-
-    right:
-      prepareRegionRects(
-        WikiRect.rightOf(hoverableRect),
-        rect => rect),
-
-    top:
-      prepareRegionRects(
-        WikiRect.above(hoverableRect),
-        rect => WikiRect.fromRect({
-          x: rect.x,
-          y: rect.bottom,
-          width: rect.width,
-          height: -rect.height,
-        })),
-
-    bottom:
-      prepareRegionRects(
-        WikiRect.beneath(hoverableRect),
-        rect => rect),
-  };
-
-  const neededVerticalOverlap = 30;
-  const neededHorizontalOverlap = 30;
-
-  // 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;
-
-      const regionRect = regionRects[i];
-      if (regionRect.width > 0) {
-        return rect;
-      } else {
-        return WikiRect.fromRect({
-          x: regionRect.right - tooltipRect.width,
-          y: rect.y,
-          width: rect.width,
-          height: rect.height,
-        });
-      }
-    };
-
-    orientations.up =
-      regionRects
-        .map(rect => rect?.intersectionWith(upTopDown))
-        .map(orientHorizontally)
-        .map(keepIfFits);
-
-    orientations.down =
-      regionRects
-        .map(rect => rect?.intersectionWith(downBottomUp))
-        .map(rect =>
-          (rect
-            ? rect.intersectionWith(WikiRect.fromRect({
-                x: rect.x,
-                y: rect.bottom - tooltipRect.height,
-                width: rect.width,
-                height: tooltipRect.height,
-              }))
-            : null))
-        .map(orientHorizontally)
-        .map(keepIfFits);
-
-    const centerRect =
-      WikiRect.fromRect({
-        x: -Infinity, width: Infinity,
-        y: hoverableRect.top
-         + hoverableRect.height / 2
-         - tooltipRect.height / 2,
-        height: tooltipRect.height,
-      });
-
-    orientations.center =
-      regionRects
-        .map(rect => rect?.intersectionWith(centerRect))
-        .map(orientHorizontally)
-        .map(keepIfFits);
-
-    return orientations;
-  };
-
-  const orientationRects = {
-    left: prepareVerticalOrientationRects(regionRects.left),
-    right: prepareVerticalOrientationRects(regionRects.right),
-  };
-
-  return {
-    numBaselineRects: baselineRects.length,
-    idealBaseline: baselineRects[0],
-    ...orientationRects,
-  };
-}
-
-function getTooltipBaselineOpportunityAreas(tooltip) {
-  // Returns multiple basic areas in order of preference, with front of the
-  // array representing greater preference.
-
-  const {stickyContainers} = stickyHeadingInfo;
-  const results = [];
-
-  const windowRect =
-    WikiRect.fromWindow().toInset(10);
-
-  const workingRect =
-    WikiRect.fromRect(windowRect);
-
-  const tooltipRect =
-    peekTooltipClientRect(tooltip);
-
-  // As a baseline, always treat the window rect as fitting the tooltip.
-  results.unshift(WikiRect.fromRect(workingRect));
-
-  const containingParent =
-    getVisuallyContainingElement(tooltip);
-
-  if (containingParent) {
-    const containingRect =
-      WikiRect.fromElement(containingParent);
-
-    // Only respect a portion of the container's padding, giving
-    // the tooltip the impression of a "raised" element.
-    const padding = side =>
-      0.5 *
-      parseFloat(cssProp(containingParent, 'padding-' + side));
-
-    const insetContainingRect =
-      containingRect.toInset({
-        left: padding('left'),
-        right: padding('right'),
-        top: padding('top'),
-        bottom: padding('bottom'),
-      });
-
-    workingRect.chopExtendingOutside(insetContainingRect);
-
-    if (!workingRect.fits(tooltipRect)) {
-      return results;
-    }
-
-    results.unshift(WikiRect.fromRect(workingRect));
-  }
-
-  // This currently assumes a maximum of one sticky container
-  // per visually containing element.
-
-  const stickyContainer =
-    stickyContainers
-      .find(el => el.parentElement === containingParent);
-
-  if (stickyContainer) {
-    const stickyRect =
-      stickyContainer.getBoundingClientRect()
-
-    // Add some padding so the tooltip doesn't line up exactly
-    // with the edge of the sticky container.
-    const beneathStickyContainer =
-      WikiRect.beneath(stickyRect, 10);
-
-    workingRect.chopExtendingOutside(beneathStickyContainer);
-
-    if (!workingRect.fits(tooltipRect)) {
-      return results;
-    }
-
-    results.unshift(WikiRect.fromRect(workingRect));
-  }
-
-  return results;
-}
-
-function addHoverableTooltipPageListeners() {
-  const {state} = hoverableTooltipInfo;
-
-  const getTouchIdentifiers = domEvent =>
-    Array.from(domEvent.changedTouches)
-      .map(touch => touch.identifier)
-      .filter(identifier => typeof identifier !== 'undefined');
-
-  document.body.addEventListener('touchstart', domEvent => {
-    for (const identifier of getTouchIdentifiers(domEvent)) {
-      state.currentTouchIdentifiers.add(identifier);
-    }
-  });
-
-  window.addEventListener('scroll', () => {
-    for (const identifier of state.currentTouchIdentifiers) {
-      state.touchIdentifiersBanishedByScrolling.add(identifier);
-    }
-  });
-
-  document.body.addEventListener('touchend', domEvent => {
-    setTimeout(() => {
-      for (const identifier of getTouchIdentifiers(domEvent)) {
-        state.currentTouchIdentifiers.delete(identifier);
-        state.touchIdentifiersBanishedByScrolling.delete(identifier);
-      }
-    });
-  });
-
-  const getHoverablesAndTooltips = () => [
-    ...Array.from(state.registeredHoverables.keys()),
-    ...Array.from(state.registeredTooltips.keys()),
-  ];
-
-  document.body.addEventListener('touchend', domEvent => {
-    const touches = Array.from(domEvent.changedTouches);
-    const identifiers = touches.map(touch => touch.identifier);
-
-    // Don't process touch events that were "banished" because the page was
-    // scrolled while those touches were active, and most likely as a result of
-    // them.
-    filterMultipleArrays(touches, identifiers,
-      (_touch, identifier) =>
-        !state.touchIdentifiersBanishedByScrolling.has(identifier));
-
-    if (empty(touches)) return;
-
-    const pointIsOverHoverableOrTooltip =
-      pointIsOverAnyOf(getHoverablesAndTooltips());
-
-    const anyTouchOverAnyHoverableOrTooltip =
-      touches.some(({clientX, clientY}) =>
-        pointIsOverHoverableOrTooltip(clientX, clientY));
-
-    if (!anyTouchOverAnyHoverableOrTooltip) {
-      hideCurrentlyShownTooltip();
-    }
-  });
-
-  document.body.addEventListener('click', domEvent => {
-    const {clientX, clientY} = domEvent;
-
-    const pointIsOverHoverableOrTooltip =
-      pointIsOverAnyOf(getHoverablesAndTooltips());
-
-    if (!pointIsOverHoverableOrTooltip(clientX, clientY)) {
-      // Hide with "intent to replace" - we aren't actually going to replace
-      // the tooltip with a new one, but this intent indicates that it should
-      // be hidden right away, instead of showing. What we're really replacing,
-      // or rather removing, is the state of interacting with tooltips at all.
-      hideCurrentlyShownTooltip(true);
-
-      // Part of that state is fast hovering, which should be canceled out.
-      state.fastHovering = false;
-      if (state.endFastHoveringTimeout) {
-        clearTimeout(state.endFastHoveringTimeout);
-        state.endFastHoveringTimeout = null;
-      }
-
-      // Also cancel out of transitioning a tooltip hidden - this isn't caught
-      // by `hideCurrentlyShownTooltip` because a transitioning-hidden tooltip
-      // doesn't count as "shown" anymore.
-      cancelTransitioningTooltipHidden();
-    }
-  });
-}
-
-clientSteps.addPageListeners.push(addHoverableTooltipPageListeners);
-
-// Data & info card ---------------------------------------
-
-/*
-function colorLink(a, color) {
-  console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet');
-  return;
-
-  // eslint-disable-next-line no-unreachable
-  const chroma = {};
-
-  if (color) {
-    const {primary, dim} = getColors(color, {chroma});
-    a.style.setProperty('--primary-color', primary);
-    a.style.setProperty('--dim-color', dim);
-  }
-}
-
-function link(a, type, {name, directory, color}) {
-  colorLink(a, color);
-  a.innerText = name;
-  a.href = getLinkHref(type, directory);
-}
-
-function joinElements(type, elements) {
-  // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on
-  // strings. So instead, we'll pass the element's outer HTML's (which means
-  // the entire HTML of that element).
-  //
-  // That does mean this function returns a string, so always 8e sure to
-  // set innerHTML when using it (not appendChild).
-
-  return list[type](elements.map((el) => el.outerHTML));
-}
-
-const infoCard = (() => {
-  const container = document.getElementById('info-card-container');
-
-  let cancelShow = false;
-  let hideTimeout = null;
-  let showing = false;
-
-  container.addEventListener('mouseenter', cancelHide);
-  container.addEventListener('mouseleave', readyHide);
-
-  function show(type, target) {
-    cancelShow = false;
-
-    fetchData(type, target.dataset[type]).then((data) => {
-      // Manual DOM 'cuz we're laaaazy.
-
-      if (cancelShow) {
-        return;
-      }
-
-      showing = true;
-
-      const rect = target.getBoundingClientRect();
-
-      container.style.setProperty('--primary-color', data.color);
-
-      container.style.top = window.scrollY + rect.bottom + 'px';
-      container.style.left = window.scrollX + rect.left + 'px';
-
-      // Use a short timeout to let a currently hidden (or not yet shown)
-      // info card teleport to the position set a8ove. (If it's currently
-      // shown, it'll transition to that position.)
-      setTimeout(() => {
-        container.classList.remove('hide');
-        container.classList.add('show');
-      }, 50);
-
-      // 8asic details.
-
-      const nameLink = container.querySelector('.info-card-name a');
-      link(nameLink, 'track', data);
-
-      const albumLink = container.querySelector('.info-card-album a');
-      link(albumLink, 'album', data.album);
-
-      const artistSpan = container.querySelector('.info-card-artists span');
-      artistSpan.innerHTML = joinElements(
-        'conjunction',
-        data.artists.map(({artist}) => {
-          const a = document.createElement('a');
-          a.href = getLinkHref('artist', artist.directory);
-          a.innerText = artist.name;
-          return a;
-        })
-      );
-
-      const coverArtistParagraph = container.querySelector(
-        '.info-card-cover-artists'
-      );
-      const coverArtistSpan = coverArtistParagraph.querySelector('span');
-      if (data.coverArtists.length) {
-        coverArtistParagraph.style.display = 'block';
-        coverArtistSpan.innerHTML = joinElements(
-          'conjunction',
-          data.coverArtists.map(({artist}) => {
-            const a = document.createElement('a');
-            a.href = getLinkHref('artist', artist.directory);
-            a.innerText = artist.name;
-            return a;
-          })
-        );
-      } else {
-        coverArtistParagraph.style.display = 'none';
-      }
-
-      // Cover art.
-
-      const [containerNoReveal, containerReveal] = [
-        container.querySelector('.info-card-art-container.no-reveal'),
-        container.querySelector('.info-card-art-container.reveal'),
-      ];
-
-      const [containerShow, containerHide] = data.cover.warnings.length
-        ? [containerReveal, containerNoReveal]
-        : [containerNoReveal, containerReveal];
-
-      containerHide.style.display = 'none';
-      containerShow.style.display = 'block';
-
-      const img = containerShow.querySelector('.info-card-art');
-      img.src = rebase(data.cover.paths.small, 'rebaseMedia');
-
-      const imgLink = containerShow.querySelector('a');
-      colorLink(imgLink, data.color);
-      imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia');
-
-      if (containerShow === containerReveal) {
-        const cw = containerShow.querySelector('.info-card-art-warnings');
-        cw.innerText = list.unit(data.cover.warnings);
-
-        const reveal = containerShow.querySelector('.reveal');
-        reveal.classList.remove('revealed');
-      }
-    });
-  }
-
-  function hide() {
-    container.classList.remove('show');
-    container.classList.add('hide');
-    cancelShow = true;
-    showing = false;
-  }
-
-  function readyHide() {
-    if (!hideTimeout && showing) {
-      hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY);
-    }
-  }
-
-  function cancelHide() {
-    if (hideTimeout) {
-      clearTimeout(hideTimeout);
-      hideTimeout = null;
-    }
-  }
-
-  return {
-    show,
-    hide,
-    readyHide,
-    cancelHide,
-  };
-})();
-
-// Info cards are disa8led for now since they aren't quite ready for release,
-// 8ut you can try 'em out 8y setting this localStorage flag!
-//
-//     localStorage.tryInfoCards = true;
-//
-if (localStorage.tryInfoCards) {
-  addInfoCardLinkHandlers('track');
-}
-*/
-
-// Custom hash links --------------------------------------
-
-const hashLinkInfo = initInfo('hashLinkInfo', {
-  links: null,
-  hrefs: null,
-  targets: null,
-
-  state: {
-    highlightedTarget: null,
-    scrollingAfterClick: false,
-    concludeScrollingStateInterval: null,
-  },
-
-  event: {
-    beforeHashLinkScrolls: [],
-    whenHashLinkClicked: [],
-  },
-});
-
-function getHashLinkReferences() {
-  const info = hashLinkInfo;
-
-  info.links =
-    Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])'));
-
-  info.hrefs =
-    info.links
-      .map(link => link.getAttribute('href'));
-
-  info.targets =
-    info.hrefs
-      .map(href => document.getElementById(href.slice(1)));
-
-  filterMultipleArrays(
-    info.links,
-    info.hrefs,
-    info.targets,
-    (_link, _href, target) => target);
-}
-
-function processScrollingAfterHashLinkClicked() {
-  const {state} = hashLinkInfo;
-
-  if (state.concludeScrollingStateInterval) return;
-
-  let lastScroll = window.scrollY;
-  state.scrollingAfterClick = true;
-  state.concludeScrollingStateInterval = setInterval(() => {
-    if (Math.abs(window.scrollY - lastScroll) < 10) {
-      clearInterval(state.concludeScrollingStateInterval);
-      state.scrollingAfterClick = false;
-      state.concludeScrollingStateInterval = null;
-    } else {
-      lastScroll = window.scrollY;
-    }
-  }, 200);
-}
-
-function addHashLinkListeners() {
-  // Instead of defining a scroll offset (to account for the sticky heading)
-  // in JavaScript, we interface with the CSS property 'scroll-margin-top'.
-  // This lets the scroll offset be consolidated where it makes sense, and
-  // sets an appropriate offset when (re)loading a page with hash for free!
-
-  const info = hashLinkInfo;
-  const {state, event} = info;
-
-  for (const {hashLink, href, target} of stitchArrays({
-    hashLink: info.links,
-    href: info.hrefs,
-    target: info.targets,
-  })) {
-    hashLink.addEventListener('click', evt => {
-      if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) {
-        return;
-      }
-
-      // Don't do anything if the target element isn't actually visible!
-      if (target.offsetParent === null) {
-        return;
-      }
-
-      // Allow event handlers to prevent scrolling.
-      const listenerResults =
-        dispatchInternalEvent(event, 'beforeHashLinkScrolls', {
-          link: hashLink,
-          target,
-        });
-
-      if (listenerResults.includes(false)) {
-        return;
-      }
-
-      // Hide skipper box right away, so the layout is updated on time for the
-      // math operations coming up next.
-      const skipper = document.getElementById('skippers');
-      skipper.style.display = 'none';
-      setTimeout(() => skipper.style.display = '');
-
-      const box = target.getBoundingClientRect();
-      const style = window.getComputedStyle(target);
-
-      const scrollY =
-          window.scrollY
-        + box.top
-        - style['scroll-margin-top'].replace('px', '');
-
-      evt.preventDefault();
-      history.pushState({}, '', href);
-      window.scrollTo({top: scrollY, behavior: 'smooth'});
-      target.focus({preventScroll: true});
-
-      const maxScroll =
-          document.body.scrollHeight
-        - window.innerHeight;
-
-      if (scrollY > maxScroll && target.classList.contains('content-heading')) {
-        if (state.highlightedTarget) {
-          state.highlightedTarget.classList.remove('highlight-hash-link');
-        }
-
-        target.classList.add('highlight-hash-link');
-        state.highlightedTarget = target;
-      }
-
-      processScrollingAfterHashLinkClicked();
-
-      dispatchInternalEvent(event, 'whenHashLinkClicked', {
-        link: hashLink,
-        target,
-      });
-    });
-  }
-
-  for (const target of info.targets) {
-    target.addEventListener('animationend', evt => {
-      if (evt.animationName !== 'highlight-hash-link') return;
-      target.classList.remove('highlight-hash-link');
-      if (target !== state.highlightedTarget) return;
-      state.highlightedTarget = null;
-    });
-  }
-}
-
-clientSteps.getPageReferences.push(getHashLinkReferences);
-clientSteps.addPageListeners.push(addHashLinkListeners);
-
-// Sticky content heading ---------------------------------
-
-const stickyHeadingInfo = initInfo('stickyHeadingInfo', {
-  stickyContainers: null,
-
-  stickySubheadingRows: null,
-  stickySubheadings: null,
-
-  stickyCoverContainers: null,
-  stickyCoverTextAreas: null,
-  stickyCovers: null,
-
-  contentContainers: null,
-  contentHeadings: null,
-  contentCovers: null,
-  contentCoversReveal: null,
-
-  state: {
-    displayedHeading: null,
-  },
-
-  event: {
-    whenDisplayedHeadingChanges: [],
-  },
-});
-
-function getStickyHeadingReferences() {
-  const info = stickyHeadingInfo;
-
-  info.stickyContainers =
-    Array.from(document.getElementsByClassName('content-sticky-heading-container'));
-
-  info.stickyCoverContainers =
-    info.stickyContainers
-      .map(el => el.querySelector('.content-sticky-heading-cover-container'));
-
-  info.stickyCovers =
-    info.stickyCoverContainers
-      .map(el => el?.querySelector('.content-sticky-heading-cover'));
-
-  info.stickyCoverTextAreas =
-    info.stickyCovers
-      .map(el => el?.querySelector('.image-text-area'));
-
-  info.stickySubheadingRows =
-    info.stickyContainers
-      .map(el => el.querySelector('.content-sticky-subheading-row'));
-
-  info.stickySubheadings =
-    info.stickySubheadingRows
-      .map(el => el.querySelector('h2'));
-
-  info.contentContainers =
-    info.stickyContainers
-      .map(el => el.parentElement);
-
-  info.contentCovers =
-    info.contentContainers
-      .map(el => el.querySelector('#cover-art-container'));
-
-  info.contentCoversReveal =
-    info.contentCovers
-      .map(el => el ? !!el.querySelector('.reveal') : null);
-
-  info.contentHeadings =
-    info.contentContainers
-      .map(el => Array.from(el.querySelectorAll('.content-heading')));
-}
-
-function removeTextPlaceholderStickyHeadingCovers() {
-  const info = stickyHeadingInfo;
-
-  const hasTextArea =
-    info.stickyCoverTextAreas.map(el => !!el);
-
-  const coverContainersWithTextArea =
-    info.stickyCoverContainers
-      .filter((_el, index) => hasTextArea[index]);
-
-  for (const el of coverContainersWithTextArea) {
-    el.remove();
-  }
-
-  info.stickyCoverContainers =
-    info.stickyCoverContainers
-      .map((el, index) => hasTextArea[index] ? null : el);
-
-  info.stickyCovers =
-    info.stickyCovers
-      .map((el, index) => hasTextArea[index] ? null : el);
-
-  info.stickyCoverTextAreas =
-    info.stickyCoverTextAreas
-      .slice()
-      .fill(null);
-}
-
-function addRevealClassToStickyHeadingCovers() {
-  const info = stickyHeadingInfo;
-
-  const stickyCoversWhichReveal =
-    info.stickyCovers
-      .filter((_el, index) => info.contentCoversReveal[index]);
-
-  for (const el of stickyCoversWhichReveal) {
-    el.classList.add('content-sticky-heading-cover-needs-reveal');
-  }
-}
-
-function addRevealListenersForStickyHeadingCovers() {
-  const info = stickyHeadingInfo;
-
-  const stickyCovers = info.stickyCovers.slice();
-  const contentCovers = info.contentCovers.slice();
-
-  filterMultipleArrays(
-    stickyCovers,
-    contentCovers,
-    (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]);
-
-  for (const {stickyCover, contentCover} of stitchArrays({
-    stickyCover: stickyCovers,
-    contentCover: contentCovers,
-  })) {
-    // TODO: Janky - should use internal event instead of DOM event
-    contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => {
-      stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal');
-    });
-  }
-}
-
-function topOfViewInside(el, scroll = window.scrollY) {
-  return (
-    scroll > el.offsetTop &&
-    scroll < el.offsetTop + el.offsetHeight);
-}
-
-function updateStickyCoverVisibility(index) {
-  const info = stickyHeadingInfo;
-
-  const stickyCoverContainer = info.stickyCoverContainers[index];
-  const contentCover = info.contentCovers[index];
-
-  if (contentCover && stickyCoverContainer) {
-    if (contentCover.getBoundingClientRect().bottom < 4) {
-      stickyCoverContainer.classList.add('visible');
-    } else {
-      stickyCoverContainer.classList.remove('visible');
-    }
-  }
-}
-
-function getContentHeadingClosestToStickySubheading(index) {
-  const info = stickyHeadingInfo;
-
-  const contentContainer = info.contentContainers[index];
-
-  if (!topOfViewInside(contentContainer)) {
-    return null;
-  }
-
-  const stickySubheading = info.stickySubheadings[index];
-
-  if (stickySubheading.childNodes.length === 0) {
-    // Supply a non-breaking space to ensure correct basic line height.
-    stickySubheading.appendChild(document.createTextNode('\xA0'));
-  }
-
-  const stickyContainer = info.stickyContainers[index];
-  const stickyRect = stickyContainer.getBoundingClientRect();
-
-  // TODO: Should this compute with the subheading row instead of h2?
-  const subheadingRect = stickySubheading.getBoundingClientRect();
-
-  const stickyBottom = stickyRect.bottom + subheadingRect.height;
-
-  // Iterate from bottom to top of the content area.
-  const contentHeadings = info.contentHeadings[index];
-  for (const heading of contentHeadings.slice().reverse()) {
-    const headingRect = heading.getBoundingClientRect();
-    if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) {
-      return heading;
-    }
-  }
-
-  return null;
-}
-
-function updateStickySubheadingContent(index) {
-  const info = stickyHeadingInfo;
-  const {event, state} = info;
-
-  const closestHeading = getContentHeadingClosestToStickySubheading(index);
-
-  if (state.displayedHeading === closestHeading) return;
-
-  const stickySubheadingRow = info.stickySubheadingRows[index];
-
-  if (closestHeading) {
-    const stickySubheading = info.stickySubheadings[index];
-
-    // Array.from needed to iterate over a live array with for..of
-    for (const child of Array.from(stickySubheading.childNodes)) {
-      child.remove();
-    }
-
-    const textContainer =
-      closestHeading.querySelector('.content-heading-main-title')
-        // Just for compatibility with older builds of the site.
-        ?? closestHeading;
-
-    for (const child of textContainer.childNodes) {
-      if (child.tagName === 'A') {
-        for (const grandchild of child.childNodes) {
-          stickySubheading.appendChild(grandchild.cloneNode(true));
-        }
-      } else {
-        stickySubheading.appendChild(child.cloneNode(true));
-      }
-    }
-
-    stickySubheadingRow.classList.add('visible');
-  } else {
-    stickySubheadingRow.classList.remove('visible');
-  }
-
-  const oldDisplayedHeading = state.displayedHeading;
-
-  state.displayedHeading = closestHeading;
-
-  dispatchInternalEvent(event, 'whenDisplayedHeadingChanges', index, {
-    oldHeading: oldDisplayedHeading,
-    newHeading: closestHeading,
-  });
-}
-
-function updateStickyHeadings(index) {
-  updateStickyCoverVisibility(index);
-  updateStickySubheadingContent(index);
-}
-
-function initializeStateForStickyHeadings() {
-  for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) {
-    updateStickyHeadings(i);
-  }
-}
-
-function addScrollListenerForStickyHeadings() {
-  document.addEventListener('scroll', () => {
-    for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) {
-      updateStickyHeadings(i);
-    }
-  });
-}
-
-clientSteps.getPageReferences.push(getStickyHeadingReferences);
-clientSteps.mutatePageContent.push(removeTextPlaceholderStickyHeadingCovers);
-clientSteps.mutatePageContent.push(addRevealClassToStickyHeadingCovers);
-clientSteps.initializeState.push(initializeStateForStickyHeadings);
-clientSteps.addPageListeners.push(addRevealListenersForStickyHeadingCovers);
-clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings);
-
-// Image overlay ------------------------------------------
-
-// TODO: Update to clientSteps style.
-
-function addImageOverlayClickHandlers() {
-  const container = document.getElementById('image-overlay-container');
-
-  if (!container) {
-    console.warn(`#image-overlay-container missing, image overlay module disabled.`);
-    return;
-  }
-
-  for (const link of document.querySelectorAll('.image-link')) {
-    if (link.closest('.no-image-preview')) {
-      continue;
-    }
-
-    link.addEventListener('click', handleImageLinkClicked);
-  }
-
-  const actionContainer = document.getElementById('image-overlay-action-container');
-
-  container.addEventListener('click', handleContainerClicked);
-  document.body.addEventListener('keydown', handleKeyDown);
-
-  function handleContainerClicked(evt) {
-    // Only hide the image overlay if actually clicking the background.
-    if (evt.target !== container) {
-      return;
-    }
-
-    // If you clicked anything close to or beneath the action bar, don't hide
-    // the image overlay.
-    const rect = actionContainer.getBoundingClientRect();
-    if (evt.clientY >= rect.top - 40) {
-      return;
-    }
-
-    container.classList.remove('visible');
-  }
-
-  function handleKeyDown(evt) {
-    if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) {
-      container.classList.remove('visible');
-    }
-  }
-}
-
-function handleImageLinkClicked(evt) {
-  if (evt.metaKey || evt.shiftKey || evt.altKey) {
-    return;
-  }
-
-  evt.preventDefault();
-
-  // Don't show the overlay if the image still needs to be revealed.
-  if (evt.target.closest('.reveal:not(.revealed)')) {
-    return;
-  }
-
-  const container = document.getElementById('image-overlay-container');
-  container.classList.add('visible');
-  container.classList.remove('loaded');
-  container.classList.remove('errored');
-
-  const allViewOriginal = document.getElementsByClassName('image-overlay-view-original');
-  const mainImage = document.getElementById('image-overlay-image');
-  const thumbImage = document.getElementById('image-overlay-image-thumb');
-
-  const {href: originalSrc} = evt.target.closest('a');
-
-  const {
-    src: embeddedSrc,
-    dataset: {
-      originalSize: originalFileSize,
-      thumbs: availableThumbList,
-    },
-  } = evt.target.closest('a').querySelector('img');
-
-  updateFileSizeInformation(originalFileSize);
-
-  let mainSrc = null;
-  let thumbSrc = null;
-
-  if (availableThumbList) {
-    const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList);
-    const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList);
-    mainSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`);
-    thumbSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`);
-    // Show the thumbnail size on each <img> element's data attributes.
-    // Y'know, just for debugging convenience.
-    mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`;
-    thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`;
-  } else {
-    mainSrc = originalSrc;
-    thumbSrc = null;
-    mainImage.dataset.displayingThumb = '';
-    thumbImage.dataset.displayingThumb = '';
-  }
-
-  if (thumbSrc) {
-    thumbImage.src = thumbSrc;
-    thumbImage.style.display = null;
-  } else {
-    thumbImage.src = '';
-    thumbImage.style.display = 'none';
-  }
-
-  for (const viewOriginal of allViewOriginal) {
-    viewOriginal.href = originalSrc;
-  }
-
-  mainImage.addEventListener('load', handleMainImageLoaded);
-  mainImage.addEventListener('error', handleMainImageErrored);
-
-  container.style.setProperty('--download-progress', '0%');
-  loadImage(mainSrc, progress => {
-    container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%');
-  }).then(
-    blobUrl => {
-      mainImage.src = blobUrl;
-      container.style.setProperty('--download-progress', '100%');
-    },
-    handleMainImageErrored);
-
-  function handleMainImageLoaded() {
-    mainImage.removeEventListener('load', handleMainImageLoaded);
-    mainImage.removeEventListener('error', handleMainImageErrored);
-    container.classList.add('loaded');
-  }
-
-  function handleMainImageErrored() {
-    mainImage.removeEventListener('load', handleMainImageLoaded);
-    mainImage.removeEventListener('error', handleMainImageErrored);
-    container.classList.add('errored');
-  }
-}
-
-function parseThumbList(availableThumbList) {
-  // Parse all the available thumbnail sizes! These are provided by the actual
-  // content generation on each image.
-  const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250'
-  const availableSizes =
-    (availableThumbList || defaultThumbList)
-      .split(' ')
-      .map(part => part.split(':'))
-      .map(([thumb, length]) => ({thumb, length: parseInt(length)}))
-      .sort((a, b) => a.length - b.length);
-
-  return availableSizes;
-}
-
-function getPreferredThumbSize(availableThumbList) {
-  // Assuming a square, the image will be constrained to the lesser window
-  // dimension. Coefficient here matches CSS dimensions for image overlay.
-  const constrainedLength = Math.floor(Math.min(
-    0.80 * window.innerWidth,
-    0.80 * window.innerHeight));
-
-  // Match device pixel ratio, which is 2x for "retina" displays and certain
-  // device configurations.
-  const visualLength = window.devicePixelRatio * constrainedLength;
-
-  const availableSizes = parseThumbList(availableThumbList);
-
-  // Starting from the smallest dimensions, find (and return) the first
-  // available length which hits a "good enough" threshold - it's got to be
-  // at least that percent of the way to the actual displayed dimensions.
-  const goodEnoughThreshold = 0.90;
-
-  // (The last item is skipped since we'd be falling back to it anyway.)
-  for (const {thumb, length} of availableSizes.slice(0, -1)) {
-    if (Math.floor(visualLength * goodEnoughThreshold) <= length) {
-      return {thumb, length};
-    }
-  }
-
-  // If none of the items in the list were big enough to hit the "good enough"
-  // threshold, just use the largest size available.
-  return availableSizes[availableSizes.length - 1];
-}
-
-function getSmallestThumbSize(availableThumbList) {
-  // Just snag the smallest size. This'll be used for displaying the "preview"
-  // as the bigger one is loading.
-  const availableSizes = parseThumbList(availableThumbList);
-  return availableSizes[0];
-}
-
-function updateFileSizeInformation(fileSize) {
-  const fileSizeWarningThreshold = 8 * 10 ** 6;
-
-  const actionContentWithoutSize = document.getElementById('image-overlay-action-content-without-size');
-  const actionContentWithSize = document.getElementById('image-overlay-action-content-with-size');
-
-  if (!fileSize) {
-    actionContentWithSize.classList.remove('visible');
-    actionContentWithoutSize.classList.add('visible');
-    return;
-  }
-
-  actionContentWithoutSize.classList.remove('visible');
-  actionContentWithSize.classList.add('visible');
-
-  const megabytesContainer = document.getElementById('image-overlay-file-size-megabytes');
-  const kilobytesContainer = document.getElementById('image-overlay-file-size-kilobytes');
-  const megabytesContent = megabytesContainer.querySelector('.image-overlay-file-size-count');
-  const kilobytesContent = kilobytesContainer.querySelector('.image-overlay-file-size-count');
-  const fileSizeWarning = document.getElementById('image-overlay-file-size-warning');
-
-  fileSize = parseInt(fileSize);
-  const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10;
-
-  if (fileSize > fileSizeWarningThreshold) {
-    fileSizeWarning.classList.add('visible');
-  } else {
-    fileSizeWarning.classList.remove('visible');
-  }
-
-  if (fileSize > 10 ** 6) {
-    megabytesContainer.classList.add('visible');
-    kilobytesContainer.classList.remove('visible');
-    megabytesContent.innerText = round(6);
-  } else {
-    megabytesContainer.classList.remove('visible');
-    kilobytesContainer.classList.add('visible');
-    kilobytesContent.innerText = round(3);
-  }
-
-  void fileSizeWarning;
-}
-
-addImageOverlayClickHandlers();
-
-/**
- * Credits: Parziphal, Feb 13, 2017
- * https://stackoverflow.com/a/42196770
- *
- * Loads an image with progress callback.
- *
- * The `onprogress` callback will be called by XMLHttpRequest's onprogress
- * event, and will receive the loading progress ratio as an whole number.
- * However, if it's not possible to compute the progress ratio, `onprogress`
- * will be called only once passing -1 as progress value. This is useful to,
- * for example, change the progress animation to an undefined animation.
- *
- * @param  {string}   imageUrl   The image to load
- * @param  {Function} onprogress
- * @return {Promise}
- */
-function loadImage(imageUrl, onprogress) {
-  return new Promise((resolve, reject) => {
-    var xhr = new XMLHttpRequest();
-    var notifiedNotComputable = false;
-
-    xhr.open('GET', imageUrl, true);
-    xhr.responseType = 'arraybuffer';
-
-    xhr.onprogress = function(ev) {
-      if (ev.lengthComputable) {
-        onprogress(parseInt((ev.loaded / ev.total) * 1000) / 10);
-      } else {
-        if (!notifiedNotComputable) {
-          notifiedNotComputable = true;
-          onprogress(-1);
-        }
-      }
-    }
-
-    xhr.onloadend = function() {
-      if (!xhr.status.toString().match(/^2/)) {
-        reject(xhr);
-      } else {
-        if (!notifiedNotComputable) {
-          onprogress(100);
-        }
-
-        var options = {}
-        var headers = xhr.getAllResponseHeaders();
-        var m = headers.match(/^Content-Type:\s*(.*?)$/mi);
-
-        if (m && m[1]) {
-          options.type = m[1];
-        }
-
-        var blob = new Blob([this.response], options);
-
-        resolve(window.URL.createObjectURL(blob));
-      }
-    }
-
-    xhr.send();
-  });
-}
-
-// "Additional names" box ---------------------------------
-
-const additionalNamesBoxInfo = initInfo('additionalNamesBox', {
-  box: null,
-  links: null,
-  mainContentContainer: null,
-
-  state: {
-    visible: false,
-  },
-});
-
-function getAdditionalNamesBoxReferences() {
-  const info = additionalNamesBoxInfo;
-
-  info.box =
-    document.getElementById('additional-names-box');
-
-  info.links =
-    document.querySelectorAll('a[href="#additional-names-box"]');
-
-  info.mainContentContainer =
-    document.querySelector('#content .main-content-container');
-}
-
-function addAdditionalNamesBoxInternalListeners() {
-  const info = additionalNamesBoxInfo;
-
-  hashLinkInfo.event.beforeHashLinkScrolls.push(({target}) => {
-    if (target === info.box) {
-      return false;
-    }
-  });
-}
-
-function addAdditionalNamesBoxListeners() {
-  const info = additionalNamesBoxInfo;
-
-  for (const link of info.links) {
-    link.addEventListener('click', domEvent => {
-      handleAdditionalNamesBoxLinkClicked(domEvent);
-    });
-  }
-}
-
-function handleAdditionalNamesBoxLinkClicked(domEvent) {
-  const info = additionalNamesBoxInfo;
-  const {state} = info;
-
-  domEvent.preventDefault();
-
-  if (!info.box || !info.mainContentContainer) return;
-
-  const margin =
-    +(cssProp(info.box, 'scroll-margin-top').replace('px', ''));
-
-  const {top} =
-    (state.visible
-      ? info.box.getBoundingClientRect()
-      : info.mainContentContainer.getBoundingClientRect());
-
-  if (top + 20 < margin || top > 0.4 * window.innerHeight) {
-    if (!state.visible) {
-      toggleAdditionalNamesBox();
-    }
-
-    window.scrollTo({
-      top: window.scrollY + top - margin,
-      behavior: 'smooth',
-    });
-  } else {
-    toggleAdditionalNamesBox();
-  }
-}
-
-function toggleAdditionalNamesBox() {
-  const info = additionalNamesBoxInfo;
-  const {state} = info;
-
-  state.visible = !state.visible;
-  info.box.style.display =
-    (state.visible
-      ? 'block'
-      : 'none');
-}
-
-clientSteps.getPageReferences.push(getAdditionalNamesBoxReferences);
-clientSteps.addInternalListeners.push(addAdditionalNamesBoxInternalListeners);
-clientSteps.addPageListeners.push(addAdditionalNamesBoxListeners);
-
-// Group contributions table ------------------------------
-
-// TODO: Update to clientSteps style.
-
-const groupContributionsTableInfo =
-  Array.from(document.querySelectorAll('#content dl'))
-    .filter(dl => dl.querySelector('a.group-contributions-sort-button'))
-    .map(dl => ({
-      sortingByCountLink: dl.querySelector('dt.group-contributions-sorted-by-count a.group-contributions-sort-button'),
-      sortingByDurationLink: dl.querySelector('dt.group-contributions-sorted-by-duration a.group-contributions-sort-button'),
-      sortingByCountElements: dl.querySelectorAll('.group-contributions-sorted-by-count'),
-      sortingByDurationElements: dl.querySelectorAll('.group-contributions-sorted-by-duration'),
-    }));
-
-function sortGroupContributionsTableBy(info, sort) {
-  const [showThese, hideThese] =
-    (sort === 'count'
-      ? [info.sortingByCountElements, info.sortingByDurationElements]
-      : [info.sortingByDurationElements, info.sortingByCountElements]);
-
-  for (const element of showThese) element.classList.add('visible');
-  for (const element of hideThese) element.classList.remove('visible');
-}
-
-for (const info of groupContributionsTableInfo) {
-  info.sortingByCountLink.addEventListener('click', evt => {
-    evt.preventDefault();
-    sortGroupContributionsTableBy(info, 'duration');
-  });
-
-  info.sortingByDurationLink.addEventListener('click', evt => {
-    evt.preventDefault();
-    sortGroupContributionsTableBy(info, 'count');
-  });
-}
-
-// Generic links with tooltips ----------------------------
-
-const textWithTooltipInfo = initInfo('textWithTooltipInfo', {
-  hoverables: null,
-  tooltips: null,
-});
-
-function getTextWithTooltipReferences() {
-  const info = textWithTooltipInfo;
-
-  const spans =
-    Array.from(document.querySelectorAll('.text-with-tooltip'));
-
-  info.hoverables =
-    spans.map(span => span.children[0]);
-
-  info.tooltips =
-    spans.map(span => span.children[1]);
-}
-
-function addTextWithTooltipPageListeners() {
-  const info = textWithTooltipInfo;
-
-  for (const {hoverable, tooltip} of stitchArrays({
-    hoverable: info.hoverables,
-    tooltip: info.tooltips,
-  })) {
-    registerTooltipElement(tooltip);
-    registerTooltipHoverableElement(hoverable, tooltip);
-  }
-}
-
-clientSteps.getPageReferences.push(getTextWithTooltipReferences);
-clientSteps.addPageListeners.push(addTextWithTooltipPageListeners);
-
-// Datetimestamp tooltips ---------------------------------
-
-const datetimestampTooltipInfo = initInfo('datetimestampTooltipInfo', {
-  hoverables: null,
-  tooltips: null,
-});
-
-function getDatestampTooltipReferences() {
-  const info = datetimestampTooltipInfo;
-
-  const spans =
-    Array.from(document.querySelectorAll('span.datetimestamp.has-tooltip'));
-
-  info.hoverables =
-    spans.map(span => span.querySelector('time'));
-
-  info.tooltips =
-    spans.map(span => span.querySelector('span.datetimestamp-tooltip'));
-}
-
-function addDatestampTooltipPageListeners() {
-  const info = datetimestampTooltipInfo;
-
-  for (const {hoverable, tooltip} of stitchArrays({
-    hoverable: info.hoverables,
-    tooltip: info.tooltips,
-  })) {
-    registerTooltipElement(tooltip);
-    registerTooltipHoverableElement(hoverable, tooltip);
-  }
-}
-
-clientSteps.getPageReferences.push(getDatestampTooltipReferences);
-clientSteps.addPageListeners.push(addDatestampTooltipPageListeners);
-
-// Artist external link tooltips --------------------------
-
-// These don't need to have tooltip events specially added as
-// they're implemented with "text with tooltip" components.
-
-const artistExternalLinkTooltipInfo = initInfo('artistExternalLinkTooltipInfo', {
-  tooltips: null,
-  tooltipRows: null,
-
-  settings: {
-    // This is the maximum distance, in CSS pixels, that the mouse
-    // can appear to be moving per second while still considered
-    // "idle". A greater value means higher tolerance for small
-    // movements.
-    maximumIdleSpeed: 40,
-
-    // Leaving the mouse idle for this amount of time, over a single
-    // row of the tooltip, will cause a column of supplemental info
-    // to display.
-    mouseIdleShowInfoDelay: 1000,
-
-    // If none of these tooltips are visible for this amount of time,
-    // the supplemental info column is hidden. It'll never disappear
-    // while a tooltip is actually visible.
-    hideInfoAfterTooltipHiddenDelay: 2250,
-  },
-
-  state: {
-    // This is shared by all tooltips.
-    showingTooltipInfo: false,
-
-    mouseIdleTimeout: null,
-    hideInfoTimeout: null,
-
-    mouseMovementPositions: [],
-    mouseMovementTimestamps: [],
-  },
-});
-
-function getArtistExternalLinkTooltipPageReferences() {
-  const info = artistExternalLinkTooltipInfo;
-
-  info.tooltips =
-    Array.from(document.getElementsByClassName('icons-tooltip'));
-
-  info.tooltipRows =
-    info.tooltips.map(tooltip =>
-      Array.from(tooltip.getElementsByClassName('icon')));
-}
-
-function addArtistExternalLinkTooltipInternalListeners() {
-  const info = artistExternalLinkTooltipInfo;
-
-  hoverableTooltipInfo.event.whenTooltipShows.push(({tooltip}) => {
-    const {state} = info;
-
-    if (info.tooltips.includes(tooltip)) {
-      clearTimeout(state.hideInfoTimeout);
-      state.hideInfoTimeout = null;
-    }
-  });
-
-  hoverableTooltipInfo.event.whenTooltipHides.push(() => {
-    const {settings, state} = info;
-
-    if (state.showingTooltipInfo) {
-      state.hideInfoTimeout =
-        setTimeout(() => {
-          state.hideInfoTimeout = null;
-          hideArtistExternalLinkTooltipInfo();
-        }, settings.hideInfoAfterTooltipHiddenDelay);
-    } else {
-      clearTimeout(state.mouseIdleTimeout);
-      state.mouseIdleTimeout = null;
-    }
-  });
-}
-
-function addArtistExternalLinkTooltipPageListeners() {
-  const info = artistExternalLinkTooltipInfo;
-
-  for (const tooltip of info.tooltips) {
-    tooltip.addEventListener('mousemove', domEvent => {
-      handleArtistExternalLinkTooltipMouseMoved(domEvent);
-    });
-
-    tooltip.addEventListener('mouseout', () => {
-      const {state} = info;
-
-      clearTimeout(state.mouseIdleTimeout);
-      state.mouseIdleTimeout = null;
-    });
-  }
-
-  for (const tooltipRow of info.tooltipRows.flat()) {
-    tooltipRow.addEventListener('mouseover', () => {
-      const {state} = info;
-
-      clearTimeout(state.mouseIdleTimeout);
-      state.mouseIdleTimeout = null;
-    });
-  }
-}
-
-function handleArtistExternalLinkTooltipMouseMoved(domEvent) {
-  const info = artistExternalLinkTooltipInfo;
-  const {settings, state} = info;
-
-  if (state.showingTooltipInfo) {
-    return;
-  }
-
-  // Clean out expired mouse movements
-
-  const expiryTime = 1000;
-
-  if (!empty(state.mouseMovementTimestamps)) {
-    const firstRecentMovementIndex =
-      state.mouseMovementTimestamps
-        .findIndex(value => Date.now() - value <= expiryTime);
-
-    if (firstRecentMovementIndex === -1) {
-      state.mouseMovementTimestamps.splice(0);
-      state.mouseMovementPositions.splice(0);
-    } else if (firstRecentMovementIndex > 0) {
-      state.mouseMovementTimestamps.splice(0, firstRecentMovementIndex - 1);
-      state.mouseMovementPositions.splice(0, firstRecentMovementIndex - 1);
-    }
-  }
-
-  const currentMovementDistance =
-    Math.sqrt(domEvent.movementX ** 2 + domEvent.movementY ** 2);
-
-  state.mouseMovementTimestamps.push(Date.now());
-  state.mouseMovementPositions.push([domEvent.screenX, domEvent.screenY]);
-
-  // We can't really compute speed without having
-  // at least two data points!
-  if (state.mouseMovementPositions.length < 2) {
-    return;
-  }
-
-  const movementTravelDistances =
-    state.mouseMovementPositions.map((current, index, array) => {
-      if (index === 0) return 0;
-
-      const previous = array[index - 1];
-      const deltaX = current[0] - previous[0];
-      const deltaY = current[1] - previous[1];
-      return Math.sqrt(deltaX ** 2 + deltaY ** 2);
-    });
-
-  const totalTravelDistance =
-    accumulateSum(movementTravelDistances);
-
-  // In seconds rather than milliseconds.
-  const timeSinceFirstMovement =
-    (Date.now() - state.mouseMovementTimestamps[0]) / 1000;
-
-  const averageSpeed =
-    Math.floor(totalTravelDistance / timeSinceFirstMovement);
-
-  if (averageSpeed > settings.maximumIdleSpeed) {
-    clearTimeout(state.mouseIdleTimeout);
-    state.mouseIdleTimeout = null;
-  }
-
-  if (state.mouseIdleTimeout) {
-    return;
-  }
-
-  state.mouseIdleTimeout =
-    setTimeout(() => {
-      state.mouseIdleTimeout = null;
-      showArtistExternalLinkTooltipInfo();
-    }, settings.mouseIdleShowInfoDelay);
-}
-
-function showArtistExternalLinkTooltipInfo() {
-  const info = artistExternalLinkTooltipInfo;
-  const {state} = info;
-
-  state.showingTooltipInfo = true;
-
-  for (const tooltip of info.tooltips) {
-    tooltip.classList.add('show-info');
-  }
-}
-
-function hideArtistExternalLinkTooltipInfo() {
-  const info = artistExternalLinkTooltipInfo;
-  const {state} = info;
-
-  state.showingTooltipInfo = false;
-
-  for (const tooltip of info.tooltips) {
-    tooltip.classList.remove('show-info');
-  }
-}
-
-clientSteps.getPageReferences.push(getArtistExternalLinkTooltipPageReferences);
-clientSteps.addInternalListeners.push(addArtistExternalLinkTooltipInternalListeners);
-clientSteps.addPageListeners.push(addArtistExternalLinkTooltipPageListeners);
-
-// Sticky commentary sidebar ------------------------------
-
-const albumCommentarySidebarInfo = initInfo('albumCommentarySidebarInfo', {
-  sidebar: null,
-  sidebarHeading: null,
-
-  sidebarTrackLinks: null,
-  sidebarTrackDirectories: null,
-
-  sidebarTrackSections: null,
-  sidebarTrackSectionStartIndices: null,
-
-  state: {
-    currentTrackSection: null,
-    currentTrackLink: null,
-    justChangedTrackSection: false,
-  },
-});
-
-function getAlbumCommentarySidebarReferences() {
-  const info = albumCommentarySidebarInfo;
-
-  info.sidebar =
-    document.getElementById('sidebar-left');
-
-  info.sidebarHeading =
-    info.sidebar.querySelector('h1');
-
-  info.sidebarTrackLinks =
-    Array.from(info.sidebar.querySelectorAll('li a'));
-
-  info.sidebarTrackDirectories =
-    info.sidebarTrackLinks
-      .map(el => el.getAttribute('href')?.slice(1) ?? null);
-
-  info.sidebarTrackSections =
-    Array.from(info.sidebar.getElementsByTagName('details'));
-
-  info.sidebarTrackSectionStartIndices =
-    info.sidebarTrackSections
-      .map(details => details.querySelector('ol, ul'))
-      .reduce(
-        (accumulator, _list, index, array) =>
-          (empty(accumulator)
-            ? [0]
-            : [
-              ...accumulator,
-              (accumulator[accumulator.length - 1] +
-                array[index - 1].querySelectorAll('li a').length),
-            ]),
-        []);
-}
-
-function scrollAlbumCommentarySidebar() {
-  const info = albumCommentarySidebarInfo;
-  const {state} = info;
-  const {currentTrackLink, currentTrackSection} = state;
-
-  if (!currentTrackLink) {
-    return;
-  }
-
-  const {sidebar, sidebarHeading} = info;
-
-  const scrollTop = sidebar.scrollTop;
-
-  const headingRect = sidebarHeading.getBoundingClientRect();
-  const sidebarRect = sidebar.getBoundingClientRect();
-
-  const stickyPadding = headingRect.height;
-  const sidebarViewportHeight = sidebarRect.height - stickyPadding;
-
-  const linkRect = currentTrackLink.getBoundingClientRect();
-  const sectionRect = currentTrackSection.getBoundingClientRect();
-
-  const sectionTopEdge =
-    sectionRect.top - (sidebarRect.top - scrollTop);
-
-  const sectionHeight =
-    sectionRect.height;
-
-  const sectionScrollTop =
-    sectionTopEdge - stickyPadding - 10;
-
-  const linkTopEdge =
-    linkRect.top - (sidebarRect.top - scrollTop);
-
-  const linkBottomEdge =
-    linkRect.bottom - (sidebarRect.top - scrollTop);
-
-  const linkScrollTop =
-    linkTopEdge - stickyPadding - 5;
-
-  const linkVisibleFromTopOfSection =
-    linkBottomEdge - sectionTopEdge > sidebarViewportHeight;
-
-  const linkScrollBottom =
-    linkScrollTop - sidebarViewportHeight + linkRect.height + 20;
-
-  const maxScrollInViewport =
-    scrollTop + stickyPadding + sidebarViewportHeight;
-
-  const minScrollInViewport =
-    scrollTop + stickyPadding;
-
-  if (linkBottomEdge > maxScrollInViewport) {
-    if (linkVisibleFromTopOfSection) {
-      sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'});
-    } else {
-      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
-    }
-  } else if (linkTopEdge < minScrollInViewport) {
-    if (linkVisibleFromTopOfSection) {
-      sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'});
-    } else {
-      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
-    }
-  } else if (state.justChangedTrackSection) {
-    if (sectionHeight < sidebarViewportHeight) {
-      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
-    }
-  }
-}
-
-function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) {
-  const info = albumCommentarySidebarInfo;
-  const {state} = info;
-
-  const trackIndex =
-    (trackDirectory
-      ? info.sidebarTrackDirectories
-          .indexOf(trackDirectory)
-      : -1);
-
-  const sectionIndex =
-    (trackIndex >= 0
-      ? info.sidebarTrackSectionStartIndices
-          .findIndex((start, index, array) =>
-            (index === array.length - 1
-              ? true
-              : trackIndex < array[index + 1]))
-      : -1);
-
-  const sidebarTrackLink =
-    (trackIndex >= 0
-      ? info.sidebarTrackLinks[trackIndex]
-      : null);
-
-  const sidebarTrackSection =
-    (sectionIndex >= 0
-      ? info.sidebarTrackSections[sectionIndex]
-      : null);
-
-  state.currentTrackLink?.classList?.remove('current');
-  state.currentTrackLink = sidebarTrackLink;
-  state.currentTrackLink?.classList?.add('current');
-
-  if (sidebarTrackSection !== state.currentTrackSection) {
-    if (sidebarTrackSection && !sidebarTrackSection.open) {
-      if (state.currentTrackSection) {
-        state.currentTrackSection.open = false;
-      }
-
-      sidebarTrackSection.open = true;
-    }
-
-    state.currentTrackSection?.classList?.remove('current');
-    state.currentTrackSection = sidebarTrackSection;
-    state.currentTrackSection?.classList?.add('current');
-    state.justChangedTrackSection = true;
-  } else {
-    state.justChangedTrackSection = false;
-  }
-}
-
-function addAlbumCommentaryInternalListeners() {
-  const info = albumCommentarySidebarInfo;
-
-  const mainContentIndex =
-    (stickyHeadingInfo.contentContainers ?? [])
-      .findIndex(({id}) => id === 'content');
-
-  if (mainContentIndex === -1) return;
-
-  stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => {
-    if (index !== mainContentIndex) return;
-    if (hashLinkInfo.state.scrollingAfterClick) return;
-
-    const trackDirectory =
-      (newHeading
-        ? newHeading.id
-        : null);
-
-    markDirectoryAsCurrentForAlbumCommentary(trackDirectory);
-    scrollAlbumCommentarySidebar();
-  });
-
-  hashLinkInfo.event.whenHashLinkClicked.push(({link}) => {
-    const hash = link.getAttribute('href').slice(1);
-    if (!info.sidebarTrackDirectories.includes(hash)) return;
-    markDirectoryAsCurrentForAlbumCommentary(hash);
-  });
-}
-
-if (document.documentElement.dataset.urlKey === 'localized.albumCommentary') {
-  clientSteps.getPageReferences.push(getAlbumCommentarySidebarReferences);
-  clientSteps.addInternalListeners.push(addAlbumCommentaryInternalListeners);
-}
-
-// Run setup steps ----------------------------------------
-
-for (const [key, steps] of Object.entries(clientSteps)) {
-  for (const step of steps) {
-    try {
-      step();
-    } catch (error) {
-      console.warn(`During ${key}, failed to run ${step.name}`);
-      console.debug(error);
-    }
-  }
-}
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/site6.css b/src/static/css/site.css
index 73721956..8c53f877 100644
--- a/src/static/site6.css
+++ b/src/static/css/site.css
@@ -32,17 +32,22 @@
 /* Layout - Common */
 
 body {
-  margin: 10px;
+  position: relative;
+  margin: 0;
+  padding: 10px;
   overflow-y: scroll;
 }
 
 body::before {
   content: "";
+}
+
+body::before, .wallpaper-part {
   position: fixed;
   top: 0;
   left: 0;
-  width: 100%;
-  height: 100%;
+  width: 100vw;
+  height: 100vh;
   z-index: -1;
 
   /* NB: these are 100 LVW, "largest view width", etc.
@@ -56,7 +61,7 @@ body::before {
 
 #page-container {
   max-width: 1100px;
-  margin: 10px auto 50px;
+  margin: 0 auto 40px;
   padding: 15px 0;
 }
 
@@ -170,6 +175,20 @@ body::before {
   flex-grow: 1;
 }
 
+.sidebar-column.initially-hidden {
+  display: none;
+}
+
+.sidebar-column.always-content-column {
+  /* duplicated in thin & medium media query */
+  position: static !important;
+  max-width: unset !important;
+  flex-basis: unset !important;
+  margin-right: 0 !important;
+  margin-left: 0 !important;
+  width: 100%;
+}
+
 .sidebar-multiple {
   display: flex;
   flex-direction: column;
@@ -219,12 +238,22 @@ body::before {
 
 /* Design & Appearance - Layout elements */
 
+:root {
+  color-scheme: dark;
+}
+
 body {
   background: black;
 }
 
 body::before {
-  background-image: url("../media/bg.jpg");
+  /* This is where the basic background-image rule
+   * gets applied... but the path *to* that media file
+   * isn't part of the CSS itself anymore!
+   */
+}
+
+body::before, .wallpaper-part {
   background-position: center;
   background-size: cover;
   opacity: 0.5;
@@ -253,6 +282,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);
@@ -318,6 +352,11 @@ body::before {
   margin: 0;
 }
 
+.sidebar h2:first-child {
+  margin-top: 0.5em;
+  margin-bottom: 0.5em;
+}
+
 .sidebar h3 {
   font-size: 1.1em;
   font-style: oblique;
@@ -369,6 +408,42 @@ body::before {
   padding-left: 10px;
 }
 
+.sidebar details.has-tree-list[open] summary {
+  font-weight: 800;
+}
+
+.sidebar dl.tree-list {
+  margin-top: 0.25em;
+  line-height: 1.25em;
+  padding-left: 15px;
+}
+
+.sidebar dl.tree-list dt {
+  display: list-item;
+  list-style-type: disc;
+  padding-left: 0;
+  margin-left: 20px;
+}
+
+.sidebar dl.tree-list dl {
+  padding-left: 15px;
+}
+
+.sidebar dl.tree-list dd {
+  margin-left: 0;
+}
+
+.sidebar dl.tree-list dt.current a {
+  font-weight: 800;
+  border-bottom: 1px solid;
+}
+
+.sidebar .times-used {
+  opacity: 0.7;
+  font-size: 0.9em;
+  cursor: default;
+}
+
 .sidebar li.current {
   font-weight: 800;
 }
@@ -386,14 +461,49 @@ body::before {
   padding-left: 5px;
 }
 
+.sidebar > details.current summary span b {
+  font-weight: 800;
+}
+
+summary > span b {
+  font-weight: normal;
+  color: var(--primary-color);
+}
+
 summary > span:hover {
   cursor: pointer;
   text-decoration: underline;
   text-decoration-color: var(--primary-color);
 }
 
-summary .group-name {
-  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;
 }
 
 .sidebar > details ul,
@@ -430,6 +540,330 @@ summary .group-name {
   font-weight: normal;
 }
 
+.sidebar-column.search-showing-results {
+  position: sticky;
+  top: 5px;
+  align-self: flex-start !important; /* pls */
+}
+
+.sidebar-box-joiner {
+  width: 0;
+  margin-left: auto;
+  margin-right: auto;
+  border-right: 1px dashed var(--primary-color);
+  height: 10px;
+}
+
+.sidebar-box-joiner + .sidebar {
+  margin-top: 0 !important;
+}
+
+.track-release-sidebar-box {
+  --content-padding: 3px;
+}
+
+.track-release-sidebar-box h1 {
+  margin: 0;
+  font-weight: normal;
+  font-size: 0.9em;
+  font-style: oblique;
+}
+
+.track-release-sidebar-box + .track-release-sidebar-box,
+.track-release-sidebar-box + .track-list-sidebar-box,
+.track-list-sidebar-box + .track-release-sidebar-box {
+  margin-top: 5px !important;
+  border-top-left-radius: 0;
+  border-top-right-radius: 0;
+}
+
+.track-release-sidebar-box:has(+ .track-list-sidebar-box),
+.track-list-sidebar-box:has(+ .track-release-sidebar-box) {
+  border-bottom-right-radius: 0;
+  border-bottom-left-radius: 0;
+}
+
+.track-list-sidebar-box summary {
+  padding-left: 20px !important;
+  text-indent: -15px !important;
+}
+
+.track-list-sidebar-box .track-section-range {
+  white-space: nowrap;
+}
+
+.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,
+.wiki-search-result:focus::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;
 }
@@ -460,6 +894,10 @@ a.current {
   font-weight: 800;
 }
 
+a.series {
+  font-style: oblique;
+}
+
 a:not([href]) {
   cursor: default;
 }
@@ -468,18 +906,49 @@ a:not([href]):hover {
   text-decoration: none;
 }
 
+a .normal-content {
+  color: white;
+}
+
 .external-link:not(.from-content) {
   white-space: nowrap;
 }
 
 .external-link.indicate-external::after {
   content: '\00a0➚';
+  font-style: normal;
 }
 
 .external-link.indicate-external:hover::after {
   color: white;
 }
 
+.image-media-link::after {
+  content: '';
+  display: inline-block;
+  width: 22px;
+  height: 1em;
+
+  background-color: var(--primary-color);
+
+  /* mask-image is set in content JavaScript,
+   * because we can't identify the correct nor
+   * absolute path to the file from CSS.
+   */
+
+  mask-repeat: no-repeat;
+  mask-position: calc(100% - 2px);
+  vertical-align: text-bottom;
+}
+
+.image-media-link:hover::after {
+  background-color: white;
+}
+
+.nav-link {
+  display: inline-block;
+}
+
 .nav-main-links .nav-link.current > span.nav-link-content > a {
   font-weight: 800;
 }
@@ -488,23 +957,103 @@ a:not([href]):hover {
   display: inline-block;
 }
 
-.nav-links-index .nav-link.has-divider::before,
-.nav-links-groups .nav-link.has-divider::before {
+.nav-links-index .nav-link:not(:first-child)::before,
+.nav-links-groups .nav-link:not(:first-child)::before {
   content: "\0020\00b7\0020";
   font-weight: 800;
 }
 
-.nav-links-hierarchical .nav-link.has-divider::before {
+.nav-links-hierarchical .nav-link + .nav-link::before,
+.nav-links-hierarchical .nav-link + .blockwrap .nav-link::before {
   content: "\0020/\0020";
 }
 
-#header .chronology .heading,
-#header .chronology .buttons {
+.series-nav-links {
+  display: inline-block;
+}
+
+.series-nav-links:not(:first-child)::before {
+  content: "\00a0»\00a0";
+  font-weight: normal;
+}
+
+.series-nav-links:not(:last-child)::after {
+  content: ",\00a0";
+}
+
+.series-nav-links + .series-nav-links::before {
+  content: "";
+}
+
+.dot-switcher > span:not(:first-child) {
+  display: inline-block;
   white-space: nowrap;
 }
 
+/* Yeah, all this stuff only applies to elements of the dot switcher
+ * besides the first, which will necessarily have a bullet point at left.
+ */
+.dot-switcher *:where(.dot-switcher > span:not(:first-child) > *) {
+  display: inline-block;
+  white-space: wrap;
+  text-align: left;
+  vertical-align: top;
+}
+
+.dot-switcher > span:not(:first-child)::before {
+  content: "\0020\00b7\0020";
+  white-space: pre;
+  font-weight: 800;
+}
+
+.dot-switcher > span.current {
+  font-weight: 800;
+}
+
+.dot-switcher.intrapage > span:not(.current) a {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+.dot-switcher.intrapage > span.current a {
+  /* Keeping cursor: pointer (the default) is intentional here. */
+  text-decoration: none !important;
+}
+
 #secondary-nav {
   text-align: center;
+
+  /* Default to visible. It'll automatically be hidden
+   * in layouts where the sidebar is visible.
+   */
+  display: block;
+}
+
+#secondary-nav.album-secondary-nav {
+  display: flex;
+  justify-content: space-around;
+  padding-left: 7.5% !important;
+  padding-right: 7.5% !important;
+  flex-wrap: wrap;
+  line-height: 1.4;
+}
+
+#secondary-nav.album-secondary-nav.with-previous-next .group-with-series {
+  width: 100%;
+}
+
+#secondary-nav.album-secondary-nav.with-previous-next > * {
+  margin-left: 5px;
+  margin-right: 5px;
+}
+
+#secondary-nav.album-secondary-nav .group-nav-links .dot-switcher,
+#secondary-nav.album-secondary-nav .series-nav-links .dot-switcher {
+  white-space: nowrap;
+}
+
+.inert-previous-next-link {
+  opacity: 0.7;
 }
 
 .nowrap {
@@ -530,7 +1079,11 @@ a:not([href]):hover {
 }
 
 .text-with-tooltip.datetimestamp .text-with-tooltip-interaction-cue,
-.text-with-tooltip.missing-duration .text-with-tooltip-interaction-cue {
+.text-with-tooltip.missing-duration .text-with-tooltip-interaction-cue,
+.text-with-tooltip.commentary-date .text-with-tooltip-interaction-cue,
+.text-with-tooltip.wiki-edits .text-with-tooltip-interaction-cue,
+.text-with-tooltip.rerelease .text-with-tooltip-interaction-cue,
+.text-with-tooltip.first-release .text-with-tooltip-interaction-cue {
   cursor: default;
 }
 
@@ -547,7 +1100,17 @@ a:not([href]):hover {
   text-decoration: none !important;
 }
 
+.text-with-tooltip.wiki-edits > .hoverable {
+  white-space: nowrap;
+}
+
+.isolate-tooltip-z-indexing > * {
+  position: relative;
+  z-index: -1;
+}
+
 .tooltip {
+  font-size: 1rem;
   position: absolute;
   z-index: 3;
   left: -10px;
@@ -555,7 +1118,12 @@ a:not([href]):hover {
   display: none;
 }
 
-li:not(:first-child:last-child) .tooltip,
+.cover-artwork .tooltip,
+#sidebar .tooltip {
+  font-size: 0.9rem;
+}
+
+li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)),
 .offset-tooltips > :not(:first-child:last-child) .tooltip {
   left: 14px;
 }
@@ -576,30 +1144,46 @@ li:not(:first-child:last-child) .tooltip,
   box-shadow:
     0 3px 4px 4px #000000aa,
     0 -2px 4px -2px var(--primary-color) inset;
+
+  text-indent: 0;
 }
 
-.icons-tooltip {
+.contribution-tooltip {
   padding: 3px 6px 6px 6px;
   left: -34px;
 }
 
 .datetimestamp-tooltip,
-.missing-duration-tooltip {
+.missing-duration-tooltip,
+.commentary-date-tooltip,
+.rerelease-tooltip,
+.first-release-tooltip {
   padding: 3px 4px 2px 2px;
   left: -10px;
 }
 
-.thing-name-tooltip {
+.thing-name-tooltip,
+.wiki-edits-tooltip {
   padding: 3px 4px 2px 2px;
-  left: -6px !important;
+  left: -6px;
+}
 
-  /* Terrifying?
-   * https://stackoverflow.com/a/64424759/4633828
-   */
-  margin-right: -120px;
+.thing-name-tooltip .tooltip-content,
+.wiki-edits-tooltip .tooltip-content {
+  font-size: 0.85em;
 }
 
-.icons-tooltip .tooltip-content {
+.thing-name-tooltip .tooltip-content {
+  width: max-content;
+  max-width: 120px;
+}
+
+.wiki-edits-tooltip .tooltip-content {
+  width: max-content;
+  max-width: 200px;
+}
+
+.contribution-tooltip .tooltip-content {
   padding: 6px 2px 2px 2px;
 
   -webkit-user-select: none;
@@ -610,78 +1194,160 @@ 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];
+}
+
+.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;
 }
 
-.icons-tooltip .icon {
+.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,
+.tooltip-content hr.cute {
+  grid-column-start: icon-start;
+  grid-column-end: platform-end;
+  border-top: 1px dotted var(--primary-color);
+}
+
+/* Don't mind me... */
+.tooltip-content .tooltip-divider,
+.tooltip-content hr.cute {
+  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;
 }
 
 .datetimestamp-tooltip .tooltip-content,
-.missing-duration-tooltip .tooltip-content {
+.missing-duration-tooltip .tooltip-content,
+.commentary-date-tooltip .tooltip-content {
   padding: 5px 6px;
   white-space: nowrap;
   font-size: 0.9em;
 }
 
-.thing-name-tooltip .tooltip-content {
+.thing-name-tooltip .tooltip-content,
+.wiki-edits-tooltip .tooltip-content {
   padding: 3px 4.5px;
 }
 
-.icons {
-  font-style: normal;
-  white-space: nowrap;
-}
-
-.icons a:hover {
-  filter: brightness(1.4);
-}
-
-.icons a {
-  padding: 0 3px;
+.rerelease-tooltip .tooltip-content,
+.first-release-tooltip .tooltip-content {
+  padding: 3px 4.5px;
+  width: 260px;
+  font-size: 0.9em;
 }
 
-.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;
@@ -689,25 +1355,8 @@ 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 {
+.other-group-accent,
+.rerelease-line {
   opacity: 0.7;
   font-style: oblique;
 }
@@ -720,6 +1369,30 @@ li:not(:first-child:last-child) .tooltip,
   color: var(--page-primary-color);
 }
 
+s.spoiler {
+  display: inline-block;
+  color: transparent;
+  text-decoration: underline;
+  text-decoration-color: white;
+  text-decoration-style: dashed;
+  text-decoration-skip: none;
+  text-decoration-skip-ink: none;
+}
+
+s.spoiler::selection {
+  color: black;
+  background: white;
+}
+
+s.spoiler::-moz-selection {
+  color: black;
+  background: white;
+}
+
+progress {
+  accent-color: var(--primary-color);
+}
+
 .content-columns {
   columns: 2;
 }
@@ -737,12 +1410,16 @@ p .current {
   font-weight: 800;
 }
 
-#cover-art-container {
+hr.cute,
+#content hr.cute,
+.sidebar hr.cute {
+  border-color: var(--primary-color);
+  border-style: none none dotted none;
+}
+
+.cover-artwork {
   font-size: 0.8em;
   border: 2px solid var(--primary-color);
-  box-shadow:
-    0 2px 14px -6px var(--primary-color),
-    0 0 12px 12px #00000080;
 
   border-radius: 0 0 4px 4px;
   background: var(--bg-black-color);
@@ -751,33 +1428,53 @@ p .current {
           backdrop-filter: blur(3px);
 }
 
-#cover-art-container:has(.image-details),
-#cover-art-container.has-image-details {
+.cover-artwork:has(.image-details),
+.cover-artwork.has-image-details {
   border-radius: 0 0 6px 6px;
 }
 
-#cover-art-container:not(:has(.image-details)),
-#cover-art-container:not(.has-image-details) {
+.cover-artwork:not(:has(.image-details)),
+.cover-artwork:not(.has-image-details) {
   /* Hacky: `overflow: hidden` hides tag tooltips, so it can't be applied
    * if we've got tags/details visible. But it's okay, because we only
    * need to apply it if it *doesn't* - that's when the rounded border
-   * of #cover-art-container needs to cut off its child image-container
+   * of the .cover-artwork needs to cut off its child .image-container
    * (which has a background that otherwise causes sharp corners).
    */
   overflow: hidden;
 }
 
-#cover-art-container .image-container {
-  /* Border is handled on the cover-art-container. */
+#artwork-column .cover-artwork {
+  box-shadow:
+    0 2px 14px -6px var(--primary-color),
+    0 0 12px 12px #00000080;
+}
+
+#artwork-column .cover-artwork:not(:first-child) {
+  margin-top: 20px;
+  margin-left: 30px;
+  margin-right: 5px;
+}
+
+#artwork-column .cover-artwork:last-child:not(:first-child) {
+  margin-bottom: 25px;
+}
+
+.cover-artwork .image-container {
+  /* Border is handled on the .cover-artwork. */
   border: none;
-  border-radius: 0;
+  border-radius: 0 !important;
 }
 
-#cover-art-container .image-details {
+.cover-artwork .image-details {
   border-top-color: var(--deep-color);
 }
 
-#cover-art-container .image {
+.cover-artwork .image-details + .image-details {
+  border-top-color: var(--primary-color);
+}
+
+.cover-artwork .image {
   display: block;
   width: 100%;
   height: 100%;
@@ -786,33 +1483,127 @@ p .current {
 .image-details {
   display: block;
 
-  padding: 6px 9px 4px 9px;
   margin-top: 0;
   margin-bottom: 0;
+
+  /* Styles below only apply for first image-details. */
+
+  margin-left: 0;
+  margin-right: 0;
+  padding-left: 9px;
+  padding-right: 9px;
+
+  padding-top: 6px;
+  padding-bottom: 4px;
+
   border-top: 1px dashed var(--dim-color);
 }
 
-ul.image-details li {
+.image-details + .image-details {
+  display: block;
+
+  margin-left: 6px;
+  margin-right: 6px;
+  padding-left: 3px;
+  padding-right: 3px;
+
+  padding-top: 4px;
+  padding-bottom: 4px;
+
+  border-top: 1px dotted var(--primary-color);
+}
+
+.image-details:last-child {
+  margin-bottom: 2px;
+}
+
+ul.image-details.art-tag-details {
+  padding-bottom: 0;
+}
+
+ul.image-details.art-tag-details li {
   display: inline-block;
-  margin: 0;
 }
 
-#cover-art-container ul li:not(:last-child)::after {
+ul.image-details.art-tag-details li:not(:last-child)::after {
   content: " \00b7 ";
 }
 
+p.image-details.illustrator-details {
+  text-align: center;
+  font-style: oblique;
+}
+
+p.image-details.origin-details {
+  margin-bottom: 2px;
+}
+
+.album-art-info {
+  font-size: 0.8em;
+  border: 2px solid var(--deep-color);
+
+  margin: 10px min(15px, 1vw) 15px;
+
+  background: var(--bg-black-color);
+  padding: 6px;
+  border-radius: 5px;
+
+  -webkit-backdrop-filter: blur(3px);
+          backdrop-filter: blur(3px);
+}
+
+.album-art-info p {
+  margin: 0;
+}
+
+p.content-heading:has(+ .commentary-entry-heading.dated) {
+  clear: right;
+}
+
 .commentary-entry-heading {
+  display: flex;
+  flex-direction: row;
+
   margin-left: 15px;
   padding-left: 5px;
   max-width: 625px;
   padding-bottom: 0.2em;
-  border-bottom: 1px dotted var(--primary-color);
+
+  border-bottom: 1px solid var(--dim-color);
+}
+
+.commentary-entry-heading-text {
+  flex-grow: 1;
+  padding-left: 1.25ch;
+  text-indent: -1.25ch;
 }
 
 .commentary-entry-accent {
   font-style: oblique;
 }
 
+.commentary-entry-heading .commentary-date {
+  flex-shrink: 0;
+
+  margin-left: 0.75ch;
+  align-self: flex-end;
+
+  padding-left: 0.5ch;
+  padding-right: 0.25ch;
+}
+
+.commentary-entry-heading .hoverable {
+  box-shadow: 1px 2px 6px 5px #04040460;
+}
+
+.commentary-entry-body summary {
+  list-style-position: outside;
+}
+
+.commentary-entry-body summary > span {
+  color: var(--primary-color);
+}
+
 .commentary-art {
   float: right;
   width: 30%;
@@ -827,23 +1618,51 @@ ul.image-details li {
   box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.25) !important;
 }
 
+.lyrics-switcher {
+  padding-left: 20px;
+}
+
+.lyrics-switcher > span:not(:first-child)::before {
+  content: "\0020\00b7\0020";
+  font-weight: 800;
+}
+
+.lyrics-entry {
+  padding-left: 40px;
+  max-width: 600px;
+}
+
 .js-hide,
 .js-show-once-data,
 .js-hide-once-data {
   display: none;
 }
 
-.content-image-container {
+.content-image-container,
+.content-video-container {
   margin-top: 1em;
   margin-bottom: 1em;
 }
 
-.content-image-container.align-center {
+.content-image-container.align-center,
+.content-video-container.align-center,
+.content-audio-container.align-center {
   text-align: center;
   margin-top: 1.5em;
   margin-bottom: 1.5em;
 }
 
+a.align-center, img.align-center, audio.align-center {
+  display: block;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+center {
+  margin-top: 1em;
+  margin-bottom: 1em;
+}
+
 .content-image {
   display: inline-block !important;
 }
@@ -895,11 +1714,6 @@ h1 {
   font-size: 2em;
 }
 
-html[data-url-key="localized.home"] #content h1 {
-  text-align: center;
-  font-size: 2.5em;
-}
-
 #content.flash-index h2 {
   text-align: center;
   font-size: 2.5em;
@@ -917,13 +1731,27 @@ html[data-url-key="localized.home"] #content h1 {
   margin-bottom: 0.25em;
 }
 
+#content.top-index.has-subtitle h1 {
+  margin-bottom: 0.35em;
+}
+
+#content.top-index h2.page-subtitle {
+  font-size: 1.8em;
+  margin-top: 0.35em;
+  margin-bottom: 0.5em;
+}
+
 .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 {
@@ -935,10 +1763,73 @@ 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;
 }
 
+.gallery-set-switcher {
+  text-align: center;
+}
+
+.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 > blockquote {
+  margin-left: 0 !important;
+}
+
+.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;
@@ -995,8 +1886,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,
@@ -1011,9 +1917,16 @@ dl dt {
 }
 
 dl dt {
+  /* Heads up, this affects the measurement
+   * for dl dt which are .content-heading!
+   */
   margin-bottom: 2px;
 }
 
+dl dt[id]:not(.content-heading) {
+  --custom-scroll-offset: calc(2.5em - 2px);
+}
+
 dl dd {
   margin-bottom: 1em;
 }
@@ -1029,19 +1942,45 @@ ul > li.has-details {
   margin-left: -17px;
 }
 
-.album-group-list dt {
+.album-group-list dt,
+.group-series-list dt {
   font-style: oblique;
   padding-left: 0;
 }
 
-.album-group-list dd {
+.album-group-list dd,
+.group-series-list dd {
   margin-left: 0;
 }
 
-.group-chronology-link {
+.album-group-list blockquote {
+  max-width: 540px;
+  margin-bottom: 9px;
+  margin-top: 3px;
+}
+
+.album-group-list blockquote p:first-child {
+  margin-top: 0;
+}
+
+.album-group-list blockquote p:last-child {
+  margin-bottom: 0;
+}
+
+.group-chronology-link,
+.series-chronology-link {
   font-style: oblique;
 }
 
+.group-chronology-link a,
+.series-chronology-link a {
+  font-style: normal;
+}
+
+.group-view-switcher {
+  margin-left: 1ch;
+}
+
 #content hr {
   border: 1px inset #808080;
   width: 100%;
@@ -1144,6 +2083,14 @@ html[data-url-key="localized.albumCommentary"] .content-heading-accent {
   display: inline-block;
 }
 
+html[data-url-key="localized.albumCommentary"] p.track-info {
+  margin-left: 20px;
+}
+
+html[data-url-key="localized.groupInfo"] .by a {
+  color: var(--page-primary-color);
+}
+
 html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line,
 html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line,
 html[data-url-key="localized.listing"][data-url-value0="random"] #data-error-line {
@@ -1175,19 +2122,9 @@ h1 a[href="#additional-names-box"]:hover {
   --custom-scroll-offset: calc(0.5em - 2px);
 
   margin: 1em 0 1em -10px;
-  padding: 15px 20px 10px 20px;
-  width: max-content;
   max-width: min(60vw, 600px);
 
-  border: 1px dotted var(--primary-color);
-  border-radius: 6px;
-
-  background:
-    linear-gradient(var(--bg-color), var(--bg-color)),
-    linear-gradient(#000000bb, #000000bb),
-    var(--primary-color);
-
-  box-shadow: 0 -2px 6px -1px var(--dim-color) inset;
+  padding: 15px 20px 10px 20px;
 
   display: none;
 }
@@ -1224,6 +2161,177 @@ h1 a[href="#additional-names-box"]:hover {
   vertical-align: text-bottom;
 }
 
+#content.top-index #additional-names-box {
+  margin-left: auto;
+  margin-right: auto;
+  margin-bottom: 2em;
+}
+
+#content.top-index #additional-names-box {
+  text-align: center;
+  margin-bottom: 0.75em;
+}
+
+/* Specific pages - homepage */
+
+html[data-url-key="localized.home"] #content h1 {
+  text-align: center;
+  font-size: 2.5em;
+}
+
+html[data-language-code="preview-en"][data-url-key="localized.home"] #content h1::after {
+  font-family: cursive;
+  display: block;
+  content: "(Preview Build)";
+  font-size: 0.8em;
+}
+
+/* Specific pages - art tag gallery */
+
+html[data-url-key="localized.artTagGallery"] #descends-from-line {
+  margin-bottom: 0.25em;
+}
+
+html[data-url-key="localized.artTagGallery"] #descendants-line {
+  margin-top: 0.25em;
+}
+
+html[data-url-key="localized.artTagGallery"] #descends-from-line a,
+html[data-url-key="localized.artTagGallery"] #descendants-line a {
+  display: inline-block;
+}
+
+
+html[data-url-key="localized.artTagGallery"] #featured-direct-line,
+html[data-url-key="localized.artTagGallery"] #featured-indirect-line,
+html[data-url-key="localized.artTagGallery"] #showing-direct-line,
+html[data-url-key="localized.artTagGallery"] #showing-indirect-line {
+  display: none;
+}
+
+html[data-url-key="localized.artTagGallery"] #showing-all-line a,
+html[data-url-key="localized.artTagGallery"] #showing-direct-line a,
+html[data-url-key="localized.artTagGallery"] #showing-indirect-line a {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+/* Specific pages - "Art Tag Network" listing */
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd) {
+  margin-left: 20px;
+  margin-bottom: 0;
+  padding-left: 10px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd):not(:last-child) {
+  padding-bottom: 20px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] #network-stat-line {
+  padding-left: 10px;
+  margin-left: 20px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] #network-stat-line a {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] .network-tag-stat {
+  display: inline-block;
+  text-align: right;
+  min-width: 5ch;
+  margin-right: 2px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] #network-top-dl > dt:has(.network-tag.with-stat:not([style*="display: none"])) {
+  padding-left: 20px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dt + dt:has(+ dd) {
+  padding-top: 20px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dt:has(+ dd) .network-tag-stat {
+  text-align: center;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt {
+  padding-left: 10px;
+  margin-left: 20px;
+  margin-bottom: 0;
+  padding-bottom: 2px;
+  max-width: unset;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd).even,
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:not(#network-top-dl > dt).even {
+  border-left: 1px solid #eaeaea;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd).odd,
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:not(#network-top-dl > dt).odd {
+  border-left: 1px solid #7b7b7b;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd,
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt {
+  position: relative;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:last-child:not(#network-top-dl > dd).odd::after,
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:last-child:not(#network-top-dl > dt).odd::after {
+  content: "";
+  display: block;
+  width: 7px;
+  height: 7px;
+  background: #7b7b7b;
+  position: absolute;
+  bottom: -4px;
+  left: -4px;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:last-child:not(#network-top-dl > dd).even::after,
+html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:last-child:not(#network-top-dl > dt).even::after {
+  content: "";
+  display: block;
+  width: 6px;
+  height: 6px;
+  background: #eaeaea;
+  position: absolute;
+  bottom: -3px;
+  left: -3px;
+  border-bottom-right-radius: 6px;
+  border-top-left-radius: 3px;
+}
+
+/* "Drops" */
+
+.drop {
+  padding: 15px 20px;
+  width: max-content;
+  max-width: min(60vw, 600px);
+
+  border: 1px dotted var(--primary-color);
+  border-radius: 6px;
+
+  background:
+    linear-gradient(var(--bg-color), var(--bg-color)),
+    linear-gradient(#000000bb, #000000bb),
+    var(--primary-color);
+
+  box-shadow: 0 -2px 6px -1px var(--dim-color) inset;
+}
+
+.commentary-drop {
+  margin-top: 25px;
+  margin-bottom: 15px;
+  margin-left: 20px;
+  padding: 10px 20px;
+  max-width: min(60vw, 300px);
+}
+
 /* Images */
 
 .image-container {
@@ -1242,6 +2350,21 @@ h1 a[href="#additional-names-box"]:hover {
   color: white;
 }
 
+/* Videos and audios (in content) get a lite version of image-container. */
+.content-video-container,
+.content-audio-container {
+  width: min-content;
+  background-color: var(--dark-color);
+  border: 2px solid var(--primary-color);
+  border-radius: 2.5px 2.5px 3px 3px;
+  padding: 5px;
+}
+
+.content-video-container video,
+.content-audio-container audio {
+  display: block;
+}
+
 .image-text-area {
   position: absolute;
   top: 0;
@@ -1311,7 +2434,8 @@ img {
     6px -6px 2px -4px white inset;
 }
 
-img.pixelate, .pixelate img {
+img.pixelate, .pixelate img,
+video.pixelate, .pixelate video {
   image-rendering: crisp-edges;
 }
 
@@ -1346,7 +2470,6 @@ img.pixelate, .pixelate img {
 
   font-size: 1.6em;
   opacity: 0.8;
-  background-image: url("warning.svg");
 }
 
 .reveal-interaction {
@@ -1837,6 +2960,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;
 }
@@ -1877,14 +3007,32 @@ h3.content-heading {
   );
 }
 
+.content-sticky-heading-root {
+  width: calc(100% + 2 * var(--content-padding));
+  margin: calc(-1 * var(--content-padding));
+  margin-bottom: 0;
+}
+
+.content-sticky-heading-anchor,
 .content-sticky-heading-container {
+  width: 100%;
+}
+
+.content-sticky-heading-root:not([inert]) {
   position: sticky;
   top: 0;
+}
 
-  margin: calc(-1 * var(--content-padding));
-  margin-bottom: calc(0.5 * var(--content-padding));
+.content-sticky-heading-anchor:not(:where(.content-sticky-heading-root[inert]) *) {
+  position: relative;
+}
+
+.content-sticky-heading-container:not(:where(.content-sticky-heading-root[inert]) *) {
+  position: absolute;
+}
 
-  transform: translateY(-5px);
+.content-sticky-heading-root[inert] {
+  visibility: hidden;
 }
 
 main.long-content .content-sticky-heading-container {
@@ -1924,9 +3072,60 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   grid-template-columns: 1fr min(40%, 400px);
 }
 
+.content-sticky-heading-container.cover-visible .content-sticky-heading-row {
+  grid-template-columns: 1fr min(40%, 90px);
+}
+
+.content-sticky-heading-root.has-cover {
+  padding-right: min(40%, 400px);
+}
+
 .content-sticky-heading-row h1 {
+  position: relative;
   margin: 0;
   padding-right: 20px;
+  line-height: 1.4;
+}
+
+.content-sticky-heading-row h1 .reference-collapsed-heading {
+  position: absolute;
+  white-space: nowrap;
+  visibility: hidden;
+}
+
+.content-sticky-heading-container.collapse h1 {
+  white-space: nowrap;
+  overflow-wrap: normal;
+
+  animation: collapse-sticky-heading 0.35s forwards;
+  text-overflow: ellipsis;
+  overflow-x: hidden;
+}
+
+@keyframes collapse-sticky-heading {
+  from {
+    height: var(--uncollapsed-heading-height);
+  }
+
+  99.9% {
+    height: var(--collapsed-heading-height);
+  }
+
+  to {
+    height: auto;
+  }
+}
+
+.content-sticky-heading-container h1 a {
+  transition: text-decoration-color 0.35s;
+}
+
+.content-sticky-heading-container h1 a:not([href]) {
+  color: inherit;
+  cursor: text;
+  text-decoration: underline;
+  text-decoration-style: dotted;
+  text-decoration-color: transparent;
 }
 
 .content-sticky-heading-cover-container {
@@ -1954,7 +3153,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   transition: transform 0.35s, opacity 0.30s;
 }
 
-.content-sticky-heading-cover .image-container {
+.content-sticky-heading-cover .cover-artwork {
   border-width: 1px;
   border-radius: 1.25px;
   box-shadow: none;
@@ -2030,40 +3229,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);
+  scrollbar-color: var(--dim-color) var(--dark-color);
 }
 
-.sidebar-column.sidebar.sticky-column::-webkit-scrollbar {
+.wiki-search-sidebar-box .wiki-search-results-container {
+  overflow-y: scroll;
+  scrollbar-width: thin;
+  scrollbar-color: var(--dim-color) var(--dark-color);
+}
+
+.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;
@@ -2099,6 +3298,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;
@@ -2147,31 +3347,34 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
           backdrop-filter: blur(3px);
 }
 
-#image-overlay-image-container {
+#image-overlay-image-area {
   display: block;
-  position: relative;
   overflow: hidden;
   width: 80vmin;
-  height: 80vmin;
   margin-left: auto;
   margin-right: auto;
 }
 
+#image-overlay-image-layout {
+  display: block;
+  position: relative;
+  margin: 4px 3px;
+  background: rgba(0, 0, 0, 0.65);
+}
+
 #image-overlay-image,
 #image-overlay-image-thumb {
-  display: inline-block;
-  object-fit: contain;
+  display: block;
   width: 100%;
-  height: 100%;
-  background: rgba(0, 0, 0, 0.65);
+  height: auto;
 }
 
 #image-overlay-image {
   position: absolute;
-  top: 3px;
-  left: 3px;
-  width: calc(100% - 6px);
-  height: calc(100% - 4px);
+}
+
+#image-overlay-container.no-thumb #image-overlay-image {
+  position: static;
 }
 
 #image-overlay-image-thumb {
@@ -2185,7 +3388,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   transition: opacity 0.25s;
 }
 
-#image-overlay-image-container::after {
+#image-overlay-image-area::after {
   content: "";
   display: block;
   position: absolute;
@@ -2198,18 +3401,18 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   transition: 0.25s;
 }
 
-#image-overlay-container.loaded #image-overlay-image-container::after {
+#image-overlay-container.loaded #image-overlay-image-area::after {
   width: 100%;
   background: white;
   opacity: 0;
 }
 
-#image-overlay-container.errored #image-overlay-image-container::after {
+#image-overlay-container.errored #image-overlay-image-area::after {
   width: 100%;
   background: red;
 }
 
-#image-overlay-container:not(.visible) #image-overlay-image-container::after {
+#image-overlay-container:not(.visible) #image-overlay-image-area::after {
   width: 0 !important;
 }
 
@@ -2237,20 +3440,11 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   font-size: 0.9em;
 }
 
-/* important easter egg mode */
-
-html[data-language-code="preview-en"][data-url-key="localized.home"] #content
-  h1::after {
-  font-family: cursive;
-  display: block;
-  content: "(Preview Build)";
-  font-size: 0.8em;
-}
-
 /* Layout - Wide (most computers) */
 
 @media (min-width: 900px) {
-  #page-container:not(.has-zero-sidebars) #secondary-nav {
+  #page-container.showing-sidebar-left:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible),
+  #page-container.showing-sidebar-right:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible) {
     display: none;
   }
 }
@@ -2267,15 +3461,16 @@ 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;
   }
 }
 
 /* Layout - Wide or Medium */
 
 @media (min-width: 600px) {
-  .content-sticky-heading-container {
+  .content-sticky-heading-root {
     /* Safari doesn't always play nicely with position: sticky,
      * this seems to fix images sometimes displaying above the
      * position: absolute subheading (h2) child
@@ -2289,7 +3484,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
   /* Cover art floats to the right. It's positioned in HTML beneath the
    * heading, so pull it up a little to "float" on top.
    */
-  #cover-art-container {
+  #artwork-column {
     float: right;
     width: 40%;
     max-width: 400px;
@@ -2299,12 +3494,21 @@ 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)) {
+  /* ...Except on top-indexes, where cover art is displayed prominently
+   * between the heading and subheading.
+   */
+  #content.top-index #artwork-column {
+    float: none;
+    margin: 2em auto 2.5em auto;
+    max-width: 375px;
+  }
+
+  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;
   }
@@ -2313,23 +3517,24 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
 /* Layout - Medium or Thin */
 
 @media (max-width: 899.98px) {
-  .sidebar-column:not(.no-hide) {
+  .sidebar.collapsible,
+  .sidebar-box-joiner.collapsible,
+  .sidebar-column.all-boxes-collapsible {
     display: none;
   }
 
-  #secondary-nav {
-    display: block;
-  }
+  /* Duplicated for "sidebars in content column" */
 
-  .layout-columns.vertical-when-thin {
+  .layout-columns {
     flex-direction: column;
   }
 
-  .layout-columns.vertical-when-thin > *:not(:last-child) {
+  .layout-columns > *:not(:last-child) {
     margin-bottom: 10px;
   }
 
-  .sidebar-column.no-hide {
+  .sidebar-column {
+    position: static !important;
     max-width: unset !important;
     flex-basis: unset !important;
     margin-right: 0 !important;
@@ -2341,11 +3546,53 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     display: none;
   }
 
+  .wiki-search-sidebar-box {
+    max-height: max(245px, 60vh, calc(100vh - 205px));
+  }
+
+  /* End duplicated for "sidebars in content column" */
+
   .grid-listing > .grid-item {
     flex-basis: 40%;
   }
 }
 
+/* Layout - "sidebars in content column"
+ * This is the same code as immediately above, for medium and
+ * thin layouts, but can be opted into by the page itself
+ * instead of through a media query.
+ */
+
+#page-container.sidebars-in-content-column
+.layout-columns {
+  flex-direction: column;
+}
+
+#page-container.sidebars-in-content-column
+.layout-columns > *:not(:last-child) {
+  margin-bottom: 10px;
+}
+
+#page-container.sidebars-in-content-column
+.sidebar-column {
+  position: static !important;
+  max-width: unset !important;
+  flex-basis: unset !important;
+  margin-right: 0 !important;
+  margin-left: 0 !important;
+  width: 100%;
+}
+
+#page-container.sidebars-in-content-column
+.sidebar .news-entry:not(.first-news-entry) {
+  display: none;
+}
+
+#page-container.sidebars-in-content-column
+.wiki-search-sidebar-box {
+  max-height: max(245px, 60vh, calc(100vh - 205px));
+}
+
 /* Layout - Thin (phones) */
 
 @media (max-width: 600px) {
@@ -2353,11 +3600,11 @@ 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 {
+  #artwork-column {
     margin: 25px 0 5px 0;
     width: 100%;
     max-width: unset;
@@ -2374,7 +3621,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
 
   /* Show sticky heading above cover art */
 
-  .content-sticky-heading-container {
+  .content-sticky-heading-root {
     z-index: 2;
   }
 
diff --git a/src/static/js/client-util.js b/src/static/js/client-util.js
new file mode 100644
index 00000000..71112313
--- /dev/null
+++ b/src/static/js/client-util.js
@@ -0,0 +1,129 @@
+/* eslint-env browser */
+
+export function rebase(href, rebaseKey = 'rebaseLocalized') {
+  let result = document.documentElement.dataset[rebaseKey] || './';
+
+  if (!result.endsWith('/')) {
+    result += '/';
+  }
+
+  if (href.startsWith('/')) {
+    href = href.slice(1);
+  }
+
+  result += href;
+
+  return result;
+}
+
+export function cssProp(el, ...args) {
+  if (typeof args[0] === 'string' && args.length === 1) {
+    return getComputedStyle(el).getPropertyValue(args[0]).trim();
+  }
+
+  if (typeof args[0] === 'string' && args.length === 2) {
+    if (args[1] === null) {
+      el.style.removeProperty(args[0]);
+    } else {
+      el.style.setProperty(args[0], args[1]);
+    }
+    return;
+  }
+
+  if (typeof args[0] === 'object') {
+    for (const [property, value] of Object.entries(args[0])) {
+      cssProp(el, property, value);
+    }
+  }
+}
+
+export 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.
+export function pointIsOverAnyOf(elements) {
+  return (clientX, clientY) => {
+    const element = document.elementFromPoint(clientX, clientY);
+    return elements.some(el => el.contains(element));
+  };
+}
+
+export function getVisuallyContainingElement(child) {
+  let parent = child.parentElement;
+
+  while (parent) {
+    if (
+      cssProp(parent, 'overflow') === 'hidden' ||
+      cssProp(parent, 'contain') === 'paint'
+    ) {
+      return parent;
+    }
+
+    parent = parent.parentElement;
+  }
+
+  return null;
+}
+
+// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to
+// separ8te the tooling around that into common-shared code too.
+
+/*
+const getLinkHref = (type, directory) => rebase(`${type}/${directory}`);
+*/
+
+export const openAlbum = d => rebase(`album/${d}`);
+export const openArtTag = d => rebase(`tag/${d}`);
+export const openArtist = d => rebase(`artist/${d}`);
+export const openFlash = d => rebase(`flash/${d}`);
+export const openGroup = d => rebase(`group/${d}`);
+export const openTrack = d => rebase(`track/${d}`);
+
+// TODO: This should also use urlSpec.
+
+/*
+export function fetchData(type, directory) {
+  return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')).then(
+    (res) => res.json()
+  );
+}
+*/
+
+// TODO: This should probably be imported from another file.
+export function dispatchInternalEvent(event, eventName, ...args) {
+  const info = event[Symbol.for('hsmusic.clientInfo')];
+
+  if (!info) {
+    throw new Error(`Expected event to be stored on clientInfo`);
+  }
+
+  const infoName = info.id;
+
+  const {[eventName]: listeners} = event;
+
+  if (!listeners) {
+    throw new Error(`Event name "${eventName}" isn't stored on ${infoName}.event`);
+  }
+
+  let results = [];
+  for (const listener of listeners) {
+    try {
+      results.push(listener(...args));
+    } catch (error) {
+      console.error(`Uncaught error in listener for ${infoName}.${eventName}`);
+      console.error(error);
+      results.push(undefined);
+    }
+  }
+
+  return results;
+}
diff --git a/src/static/js/client/additional-names-box.js b/src/static/js/client/additional-names-box.js
new file mode 100644
index 00000000..195ba25d
--- /dev/null
+++ b/src/static/js/client/additional-names-box.js
@@ -0,0 +1,150 @@
+/* eslint-env browser */
+
+import {cssProp} from '../client-util.js';
+
+import {info as hashLinkInfo} from './hash-link.js';
+import {info as stickyHeadingInfo} from './sticky-heading.js';
+
+export const info = {
+  id: 'additionalNamesBoxInfo',
+
+  box: null,
+
+  links: null,
+  stickyHeadingLink: null,
+
+  contentContainer: null,
+  mainContentContainer: null,
+
+  state: {
+    visible: false,
+  },
+};
+
+export function getPageReferences() {
+  info.box =
+    document.getElementById('additional-names-box');
+
+  info.links =
+    document.querySelectorAll('a[href="#additional-names-box"]');
+
+  info.stickyHeadingLink =
+    document.querySelector(
+      '.content-sticky-heading-container' +
+      ' ' +
+      'a[href="#additional-names-box"]' +
+      ':not(:where([inert] *))');
+
+  info.contentContainer =
+    document.querySelector('#content');
+
+  info.mainContentContainer =
+    document.querySelector('#content .main-content-container');
+}
+
+export function addInternalListeners() {
+  hashLinkInfo.event.beforeHashLinkScrolls.push(({target}) => {
+    if (target === info.box) {
+      return false;
+    }
+  });
+
+  stickyHeadingInfo.event.whenStuckStatusChanges.push((index, stuck) => {
+    const {state} = info;
+
+    if (!info.stickyHeadingLink) return;
+
+    const container = stickyHeadingInfo.contentContainers[index];
+    if (container !== info.contentContainer) return;
+
+    if (stuck) {
+      if (!state.visible) {
+        info.stickyHeadingLink.removeAttribute('href');
+
+        if (info.stickyHeadingLink.hasAttribute('title')) {
+          info.stickyHeadingLink.dataset.restoreTitle = info.stickyHeadingLink.getAttribute('title');
+          info.stickyHeadingLink.removeAttribute('title');
+        }
+      }
+    } else {
+      info.stickyHeadingLink.setAttribute('href', '#additional-names-box');
+
+      const {restoreTitle} = info.stickyHeadingLink.dataset;
+      if (restoreTitle) {
+        info.stickyHeadingLink.setAttribute('title', restoreTitle);
+        delete info.stickyHeadingLink.dataset.restoreTitle;
+      }
+    }
+  });
+}
+
+export function addPageListeners() {
+  for (const link of info.links) {
+    link.addEventListener('click', domEvent => {
+      handleAdditionalNamesBoxLinkClicked(domEvent);
+    });
+  }
+}
+
+function handleAdditionalNamesBoxLinkClicked(domEvent) {
+  const {state} = info;
+
+  domEvent.preventDefault();
+
+  if (!domEvent.target.hasAttribute('href')) return;
+  if (!info.box || !info.mainContentContainer) return;
+
+  const margin =
+    +(cssProp(info.box, 'scroll-margin-top').replace('px', ''));
+
+  const {top} =
+    (state.visible
+      ? info.box.getBoundingClientRect()
+      : info.mainContentContainer.getBoundingClientRect());
+
+  const {bottom, height} =
+    (state.visible
+      ? info.box.getBoundingClientRect()
+      : {bottom: null});
+
+  const boxFitsInFrame =
+    (height
+      ? height < window.innerHeight - margin - 60
+      : null);
+
+  const worthScrolling =
+    top + 20 < margin ||
+
+    (height && boxFitsInFrame
+      ? top > 0.7 * window.innerHeight
+   : height && !boxFitsInFrame
+      ? top > 0.4 * window.innerHeight
+      : top > 0.5 * window.innerHeight) ||
+
+    (bottom && boxFitsInFrame
+      ? bottom > window.innerHeight - 20
+      : false);
+
+  if (worthScrolling) {
+    if (!state.visible) {
+      toggleAdditionalNamesBox();
+    }
+
+    window.scrollTo({
+      top: window.scrollY + top - margin,
+      behavior: 'smooth',
+    });
+  } else {
+    toggleAdditionalNamesBox();
+  }
+}
+
+export function toggleAdditionalNamesBox() {
+  const {state} = info;
+
+  state.visible = !state.visible;
+  info.box.style.display =
+    (state.visible
+      ? 'block'
+      : 'none');
+}
diff --git a/src/static/js/client/album-commentary-sidebar.js b/src/static/js/client/album-commentary-sidebar.js
new file mode 100644
index 00000000..c5eaf81b
--- /dev/null
+++ b/src/static/js/client/album-commentary-sidebar.js
@@ -0,0 +1,212 @@
+/* eslint-env browser */
+
+import {empty} from '../../shared-util/sugar.js';
+
+import {info as hashLinkInfo} from './hash-link.js';
+import {info as stickyHeadingInfo} from './sticky-heading.js';
+
+export const info = {
+  id: 'albumCommentarySidebarInfo',
+
+  sidebar: null,
+  sidebarHeading: null,
+
+  sidebarTrackLinks: null,
+  sidebarTrackDirectories: null,
+
+  sidebarTrackSections: null,
+  sidebarTrackSectionStartIndices: null,
+
+  state: {
+    currentTrackSection: null,
+    currentTrackLink: null,
+    justChangedTrackSection: false,
+  },
+};
+
+export function getPageReferences() {
+  if (document.documentElement.dataset.urlKey !== 'localized.albumCommentary') {
+    return;
+  }
+
+  info.sidebar =
+    document.getElementById('sidebar-left');
+
+  info.sidebarHeading =
+    info.sidebar.querySelector('h1');
+
+  info.sidebarTrackLinks =
+    Array.from(info.sidebar.querySelectorAll('li a'));
+
+  info.sidebarTrackDirectories =
+    info.sidebarTrackLinks
+      .map(el => el.getAttribute('href')?.slice(1) ?? null);
+
+  info.sidebarTrackSections =
+    Array.from(info.sidebar.getElementsByTagName('details'));
+
+  info.sidebarTrackSectionStartIndices =
+    info.sidebarTrackSections
+      .map(details => details.querySelector('ol, ul'))
+      .reduce(
+        (accumulator, _list, index, array) =>
+          (empty(accumulator)
+            ? [0]
+            : [
+              ...accumulator,
+              (accumulator[accumulator.length - 1] +
+                array[index - 1].querySelectorAll('li a').length),
+            ]),
+        []);
+}
+
+function scrollAlbumCommentarySidebar() {
+  const {state} = info;
+  const {currentTrackLink, currentTrackSection} = state;
+
+  if (!currentTrackLink) {
+    return;
+  }
+
+  const {sidebar, sidebarHeading} = info;
+
+  const scrollTop = sidebar.scrollTop;
+
+  const headingRect = sidebarHeading.getBoundingClientRect();
+  const sidebarRect = sidebar.getBoundingClientRect();
+
+  const stickyPadding = headingRect.height;
+  const sidebarViewportHeight = sidebarRect.height - stickyPadding;
+
+  const linkRect = currentTrackLink.getBoundingClientRect();
+  const sectionRect = currentTrackSection.getBoundingClientRect();
+
+  const sectionTopEdge =
+    sectionRect.top - (sidebarRect.top - scrollTop);
+
+  const sectionHeight =
+    sectionRect.height;
+
+  const sectionScrollTop =
+    sectionTopEdge - stickyPadding - 10;
+
+  const linkTopEdge =
+    linkRect.top - (sidebarRect.top - scrollTop);
+
+  const linkBottomEdge =
+    linkRect.bottom - (sidebarRect.top - scrollTop);
+
+  const linkScrollTop =
+    linkTopEdge - stickyPadding - 5;
+
+  const linkVisibleFromTopOfSection =
+    linkBottomEdge - sectionTopEdge > sidebarViewportHeight;
+
+  const linkScrollBottom =
+    linkScrollTop - sidebarViewportHeight + linkRect.height + 20;
+
+  const maxScrollInViewport =
+    scrollTop + stickyPadding + sidebarViewportHeight;
+
+  const minScrollInViewport =
+    scrollTop + stickyPadding;
+
+  if (linkBottomEdge > maxScrollInViewport) {
+    if (linkVisibleFromTopOfSection) {
+      sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'});
+    } else {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  } else if (linkTopEdge < minScrollInViewport) {
+    if (linkVisibleFromTopOfSection) {
+      sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'});
+    } else {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  } else if (state.justChangedTrackSection) {
+    if (sectionHeight < sidebarViewportHeight) {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  }
+}
+
+function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) {
+  const {state} = info;
+
+  const trackIndex =
+    (trackDirectory
+      ? info.sidebarTrackDirectories
+          .indexOf(trackDirectory)
+      : -1);
+
+  const sectionIndex =
+    (trackIndex >= 0
+      ? info.sidebarTrackSectionStartIndices
+          .findIndex((start, index, array) =>
+            (index === array.length - 1
+              ? true
+              : trackIndex < array[index + 1]))
+      : -1);
+
+  const sidebarTrackLink =
+    (trackIndex >= 0
+      ? info.sidebarTrackLinks[trackIndex]
+      : null);
+
+  const sidebarTrackSection =
+    (sectionIndex >= 0
+      ? info.sidebarTrackSections[sectionIndex]
+      : null);
+
+  state.currentTrackLink?.classList?.remove('current');
+  state.currentTrackLink = sidebarTrackLink;
+  state.currentTrackLink?.classList?.add('current');
+
+  if (sidebarTrackSection !== state.currentTrackSection) {
+    if (sidebarTrackSection && !sidebarTrackSection.open) {
+      if (state.currentTrackSection) {
+        state.currentTrackSection.open = false;
+      }
+
+      sidebarTrackSection.open = true;
+    }
+
+    state.currentTrackSection?.classList?.remove('current');
+    state.currentTrackSection = sidebarTrackSection;
+    state.currentTrackSection?.classList?.add('current');
+    state.justChangedTrackSection = true;
+  } else {
+    state.justChangedTrackSection = false;
+  }
+}
+
+export function addInternalListeners() {
+  if (!info.sidebar) {
+    return;
+  }
+
+  const mainContentIndex =
+    (stickyHeadingInfo.contentContainers ?? [])
+      .findIndex(({id}) => id === 'content');
+
+  if (mainContentIndex === -1) return;
+
+  stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => {
+    if (index !== mainContentIndex) return;
+    if (hashLinkInfo.state.scrollingAfterClick) return;
+
+    const trackDirectory =
+      (newHeading
+        ? newHeading.id
+        : null);
+
+    markDirectoryAsCurrentForAlbumCommentary(trackDirectory);
+    scrollAlbumCommentarySidebar();
+  });
+
+  hashLinkInfo.event.whenHashLinkClicked.push(({link}) => {
+    const hash = link.getAttribute('href').slice(1);
+    if (!info.sidebarTrackDirectories.includes(hash)) return;
+    markDirectoryAsCurrentForAlbumCommentary(hash);
+  });
+}
diff --git a/src/static/js/client/art-tag-gallery-filter.js b/src/static/js/client/art-tag-gallery-filter.js
new file mode 100644
index 00000000..fd40d1a2
--- /dev/null
+++ b/src/static/js/client/art-tag-gallery-filter.js
@@ -0,0 +1,151 @@
+/* eslint-env browser */
+
+export const info = {
+  id: 'artTagGalleryFilterInfo',
+
+  featuredAllLine: null,
+  showingAllLine: null,
+  showingAllLink: null,
+
+  featuredDirectLine: null,
+  showingDirectLine: null,
+  showingDirectLink: null,
+
+  featuredIndirectLine: null,
+  showingIndirectLine: null,
+  showingIndirectLink: null,
+
+  gridItems: null,
+  gridItemsOnlyFeaturedIndirectly: null,
+  gridItemsFeaturedDirectly: null,
+};
+
+export function getPageReferences() {
+  if (document.documentElement.dataset.urlKey !== 'localized.artTagGallery') {
+    return;
+  }
+
+  info.featuredAllLine =
+    document.getElementById('featured-all-line');
+
+  info.featuredDirectLine =
+    document.getElementById('featured-direct-line');
+
+  info.featuredIndirectLine =
+    document.getElementById('featured-indirect-line');
+
+  info.showingAllLine =
+    document.getElementById('showing-all-line');
+
+  info.showingDirectLine =
+    document.getElementById('showing-direct-line');
+
+  info.showingIndirectLine =
+    document.getElementById('showing-indirect-line');
+
+  info.showingAllLink =
+    info.showingAllLine?.querySelector('a') ?? null;
+
+  info.showingDirectLink =
+    info.showingDirectLine?.querySelector('a') ?? null;
+
+  info.showingIndirectLink =
+    info.showingIndirectLine?.querySelector('a') ?? null;
+
+  info.gridItems =
+    Array.from(
+      document.querySelectorAll('#content .grid-listing .grid-item'));
+
+  info.gridItemsOnlyFeaturedIndirectly =
+    info.gridItems
+      .filter(gridItem => gridItem.classList.contains('featured-indirectly'));
+
+  info.gridItemsFeaturedDirectly =
+    info.gridItems
+      .filter(gridItem => !gridItem.classList.contains('featured-indirectly'));
+}
+
+function filterArtTagGallery(showing) {
+  let gridItemsToShow;
+
+  switch (showing) {
+    case 'all':
+      gridItemsToShow = info.gridItems;
+      break;
+
+    case 'direct':
+      gridItemsToShow = info.gridItemsFeaturedDirectly;
+      break;
+
+    case 'indirect':
+      gridItemsToShow = info.gridItemsOnlyFeaturedIndirectly;
+      break;
+  }
+
+  for (const gridItem of info.gridItems) {
+    if (gridItemsToShow.includes(gridItem)) {
+      gridItem.style.removeProperty('display');
+    } else {
+      gridItem.style.display = 'none';
+    }
+  }
+}
+
+export function addPageListeners() {
+  const orderShowing = [
+    'all',
+    'direct',
+    'indirect',
+  ];
+
+  const orderFeaturedLines = [
+    info.featuredAllLine,
+    info.featuredDirectLine,
+    info.featuredIndirectLine,
+  ];
+
+  const orderShowingLines = [
+    info.showingAllLine,
+    info.showingDirectLine,
+    info.showingIndirectLine,
+  ];
+
+  const orderShowingLinks = [
+    info.showingAllLink,
+    info.showingDirectLink,
+    info.showingIndirectLink,
+  ];
+
+  for (let index = 0; index < orderShowing.length; index++) {
+    if (!orderShowingLines[index]) continue;
+
+    let nextIndex = index;
+    do {
+      if (nextIndex === orderShowing.length) {
+        nextIndex = 0;
+      } else {
+        nextIndex++;
+      }
+    } while (!orderShowingLinks[nextIndex]);
+
+    const currentFeaturedLine = orderFeaturedLines[index];
+    const currentShowingLine = orderShowingLines[index];
+    const currentShowingLink = orderShowingLinks[index];
+
+    const nextFeaturedLine = orderFeaturedLines[nextIndex];
+    const nextShowingLine = orderShowingLines[nextIndex];
+    const nextShowing = orderShowing[nextIndex];
+
+    currentShowingLink.addEventListener('click', event => {
+      event.preventDefault();
+
+      currentFeaturedLine.style.display = 'none';
+      currentShowingLine.style.display = 'none';
+
+      nextFeaturedLine.style.display = 'block';
+      nextShowingLine.style.display = 'block';
+
+      filterArtTagGallery(nextShowing);
+    });
+  }
+}
diff --git a/src/static/js/client/art-tag-network.js b/src/static/js/client/art-tag-network.js
new file mode 100644
index 00000000..44e10c11
--- /dev/null
+++ b/src/static/js/client/art-tag-network.js
@@ -0,0 +1,147 @@
+/* eslint-env browser */
+
+import {cssProp} from '../client-util.js';
+
+import {atOffset, stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'artTagNetworkInfo',
+
+  noneStatLink: null,
+  totalUsesStatLink: null,
+  directUsesStatLink: null,
+  descendantsStatLink: null,
+  leavesStatLink: null,
+
+  tagsWithoutStats: null,
+  tagsWithStats: null,
+
+  totalUsesStats: null,
+  directUsesStats: null,
+  descendantsStats: null,
+  leavesStats: null,
+};
+
+export function getPageReferences() {
+  if (
+    document.documentElement.dataset.urlKey !== 'localized.listing' ||
+    document.documentElement.dataset.urlValue0 !== 'tags/network'
+  ) {
+    return;
+  }
+
+  info.noneStatLink =
+    document.getElementById('network-stat-none');
+
+  info.totalUsesStatLink =
+    document.getElementById('network-stat-total-uses');
+
+  info.directUsesStatLink =
+    document.getElementById('network-stat-direct-uses');
+
+  info.descendantsStatLink =
+    document.getElementById('network-stat-descendants');
+
+  info.leavesStatLink =
+    document.getElementById('network-stat-leaves');
+
+  info.tagsWithoutStats =
+    document.querySelectorAll('.network-tag:not(.with-stat)');
+
+  info.tagsWithStats =
+    document.querySelectorAll('.network-tag.with-stat');
+
+  info.totalUsesStats =
+    Array.from(document.getElementsByClassName('network-tag-total-uses-stat'));
+
+  info.directUsesStats =
+    Array.from(document.getElementsByClassName('network-tag-direct-uses-stat'));
+
+  info.descendantsStats =
+    Array.from(document.getElementsByClassName('network-tag-descendants-stat'));
+
+  info.leavesStats =
+    Array.from(document.getElementsByClassName('network-tag-leaves-stat'));
+}
+
+export function addPageListeners() {
+  if (!info.noneStatLink) return;
+
+  const linkOrder = [
+    info.noneStatLink,
+    info.totalUsesStatLink,
+    info.directUsesStatLink,
+    info.descendantsStatLink,
+    info.leavesStatLink,
+  ];
+
+  const statsOrder = [
+    null,
+    info.totalUsesStats,
+    info.directUsesStats,
+    info.descendantsStats,
+    info.leavesStats,
+  ];
+
+  const stitched =
+    stitchArrays({
+      link: linkOrder,
+      stats: statsOrder,
+    });
+
+  for (const [index, {link}] of stitched.entries()) {
+    const next = atOffset(stitched, index, +1, {wrap: true});
+
+    link.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+
+      cssProp(link, 'display', 'none');
+      cssProp(next.link, 'display', null);
+
+      if (next.stats === null) {
+        hideArtTagNetworkStats();
+      } else {
+        showArtTagNetworkStats(next.stats);
+      }
+    });
+  }
+}
+
+function showArtTagNetworkStats(stats) {
+  for (const tagElement of info.tagsWithoutStats) {
+    cssProp(tagElement, 'display', 'none');
+  }
+
+  for (const tagElement of info.tagsWithStats) {
+    cssProp(tagElement, 'display', null);
+  }
+
+  const allStats = [
+    ...info.totalUsesStats,
+    ...info.directUsesStats,
+    ...info.descendantsStats,
+    ...info.leavesStats,
+  ];
+
+  const otherStats =
+    allStats
+      .filter(stat => !stats.includes(stat));
+
+  for (const statElement of otherStats) {
+    cssProp(statElement, 'display', 'none');
+  }
+
+  for (const statElement of stats) {
+    cssProp(statElement, 'display', null);
+  }
+}
+
+function hideArtTagNetworkStats() {
+  for (const tagElement of info.tagsWithoutStats) {
+    cssProp(tagElement, 'display', null);
+  }
+
+  for (const tagElement of info.tagsWithStats) {
+    cssProp(tagElement, 'display', 'none');
+  }
+}
diff --git a/src/static/js/client/artist-external-link-tooltip.js b/src/static/js/client/artist-external-link-tooltip.js
new file mode 100644
index 00000000..21ddfb91
--- /dev/null
+++ b/src/static/js/client/artist-external-link-tooltip.js
@@ -0,0 +1,196 @@
+/* eslint-env browser */
+
+import {accumulateSum, empty} from '../../shared-util/sugar.js';
+
+import {info as hoverableTooltipInfo, repositionCurrentTooltip}
+  from './hoverable-tooltip.js';
+
+// These don't need to have tooltip events specially added as
+// they're implemented with "text with tooltip" components.
+
+export const info = {
+  id: 'artistExternalLinkTooltipInfo',
+
+  tooltips: null,
+  tooltipRows: null,
+
+  settings: {
+    // This is the maximum distance, in CSS pixels, that the mouse
+    // can appear to be moving per second while still considered
+    // "idle". A greater value means higher tolerance for small
+    // movements.
+    maximumIdleSpeed: 40,
+
+    // Leaving the mouse idle for this amount of time, over a single
+    // row of the tooltip, will cause a column of supplemental info
+    // to display.
+    mouseIdleShowInfoDelay: 1000,
+
+    // If none of these tooltips are visible for this amount of time,
+    // the supplemental info column is hidden. It'll never disappear
+    // while a tooltip is actually visible.
+    hideInfoAfterTooltipHiddenDelay: 2250,
+  },
+
+  state: {
+    // This is shared by all tooltips.
+    showingTooltipInfo: false,
+
+    mouseIdleTimeout: null,
+    hideInfoTimeout: null,
+
+    mouseMovementPositions: [],
+    mouseMovementTimestamps: [],
+  },
+};
+
+export function getPageReferences() {
+  info.tooltips =
+    Array.from(document.getElementsByClassName('contribution-tooltip'));
+
+  info.tooltipRows =
+    info.tooltips.map(tooltip =>
+      Array.from(tooltip.getElementsByClassName('icon')));
+}
+
+export function addInternalListeners() {
+  hoverableTooltipInfo.event.whenTooltipShows.push(({tooltip}) => {
+    const {state} = info;
+
+    if (info.tooltips.includes(tooltip)) {
+      clearTimeout(state.hideInfoTimeout);
+      state.hideInfoTimeout = null;
+    }
+  });
+
+  hoverableTooltipInfo.event.whenTooltipHides.push(() => {
+    const {settings, state} = info;
+
+    if (state.showingTooltipInfo) {
+      state.hideInfoTimeout =
+        setTimeout(() => {
+          state.hideInfoTimeout = null;
+          hideArtistExternalLinkTooltipInfo();
+        }, settings.hideInfoAfterTooltipHiddenDelay);
+    } else {
+      clearTimeout(state.mouseIdleTimeout);
+      state.mouseIdleTimeout = null;
+    }
+  });
+}
+
+export function addPageListeners() {
+  for (const tooltip of info.tooltips) {
+    tooltip.addEventListener('mousemove', domEvent => {
+      handleArtistExternalLinkTooltipMouseMoved(domEvent);
+    });
+
+    tooltip.addEventListener('mouseout', () => {
+      const {state} = info;
+
+      clearTimeout(state.mouseIdleTimeout);
+      state.mouseIdleTimeout = null;
+    });
+  }
+
+  for (const tooltipRow of info.tooltipRows.flat()) {
+    tooltipRow.addEventListener('mouseover', () => {
+      const {state} = info;
+
+      clearTimeout(state.mouseIdleTimeout);
+      state.mouseIdleTimeout = null;
+    });
+  }
+}
+
+function handleArtistExternalLinkTooltipMouseMoved(domEvent) {
+  const {settings, state} = info;
+
+  if (state.showingTooltipInfo) {
+    return;
+  }
+
+  // Clean out expired mouse movements
+
+  const expiryTime = 1000;
+
+  if (!empty(state.mouseMovementTimestamps)) {
+    const firstRecentMovementIndex =
+      state.mouseMovementTimestamps
+        .findIndex(value => Date.now() - value <= expiryTime);
+
+    if (firstRecentMovementIndex === -1) {
+      state.mouseMovementTimestamps.splice(0);
+      state.mouseMovementPositions.splice(0);
+    } else if (firstRecentMovementIndex > 0) {
+      state.mouseMovementTimestamps.splice(0, firstRecentMovementIndex - 1);
+      state.mouseMovementPositions.splice(0, firstRecentMovementIndex - 1);
+    }
+  }
+
+  state.mouseMovementTimestamps.push(Date.now());
+  state.mouseMovementPositions.push([domEvent.screenX, domEvent.screenY]);
+
+  // We can't really compute speed without having
+  // at least two data points!
+  if (state.mouseMovementPositions.length < 2) {
+    return;
+  }
+
+  const movementTravelDistances =
+    state.mouseMovementPositions.map((current, index, array) => {
+      if (index === 0) return 0;
+
+      const previous = array[index - 1];
+      const deltaX = current[0] - previous[0];
+      const deltaY = current[1] - previous[1];
+      return Math.sqrt(deltaX ** 2 + deltaY ** 2);
+    });
+
+  const totalTravelDistance =
+    accumulateSum(movementTravelDistances);
+
+  // In seconds rather than milliseconds.
+  const timeSinceFirstMovement =
+    (Date.now() - state.mouseMovementTimestamps[0]) / 1000;
+
+  const averageSpeed =
+    Math.floor(totalTravelDistance / timeSinceFirstMovement);
+
+  if (averageSpeed > settings.maximumIdleSpeed) {
+    clearTimeout(state.mouseIdleTimeout);
+    state.mouseIdleTimeout = null;
+  }
+
+  if (state.mouseIdleTimeout) {
+    return;
+  }
+
+  state.mouseIdleTimeout =
+    setTimeout(() => {
+      state.mouseIdleTimeout = null;
+      showArtistExternalLinkTooltipInfo();
+    }, settings.mouseIdleShowInfoDelay);
+}
+
+function showArtistExternalLinkTooltipInfo() {
+  const {state} = info;
+
+  state.showingTooltipInfo = true;
+
+  for (const tooltip of info.tooltips) {
+    tooltip.classList.add('show-info');
+  }
+
+  repositionCurrentTooltip();
+}
+
+function hideArtistExternalLinkTooltipInfo() {
+  const {state} = info;
+
+  state.showingTooltipInfo = false;
+
+  for (const tooltip of info.tooltips) {
+    tooltip.classList.remove('show-info');
+  }
+}
diff --git a/src/static/js/client/css-compatibility-assistant.js b/src/static/js/client/css-compatibility-assistant.js
new file mode 100644
index 00000000..aa637cc4
--- /dev/null
+++ b/src/static/js/client/css-compatibility-assistant.js
@@ -0,0 +1,30 @@
+/* eslint-env browser */
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'cssCompatibilityAssistantInfo',
+
+  coverArtworks: null,
+  coverArtworkImageDetails: null,
+};
+
+export function getPageReferences() {
+  info.coverArtworks =
+    Array.from(document.querySelectorAll('.cover-artwork'));
+
+  info.coverArtworkImageDetails =
+    info.coverArtworks
+      .map(artwork => artwork.querySelector('.image-details'));
+}
+
+export function mutatePageContent() {
+  stitchArrays({
+    coverArtwork: info.coverArtworks,
+    imageDetails: info.coverArtworkImageDetails,
+  }).forEach(({coverArtwork, imageDetails}) => {
+      if (imageDetails) {
+        coverArtwork.classList.add('has-image-details');
+      }
+    });
+}
diff --git a/src/static/js/client/datetimestamp-tooltip.js b/src/static/js/client/datetimestamp-tooltip.js
new file mode 100644
index 00000000..46d1cd5b
--- /dev/null
+++ b/src/static/js/client/datetimestamp-tooltip.js
@@ -0,0 +1,36 @@
+/* eslint-env browser */
+
+// TODO: Maybe datetimestamps can just be incorporated into text-with-tooltip?
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+import {registerTooltipElement, registerTooltipHoverableElement}
+  from './hoverable-tooltip.js';
+
+export const info = {
+  id: 'datetimestampTooltipInfo',
+
+  hoverables: null,
+  tooltips: null,
+};
+
+export function getPageReferences() {
+  const spans =
+    Array.from(document.querySelectorAll('span.datetimestamp.has-tooltip'));
+
+  info.hoverables =
+    spans.map(span => span.querySelector('time'));
+
+  info.tooltips =
+    spans.map(span => span.querySelector('span.datetimestamp-tooltip'));
+}
+
+export function addPageListeners() {
+  for (const {hoverable, tooltip} of stitchArrays({
+    hoverable: info.hoverables,
+    tooltip: info.tooltips,
+  })) {
+    registerTooltipElement(tooltip);
+    registerTooltipHoverableElement(hoverable, tooltip);
+  }
+}
diff --git a/src/static/js/client/dragged-link.js b/src/static/js/client/dragged-link.js
new file mode 100644
index 00000000..56021e7f
--- /dev/null
+++ b/src/static/js/client/dragged-link.js
@@ -0,0 +1,62 @@
+/* eslint-env browser */
+
+export const info = {
+  id: `draggedLinkInfo`,
+
+  state: {
+    latestDraggedLink: null,
+    observedLinks: new WeakSet(),
+  },
+};
+
+export function getPageReferences() {
+  // First start handling all the links that currently exist.
+
+  for (const a of document.getElementsByTagName('a')) {
+    observeLink(a);
+    addDragListener(a);
+  }
+
+  // Then add a mutation observer to track new links.
+
+  const observer = new MutationObserver(records => {
+    for (const record of records) {
+      for (const node of record.addedNodes) {
+        if (node.nodeName !== 'A') continue;
+        observeLink(node);
+      }
+    }
+  });
+
+  observer.observe(document.body, {
+    subtree: true,
+    childList: true,
+  });
+}
+
+export function getLatestDraggedLink() {
+  const {state} = info;
+
+  if (state.latestDraggedLink) {
+    return state.latestDraggedLink.deref() ?? null;
+  } else {
+    return null;
+  }
+}
+
+function observeLink(link) {
+  const {state} = info;
+
+  if (state.observedLinks.has(link)) return;
+
+  state.observedLinks.add(link);
+  addDragListener(link);
+}
+
+function addDragListener(link) {
+  const {state} = info;
+
+  link.addEventListener('dragstart', _domEvent => {
+    state.latestDraggedLink = new WeakRef(link);
+  });
+}
diff --git a/src/static/js/client/hash-link.js b/src/static/js/client/hash-link.js
new file mode 100644
index 00000000..27035e29
--- /dev/null
+++ b/src/static/js/client/hash-link.js
@@ -0,0 +1,146 @@
+/* eslint-env browser */
+
+import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js';
+
+import {dispatchInternalEvent} from '../client-util.js';
+
+export const info = {
+  id: 'hashLinkInfo',
+
+  links: null,
+  hrefs: null,
+  targets: null,
+
+  state: {
+    highlightedTarget: null,
+    scrollingAfterClick: false,
+    concludeScrollingStateInterval: null,
+  },
+
+  event: {
+    beforeHashLinkScrolls: [],
+    whenHashLinkClicked: [],
+  },
+};
+
+export function getPageReferences() {
+  info.links =
+    Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])'));
+
+  info.hrefs =
+    info.links
+      .map(link => link.getAttribute('href'));
+
+  info.targets =
+    info.hrefs
+      .map(href => document.getElementById(href.slice(1)));
+
+  filterMultipleArrays(
+    info.links,
+    info.hrefs,
+    info.targets,
+    (_link, _href, target) => target);
+}
+
+function processScrollingAfterHashLinkClicked() {
+  const {state} = info;
+
+  if (state.concludeScrollingStateInterval) return;
+
+  let lastScroll = window.scrollY;
+  state.scrollingAfterClick = true;
+  state.concludeScrollingStateInterval = setInterval(() => {
+    if (Math.abs(window.scrollY - lastScroll) < 10) {
+      clearInterval(state.concludeScrollingStateInterval);
+      state.scrollingAfterClick = false;
+      state.concludeScrollingStateInterval = null;
+    } else {
+      lastScroll = window.scrollY;
+    }
+  }, 200);
+}
+
+export function addPageListeners() {
+  // Instead of defining a scroll offset (to account for the sticky heading)
+  // in JavaScript, we interface with the CSS property 'scroll-margin-top'.
+  // This lets the scroll offset be consolidated where it makes sense, and
+  // sets an appropriate offset when (re)loading a page with hash for free!
+
+  const {state, event} = info;
+
+  for (const {hashLink, href, target} of stitchArrays({
+    hashLink: info.links,
+    href: info.hrefs,
+    target: info.targets,
+  })) {
+    hashLink.addEventListener('click', evt => {
+      if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) {
+        return;
+      }
+
+      // Don't do anything if the target element isn't actually visible!
+      if (target.offsetParent === null) {
+        return;
+      }
+
+      // Allow event handlers to prevent scrolling.
+      const listenerResults =
+        dispatchInternalEvent(event, 'beforeHashLinkScrolls', {
+          link: hashLink,
+          target,
+        });
+
+      if (listenerResults.includes(false)) {
+        return;
+      }
+
+      // Hide skipper box right away, so the layout is updated on time for the
+      // math operations coming up next.
+      const skipper = document.getElementById('skippers');
+      skipper.style.display = 'none';
+      setTimeout(() => skipper.style.display = '');
+
+      const box = target.getBoundingClientRect();
+      const style = window.getComputedStyle(target);
+
+      const scrollY =
+          window.scrollY
+        + box.top
+        - style['scroll-margin-top'].replace('px', '');
+
+      evt.preventDefault();
+      history.pushState({}, '', href);
+      window.scrollTo({top: scrollY, behavior: 'smooth'});
+      target.focus({preventScroll: true});
+
+      const maxScroll =
+          document.body.scrollHeight
+        - window.innerHeight;
+
+      if (scrollY > maxScroll && target.classList.contains('content-heading')) {
+        if (state.highlightedTarget) {
+          state.highlightedTarget.classList.remove('highlight-hash-link');
+        }
+
+        target.classList.add('highlight-hash-link');
+        state.highlightedTarget = target;
+      }
+
+      processScrollingAfterHashLinkClicked();
+
+      dispatchInternalEvent(event, 'whenHashLinkClicked', {
+        link: hashLink,
+        target,
+      });
+    });
+  }
+
+  for (const target of info.targets) {
+    target.addEventListener('animationend', evt => {
+      if (evt.animationName !== 'highlight-hash-link') return;
+      target.classList.remove('highlight-hash-link');
+      if (target !== state.highlightedTarget) return;
+      state.highlightedTarget = null;
+    });
+  }
+}
diff --git a/src/static/js/client/hoverable-tooltip.js b/src/static/js/client/hoverable-tooltip.js
new file mode 100644
index 00000000..9569de3e
--- /dev/null
+++ b/src/static/js/client/hoverable-tooltip.js
@@ -0,0 +1,1102 @@
+/* eslint-env browser */
+
+import {empty, filterMultipleArrays} from '../../shared-util/sugar.js';
+
+import {WikiRect} from '../rectangles.js';
+
+import {
+  cssProp,
+  dispatchInternalEvent,
+  getVisuallyContainingElement,
+  pointIsOverAnyOf,
+} from '../client-util.js';
+
+import {info as stickyHeadingInfo} from './sticky-heading.js';
+
+export const info = {
+  id: 'hoverableTooltipInfo',
+
+  settings: {
+    // Hovering has two speed settings. The normal setting is used by default,
+    // and once a tooltip is displayed as a result of hover, the entire tooltip
+    // system will enter a "fast hover mode" - hovering will activate tooltips
+    // sooner. "Fast hover mode" is disabled after a sustained duration of not
+    // hovering over any hoverables; it's meant only to accelerate switching
+    // tooltips while still deciding, or getting a quick overview across more
+    // than one tooltip.
+    normalHoverInfoDelay: 400,
+    fastHoveringInfoDelay: 150,
+    endFastHoveringDelay: 500,
+
+    // Focusing has a single speed setting, which is how long it will take to
+    // enter a functional "focus mode" (though it's not actually implemented
+    // in terms of this state). As soon as "focus mode" is entered, the tooltip
+    // for the current hoverable is displayed, and focusing another hoverable
+    // will cause the current tooltip to be swapped for that one immediately.
+    // "Focus mode" ends as soon as anything apart from a tooltip or hoverable
+    // is focused, and it will be necessary to wait on this delay again.
+    focusInfoDelay: 750,
+
+    hideTooltipDelay: 500,
+
+    // If a tooltip that's transitioning to hidden is hovered during the grace
+    // period (or the corresponding hoverable is hovered at any point in the
+    // transition), it'll cancel out of this animation immediately.
+    transitionHiddenDuration: 300,
+    inertGracePeriod: 100,
+  },
+
+  state: {
+    // These maps store a record for each registered element and related state
+    // and registration info, if applicable.
+    registeredTooltips: new Map(),
+    registeredHoverables: new Map(),
+
+    // These are common across all tooltips, rather than stored individually,
+    // based on the principles that 1) only a single tooltip can be displayed
+    // at once, and 2) likewise, only a single hoverable can be hovered,
+    // focused, or otherwise active at once.
+    hoverTimeout: null,
+    focusTimeout: null,
+    touchTimeout: null,
+    hideTimeout: null,
+    transitionHiddenTimeout: null,
+    inertGracePeriodTimeout: null,
+    currentlyShownTooltip: null,
+    currentlyActiveHoverable: null,
+    currentlyTransitioningHiddenTooltip: null,
+    previouslyActiveHoverable: null,
+    tooltipWasJustHidden: false,
+    hoverableWasRecentlyTouched: false,
+
+    // Fast hovering is a global mode which is activated as soon as any tooltip
+    // is displayed and turns off after a delay of no hoverables being hovered.
+    // Note that fast hovering may be turned off while hovering a tooltip, but
+    // it will never be turned off while idling over a hoverable.
+    fastHovering: false,
+    endFastHoveringTimeout: false,
+
+    // These track the identifiers of current touches and a record of current
+    // identifiers that are "banished" by scrolling - that is, touches which
+    // existed while the page scrolled and were probably responsible for that
+    // scrolling. This is a bit loose (we can't actually tell which touches
+    // caused the page to scroll) but it's intended to keep scrolling the page
+    // 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: {
+    whenTooltipShows: [],
+    whenTooltipHides: [],
+  },
+};
+
+// Adds DOM event listeners, so must be called during addPageListeners step.
+export function registerTooltipElement(tooltip) {
+  const {state} = info;
+
+  if (!tooltip)
+    throw new Error(`Expected tooltip`);
+
+  if (state.registeredTooltips.has(tooltip))
+    throw new Error(`This tooltip is already registered`);
+
+  // No state or registration info here.
+  state.registeredTooltips.set(tooltip, {});
+
+  tooltip.addEventListener('mouseenter', () => {
+    handleTooltipMouseEntered(tooltip);
+  });
+
+  tooltip.addEventListener('mouseleave', () => {
+    handleTooltipMouseLeft(tooltip);
+  });
+
+  tooltip.addEventListener('focusin', event => {
+    handleTooltipReceivedFocus(tooltip, event.relatedTarget);
+  });
+
+  tooltip.addEventListener('focusout', event => {
+    // This event gets activated for tabbing *between* links inside the
+    // tooltip, which is no good and certainly doesn't represent the focus
+    // leaving the tooltip.
+    if (currentlyShownTooltipHasFocus(event.relatedTarget)) return;
+
+    handleTooltipLostFocus(tooltip, event.relatedTarget);
+  });
+}
+
+// Adds DOM event listeners, so must be called during addPageListeners step.
+export function registerTooltipHoverableElement(hoverable, tooltip) {
+  const {state} = info;
+
+  if (!hoverable || !tooltip)
+    if (hoverable)
+      throw new Error(`Expected hoverable and tooltip, got only hoverable`);
+    else
+      throw new Error(`Expected hoverable and tooltip, got neither`);
+
+  if (!state.registeredTooltips.has(tooltip))
+    throw new Error(`Register tooltip before registering hoverable`);
+
+  if (state.registeredHoverables.has(hoverable))
+    throw new Error(`This hoverable is already registered`);
+
+  state.registeredHoverables.set(hoverable, {tooltip});
+
+  hoverable.addEventListener('mouseenter', () => {
+    handleTooltipHoverableMouseEntered(hoverable);
+  });
+
+  hoverable.addEventListener('mouseleave', () => {
+    handleTooltipHoverableMouseLeft(hoverable);
+  });
+
+  hoverable.addEventListener('focusin', event => {
+    handleTooltipHoverableReceivedFocus(hoverable, event);
+  });
+
+  hoverable.addEventListener('focusout', event => {
+    handleTooltipHoverableLostFocus(hoverable, event);
+  });
+
+  hoverable.addEventListener('touchend', event => {
+    handleTooltipHoverableTouchEnded(hoverable, event);
+  });
+
+  hoverable.addEventListener('click', event => {
+    handleTooltipHoverableClicked(hoverable, event);
+  });
+}
+
+function handleTooltipMouseEntered(tooltip) {
+  const {state} = info;
+
+  if (state.currentlyTransitioningHiddenTooltip) {
+    cancelTransitioningTooltipHidden(true);
+    return;
+  }
+
+  if (state.currentlyShownTooltip !== tooltip) return;
+
+  // Don't time out the current tooltip while hovering it.
+
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipMouseLeft(tooltip) {
+  const {settings, state} = info;
+
+  if (state.currentlyShownTooltip !== tooltip) return;
+
+  // Start timing out the current tooltip when it's left. This could be
+  // canceled by mousing over a hoverable, or back over the tooltip again.
+  if (!state.hideTimeout) {
+    state.hideTimeout =
+      setTimeout(() => {
+        state.hideTimeout = null;
+        hideCurrentlyShownTooltip();
+      }, settings.hideTooltipDelay);
+  }
+}
+
+function handleTooltipReceivedFocus(_tooltip) {
+  const {state} = info;
+
+  // Cancel the tooltip-hiding timeout if it exists. The tooltip will never
+  // be hidden while it contains the focus anyway, but this ensures the timeout
+  // will be suitably reset when the tooltip loses focus.
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipLostFocus(_tooltip) {
+  // Hide the current tooltip right away when it loses focus. Specify intent
+  // to replace - while we don't strictly know if another tooltip is going to
+  // immediately replace it, the mode of navigating with tab focus (once one
+  // tooltip has been activated) is a "switch focus immediately" kind of
+  // interaction in its nature.
+  hideCurrentlyShownTooltip(true);
+}
+
+function handleTooltipHoverableMouseEntered(hoverable) {
+  const {settings, state} = info;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  // If this tooltip was transitioning to hidden, hovering should cancel that
+  // animation and show it immediately.
+
+  if (tooltip === state.currentlyTransitioningHiddenTooltip) {
+    cancelTransitioningTooltipHidden(true);
+    return;
+  }
+
+  // Start a timer to show the corresponding tooltip, with the delay depending
+  // on whether fast hovering or not. This could be canceled by mousing out of
+  // the hoverable.
+
+  const hoverTimeoutDelay =
+    (state.fastHovering
+      ? settings.fastHoveringInfoDelay
+      : settings.normalHoverInfoDelay);
+
+  state.hoverTimeout =
+    setTimeout(() => {
+      state.hoverTimeout = null;
+      state.fastHovering = true;
+      showTooltipFromHoverable(hoverable);
+    }, hoverTimeoutDelay);
+
+  // Don't stop fast hovering while over any hoverable.
+  if (state.endFastHoveringTimeout) {
+    clearTimeout(state.endFastHoveringTimeout);
+    state.endFastHoveringTimeout = null;
+  }
+
+  // Don't time out the current tooltip while over any hoverable.
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipHoverableMouseLeft(_hoverable) {
+  const {settings, state} = info;
+
+  // Don't show a tooltip when not over a hoverable!
+  if (state.hoverTimeout) {
+    clearTimeout(state.hoverTimeout);
+    state.hoverTimeout = null;
+  }
+
+  // Start timing out fast hovering (if active) when not over a hoverable.
+  // This will only be canceled by mousing over another hoverable.
+  if (state.fastHovering && !state.endFastHoveringTimeout) {
+    state.endFastHoveringTimeout =
+      setTimeout(() => {
+        state.endFastHoveringTimeout = null;
+        state.fastHovering = false;
+      }, settings.endFastHoveringDelay);
+  }
+
+  // Start timing out the current tooltip when mousing not over a hoverable.
+  // This could be canceled by mousing over another hoverable, or over the
+  // currently shown tooltip.
+  if (state.currentlyShownTooltip && !state.hideTimeout) {
+    state.hideTimeout =
+      setTimeout(() => {
+        state.hideTimeout = null;
+        hideCurrentlyShownTooltip();
+      }, settings.hideTooltipDelay);
+  }
+}
+
+function handleTooltipHoverableReceivedFocus(hoverable) {
+  const {settings, state} = info;
+
+  // By default, display the corresponding tooltip after a delay.
+
+  state.focusTimeout =
+    setTimeout(() => {
+      state.focusTimeout = null;
+      showTooltipFromHoverable(hoverable);
+    }, settings.focusInfoDelay);
+
+  // If a tooltip was just hidden - which is almost certainly a result of the
+  // focus changing - then display this tooltip immediately, canceling the
+  // above timeout.
+
+  if (state.tooltipWasJustHidden) {
+    clearTimeout(state.focusTimeout);
+    state.focusTimeout = null;
+
+    showTooltipFromHoverable(hoverable);
+  }
+}
+
+function handleTooltipHoverableLostFocus(hoverable, domEvent) {
+  const {state} = info;
+
+  // Don't show a tooltip from focusing a hoverable if it isn't focused
+  // anymore! If another hoverable is receiving focus, that will be evaluated
+  // and set its own focus timeout after we clear the previous one here.
+  if (state.focusTimeout) {
+    clearTimeout(state.focusTimeout);
+    state.focusTimeout = null;
+  }
+
+  // Unless focus is entering the tooltip itself, hide the tooltip immediately.
+  // This will set the tooltipWasJustHidden flag, which is detected by a newly
+  // focused hoverable, if applicable. Always specify intent to replace when
+  // navigating via tab focus. (Check `handleTooltipLostFocus` for details.)
+  if (!currentlyShownTooltipHasFocus(domEvent.relatedTarget)) {
+    hideCurrentlyShownTooltip(true);
+  }
+}
+
+function handleTooltipHoverableTouchEnded(hoverable, domEvent) {
+  const {state} = info;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  // Don't proceed if this hoverable's tooltip is already visible - in that
+  // case touching the hoverable again should behave just like a normal click.
+  if (state.currentlyShownTooltip === tooltip) {
+    // If the hoverable was *recently* touched - meaning that this is a second
+    // touchend in short succession - then just letting the click come through
+    // naturally would (depending on timing) not actually navigate anywhere,
+    // because we've deliberately banished the *first* touch from navigation.
+    // We do want the second touch to navigate, so clear that recently-touched
+    // state, allowing this touch's click to behave as normal.
+    if (state.hoverableWasRecentlyTouched) {
+      clearTimeout(state.touchTimeout);
+      state.touchTimeout = null;
+      state.hoverableWasRecentlyTouched = false;
+    }
+
+    // Otherwise, this is just a second touch after enough time has passed
+    // that the one which showed the tooltip is no longer "recent", and we're
+    // not in any special state. The link will navigate to its page just like
+    // normal.
+    return;
+  }
+
+  const touches = Array.from(domEvent.changedTouches);
+  const identifiers = touches.map(touch => touch.identifier);
+
+  // Don't process touch events that were "banished" because the page was
+  // scrolled while those touches were active, and most likely as a result of
+  // them.
+  filterMultipleArrays(touches, identifiers,
+    (_touch, identifier) =>
+      !state.touchIdentifiersBanishedByScrolling.has(identifier));
+
+  if (empty(touches)) return;
+
+  // Don't proceed if none of the (just-ended) touches ended over the
+  // hoverable.
+
+  const pointIsOverThisHoverable = pointIsOverAnyOf([hoverable]);
+
+  const anyTouchEndedOverHoverable =
+    touches.some(({clientX, clientY}) =>
+      pointIsOverThisHoverable(clientX, clientY));
+
+  if (!anyTouchEndedOverHoverable) {
+    return;
+  }
+
+  if (state.touchTimeout) {
+    clearTimeout(state.touchTimeout);
+    state.touchTimeout = null;
+  }
+
+  // Show the tooltip right away.
+  showTooltipFromHoverable(hoverable);
+
+  // Set a state, for a brief but not instantaneous period, indicating that a
+  // hoverable was recently touched. The touchend event may precede the click
+  // event by some time, and we don't want to navigate away from the page as
+  // a result of the click event which this touch precipitated.
+  state.hoverableWasRecentlyTouched = true;
+  state.touchTimeout =
+    setTimeout(() => {
+      state.touchTimeout = null;
+      state.hoverableWasRecentlyTouched = false;
+    }, 1200);
+}
+
+function handleTooltipHoverableClicked(hoverable) {
+  const {state} = info;
+
+  // Don't navigate away from the page if the this hoverable was recently
+  // touched (and had its tooltip activated). That flag won't be set if its
+  // tooltip was already open before the touch.
+  if (
+    state.currentlyActiveHoverable === hoverable &&
+    state.hoverableWasRecentlyTouched
+  ) {
+    event.preventDefault();
+  }
+}
+
+export function currentlyShownTooltipHasFocus(focusElement = document.activeElement) {
+  const {state} = info;
+
+  const {
+    currentlyShownTooltip: tooltip,
+    currentlyActiveHoverable: hoverable,
+  } = state;
+
+  // If there's no tooltip, it can't possibly have focus.
+  if (!tooltip) return false;
+
+  // If the tooltip literally contains (or is) the focused element, then that's
+  // the principle condition we're looking for.
+  if (tooltip.contains(focusElement)) return true;
+
+  // If the hoverable *which opened the tooltip* is focused, then that also
+  // represents the tooltip being focused (in its currently shown state).
+  if (hoverable.contains(focusElement)) return true;
+
+  return false;
+}
+
+export function beginTransitioningTooltipHidden(tooltip) {
+  const {settings, state} = info;
+
+  if (state.currentlyTransitioningHiddenTooltip) {
+    cancelTransitioningTooltipHidden();
+  }
+
+  cssProp(tooltip, {
+    'display': 'block',
+    'opacity': '0',
+
+    'transition-property': 'opacity',
+    'transition-timing-function':
+      `steps(${Math.ceil(settings.transitionHiddenDuration / 60)}, end)`,
+    'transition-duration':
+      `${settings.transitionHiddenDuration / 1000}s`,
+  });
+
+  state.currentlyTransitioningHiddenTooltip = tooltip;
+  state.transitionHiddenTimeout =
+    setTimeout(() => {
+      endTransitioningTooltipHidden();
+    }, settings.transitionHiddenDuration);
+}
+
+export function cancelTransitioningTooltipHidden(andShow = false) {
+  const {state} = info;
+
+  endTransitioningTooltipHidden();
+
+  if (andShow) {
+    showTooltipFromHoverable(state.previouslyActiveHoverable);
+  }
+}
+
+export function endTransitioningTooltipHidden() {
+  const {state} = info;
+  const {currentlyTransitioningHiddenTooltip: tooltip} = state;
+
+  if (!tooltip) return;
+
+  cssProp(tooltip, {
+    'display': null,
+    'opacity': null,
+    'transition-property': null,
+    'transition-timing-function': null,
+    'transition-duration': null,
+  });
+
+  state.currentlyTransitioningHiddenTooltip = null;
+
+  if (state.inertGracePeriodTimeout) {
+    clearTimeout(state.inertGracePeriodTimeout);
+    state.inertGracePeriodTimeout = null;
+  }
+
+  if (state.transitionHiddenTimeout) {
+    clearTimeout(state.transitionHiddenTimeout);
+    state.transitionHiddenTimeout = null;
+  }
+}
+
+export function hideCurrentlyShownTooltip(intendingToReplace = false) {
+  const {settings, state, event} = info;
+  const {currentlyShownTooltip: tooltip} = state;
+
+  // If there was no tooltip to begin with, we're functionally in the desired
+  // state already, so return true.
+  if (!tooltip) return true;
+
+  // Never hide the tooltip if it's focused.
+  if (currentlyShownTooltipHasFocus()) return false;
+
+  state.currentlyActiveHoverable.classList.remove('has-visible-tooltip');
+
+  // If there's no intent to replace this tooltip, it's the last one currently
+  // apparent in the interaction, and should be hidden with a transition.
+  if (intendingToReplace) {
+    cssProp(tooltip, 'display', 'none');
+  } else {
+    beginTransitioningTooltipHidden(state.currentlyShownTooltip);
+  }
+
+  // Wait just a moment before making the tooltip inert. You might react
+  // (to the ghosting, or just to time passing) and realize you wanted
+  // to look at the tooltip after all - this delay gives a little buffer
+  // to second guess letting it disappear.
+  state.inertGracePeriodTimeout =
+    setTimeout(() => {
+      tooltip.inert = true;
+    }, settings.inertGracePeriod);
+
+  state.previouslyActiveHoverable = state.currentlyActiveHoverable;
+
+  state.currentlyShownTooltip = null;
+  state.currentlyActiveHoverable = null;
+
+  state.dynamicTooltipAnchorDirection = null;
+
+  // Set this for one tick of the event cycle.
+  state.tooltipWasJustHidden = true;
+  setTimeout(() => {
+    state.tooltipWasJustHidden = false;
+  });
+
+  dispatchInternalEvent(event, 'whenTooltipHides', {
+    tooltip,
+  });
+
+  return true;
+}
+
+export function showTooltipFromHoverable(hoverable) {
+  const {state, event} = info;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  if (!hideCurrentlyShownTooltip(true)) return false;
+
+  // Cancel out another tooltip that's transitioning hidden, if that's going
+  // on - it's a distraction that this tooltip is now replacing.
+  cancelTransitioningTooltipHidden();
+
+  hoverable.classList.add('has-visible-tooltip');
+
+  const isolator =
+    hoverable.closest('.isolate-tooltip-z-indexing > *');
+
+  if (isolator) {
+    for (const child of isolator.parentElement.children) {
+      cssProp(child, 'z-index', null);
+    }
+
+    cssProp(isolator, 'z-index', '1');
+  }
+
+  positionTooltipFromHoverableWithBrains(hoverable);
+
+  cssProp(tooltip, 'display', 'block');
+  tooltip.inert = false;
+
+  state.currentlyShownTooltip = tooltip;
+  state.currentlyActiveHoverable = hoverable;
+
+  state.tooltipWasJustHidden = false;
+
+  dispatchInternalEvent(event, 'whenTooltipShows', {
+    tooltip,
+  });
+
+  return true;
+}
+
+export function peekTooltipClientRect(tooltip) {
+  const oldDisplayStyle = cssProp(tooltip, 'display');
+  cssProp(tooltip, 'display', 'block');
+
+  // Tooltips have a bit of padding that makes the interactive
+  // area wider, so that you're less likely to accidentally let
+  // the tooltip disappear (by hovering outside it). But this
+  // isn't visual at all, so for placement we only care about
+  // the content element.
+  const content =
+    tooltip.querySelector('.tooltip-content');
+
+  try {
+    return WikiRect.fromElement(content);
+  } finally {
+    cssProp(tooltip, 'display', oldDisplayStyle);
+  }
+}
+
+export function repositionCurrentTooltip() {
+  const {state} = info;
+  const {currentlyActiveHoverable} = state;
+
+  if (!currentlyActiveHoverable) {
+    throw new Error(`No hoverable active to reposition tooltip from`);
+  }
+
+  positionTooltipFromHoverableWithBrains(currentlyActiveHoverable);
+}
+
+export function positionTooltipFromHoverableWithBrains(hoverable) {
+  const {state} = info;
+  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.
+  resetDynamicTooltipPositioning(tooltip);
+
+  const opportunities =
+    getTooltipFromHoverablePlacementOpportunityAreas(hoverable);
+
+  const tooltipRect =
+    peekTooltipClientRect(tooltip);
+
+  // If the tooltip is already in the baseline containing area,
+  // prefer to keep it positioned naturally, adjusted by CSS
+  // instead of JavaScript.
+
+  const {numBaselineRects, idealBaseline: baselineRect} = opportunities;
+
+  if (baselineRect.contains(tooltipRect)) {
+    return;
+  }
+
+  const tryDirection = (dir1, dir2, i) => {
+    selectedRect = opportunities[dir1][dir2][i];
+    return !!selectedRect;
+  };
+
+  let selectedRect = null;
+  selectRect: {
+    if (anchorDirection) {
+      for (let i = 0; i < numBaselineRects; i++) {
+        if (tryDirection(...anchorDirection, i)) {
+          break selectRect;
+        }
+      }
+    }
+
+    for (let i = 0; i < numBaselineRects; i++) {
+      for (const [dir1, dir2] of [
+        ['down', 'right'],
+        ['down', 'left'],
+        ['right', 'down'],
+        ['left', 'down'],
+        ['right', 'up'],
+        ['left', 'up'],
+        ['up', 'right'],
+        ['up', 'left'],
+      ]) {
+        if (tryDirection(dir1, dir2, i)) {
+          state.dynamicTooltipAnchorDirection = [dir1, dir2];
+          break selectRect;
+        }
+      }
+    }
+
+    selectedRect = baselineRect;
+  }
+
+  positionTooltip(tooltip, selectedRect.x, selectedRect.y);
+}
+
+export function positionTooltip(tooltip, x, y) {
+  // Imagine what it'd be like if the tooltip were positioned
+  // with zero left/top offset, and calculate its actual offsets
+  // based on that.
+
+  cssProp(tooltip, {
+    left: `0`,
+    top: `0`,
+  });
+
+  const tooltipRect =
+    peekTooltipClientRect(tooltip);
+
+  cssProp(tooltip, {
+    left: `${x - tooltipRect.x}px`,
+    top: `${y - tooltipRect.y}px`,
+  });
+}
+
+export function resetDynamicTooltipPositioning(tooltip) {
+  cssProp(tooltip, {
+    left: null,
+    top: null,
+  });
+}
+
+export function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) {
+  const {state} = info;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  const baselineRects =
+    getTooltipBaselineOpportunityAreas(tooltip);
+
+  const hoverableRect =
+    WikiRect.fromElementUnderMouse(hoverable).toExtended(5, 10);
+
+  const tooltipRect =
+    peekTooltipClientRect(tooltip);
+
+  // Get placements relative to the hoverable. Make these available by key,
+  // allowing the caller to choose by preferred orientation. Each value is
+  // an array which corresponds to the baseline areas - placement closer to
+  // front of the array indicates stronger preference. Since not all relative
+  // placements cooperate with all baseline areas, any of these arrays may
+  // include (or be entirely made of) null.
+
+  const keepIfFits = (rect) =>
+    (rect?.fits(tooltipRect)
+      ? rect
+      : null);
+
+  const prepareRegionRects = (relationalRect, direct) =>
+    baselineRects
+      .map(rect => rect.intersectionWith(relationalRect))
+      .map(direct)
+      .map(keepIfFits);
+
+  const regionRects = {
+    left:
+      prepareRegionRects(
+        WikiRect.leftOf(hoverableRect),
+        rect => WikiRect.fromRect({
+          x: rect.right,
+          y: rect.y,
+          width: -rect.width,
+          height: rect.height,
+        })),
+
+    right:
+      prepareRegionRects(
+        WikiRect.rightOf(hoverableRect),
+        rect => rect),
+
+    top:
+      prepareRegionRects(
+        WikiRect.above(hoverableRect),
+        rect => WikiRect.fromRect({
+          x: rect.x,
+          y: rect.bottom,
+          width: rect.width,
+          height: -rect.height,
+        })),
+
+    bottom:
+      prepareRegionRects(
+        WikiRect.beneath(hoverableRect),
+        rect => rect),
+  };
+
+  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 orientHorizontally = (rect, i) => {
+      if (!rect) return null;
+
+      const regionRect = regionRects[i];
+      if (regionRect.width > 0) {
+        return rect;
+      } else {
+        return WikiRect.fromRect({
+          x: regionRect.right - tooltipRect.width,
+          y: rect.y,
+          width: rect.width,
+          height: rect.height,
+        });
+      }
+    };
+
+    orientations.up =
+      regionRects
+        .map(rect => rect?.intersectionWith(upTopDown))
+        .map(orientHorizontally)
+        .map(keepIfFits);
+
+    orientations.down =
+      regionRects
+        .map(rect => rect?.intersectionWith(downBottomUp))
+        .map(rect =>
+          (rect
+            ? rect.intersectionWith(WikiRect.fromRect({
+                x: rect.x,
+                y: rect.bottom - tooltipRect.height,
+                width: rect.width,
+                height: tooltipRect.height,
+              }))
+            : null))
+        .map(orientHorizontally)
+        .map(keepIfFits);
+
+    const centerRect =
+      WikiRect.fromRect({
+        x: -Infinity, width: Infinity,
+        y: hoverableRect.top
+         + hoverableRect.height / 2
+         - tooltipRect.height / 2,
+        height: tooltipRect.height,
+      });
+
+    orientations.center =
+      regionRects
+        .map(rect => rect?.intersectionWith(centerRect))
+        .map(orientHorizontally)
+        .map(keepIfFits);
+
+    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 {
+    numBaselineRects: baselineRects.length,
+    idealBaseline: baselineRects[0],
+    ...orientationRects,
+  };
+}
+
+export function getTooltipBaselineOpportunityAreas(tooltip) {
+  // Returns multiple basic areas in order of preference, with front of the
+  // array representing greater preference.
+
+  const {stickyContainers} = stickyHeadingInfo;
+  const results = [];
+
+  const windowRect =
+    WikiRect.fromWindow().toInset(10);
+
+  const workingRect =
+    WikiRect.fromRect(windowRect);
+
+  const tooltipRect =
+    peekTooltipClientRect(tooltip);
+
+  // As a baseline, always treat the window rect as fitting the tooltip.
+  results.unshift(WikiRect.fromRect(workingRect));
+
+  const containingParent =
+    getVisuallyContainingElement(tooltip);
+
+  if (containingParent) {
+    const containingRect =
+      WikiRect.fromElement(containingParent);
+
+    // Only respect a portion of the container's padding, giving
+    // the tooltip the impression of a "raised" element.
+    const padding = side =>
+      0.5 *
+      parseFloat(cssProp(containingParent, 'padding-' + side));
+
+    const insetContainingRect =
+      containingRect.toInset({
+        left: padding('left'),
+        right: padding('right'),
+        top: padding('top'),
+        bottom: padding('bottom'),
+      });
+
+    workingRect.chopExtendingOutside(insetContainingRect);
+
+    if (!workingRect.fits(tooltipRect)) {
+      return results;
+    }
+
+    results.unshift(WikiRect.fromRect(workingRect));
+  }
+
+  // This currently assumes a maximum of one sticky container
+  // per visually containing element.
+
+  const stickyContainer =
+    stickyContainers
+      .find(el => el.parentElement === containingParent);
+
+  if (stickyContainer) {
+    const stickyRect =
+      stickyContainer.getBoundingClientRect()
+
+    // Add some padding so the tooltip doesn't line up exactly
+    // with the edge of the sticky container.
+    const beneathStickyContainer =
+      WikiRect.beneath(stickyRect, 10);
+
+    workingRect.chopExtendingOutside(beneathStickyContainer);
+
+    if (!workingRect.fits(tooltipRect)) {
+      return results;
+    }
+
+    results.unshift(WikiRect.fromRect(workingRect));
+  }
+
+  return results;
+}
+
+export function mutatePageContent() {
+  for (const isolatorRoot of document.querySelectorAll('.isolate-tooltip-z-indexing')) {
+    if (isolatorRoot.firstElementChild) {
+      cssProp(isolatorRoot.firstElementChild, 'z-index', '1');
+    }
+  }
+}
+
+export function addPageListeners() {
+  const {state} = info;
+
+  const getTouchIdentifiers = domEvent =>
+    Array.from(domEvent.changedTouches)
+      .map(touch => touch.identifier)
+      .filter(identifier => typeof identifier !== 'undefined');
+
+  document.body.addEventListener('touchstart', domEvent => {
+    for (const identifier of getTouchIdentifiers(domEvent)) {
+      state.currentTouchIdentifiers.add(identifier);
+    }
+  });
+
+  window.addEventListener('scroll', () => {
+    for (const identifier of state.currentTouchIdentifiers) {
+      state.touchIdentifiersBanishedByScrolling.add(identifier);
+    }
+  });
+
+  document.body.addEventListener('touchend', domEvent => {
+    setTimeout(() => {
+      for (const identifier of getTouchIdentifiers(domEvent)) {
+        state.currentTouchIdentifiers.delete(identifier);
+        state.touchIdentifiersBanishedByScrolling.delete(identifier);
+      }
+    });
+  });
+
+  const getHoverablesAndTooltips = () => [
+    ...Array.from(state.registeredHoverables.keys()),
+    ...Array.from(state.registeredTooltips.keys()),
+  ];
+
+  document.body.addEventListener('touchend', domEvent => {
+    const touches = Array.from(domEvent.changedTouches);
+    const identifiers = touches.map(touch => touch.identifier);
+
+    // Don't process touch events that were "banished" because the page was
+    // scrolled while those touches were active, and most likely as a result of
+    // them.
+    filterMultipleArrays(touches, identifiers,
+      (_touch, identifier) =>
+        !state.touchIdentifiersBanishedByScrolling.has(identifier));
+
+    if (empty(touches)) return;
+
+    const pointIsOverHoverableOrTooltip =
+      pointIsOverAnyOf(getHoverablesAndTooltips());
+
+    const anyTouchOverAnyHoverableOrTooltip =
+      touches.some(({clientX, clientY}) =>
+        pointIsOverHoverableOrTooltip(clientX, clientY));
+
+    if (!anyTouchOverAnyHoverableOrTooltip) {
+      hideCurrentlyShownTooltip();
+    }
+  });
+
+  document.body.addEventListener('click', domEvent => {
+    const {clientX, clientY} = domEvent;
+
+    const pointIsOverHoverableOrTooltip =
+      pointIsOverAnyOf(getHoverablesAndTooltips());
+
+    if (!pointIsOverHoverableOrTooltip(clientX, clientY)) {
+      // Hide with "intent to replace" - we aren't actually going to replace
+      // the tooltip with a new one, but this intent indicates that it should
+      // be hidden right away, instead of showing. What we're really replacing,
+      // or rather removing, is the state of interacting with tooltips at all.
+      hideCurrentlyShownTooltip(true);
+
+      // Part of that state is fast hovering, which should be canceled out.
+      state.fastHovering = false;
+      if (state.endFastHoveringTimeout) {
+        clearTimeout(state.endFastHoveringTimeout);
+        state.endFastHoveringTimeout = null;
+      }
+
+      // Also cancel out of transitioning a tooltip hidden - this isn't caught
+      // by `hideCurrentlyShownTooltip` because a transitioning-hidden tooltip
+      // doesn't count as "shown" anymore.
+      cancelTransitioningTooltipHidden();
+    }
+  });
+}
diff --git a/src/static/js/client/image-overlay.js b/src/static/js/client/image-overlay.js
new file mode 100644
index 00000000..e9e2708d
--- /dev/null
+++ b/src/static/js/client/image-overlay.js
@@ -0,0 +1,385 @@
+/* eslint-env browser */
+
+import {getColors} from '../../shared-util/colors.js';
+
+import {cssProp} from '../client-util.js';
+import {fetchWithProgress} from '../xhr-util.js';
+
+export const info = {
+  id: 'imageOverlayInfo',
+
+  container: null,
+  actionContainer: null,
+
+  viewOriginalLinks: null,
+  mainImage: null,
+  thumbImage: null,
+
+  actionContentWithoutSize: null,
+  actionContentWithSize: null,
+
+  megabytesContainer: null,
+  kilobytesContainer: null,
+  megabytesContent: null,
+  kilobytesContent: null,
+  fileSizeWarning: null,
+
+  links: null,
+};
+
+export function getPageReferences() {
+  info.container =
+    document.getElementById('image-overlay-container');
+
+  if (!info.container) return;
+
+  info.actionContainer =
+    document.getElementById('image-overlay-action-container');
+
+  info.viewOriginalLinks =
+    document.getElementsByClassName('image-overlay-view-original');
+
+  info.mainImage =
+    document.getElementById('image-overlay-image');
+
+  info.thumbImage =
+    document.getElementById('image-overlay-image-thumb');
+
+  info.actionContentWithoutSize =
+    document.getElementById('image-overlay-action-content-without-size');
+
+  info.actionContentWithSize =
+    document.getElementById('image-overlay-action-content-with-size');
+
+  info.megabytesContainer =
+    document.getElementById('image-overlay-file-size-megabytes');
+
+  info.kilobytesContainer =
+    document.getElementById('image-overlay-file-size-kilobytes');
+
+  info.megabytesContent =
+    info.megabytesContainer.querySelector('.image-overlay-file-size-count');
+
+  info.kilobytesContent =
+    info.kilobytesContainer.querySelector('.image-overlay-file-size-count');
+
+  info.fileSizeWarning =
+    document.getElementById('image-overlay-file-size-warning');
+
+  const linkQuery = [
+    '.image-link',
+    '.image-media-link',
+  ].join(', ');
+
+  info.links =
+    Array.from(document.querySelectorAll(linkQuery))
+      .filter(link => !link.closest('.no-image-preview'));
+}
+
+export function addPageListeners() {
+  if (!info.container) return;
+
+  for (const link of info.links) {
+    link.addEventListener('click', handleImageLinkClicked);
+  }
+
+  info.container.addEventListener('click', handleContainerClicked);
+  document.body.addEventListener('keydown', handleKeyDown);
+}
+
+function handleContainerClicked(evt) {
+  // Only hide the image overlay if actually clicking the background.
+  if (evt.target !== info.container) {
+    return;
+  }
+
+  // If you clicked anything near the action bar, don't hide the
+  // image overlay.
+  const rect = info.actionContainer.getBoundingClientRect();
+  if (
+    evt.clientY >= rect.top - 40 && evt.clientY <= rect.bottom + 40 &&
+    evt.clientX >= rect.left + 20 && evt.clientX <= rect.right - 20
+  ) {
+    return;
+  }
+
+  info.container.classList.remove('visible');
+}
+
+function handleKeyDown(evt) {
+  if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) {
+    info.container.classList.remove('visible');
+  }
+}
+
+async function handleImageLinkClicked(evt) {
+  if (evt.metaKey || evt.shiftKey || evt.altKey) {
+    return;
+  }
+
+  evt.preventDefault();
+
+  // Don't show the overlay if the image still needs to be revealed.
+  if (evt.target.closest('.reveal:not(.revealed)')) {
+    return;
+  }
+
+  info.container.classList.add('visible');
+  info.container.classList.remove('loaded');
+  info.container.classList.remove('errored');
+
+  const details = getImageLinkDetails(evt.target);
+
+  updateImageOverlayColors(details);
+  updateFileSizeInformation(details.originalFileSize);
+
+  for (const link of info.viewOriginalLinks) {
+    link.href = details.originalSrc;
+  }
+
+  await loadOverlayImage(details);
+}
+
+function getImageLinkDetails(imageLink) {
+  const a = imageLink.closest('a');
+  const img = a.querySelector('img');
+
+  const details = {
+    originalSrc:
+      a.href,
+
+    embeddedSrc:
+      img?.src ??
+      a.dataset.embedSrc,
+
+    originalFileSize:
+      img?.dataset.originalSize ??
+      a.dataset.originalSize ??
+      null,
+
+    availableThumbList:
+      img?.dataset.thumbs ??
+      a.dataset.thumbs ??
+      null,
+
+    dimensions:
+      img?.dataset.dimensions?.split('x') ??
+      a.dataset.dimensions?.split('x') ??
+      null,
+
+    color:
+      cssProp(imageLink, '--primary-color'),
+  };
+
+  Object.assign(details, getImageSources(details));
+
+  return details;
+}
+
+function getImageSources(details) {
+  if (details.availableThumbList) {
+    const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(details.availableThumbList);
+    const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(details.availableThumbList);
+    return {
+      mainSrc: details.embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`),
+      thumbSrc: details.embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`),
+      mainThumb: `${mainThumb}:${mainLength}`,
+      thumbThumb: `${smallThumb}:${smallLength}`,
+    };
+  } else {
+    return {
+      mainSrc: details.originalSrc,
+      thumbSrc: null,
+      mainThumb: '',
+      thumbThumb: '',
+    };
+  }
+}
+
+function updateImageOverlayColors(details) {
+  if (details.color) {
+    let colors;
+    try {
+      colors =
+        getColors(details.color, {
+          chroma: window.chroma,
+        });
+    } catch (error) {
+      console.warn(error);
+      return;
+    }
+
+    cssProp(info.container, {
+      '--primary-color': colors.primary,
+      '--deep-color': colors.deep,
+      '--deep-ghost-color': colors.deepGhost,
+      '--bg-black-color': colors.bgBlack,
+    });
+  } else {
+    cssProp(info.container, {
+      '--primary-color': null,
+      '--deep-color': null,
+      '--deep-ghost-color': null,
+      '--bg-black-color': null,
+    });
+  }
+}
+
+async function loadOverlayImage(details) {
+  if (details.thumbSrc) {
+    info.thumbImage.src = details.thumbSrc;
+    info.thumbImage.style.display = null;
+    info.container.classList.remove('no-thumb');
+  } else {
+    info.thumbImage.src = '';
+    info.thumbImage.style.display = 'none';
+    info.container.classList.add('no-thumb');
+  }
+
+  // Show the thumbnail size on each <img> element's data attributes.
+  // Y'know, just for debugging convenience.
+  info.mainImage.dataset.displayingThumb = details.mainThumb;
+  info.thumbImage.dataset.displayingThumb = details.thumbThumb;
+
+  if (details.dimensions) {
+    info.mainImage.width = details.dimensions[0];
+    info.mainImage.height = details.dimensions[1];
+    info.thumbImage.width = details.dimensions[0];
+    info.thumbImage.height = details.dimensions[1];
+    cssProp(info.thumbImage, 'aspect-ratio', details.dimensions.join('/'));
+  } else {
+    info.mainImage.removeAttribute('width');
+    info.mainImage.removeAttribute('height');
+    info.thumbImage.removeAttribute('width');
+    info.thumbImage.removeAttribute('height');
+    cssProp(info.thumbImage, 'aspect-ratio', null);
+  }
+
+  info.mainImage.addEventListener('load', handleMainImageLoaded);
+  info.mainImage.addEventListener('error', handleMainImageErrored);
+
+  const showProgress = amount => {
+    cssProp(info.container, '--download-progress', `${amount * 100}%`);
+  };
+
+  showProgress(0.00);
+
+  const response =
+    await fetchWithProgress(details.mainSrc, progress => {
+      if (progress === -1) {
+        // TODO: Indeterminate response progress cue
+        showProgress(0.00);
+      } else {
+        showProgress(0.20 + 0.80 * progress);
+      }
+    });
+
+  if (!response.status.toString().startsWith('2')) {
+    handleMainImageErrored();
+    return;
+  }
+
+  const blob = await response.blob();
+  const blobSrc = URL.createObjectURL(blob);
+
+  info.mainImage.src = blobSrc;
+  showProgress(1.00);
+
+  function handleMainImageLoaded() {
+    info.container.classList.add('loaded');
+    removeEventListeners();
+  }
+
+  function handleMainImageErrored() {
+    info.container.classList.add('errored');
+    removeEventListeners();
+  }
+
+  function removeEventListeners() {
+    info.mainImage.removeEventListener('load', handleMainImageLoaded);
+    info.mainImage.removeEventListener('error', handleMainImageErrored);
+  }
+}
+
+function parseThumbList(availableThumbList) {
+  // Parse all the available thumbnail sizes! These are provided by the actual
+  // content generation on each image.
+  const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250'
+  const availableSizes =
+    (availableThumbList || defaultThumbList)
+      .split(' ')
+      .map(part => part.split(':'))
+      .map(([thumb, length]) => ({thumb, length: parseInt(length)}))
+      .sort((a, b) => a.length - b.length);
+
+  return availableSizes;
+}
+
+function getPreferredThumbSize(availableThumbList) {
+  // Assuming a square, the image will be constrained to the lesser window
+  // dimension. Coefficient here matches CSS dimensions for image overlay.
+  const constrainedLength = Math.floor(Math.min(
+    0.80 * window.innerWidth,
+    0.80 * window.innerHeight));
+
+  // Match device pixel ratio, which is 2x for "retina" displays and certain
+  // device configurations.
+  const visualLength = window.devicePixelRatio * constrainedLength;
+
+  const availableSizes = parseThumbList(availableThumbList);
+
+  // Starting from the smallest dimensions, find (and return) the first
+  // available length which hits a "good enough" threshold - it's got to be
+  // at least that percent of the way to the actual displayed dimensions.
+  const goodEnoughThreshold = 0.90;
+
+  // (The last item is skipped since we'd be falling back to it anyway.)
+  for (const {thumb, length} of availableSizes.slice(0, -1)) {
+    if (Math.floor(visualLength * goodEnoughThreshold) <= length) {
+      return {thumb, length};
+    }
+  }
+
+  // If none of the items in the list were big enough to hit the "good enough"
+  // threshold, just use the largest size available.
+  return availableSizes[availableSizes.length - 1];
+}
+
+function getSmallestThumbSize(availableThumbList) {
+  // Just snag the smallest size. This'll be used for displaying the "preview"
+  // as the bigger one is loading.
+  const availableSizes = parseThumbList(availableThumbList);
+  return availableSizes[0];
+}
+
+function updateFileSizeInformation(fileSize) {
+  const fileSizeWarningThreshold = 8 * 10 ** 6;
+
+  if (!fileSize) {
+    info.actionContentWithSize.classList.remove('visible');
+    info.actionContentWithoutSize.classList.add('visible');
+    return;
+  }
+
+  info.actionContentWithoutSize.classList.remove('visible');
+  info.actionContentWithSize.classList.add('visible');
+
+  fileSize = parseInt(fileSize);
+  const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10;
+
+  if (fileSize > fileSizeWarningThreshold) {
+    info.fileSizeWarning.classList.add('visible');
+  } else {
+    info.fileSizeWarning.classList.remove('visible');
+  }
+
+  if (fileSize > 10 ** 6) {
+    info.megabytesContainer.classList.add('visible');
+    info.kilobytesContainer.classList.remove('visible');
+    info.megabytesContent.innerText = round(6);
+  } else {
+    info.megabytesContainer.classList.remove('visible');
+    info.kilobytesContainer.classList.add('visible');
+    info.kilobytesContent.innerText = round(3);
+  }
+}
diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js
new file mode 100644
index 00000000..81ea3415
--- /dev/null
+++ b/src/static/js/client/index.js
@@ -0,0 +1,235 @@
+/* eslint-env browser */
+
+import '../group-contributions-table.js';
+
+import * as additionalNamesBoxModule from './additional-names-box.js';
+import * as albumCommentarySidebarModule from './album-commentary-sidebar.js';
+import * as artTagGalleryFilterModule from './art-tag-gallery-filter.js';
+import * as artTagNetworkModule from './art-tag-network.js';
+import * as artistExternalLinkTooltipModule from './artist-external-link-tooltip.js';
+import * as cssCompatibilityAssistantModule from './css-compatibility-assistant.js';
+import * as datetimestampTooltipModule from './datetimestamp-tooltip.js';
+import * as draggedLinkModule from './dragged-link.js';
+import * as hashLinkModule from './hash-link.js';
+import * as hoverableTooltipModule from './hoverable-tooltip.js';
+import * as imageOverlayModule from './image-overlay.js';
+import * as intrapageDotSwitcherModule from './intrapage-dot-switcher.js';
+import * as liveMousePositionModule from './live-mouse-position.js';
+import * as quickDescriptionModule from './quick-description.js';
+import * as scriptedLinkModule from './scripted-link.js';
+import * as sidebarSearchModule from './sidebar-search.js';
+import * as stickyHeadingModule from './sticky-heading.js';
+import * as summaryNestedLinkModule from './summary-nested-link.js';
+import * as textWithTooltipModule from './text-with-tooltip.js';
+import * as wikiSearchModule from './wiki-search.js';
+
+export const modules = [
+  additionalNamesBoxModule,
+  albumCommentarySidebarModule,
+  artTagGalleryFilterModule,
+  artTagNetworkModule,
+  artistExternalLinkTooltipModule,
+  cssCompatibilityAssistantModule,
+  datetimestampTooltipModule,
+  draggedLinkModule,
+  hashLinkModule,
+  hoverableTooltipModule,
+  imageOverlayModule,
+  intrapageDotSwitcherModule,
+  liveMousePositionModule,
+  quickDescriptionModule,
+  scriptedLinkModule,
+  sidebarSearchModule,
+  stickyHeadingModule,
+  summaryNestedLinkModule,
+  textWithTooltipModule,
+  wikiSearchModule,
+];
+
+const clientInfo = window.hsmusicClientInfo = Object.create(null);
+
+const clientSteps = {
+  getPageReferences: [],
+  addInternalListeners: [],
+  mutatePageContent: [],
+  initializeState: [],
+  addPageListeners: [],
+};
+
+for (const module of modules) {
+  const {info} = module;
+
+  if (!info) {
+    throw new Error(`Module missing info`);
+  }
+
+  const {id: infoKey} = info;
+
+  if (!infoKey) {
+    throw new Error(`Module info missing id: ` + JSON.stringify(info));
+  }
+
+  clientInfo[infoKey] = info;
+
+  for (const obj of [
+    info,
+    info.state,
+    info.settings,
+    info.event,
+  ]) {
+    if (!obj) continue;
+
+    if (obj !== info) {
+      obj[Symbol.for('hsmusic.clientInfo')] = info;
+    }
+
+    Object.preventExtensions(obj);
+  }
+
+  if (info.session) {
+    const sessionSpecs = info.session;
+
+    info.session = {};
+
+    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'
+          ? (info.settings
+              ? () => spec.maxLength(info.settings)
+              : () => spec.maxLength())
+          : () => spec.maxLength);
+
+      const storageKey = `hsmusic.${infoKey}.${key}`;
+
+      let fallbackValue = defaultValue;
+
+      Object.defineProperty(info.session, key, {
+        get: () => {
+          let value;
+          try {
+            value = sessionStorage.getItem(storageKey) ?? defaultValue;
+          } catch (error) {
+            if (error instanceof DOMException) {
+              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 {
+            operation();
+          } catch (error) {
+            if (!(error instanceof DOMException)) {
+              throw error;
+            }
+          }
+        },
+      });
+    }
+
+    Object.preventExtensions(info.session);
+  }
+
+  for (const key of Object.keys(clientSteps)) {
+    if (Object.hasOwn(module, key)) {
+      const fn = module[key];
+
+      Object.defineProperty(fn, 'name', {
+        value: `${infoKey}/${fn.name}`,
+      });
+
+      clientSteps[key].push(fn);
+    }
+  }
+}
+
+for (const [key, steps] of Object.entries(clientSteps)) {
+  for (const step of steps) {
+    try {
+      step();
+    } catch (error) {
+      // TODO: Be smarter about not running later steps for the same module!
+      // Or maybe not, since an error is liable to cause explosions anyway.
+      console.error(`During ${key}, failed to run ${step.name}`);
+      console.error(error);
+    }
+  }
+}
diff --git a/src/static/js/client/intrapage-dot-switcher.js b/src/static/js/client/intrapage-dot-switcher.js
new file mode 100644
index 00000000..d06bc5a6
--- /dev/null
+++ b/src/static/js/client/intrapage-dot-switcher.js
@@ -0,0 +1,82 @@
+/* eslint-env browser */
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+import {cssProp} from '../client-util.js';
+
+export const info = {
+  id: 'intrapageDotSwitcherInfo',
+
+  // Each is a two-level array, by switcher.
+  // This is an evil data structure.
+  switcherSpans: null,
+  switcherLinks: null,
+  switcherTargets: null,
+};
+
+export function getPageReferences() {
+  const switchers =
+    Array.from(document.querySelectorAll('.dot-switcher.intrapage'));
+
+  info.switcherSpans =
+    switchers
+      .map(switcher => switcher.querySelectorAll(':scope > span'))
+      .map(spans => Array.from(spans));
+
+  info.switcherLinks =
+    info.switcherSpans
+      .map(spans => spans
+        .map(span => span.querySelector(':scope > a')));
+
+  info.switcherTargets =
+    info.switcherLinks
+      .map(links => links
+        .map(link => {
+          const targetID = link.getAttribute('data-target-id');
+          const target = document.getElementById(targetID);
+          if (target) {
+            return target;
+          } else {
+            console.warn(
+              `An intrapage dot switcher option is targetting an ID that doesn't exist, #${targetID}`,
+              link);
+            link.setAttribute('inert', '');
+            return null;
+          }
+        }));
+}
+
+export function addPageListeners() {
+  for (const {links, spans, targets} of stitchArrays({
+    spans: info.switcherSpans,
+    links: info.switcherLinks,
+    targets: info.switcherTargets,
+  })) {
+    for (const [index, {span, link, target}] of stitchArrays({
+      span: spans,
+      link: links,
+      target: targets,
+    }).entries()) {
+      const otherSpans =
+        [...spans.slice(0, index), ...spans.slice(index + 1)];
+
+      const otherTargets =
+        [...targets.slice(0, index), ...targets.slice(index + 1)];
+
+      link.addEventListener('click', domEvent => {
+        domEvent.preventDefault();
+
+        for (const otherSpan of otherSpans) {
+          otherSpan.classList.remove('current');
+        }
+
+        for (const otherTarget of otherTargets) {
+          cssProp(otherTarget, 'display', 'none');
+        }
+
+        span.classList.add('current');
+        cssProp(target, 'display', 'block');
+      });
+    }
+  }
+}
diff --git a/src/static/js/client/live-mouse-position.js b/src/static/js/client/live-mouse-position.js
new file mode 100644
index 00000000..36a28429
--- /dev/null
+++ b/src/static/js/client/live-mouse-position.js
@@ -0,0 +1,21 @@
+/* eslint-env browser */
+
+export const info = {
+  id: 'liveMousePositionInfo',
+
+  state: {
+    clientX: null,
+    clientY: null,
+  },
+};
+
+export function addPageListeners() {
+  const {state} = info;
+
+  document.body.addEventListener('mousemove', domEvent => {
+    Object.assign(state, {
+      clientX: domEvent.clientX,
+      clientY: domEvent.clientY,
+    });
+  });
+}
diff --git a/src/static/js/client/quick-description.js b/src/static/js/client/quick-description.js
new file mode 100644
index 00000000..cff82252
--- /dev/null
+++ b/src/static/js/client/quick-description.js
@@ -0,0 +1,62 @@
+/* eslint-env browser */
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'quickDescriptionInfo',
+
+  quickDescriptionContainers: null,
+
+  quickDescriptionsAreExpandable: null,
+
+  expandDescriptionLinks: null,
+  collapseDescriptionLinks: null,
+};
+
+export function getPageReferences() {
+  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'));
+}
+
+export function addPageListeners() {
+  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');
+    });
+  }
+}
diff --git a/src/static/js/client/scripted-link.js b/src/static/js/client/scripted-link.js
new file mode 100644
index 00000000..8b8d8a13
--- /dev/null
+++ b/src/static/js/client/scripted-link.js
@@ -0,0 +1,285 @@
+/* eslint-env browser */
+
+import {pick, stitchArrays} from '../../shared-util/sugar.js';
+
+import {
+  cssProp,
+  rebase,
+  openAlbum,
+  openArtist,
+  openTrack,
+} from '../client-util.js';
+
+export const info = {
+  id: 'scriptedLinkInfo',
+
+  randomLinks: null,
+  revealLinks: null,
+  revealContainers: null,
+
+  nextNavLink: null,
+  previousNavLink: null,
+  randomNavLink: null,
+
+  state: {
+    albumDirectories: null,
+    albumTrackDirectories: null,
+    artistDirectories: null,
+    artistNumContributions: null,
+  },
+};
+
+export function getPageReferences() {
+  info.randomLinks =
+    document.querySelectorAll('[data-random]');
+
+  info.revealLinks =
+    document.querySelectorAll('.reveal .image-outer-area > *');
+
+  info.revealContainers =
+    Array.from(info.revealLinks)
+      .map(link => link.closest('.reveal'));
+
+  info.nextNavLink =
+    document.getElementById('next-button');
+
+  info.previousNavLink =
+    document.getElementById('previous-button');
+
+  info.randomNavLink =
+    document.getElementById('random-button');
+}
+
+export function addPageListeners() {
+  addRandomLinkListeners();
+  addNavigationKeyPressListeners();
+  addRevealLinkClickListeners();
+}
+
+function addRandomLinkListeners() {
+  for (const a of info.randomLinks ?? []) {
+    a.addEventListener('click', domEvent => {
+      handleRandomLinkClicked(a, domEvent);
+    });
+  }
+}
+
+function handleRandomLinkClicked(a, domEvent) {
+  const href = determineRandomLinkHref(a);
+
+  if (!href) {
+    domEvent.preventDefault();
+    return;
+  }
+
+  setTimeout(() => {
+    a.href = '#'
+  });
+
+  a.href = href;
+}
+
+function determineRandomLinkHref(a) {
+  const {state} = info;
+
+  const trackDirectoriesFromAlbumDirectories = albumDirectories =>
+    albumDirectories
+      .map(directory => state.albumDirectories.indexOf(directory))
+      .map(index => state.albumTrackDirectories[index])
+      .reduce((acc, trackDirectories) => acc.concat(trackDirectories, []));
+
+  switch (a.dataset.random) {
+    case 'album': {
+      const {albumDirectories} = state;
+      if (!albumDirectories) return null;
+
+      return openAlbum(pick(albumDirectories));
+    }
+
+    case 'track': {
+      const {albumDirectories} = state;
+      if (!albumDirectories) return null;
+
+      const trackDirectories =
+        trackDirectoriesFromAlbumDirectories(
+          albumDirectories);
+
+      return openTrack(pick(trackDirectories));
+    }
+
+    case 'album-in-group-dl': {
+      const albumLinks =
+        Array.from(a
+          .closest('dt')
+          .nextElementSibling
+          .querySelectorAll('li a'))
+
+      const listAlbumDirectories =
+        albumLinks
+          .map(a => cssProp(a, '--album-directory'));
+
+      return openAlbum(pick(listAlbumDirectories));
+    }
+
+    case 'track-in-group-dl': {
+      const {albumDirectories} = state;
+      if (!albumDirectories) return null;
+
+      const albumLinks =
+        Array.from(a
+          .closest('dt')
+          .nextElementSibling
+          .querySelectorAll('li a'))
+
+      const listAlbumDirectories =
+        albumLinks
+          .map(a => cssProp(a, '--album-directory'));
+
+      const trackDirectories =
+        trackDirectoriesFromAlbumDirectories(
+          listAlbumDirectories);
+
+      return openTrack(pick(trackDirectories));
+    }
+
+    case 'track-in-sidebar': {
+      // Note that the container for track links may be <ol> or <ul>, and
+      // they can't be identified by href, since links from one track to
+      // another don't include "track" in the href.
+      const trackLinks =
+        Array.from(document
+          .querySelector('.track-list-sidebar-box')
+          .querySelectorAll('li a'));
+
+      return pick(trackLinks).href;
+    }
+
+    case 'track-in-album': {
+      const {albumDirectories, albumTrackDirectories} = state;
+      if (!albumDirectories || !albumTrackDirectories) return null;
+
+      const albumDirectory = cssProp(a, '--album-directory');
+      const albumIndex = albumDirectories.indexOf(albumDirectory);
+      const trackDirectories = albumTrackDirectories[albumIndex];
+
+      return openTrack(pick(trackDirectories));
+    }
+
+    case 'artist': {
+      const {artistDirectories} = state;
+      if (!artistDirectories) return null;
+
+      return openArtist(pick(artistDirectories));
+    }
+
+    case 'artist-more-than-one-contrib': {
+      const {artistDirectories, artistNumContributions} = state;
+      if (!artistDirectories || !artistNumContributions) return null;
+
+      const filteredArtistDirectories =
+        artistDirectories
+          .filter((_artist, index) => artistNumContributions[index] > 1);
+
+      return openArtist(pick(filteredArtistDirectories));
+    }
+  }
+}
+
+export function mutatePageContent() {
+  mutateNavigationLinkContent();
+}
+
+function mutateNavigationLinkContent() {
+  const prependTitle = (el, prepend) => {
+    if (!el) return;
+    if (!el.hasAttribute('href')) return;
+
+    el?.setAttribute(
+      'title',
+      (el.hasAttribute('title')
+        ? prepend + ' ' + el.getAttribute('title')
+        : prepend));
+  };
+
+  prependTitle(info.nextNavLink, '(Shift+N)');
+  prependTitle(info.previousNavLink, '(Shift+P)');
+  prependTitle(info.randomNavLink, '(Shift+R)');
+}
+
+function addNavigationKeyPressListeners() {
+  document.addEventListener('keypress', (event) => {
+    const {tagName} = document.activeElement ?? {};
+    if (tagName === 'INPUT' || tagName === 'TEXTAREA') {
+      return;
+    }
+
+    if (event.shiftKey) {
+      if (event.charCode === 'N'.charCodeAt(0)) {
+        info.nextNavLink?.click();
+      } else if (event.charCode === 'P'.charCodeAt(0)) {
+        info.previousNavLink?.click();
+      } else if (event.charCode === 'R'.charCodeAt(0)) {
+        info.randomNavLink?.click();
+      }
+    }
+  });
+}
+
+function addRevealLinkClickListeners() {
+  for (const {revealLink, revealContainer} of stitchArrays({
+    revealLink: Array.from(info.revealLinks ?? []),
+    revealContainer: Array.from(info.revealContainers ?? []),
+  })) {
+    revealLink.addEventListener('click', (event) => {
+      handleRevealLinkClicked(event, revealLink, revealContainer);
+    });
+  }
+}
+
+function handleRevealLinkClicked(domEvent, _revealLink, revealContainer) {
+  if (revealContainer.classList.contains('revealed')) {
+    return;
+  }
+
+  domEvent.preventDefault();
+  revealContainer.classList.add('revealed');
+  revealContainer.dispatchEvent(new CustomEvent('hsmusic-reveal'));
+}
+
+if (
+  document.documentElement.dataset.urlKey === 'localized.listing' &&
+  document.documentElement.dataset.urlValue0 === 'random'
+) {
+  const dataLoadingLine = document.getElementById('data-loading-line');
+  const dataLoadedLine = document.getElementById('data-loaded-line');
+  const dataErrorLine = document.getElementById('data-error-line');
+
+  dataLoadingLine.style.display = 'block';
+
+  fetch(rebase('random-link-data.json', 'rebaseShared'))
+    .then(data => data.json())
+    .then(data => {
+      const {state} = info;
+
+      Object.assign(state, {
+        albumDirectories: data.albumDirectories,
+        albumTrackDirectories: data.albumTrackDirectories,
+        artistDirectories: data.artistDirectories,
+        artistNumContributions: data.artistNumContributions,
+      });
+
+      dataLoadingLine.style.display = 'none';
+      dataLoadedLine.style.display = 'block';
+    }, () => {
+      dataLoadingLine.style.display = 'none';
+      dataErrorLine.style.display = 'block';
+    })
+    .then(() => {
+      for (const a of info.randomLinks) {
+        const href = determineRandomLinkHref(a);
+        if (!href) {
+          a.removeAttribute('href');
+        }
+      }
+    });
+}
diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js
new file mode 100644
index 00000000..fb902636
--- /dev/null
+++ b/src/static/js/client/sidebar-search.js
@@ -0,0 +1,1147 @@
+/* eslint-env browser */
+
+import {getColors} from '../../shared-util/colors.js';
+import {accumulateSum, empty} from '../../shared-util/sugar.js';
+
+import {
+  cssProp,
+  openAlbum,
+  openArtist,
+  openArtTag,
+  openFlash,
+  openGroup,
+  openTrack,
+  rebase,
+  templateContent,
+} from '../client-util.js';
+
+import {getLatestDraggedLink} from './dragged-link.js';
+
+import {
+  info as wikiSearchInfo,
+  getSearchWorkerDownloadContext,
+  searchAll,
+} from './wiki-search.js';
+
+export const info = {
+  id: '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,
+
+  standbyInputPlaceholder: 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,
+
+    recallingRecentSearch: null,
+    recallingRecentSearchFromMouse: null,
+
+    currentValue: null,
+
+    workerStatus: null,
+    searchStage: null,
+
+    stoppedTypingTimeout: null,
+    stoppedScrollingTimeout: null,
+    focusFirstResultTimeout: null,
+    dismissChangeEventTimeout: 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,
+
+    pressDownToFocusFirstResultLatency: 500,
+    dismissChangeEventAfterFocusingFirstResultLatency: 50,
+
+    maxActiveResultsStorage: 100000,
+  },
+};
+
+export function getPageReferences() {
+  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');
+
+  info.standbyInputPlaceholder =
+    info.searchInput.placeholder;
+
+  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');
+}
+
+export function addInternalListeners() {
+  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);
+}
+
+export function mutatePageContent() {
+  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);
+}
+
+export function addPageListeners() {
+  if (!info.searchInput) return;
+
+  info.searchInput.addEventListener('mousedown', _domEvent => {
+    const {state} = info;
+
+    if (state.recallingRecentSearch) {
+      state.recallingRecentSearchFromMouse = true;
+    }
+  });
+
+  info.searchInput.addEventListener('focus', _domEvent => {
+    const {session, state} = info;
+
+    if (state.recallingRecentSearch) {
+      info.searchInput.value = session.activeQuery;
+      info.searchInput.placeholder = info.standbyInputPlaceholder;
+      showSidebarSearchResults(session.activeQueryResults);
+      state.recallingRecentSearch = false;
+    }
+  });
+
+  info.searchLabel.addEventListener('click', domEvent => {
+    const {state} = info;
+
+    if (state.recallingRecentSearchFromMouse) {
+      if (info.searchInput.selectionStart === info.searchInput.selectionEnd) {
+        info.searchInput.select();
+      }
+
+      state.recallingRecentSearchFromMouse = false;
+      return;
+    }
+
+    const inputRect = info.searchInput.getBoundingClientRect();
+    if (domEvent.clientX < inputRect.left - 3) {
+      info.searchInput.select();
+    }
+  });
+
+  info.searchInput.addEventListener('change', _domEvent => {
+    const {state} = info;
+
+    if (state.dismissChangeEventTimeout) {
+      state.dismissChangeEventTimeout = null;
+      clearTimeout(state.dismissChangeEventTimeout);
+      return;
+    }
+
+    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(() => {
+        state.stoppedTypingTimeout = null;
+        activateSidebarSearch(info.searchInput.value);
+      }, settings.stoppedTypingDelay);
+
+    if (state.focusFirstResultTimeout) {
+      clearTimeout(state.focusFirstResultTimeout);
+      state.focusFirstResultTimeout = null;
+    }
+  });
+
+  info.searchInput.addEventListener('drop', handleDroppedIntoSearchInput);
+
+  info.searchInput.addEventListener('keydown', domEvent => {
+    const {settings, state} = info;
+
+    if (domEvent.key === 'ArrowUp' || domEvent.key === 'ArrowDown') {
+      domEvent.preventDefault();
+    }
+
+    if (domEvent.key === 'ArrowDown') {
+      if (state.stoppedTypingTimeout) {
+        clearTimeout(state.stoppedTypingTimeout);
+        state.stoppedTypingTimeout = null;
+
+        if (state.focusFirstResultTimeout) {
+          clearTimeout(state.focusFirstResultTimeout);
+        }
+
+        state.focusFirstResultTimeout =
+          setTimeout(() => {
+            state.focusFirstResultTimeout = null;
+          }, settings.pressDownToFocusFirstResultLatency);
+
+        activateSidebarSearch(info.searchInput.value);
+      } else {
+        focusFirstSidebarSearchResult();
+      }
+    }
+  });
+
+  document.addEventListener('selectionchange', _domEvent => {
+    const {state} = info;
+
+    if (state.focusFirstResultTimeout) {
+      clearTimeout(state.focusFirstResultTimeout);
+      state.focusFirstResultTimeout = null;
+    }
+  });
+
+  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);
+  });
+}
+
+export function initializeState() {
+  const {session} = info;
+
+  if (!info.searchInput) return;
+
+  if (session.activeQuery) {
+    if (session.repeatQueryOnReload) {
+      info.searchInput.value = session.activeQuery;
+      activateSidebarSearch(session.activeQuery);
+    } else if (session.activeQueryResults) {
+      considerRecallingRecentSidebarSearch();
+    }
+  }
+}
+
+function trackSidebarSearchWorkerAlive() {
+  const {state} = info;
+
+  state.workerStatus = 'alive';
+}
+
+function trackSidebarSearchWorkerReady() {
+  const {state} = info;
+
+  state.workerStatus = 'ready';
+  state.searchStage = 'searching';
+}
+
+function trackSidebarSearchWorkerFailsToInitialize() {
+  const {state} = info;
+
+  state.workerStatus = 'failed';
+  state.searchStage = 'failed';
+}
+
+function trackSidebarSearchWorkerHasRuntimeError() {
+  const {state} = info;
+
+  state.workerStatus = 'failed';
+  state.searchStage = 'failed';
+}
+
+function trackSidebarSearchDownloadsBegin(event) {
+  const {state} = info;
+
+  if (event.context === 'search-indexes') {
+    for (const key of event.keys) {
+      state.indexDownloadStatuses[key] = 'active';
+    }
+  }
+}
+
+function trackSidebarSearchDownloadEnds(event) {
+  const {state} = info;
+
+  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, state} = info;
+
+  if (!query) {
+    return;
+  }
+
+  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);
+
+  if (state.focusFirstResultTimeout) {
+    clearTimeout(state.focusFirstResultTimeout);
+    state.focusFirstResultTimeout = null;
+    focusFirstSidebarSearchResult();
+  }
+}
+
+function clearSidebarSearch() {
+  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 {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) {
+  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() {
+  cssProp(info.progressRule, 'display', 'none');
+  cssProp(info.progressContainer, 'display', 'none');
+}
+
+function showSidebarSearchFailed() {
+  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) {
+  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 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 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: window.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();
+  });
+
+  link.addEventListener('keydown', domEvent => {
+    if (domEvent.key === 'ArrowDown') {
+      const elem = link.nextElementSibling;
+      if (elem) {
+        domEvent.preventDefault();
+        elem.focus({focusVisible: true});
+      }
+    } else if (domEvent.key === 'ArrowUp') {
+      domEvent.preventDefault();
+      const elem = link.previousElementSibling;
+      if (elem) {
+        elem.focus({focusVisible: true});
+      } else {
+        info.searchInput.focus();
+      }
+    }
+  });
+
+  return link;
+}
+
+function hideSidebarSearchResults() {
+  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 focusFirstSidebarSearchResult() {
+  const {settings, state} = info;
+
+  const elem = info.results.firstChild;
+  if (!elem?.classList.contains('wiki-search-result')) {
+    return;
+  }
+
+  if (state.dismissChangeEventTimeout) {
+    clearTimeout(state.dismissChangeEventTimeout);
+  }
+
+  state.dismissChangeEventTimeout =
+    setTimeout(() => {
+      state.dismissChangeEventTimeout = null;
+    }, settings.dismissChangeEventAfterFocusingFirstResultLatency);
+
+  elem.focus({focusVisible: true});
+}
+
+function saveSidebarSearchResultsScrollOffset() {
+  const {session} = info;
+
+  session.resultsScrollOffset = info.resultsContainer.scrollTop;
+}
+
+function restoreSidebarSearchResultsScrollOffset() {
+  const {session} = info;
+
+  if (session.resultsScrollOffset) {
+    info.resultsContainer.scrollTop = session.resultsScrollOffset;
+  }
+}
+
+function showSearchSidebarColumn() {
+  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 {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 {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} = info;
+
+  if (!state.tidiedSidebar) {
+    return;
+  }
+
+  for (const details of state.collapsedDetailsForTidiness) {
+    details.setAttribute('open', '');
+  }
+
+  state.collapsedDetailsForTidiness = [];
+  state.tidiedSidebar = null;
+
+  info.searchInput.placeholder = info.standbyInputPlaceholder;
+}
+
+function considerRecallingRecentSidebarSearch() {
+  const {session, state} = info;
+
+  if (document.documentElement.dataset.urlKey === 'localized.home') {
+    return forgetRecentSidebarSearch();
+  }
+
+  info.searchInput.placeholder = session.activeQuery;
+  state.recallingRecentSearch = true;
+}
+
+function forgetRecentSidebarSearch() {
+  const {session} = info;
+
+  session.activeQuery = null;
+  session.activeQueryResults = null;
+}
+
+async function handleDroppedIntoSearchInput(domEvent) {
+  const itemByType = type =>
+    Array.from(domEvent.dataTransfer.items)
+      .find(item => item.type === type);
+
+  const textItem = itemByType('text/plain');
+
+  if (!textItem) return;
+
+  domEvent.preventDefault();
+
+  const getAssTring = item =>
+    new Promise(res => item.getAsString(res))
+      .then(string => string.trim());
+
+  const timer = Date.now();
+
+  let droppedText =
+    await getAssTring(textItem);
+
+  if (Date.now() - timer > 500) return;
+  if (!droppedText) return;
+
+  let droppedURL;
+  try {
+    droppedURL = new URL(droppedText);
+  } catch (error) {
+    droppedURL = null;
+  }
+
+  if (droppedURL) matchLink: {
+    const isDroppedURL = a =>
+      a.toString() === droppedURL.toString();
+
+    const matchingLinks =
+      Array.from(document.getElementsByTagName('a'))
+        .filter(a =>
+          isDroppedURL(new URL(a.href, document.documentURI)));
+
+    const latestDraggedLink = getLatestDraggedLink();
+
+    if (!matchingLinks.includes(latestDraggedLink)) {
+      break matchLink;
+    }
+
+    let matchedLink = latestDraggedLink;
+
+    if (matchedLink.querySelector('.normal-content')) {
+      matchedLink = matchedLink.cloneNode(true);
+      for (const node of matchedLink.querySelectorAll('.normal-content')) {
+        node.remove();
+      }
+    }
+
+    droppedText = matchedLink.innerText;
+  }
+
+  if (droppedText.includes('-')) splitDashes: {
+    if (droppedURL) break splitDashes;
+    if (droppedText.includes(' ')) break splitDashes;
+
+    const parts = droppedText.split('-');
+    if (parts.length === 2) break splitDashes;
+
+    droppedText = parts.join(' ');
+  }
+
+  info.searchInput.value = droppedText;
+  activateSidebarSearch(info.searchInput.value);
+}
diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js
new file mode 100644
index 00000000..b65574d0
--- /dev/null
+++ b/src/static/js/client/sticky-heading.js
@@ -0,0 +1,345 @@
+/* eslint-env browser */
+
+import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js';
+import {cssProp, dispatchInternalEvent, templateContent}
+  from '../client-util.js';
+
+export const info = {
+  id: 'stickyHeadingInfo',
+
+  stickyRoots: null,
+
+  stickyContainers: null,
+  staticContainers: null,
+
+  stickyHeadingRows: null,
+  stickyHeadings: null,
+  stickySubheadingRows: null,
+  stickySubheadings: null,
+
+  stickyCoverContainers: null,
+  stickyCoverTextAreas: null,
+  stickyCovers: null,
+
+  contentContainers: null,
+  contentHeadings: null,
+  contentCoverColumns: null,
+  contentCovers: null,
+  contentCoversReveal: null,
+
+  referenceCollapsedHeading: null,
+
+  state: {
+    displayedHeading: null,
+  },
+
+  event: {
+    whenDisplayedHeadingChanges: [],
+    whenStuckStatusChanges: [],
+  },
+};
+
+export function getPageReferences() {
+  info.stickyRoots =
+    Array.from(document.querySelectorAll('.content-sticky-heading-root:not([inert])'));
+
+  info.stickyContainers =
+    info.stickyRoots
+      .map(el => el.querySelector('.content-sticky-heading-container'));
+
+  info.staticContainers =
+    info.stickyRoots
+      .map(el => el.nextElementSibling);
+
+  info.stickyCoverContainers =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-heading-cover-container'));
+
+  info.stickyCovers =
+    info.stickyCoverContainers
+      .map(el => el?.querySelector('.content-sticky-heading-cover'));
+
+  info.stickyCoverTextAreas =
+    info.stickyCovers
+      .map(el => el?.querySelector('.image-text-area'));
+
+  info.stickyHeadingRows =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-heading-row'));
+
+  info.stickyHeadings =
+    info.stickyHeadingRows
+      .map(el => el.querySelector('h1'));
+
+  info.stickySubheadingRows =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-subheading-row'));
+
+  info.stickySubheadings =
+    info.stickySubheadingRows
+      .map(el => el.querySelector('h2'));
+
+  info.contentContainers =
+    info.stickyContainers
+      .map(el => el.closest('.content-sticky-heading-root').parentElement);
+
+  info.contentCoverColumns =
+    info.contentContainers
+      .map(el => el.querySelector('#artwork-column'));
+
+  info.contentCovers =
+    info.contentCoverColumns
+      .map(el => el ? el.querySelector('.cover-artwork') : null);
+
+  info.contentCoversReveal =
+    info.contentCovers
+      .map(el => el ? !!el.querySelector('.reveal') : null);
+
+  info.contentHeadings =
+    info.contentContainers
+      .map(el => Array.from(el.querySelectorAll('.content-heading')));
+
+  info.referenceCollapsedHeading =
+    info.stickyHeadings
+      .map(el => el.querySelector('.reference-collapsed-heading'));
+}
+
+export function mutatePageContent() {
+  removeTextPlaceholderStickyHeadingCovers();
+  addRevealClassToStickyHeadingCovers();
+}
+
+function removeTextPlaceholderStickyHeadingCovers() {
+  const hasTextArea =
+    info.stickyCoverTextAreas.map(el => !!el);
+
+  const coverContainersWithTextArea =
+    info.stickyCoverContainers
+      .filter((_el, index) => hasTextArea[index]);
+
+  for (const el of coverContainersWithTextArea) {
+    el.remove();
+  }
+
+  info.stickyCoverContainers =
+    info.stickyCoverContainers
+      .map((el, index) => hasTextArea[index] ? null : el);
+
+  info.stickyCovers =
+    info.stickyCovers
+      .map((el, index) => hasTextArea[index] ? null : el);
+
+  info.stickyCoverTextAreas =
+    info.stickyCoverTextAreas
+      .slice()
+      .fill(null);
+}
+
+function addRevealClassToStickyHeadingCovers() {
+  const stickyCoversWhichReveal =
+    info.stickyCovers
+      .filter((_el, index) => info.contentCoversReveal[index]);
+
+  for (const el of stickyCoversWhichReveal) {
+    el.classList.add('content-sticky-heading-cover-needs-reveal');
+  }
+}
+
+function addRevealListenersForStickyHeadingCovers() {
+  const stickyCovers = info.stickyCovers.slice();
+  const contentCovers = info.contentCovers.slice();
+
+  filterMultipleArrays(
+    stickyCovers,
+    contentCovers,
+    (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]);
+
+  for (const {stickyCover, contentCover} of stitchArrays({
+    stickyCover: stickyCovers,
+    contentCover: contentCovers,
+  })) {
+    // TODO: Janky - should use internal event instead of DOM event
+    contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => {
+      stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal');
+    });
+  }
+}
+
+function topOfViewInside(el, scroll = window.scrollY) {
+  return (
+    scroll > el.offsetTop &&
+    scroll < el.offsetTop + el.offsetHeight);
+}
+
+function updateStuckStatus(index) {
+  const {event} = info;
+
+  const contentContainer = info.contentContainers[index];
+  const stickyContainer = info.stickyContainers[index];
+
+  const wasStuck = stickyContainer.classList.contains('stuck');
+  const stuck = topOfViewInside(contentContainer);
+
+  if (stuck === wasStuck) return;
+
+  if (stuck) {
+    stickyContainer.classList.add('stuck');
+  } else {
+    stickyContainer.classList.remove('stuck');
+  }
+
+  dispatchInternalEvent(event, 'whenStuckStatusChanges', index, stuck);
+}
+
+function updateCollapseStatus(index) {
+  const stickyContainer = info.stickyContainers[index];
+  const staticContainer = info.staticContainers[index];
+  const stickyHeading = info.stickyHeadings[index];
+  const referenceCollapsedHeading = info.referenceCollapsedHeading[index];
+
+  const {height: uncollapsedHeight} = stickyHeading.getBoundingClientRect();
+  const {height: collapsedHeight} = referenceCollapsedHeading.getBoundingClientRect();
+
+  if (
+    staticContainer.getBoundingClientRect().bottom < 4 ||
+    staticContainer.getBoundingClientRect().top < -80
+  ) {
+    if (!stickyContainer.classList.contains('collapse')) {
+      stickyContainer.classList.add('collapse');
+      cssProp(stickyContainer, '--uncollapsed-heading-height', uncollapsedHeight + 'px');
+      cssProp(stickyContainer, '--collapsed-heading-height', collapsedHeight + 'px');
+    }
+  } else {
+    stickyContainer.classList.remove('collapse');
+  }
+}
+
+function updateStickyCoverVisibility(index) {
+  const stickyCoverContainer = info.stickyCoverContainers[index];
+  const stickyContainer = info.stickyContainers[index];
+  const contentCoverColumn = info.contentCoverColumns[index];
+
+  if (contentCoverColumn && stickyCoverContainer) {
+    if (contentCoverColumn.getBoundingClientRect().bottom < 4) {
+      stickyCoverContainer.classList.add('visible');
+      stickyContainer.classList.add('cover-visible');
+    } else {
+      stickyCoverContainer.classList.remove('visible');
+      stickyContainer.classList.remove('cover-visible');
+    }
+  }
+}
+
+function getContentHeadingClosestToStickySubheading(index) {
+  const contentContainer = info.contentContainers[index];
+
+  if (!topOfViewInside(contentContainer)) {
+    return null;
+  }
+
+  const stickyHeadingRow = info.stickyHeadingRows[index];
+  const stickyRect = stickyHeadingRow.getBoundingClientRect();
+
+  // Subheadings only appear when the sticky heading is collapsed,
+  // so the used bottom edge should always be *as though* it's only
+  // displaying one line of text. Subtract the current discrepancy.
+  const stickyHeading = info.stickyHeadings[index];
+  const referenceCollapsedHeading = info.referenceCollapsedHeading[index];
+  const correctBottomEdge =
+    stickyHeading.getBoundingClientRect().height -
+    referenceCollapsedHeading.getBoundingClientRect().height;
+
+  const stickyBottom =
+    (stickyRect.bottom
+   - correctBottomEdge);
+
+  // Iterate from bottom to top of the content area.
+  const contentHeadings = info.contentHeadings[index];
+  for (const heading of contentHeadings.slice().reverse()) {
+    const headingRect = heading.getBoundingClientRect();
+    if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 40) {
+      return heading;
+    }
+  }
+
+  return null;
+}
+
+function updateStickySubheadingContent(index) {
+  const {event, state} = info;
+
+  const stickyContainer = info.stickyContainers[index];
+
+  const closestHeading =
+    (stickyContainer.classList.contains('collapse')
+      ? getContentHeadingClosestToStickySubheading(index)
+      : null);
+
+  if (state.displayedHeading === closestHeading) return;
+
+  const stickySubheadingRow = info.stickySubheadingRows[index];
+
+  if (closestHeading) {
+    const stickySubheading = info.stickySubheadings[index];
+
+    // Array.from needed to iterate over a live array with for..of
+    for (const child of Array.from(stickySubheading.childNodes)) {
+      child.remove();
+    }
+
+    const textContainer =
+      templateContent(
+        closestHeading.querySelector('.content-heading-sticky-title')) ??
+      closestHeading.querySelector('.content-heading-main-title') ??
+      closestHeading;
+
+    for (const child of textContainer.childNodes) {
+      if (child.tagName === 'A') {
+        for (const grandchild of child.childNodes) {
+          stickySubheading.appendChild(grandchild.cloneNode(true));
+        }
+      } else {
+        stickySubheading.appendChild(child.cloneNode(true));
+      }
+    }
+
+    stickySubheadingRow.classList.add('visible');
+  } else {
+    stickySubheadingRow.classList.remove('visible');
+  }
+
+  const oldDisplayedHeading = state.displayedHeading;
+
+  state.displayedHeading = closestHeading;
+
+  dispatchInternalEvent(event, 'whenDisplayedHeadingChanges', index, {
+    oldHeading: oldDisplayedHeading,
+    newHeading: closestHeading,
+  });
+}
+
+export function updateStickyHeadings(index) {
+  updateStuckStatus(index);
+  updateCollapseStatus(index);
+  updateStickyCoverVisibility(index);
+  updateStickySubheadingContent(index);
+}
+
+export function initializeState() {
+  for (let i = 0; i < info.stickyContainers.length; i++) {
+    updateStickyHeadings(i);
+  }
+}
+
+export function addPageListeners() {
+  addRevealListenersForStickyHeadingCovers();
+  addScrollListenerForStickyHeadings();
+}
+
+function addScrollListenerForStickyHeadings() {
+  document.addEventListener('scroll', () => {
+    for (let i = 0; i < info.stickyContainers.length; i++) {
+      updateStickyHeadings(i);
+    }
+  });
+}
diff --git a/src/static/js/client/summary-nested-link.js b/src/static/js/client/summary-nested-link.js
new file mode 100644
index 00000000..23857fa5
--- /dev/null
+++ b/src/static/js/client/summary-nested-link.js
@@ -0,0 +1,48 @@
+/* eslint-env browser */
+
+import {
+  empty,
+  filterMultipleArrays,
+  stitchArrays,
+} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'summaryNestedLinkInfo',
+
+  summaries: null,
+  links: null,
+};
+
+export function getPageReferences() {
+  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));
+}
+
+export function addPageListeners() {
+  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');
+      });
+    }
+  }
+}
diff --git a/src/static/js/client/text-with-tooltip.js b/src/static/js/client/text-with-tooltip.js
new file mode 100644
index 00000000..dd207e04
--- /dev/null
+++ b/src/static/js/client/text-with-tooltip.js
@@ -0,0 +1,34 @@
+/* eslint-env browser */
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+import {registerTooltipElement, registerTooltipHoverableElement}
+  from './hoverable-tooltip.js';
+
+export const info = {
+  id: 'textWithTooltipInfo',
+
+  hoverables: null,
+  tooltips: null,
+};
+
+export function getPageReferences() {
+  const spans =
+    Array.from(document.querySelectorAll('.text-with-tooltip'));
+
+  info.hoverables =
+    spans.map(span => span.children[0]);
+
+  info.tooltips =
+    spans.map(span => span.children[1]);
+}
+
+export function addPageListeners() {
+  for (const {hoverable, tooltip} of stitchArrays({
+    hoverable: info.hoverables,
+    tooltip: info.tooltips,
+  })) {
+    registerTooltipElement(tooltip);
+    registerTooltipHoverableElement(hoverable, tooltip);
+  }
+}
diff --git a/src/static/js/client/wiki-search.js b/src/static/js/client/wiki-search.js
new file mode 100644
index 00000000..2446c172
--- /dev/null
+++ b/src/static/js/client/wiki-search.js
@@ -0,0 +1,239 @@
+/* eslint-env browser */
+
+import {promiseWithResolvers} from '../../shared-util/sugar.js';
+
+import {dispatchInternalEvent} from '../client-util.js';
+
+export const info = {
+  id: '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: [],
+  },
+};
+
+export async function initializeSearchWorker() {
+  const {state} = info;
+
+  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} = info;
+
+  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} = info;
+  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} = info;
+  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} = info;
+  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} = info;
+  const {context: contextKey, key} = message.data;
+
+  const context = getSearchWorkerDownloadContext(contextKey);
+
+  context[key] = 1.00;
+
+  dispatchInternalEvent(event, 'whenDownloadEnds', {
+    context: contextKey,
+    key,
+  });
+}
+
+export function getSearchWorkerDownloadContext(context, initialize = false) {
+  const {state} = info;
+
+  if (context in state.downloads) {
+    return state.downloads[context];
+  }
+
+  if (!initialize) {
+    return null;
+  }
+
+  return state.downloads[context] = Object.create(null);
+}
+
+export async function postSearchWorkerAction(action, options) {
+  const {state} = info;
+
+  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;
+}
+
+export async function searchAll(query, options = {}) {
+  return await postSearchWorkerAction('search', {
+    query,
+    options,
+  });
+}
diff --git a/src/static/js/group-contributions-table.js b/src/static/js/group-contributions-table.js
new file mode 100644
index 00000000..72ad2327
--- /dev/null
+++ b/src/static/js/group-contributions-table.js
@@ -0,0 +1,35 @@
+/* eslint-env browser */
+
+// TODO: Update to clientSteps style.
+
+const groupContributionsTableInfo =
+  Array.from(document.querySelectorAll('#content dl'))
+    .filter(dl => dl.querySelector('a.group-contributions-sort-button'))
+    .map(dl => ({
+      sortingByCountLink: dl.querySelector('dt.group-contributions-sorted-by-count a.group-contributions-sort-button'),
+      sortingByDurationLink: dl.querySelector('dt.group-contributions-sorted-by-duration a.group-contributions-sort-button'),
+      sortingByCountElements: dl.querySelectorAll('.group-contributions-sorted-by-count'),
+      sortingByDurationElements: dl.querySelectorAll('.group-contributions-sorted-by-duration'),
+    }));
+
+function sortGroupContributionsTableBy(info, sort) {
+  const [showThese, hideThese] =
+    (sort === 'count'
+      ? [info.sortingByCountElements, info.sortingByDurationElements]
+      : [info.sortingByDurationElements, info.sortingByCountElements]);
+
+  for (const element of showThese) element.classList.add('visible');
+  for (const element of hideThese) element.classList.remove('visible');
+}
+
+for (const info of groupContributionsTableInfo) {
+  info.sortingByCountLink.addEventListener('click', evt => {
+    evt.preventDefault();
+    sortGroupContributionsTableBy(info, 'duration');
+  });
+
+  info.sortingByDurationLink.addEventListener('click', evt => {
+    evt.preventDefault();
+    sortGroupContributionsTableBy(info, 'count');
+  });
+}
diff --git a/src/static/js/info-card.js b/src/static/js/info-card.js
new file mode 100644
index 00000000..1d9f7c86
--- /dev/null
+++ b/src/static/js/info-card.js
@@ -0,0 +1,181 @@
+/* eslint-env browser */
+
+// Note: This is a super ancient chunk of code which isn't actually in use,
+// so it's just commented out here.
+
+/*
+function colorLink(a, color) {
+  console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet');
+  return;
+
+  // eslint-disable-next-line no-unreachable
+  const chroma = {};
+
+  if (color) {
+    const {primary, dim} = getColors(color, {chroma});
+    a.style.setProperty('--primary-color', primary);
+    a.style.setProperty('--dim-color', dim);
+  }
+}
+
+function link(a, type, {name, directory, color}) {
+  colorLink(a, color);
+  a.innerText = name;
+  a.href = getLinkHref(type, directory);
+}
+
+function joinElements(type, elements) {
+  // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on
+  // strings. So instead, we'll pass the element's outer HTML's (which means
+  // the entire HTML of that element).
+  //
+  // That does mean this function returns a string, so always 8e sure to
+  // set innerHTML when using it (not appendChild).
+
+  return list[type](elements.map((el) => el.outerHTML));
+}
+
+const infoCard = (() => {
+  const container = document.getElementById('info-card-container');
+
+  let cancelShow = false;
+  let hideTimeout = null;
+  let showing = false;
+
+  container.addEventListener('mouseenter', cancelHide);
+  container.addEventListener('mouseleave', readyHide);
+
+  function show(type, target) {
+    cancelShow = false;
+
+    fetchData(type, target.dataset[type]).then((data) => {
+      // Manual DOM 'cuz we're laaaazy.
+
+      if (cancelShow) {
+        return;
+      }
+
+      showing = true;
+
+      const rect = target.getBoundingClientRect();
+
+      container.style.setProperty('--primary-color', data.color);
+
+      container.style.top = window.scrollY + rect.bottom + 'px';
+      container.style.left = window.scrollX + rect.left + 'px';
+
+      // Use a short timeout to let a currently hidden (or not yet shown)
+      // info card teleport to the position set a8ove. (If it's currently
+      // shown, it'll transition to that position.)
+      setTimeout(() => {
+        container.classList.remove('hide');
+        container.classList.add('show');
+      }, 50);
+
+      // 8asic details.
+
+      const nameLink = container.querySelector('.info-card-name a');
+      link(nameLink, 'track', data);
+
+      const albumLink = container.querySelector('.info-card-album a');
+      link(albumLink, 'album', data.album);
+
+      const artistSpan = container.querySelector('.info-card-artists span');
+      artistSpan.innerHTML = joinElements(
+        'conjunction',
+        data.artists.map(({artist}) => {
+          const a = document.createElement('a');
+          a.href = getLinkHref('artist', artist.directory);
+          a.innerText = artist.name;
+          return a;
+        })
+      );
+
+      const coverArtistParagraph = container.querySelector(
+        '.info-card-cover-artists'
+      );
+      const coverArtistSpan = coverArtistParagraph.querySelector('span');
+      if (data.coverArtists.length) {
+        coverArtistParagraph.style.display = 'block';
+        coverArtistSpan.innerHTML = joinElements(
+          'conjunction',
+          data.coverArtists.map(({artist}) => {
+            const a = document.createElement('a');
+            a.href = getLinkHref('artist', artist.directory);
+            a.innerText = artist.name;
+            return a;
+          })
+        );
+      } else {
+        coverArtistParagraph.style.display = 'none';
+      }
+
+      // Cover art.
+
+      const [containerNoReveal, containerReveal] = [
+        container.querySelector('.info-card-art-container.no-reveal'),
+        container.querySelector('.info-card-art-container.reveal'),
+      ];
+
+      const [containerShow, containerHide] = data.cover.warnings.length
+        ? [containerReveal, containerNoReveal]
+        : [containerNoReveal, containerReveal];
+
+      containerHide.style.display = 'none';
+      containerShow.style.display = 'block';
+
+      const img = containerShow.querySelector('.info-card-art');
+      img.src = rebase(data.cover.paths.small, 'rebaseMedia');
+
+      const imgLink = containerShow.querySelector('a');
+      colorLink(imgLink, data.color);
+      imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia');
+
+      if (containerShow === containerReveal) {
+        const cw = containerShow.querySelector('.info-card-art-warnings');
+        cw.innerText = list.unit(data.cover.warnings);
+
+        const reveal = containerShow.querySelector('.reveal');
+        reveal.classList.remove('revealed');
+      }
+    });
+  }
+
+  function hide() {
+    container.classList.remove('show');
+    container.classList.add('hide');
+    cancelShow = true;
+    showing = false;
+  }
+
+  function readyHide() {
+    if (!hideTimeout && showing) {
+      hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY);
+    }
+  }
+
+  function cancelHide() {
+    if (hideTimeout) {
+      clearTimeout(hideTimeout);
+      hideTimeout = null;
+    }
+  }
+
+  return {
+    show,
+    hide,
+    readyHide,
+    cancelHide,
+  };
+})();
+
+// Info cards are disa8led for now since they aren't quite ready for release,
+// 8ut you can try 'em out 8y setting this localStorage flag!
+//
+//     localStorage.tryInfoCards = true;
+//
+if (localStorage.tryInfoCards) {
+  addInfoCardLinkHandlers('track');
+}
+*/
+
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/localization-nonsense.js b/src/static/js/localization-nonsense.js
new file mode 100644
index 00000000..8b6d1ef0
--- /dev/null
+++ b/src/static/js/localization-nonsense.js
@@ -0,0 +1,30 @@
+// Another old, unused chunk of code.
+
+/*
+const language = document.documentElement.getAttribute('lang');
+
+let list;
+if (typeof Intl === 'object' && typeof Intl.ListFormat === 'function') {
+  const getFormat = (type) => {
+    const formatter = new Intl.ListFormat(language, {type});
+    return formatter.format.bind(formatter);
+  };
+
+  list = {
+    conjunction: getFormat('conjunction'),
+    disjunction: getFormat('disjunction'),
+    unit: getFormat('unit'),
+  };
+} else {
+  // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free.
+  // We use the same mock for every list 'cuz we don't have any of the
+  // necessary CLDR info to appropri8tely distinguish 8etween them.
+  const arbitraryMock = (array) => array.join(', ');
+
+  list = {
+    conjunction: arbitraryMock,
+    disjunction: arbitraryMock,
+    unit: arbitraryMock,
+  };
+}
+*/
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/rectangles.js b/src/static/js/rectangles.js
new file mode 100644
index 00000000..b00ed98e
--- /dev/null
+++ b/src/static/js/rectangles.js
@@ -0,0 +1,555 @@
+/* eslint-env browser */
+
+import {info as liveMousePositionInfo} from './client/live-mouse-position.js';
+
+export class WikiRect extends DOMRect {
+  // Useful constructors
+
+  static fromWindow() {
+    const {clientWidth: width, clientHeight: height} =
+      document.documentElement;
+
+    return Reflect.construct(this, [0, 0, width, height]);
+  }
+
+  static fromElement(element) {
+    return this.fromRect(element.getBoundingClientRect());
+  }
+
+  static fromMouse() {
+    const {clientX, clientY} = liveMousePositionInfo.state;
+
+    return WikiRect.fromRect({
+      x: clientX,
+      y: clientY,
+      width: 0,
+      height: 0,
+    });
+  }
+
+  static fromElementUnderMouse(element) {
+    const mouseRect = WikiRect.fromMouse();
+
+    const rects =
+      Array.from(element.getClientRects())
+        .map(rect => WikiRect.fromRect(rect));
+
+    const rectUnderMouse =
+      rects.find(rect => rect.contains(mouseRect));
+
+    if (rectUnderMouse) {
+      return rectUnderMouse;
+    } else {
+      return rects[0];
+    }
+  }
+
+  static leftOf(origin, offset = 0) {
+    // Returns a rectangle representing everywhere to the left of the provided
+    // point or rectangle (with no top or bottom bounds), towards negative x.
+    // If an offset is provided, this is added onto the origin.
+
+    return this.#past(origin, offset, {
+      origin: 'x',
+      extent: 'width',
+      edge: 'left',
+      direction: -Infinity,
+      construct: from =>
+        [from, -Infinity, -Infinity, Infinity],
+    });
+  }
+
+  static rightOf(origin, offset = 0) {
+    // Returns a rectangle representing everywhere to the right of the
+    // provided point or rectangle (with no top or bottom bounds), towards
+    // positive x. If an offset is provided, this is added onto the origin.
+
+    return this.#past(origin, offset, {
+      origin: 'x',
+      extent: 'width',
+      edge: 'right',
+      direction: Infinity,
+      construct: from =>
+        [from, -Infinity, Infinity, Infinity],
+    });
+  }
+
+  static above(origin, offset = 0) {
+    // Returns a rectangle representing everywhere above the provided point
+    // or rectangle (with no left or right bounds), towards negative y.
+    // If an offset is provided, this is added onto the origin.
+
+    return this.#past(origin, offset, {
+      origin: 'y',
+      extent: 'height',
+      edge: 'top',
+      direction: -Infinity,
+      construct: from =>
+        [-Infinity, from, Infinity, -Infinity],
+    });
+  }
+
+  static beneath(origin, offset = 0) {
+    // Returns a rectangle representing everywhere beneath the provided point
+    // or rectangle (with no left or right bounds), towards positive y.
+    // If an offset is provided, this is added onto the origin.
+
+    return this.#past(origin, offset, {
+      origin: 'y',
+      extent: 'height',
+      edge: 'bottom',
+      direction: Infinity,
+      construct: from =>
+        [-Infinity, from, Infinity, Infinity],
+    });
+  }
+
+  // Constructor helpers
+
+  static #past(origin, offset, opts) {
+    if (!isFinite(offset)) {
+      throw new TypeError(`Didn't expect infinite offset`);
+    }
+
+    const {direction, edge} = opts;
+
+    if (typeof origin === 'object') {
+      const {origin: originProperty, extent: extentProperty} = opts;
+
+      const normalized =
+        WikiRect.fromRect(origin).toNormalized();
+
+      if (normalized[extentProperty] === direction) {
+        throw new TypeError(`Provided rectangle already extends to ${edge} edge`);
+      }
+
+      if (normalized[extentProperty] === -direction) {
+        return this.#past(normalized[originProperty], offset, opts);
+      }
+
+      if (normalized.y === direction) {
+        throw new TypeError(`Provided rectangle already starts at ${edge} edge`);
+      }
+
+      return this.#past(normalized[edge], offset, opts);
+    }
+
+    const {construct} = opts;
+
+    if (origin === direction) {
+      throw new TypeError(`Provided point is already at ${edge} edge`);
+    }
+
+    return Reflect.construct(this, construct(origin + offset)).toNormalized();
+  }
+
+  // Predicates
+
+  static rejectInfiniteOriginNonZeroFiniteExtent({origin, extent}) {
+    // Indicate that, in this context, it's meaningless to provide
+    // a finite extent starting at an infinite origin and going towards
+    // or away from zero (i.e. a rectangle along a cardinal edge).
+
+    if (!isFinite(origin) && isFinite(extent) && extent !== 0) {
+      throw new TypeError(`Didn't expect infinite origin paired with finite extent`);
+    }
+  }
+
+  static rejectInfiniteOriginZeroExtent({origin, extent}) {
+    // Indicate that, in this context, it's meaningless to provide
+    // a zero extent at an infinite origin (i.e. a cardinal edge).
+
+    if (!isFinite(origin) && extent === 0) {
+      throw new TypeError(`Didn't expect infinite origin paired with zero extent`);
+    }
+  }
+
+  static rejectNonOpposingInfiniteOriginInfiniteExtent({origin, extent}) {
+    // Indicate that, in this context, it's meaningless to provide
+    // an infinite extent going in the same direction as its infinite
+    // origin (an area "infinitely past" a cardinal edge).
+
+    if (!isFinite(origin) && origin === extent) {
+      throw new TypeError(`Didn't expect non-opposing infinite origin and extent`);
+    }
+  }
+
+  // Transformations
+
+  static normalizeOriginExtent({origin, extent}) {
+    // Varying behavior based on inputs:
+    //
+    //  - For finite origin and finite extent, flip the orientation
+    //    (if necessary) so that extent is positive.
+    //  - For finite origin and infinite extent (i.e. an origin up to
+    //    a cardinal edge), leave as-is.
+    //  - For infinite origin and infinite extent, flip the orientation
+    //    (if necessary) so origin is negative and extent is positive.
+    //  - For infinite origin and zero extent (i.e. a cardinal edge),
+    //    leave as-is.
+    //  - For all other cases, error.
+    //
+
+    this.rejectInfiniteOriginNonZeroFiniteExtent({origin, extent});
+    this.rejectNonOpposingInfiniteOriginInfiniteExtent({origin, extent});
+
+    if (isFinite(origin) && isFinite(extent) && extent < 0) {
+      return {origin: origin + extent, extent: -extent};
+    }
+
+    if (!isFinite(origin) && !isFinite(extent)) {
+      return {origin: -Infinity, extent: Infinity};
+    }
+
+    return {origin, extent};
+  }
+
+  toNormalized() {
+    const {origin: newX, extent: newWidth} =
+      WikiRect.normalizeOriginExtent({
+        origin: this.x,
+        extent: this.width,
+      });
+
+    const {origin: newY, extent: newHeight} =
+      WikiRect.normalizeOriginExtent({
+        origin: this.y,
+        extent: this.height,
+      });
+
+    return Reflect.construct(this.constructor, [newX, newY, newWidth, newHeight]);
+  }
+
+  static intersectionFromOriginsExtents(...entries) {
+    // An intersection is the common subsection across two or more regions.
+
+    const [first, second, ...rest] = entries;
+
+    if (entries.length >= 3) {
+      return this.intersection(first, this.intersection(second, ...rest));
+    }
+
+    if (entries.length === 2) {
+      if (first === null || second === null) {
+        return null;
+      }
+
+      this.rejectInfiniteOriginZeroExtent(first);
+      this.rejectInfiniteOriginZeroExtent(second);
+
+      const {origin: origin1, extent: extent1} = this.normalizeOriginExtent(first);
+      const {origin: origin2, extent: extent2} = this.normalizeOriginExtent(second);
+
+      // After normalizing, *each* region will be one of these:
+      //
+      //  - Finite origin, finite extent
+      //    (a standard region, bounded on both sides)
+      //  - Finite origin, infinite extent
+      //    (everything to one direction of a given origin)
+      //  - Infinite origin, infinite extent
+      //    (everything everywhere)
+      //
+      // So we need to handle any *combination* of these kinds of regions.
+
+      // If either origin is infinite, that region represents everywhere,
+      // so it'll never limit the region of the other.
+
+      if (!isFinite(origin1)) {
+        return {origin: origin2, extent: extent2};
+      }
+
+      if (!isFinite(origin2)) {
+        return {origin: origin1, extent: extent1};
+      }
+
+      // If neither origin is infinite, both regions are bounded on at least
+      // one side, and may limit the other accordingly. Find the minimum and
+      // maximum points in each region, letting Infinity propagate through,
+      // which represents no boundary in that direction.
+
+      const minimum1 = Math.min(origin1, origin1 + extent1);
+      const minimum2 = Math.min(origin2, origin2 + extent2);
+      const maximum1 = Math.max(origin1, origin1 + extent1);
+      const maximum2 = Math.max(origin2, origin2 + extent2);
+
+      // Now get the maximum of the regions' minimums, and the minimum of the
+      // regions' maximums. These are the limits of the new region; computing
+      // with minimums and maximums in this way "polarizes" the limits, so we
+      // can perform specific polarized math in the following steps.
+      //
+      // Infinity will also propagate here, but with some important
+      // restricitons: only maxOfMinimums can be positive Infinity, and only
+      // minOfMaximums can be negative Infinity; and if either is Infinity,
+      // the other is not, since otherwise we'd be working with two everywhere
+      // regions, and would've just returned an everywhere region above.
+
+      const maxOfMinimums = Math.max(minimum1, minimum2);
+      const minOfMaximums = Math.min(maximum1, maximum2);
+
+      // Now check if the maximum of minimums is greater than the minimum of
+      // maximums. If so, the regions don't have any overlap - one region
+      // limits the overlap to end before the other region starts. This works
+      // because we've polarized the limits above!
+
+      if (maxOfMinimums > minOfMaximums) {
+        return null;
+      }
+
+      // Otherwise there's at least some overlap, even if it's just one point
+      // (i.e. one ends exactly where the other begins). We have to take care
+      // of infinities in particular, now. As mentioned above, only one of the
+      // points will be infinity (at most). So the origin is the non-infinite
+      // point, and the extent is in the direction of the infinite point.
+
+      if (minOfMaximums === -Infinity) {
+        return {origin: maxOfMinimums, extent: -Infinity};
+      }
+
+      if (maxOfMinimums === Infinity) {
+        return {origin: minOfMaximums, extent: Infinity};
+      }
+
+      // If neither point is infinity, we're working with two regions that are
+      // both bounded on both sides, so the overlapping region is just the
+      // region constrained by the limits above. Since these are polarized,
+      // start from maxOfMinimums and extend to minOfMaximums, resulting in
+      // a standard, already-normalized region.
+
+      return {
+        origin: maxOfMinimums,
+        extent: minOfMaximums - maxOfMinimums,
+      };
+    }
+
+    if (entries.length === 1) {
+      return first;
+    }
+
+    throw new TypeError(`Expected at least one {origin, extent} entry`);
+  }
+
+  intersectionWith(rect) {
+    const horizontalIntersection =
+      WikiRect.intersectionFromOriginsExtents(
+        {origin: this.x, extent: this.width},
+        {origin: rect.x, extent: rect.width});
+
+    const verticalIntersection =
+      WikiRect.intersectionFromOriginsExtents(
+        {origin: this.y, extent: this.height},
+        {origin: rect.y, extent: rect.height});
+
+    if (!horizontalIntersection) return null;
+    if (!verticalIntersection) return null;
+
+    const {origin: x, extent: width} = horizontalIntersection;
+    const {origin: y, extent: height} = verticalIntersection;
+
+    return Reflect.construct(this.constructor, [x, y, width, height]);
+  }
+
+  chopExtendingOutside(rect) {
+    this.intersectionWith(rect).writeOnto(this);
+  }
+
+  static insetOriginExtent({origin, extent, start = 0, end = 0}) {
+    const normalized =
+      this.normalizeOriginExtent({origin, extent});
+
+    // If this would crush the bounds past each other, just return
+    // the halfway point.
+    if (extent < start + end) {
+      return {origin: origin + (start + end) / 2, extent: 0};
+    }
+
+    return {
+      origin: normalized.origin + start,
+      extent: normalized.extent - start - end,
+    };
+  }
+
+  toInset(arg1, arg2) {
+    if (typeof arg1 === 'number' && typeof arg2 === 'number') {
+      return this.toInset({
+        left: arg2,
+        right: arg2,
+        top: arg1,
+        bottom: arg1,
+      });
+    } else if (typeof arg1 === 'number') {
+      return this.toInset({
+        left: arg1,
+        right: arg1,
+        top: arg1,
+        bottom: arg1,
+      });
+    }
+
+    const {top, left, bottom, right} = arg1;
+
+    const {origin: x, extent: width} =
+      WikiRect.insetOriginExtent({
+        origin: this.x,
+        extent: this.width,
+        start: left,
+        end: right,
+      });
+
+    const {origin: y, extent: height} =
+      WikiRect.insetOriginExtent({
+        origin: this.y,
+        extent: this.height,
+        start: top,
+        end: bottom,
+      });
+
+    return Reflect.construct(this.constructor, [x, y, width, height]);
+  }
+
+  static extendOriginExtent({origin, extent, start = 0, end = 0}) {
+    const normalized =
+      this.normalizeOriginExtent({origin, extent});
+
+    return {
+      origin: normalized.origin - start,
+      extent: normalized.extent + start + end,
+    };
+  }
+
+  toExtended(arg1, arg2) {
+    if (typeof arg1 === 'number' && typeof arg2 === 'number') {
+      return this.toExtended({
+        left: arg2,
+        right: arg2,
+        top: arg1,
+        bottom: arg1,
+      });
+    } else if (typeof arg1 === 'number') {
+      return this.toExtended({
+        left: arg1,
+        right: arg1,
+        top: arg1,
+        bottom: arg1,
+      });
+    }
+
+    const {top, left, bottom, right} = arg1;
+
+    const {origin: x, extent: width} =
+      WikiRect.extendOriginExtent({
+        origin: this.x,
+        extent: this.width,
+        start: left,
+        end: right,
+      });
+
+    const {origin: y, extent: height} =
+      WikiRect.extendOriginExtent({
+        origin: this.y,
+        extent: this.height,
+        start: top,
+        end: bottom,
+      });
+
+    return Reflect.construct(this.constructor, [x, y, width, height]);
+  }
+
+  // Comparisons
+
+  equals(rect) {
+    const rectNormalized = WikiRect.fromRect(rect).toNormalized();
+    const thisNormalized = this.toNormalized();
+
+    return (
+      rectNormalized.x === thisNormalized.x &&
+      rectNormalized.y === thisNormalized.y &&
+      rectNormalized.width === thisNormalized.width &&
+      rectNormalized.height === thisNormalized.height
+    );
+  }
+
+  contains(rect) {
+    return !!this.intersectionWith(rect)?.equals(rect);
+  }
+
+  containedWithin(rect) {
+    return !!this.intersectionWith(rect)?.equals(this);
+  }
+
+  fits(rect) {
+    const rectNormalized = WikiRect.fromRect(rect).toNormalized();
+    const thisNormalized = this.toNormalized();
+
+    return (
+      (!isFinite(this.width) || rectNormalized.width <= thisNormalized.width) &&
+      (!isFinite(this.height) || rectNormalized.height <= thisNormalized.height)
+    );
+  }
+
+  fitsWithin(rect) {
+    const rectNormalized = WikiRect.fromRect(rect).toNormalized();
+    const thisNormalized = this.toNormalized();
+
+    return (
+      (!isFinite(rect.width) || thisNormalized.width <= rectNormalized.width) &&
+      (!isFinite(rect.height) || thisNormalized.height <= rectNormalized.height)
+    );
+  }
+
+  // Interfacing utilities
+
+  static fromRect(rect) {
+    return Reflect.construct(this, [rect.x, rect.y, rect.width, rect.height]);
+  }
+
+  writeOnto(destination) {
+    Object.assign(destination, {
+      x: this.x,
+      y: this.y,
+      width: this.width,
+      height: this.height,
+    });
+  }
+
+  // Other utilities
+
+  #display = null;
+
+  display() {
+    if (!this.#display) {
+      this.#display = document.createElement('div');
+      document.body.appendChild(this.#display);
+    }
+
+    Object.assign(this.#display.style, {
+      position: 'fixed',
+      background: '#000c',
+      border: '3px solid var(--primary-color)',
+      borderRadius: '4px',
+      top: this.top + 'px',
+      left: this.left + 'px',
+      width: this.width + 'px',
+      height: this.height + 'px',
+      pointerEvents: 'none',
+    });
+
+    let i = 0;
+    const int = setInterval(() => {
+      i++;
+      if (i >= 3) clearInterval(int);
+      if (!this.#display) return;
+
+      this.#display.style.display = 'none';
+      setTimeout(() => {
+        this.#display.style.display = '';
+      }, 200);
+    }, 600);
+  }
+
+  hide() {
+    if (this.#display) {
+      this.#display.remove();
+      this.#display = null;
+    }
+  }
+}
diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js
new file mode 100644
index 00000000..1b4684ad
--- /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/js/xhr-util.js b/src/static/js/xhr-util.js
new file mode 100644
index 00000000..8a43072c
--- /dev/null
+++ b/src/static/js/xhr-util.js
@@ -0,0 +1,64 @@
+/* eslint-env browser */
+
+/**
+ * This fetch function is adapted from a `loadImage` function
+ * credited to Parziphal, Feb 13, 2017.
+ * https://stackoverflow.com/a/42196770
+ *
+ * The callback is generally run with the loading progress as a decimal 0-1.
+ * However, if it's not possible to compute the progress ration (which might
+ * only become apparent after a progress amount *has* been sent!),
+ * the callback will be run with the value -1.
+ *
+ * The return promise resolves to a manually instantiated Response object
+ * which generally behaves the same as a normal fetch response; access headers,
+ * text, blob, arrayBuffer as usual. Accordingly, non-200 responses do *not*
+ * reject the prmoise, so be sure to check the response status yourself.
+ */
+export function fetchWithProgress(url, progressCallback) {
+  return new Promise(resolve => {
+    const xhr = new XMLHttpRequest();
+    let notifiedNotComputable = false;
+
+    xhr.open('GET', url, true);
+    xhr.responseType = 'arraybuffer';
+
+    xhr.onprogress = event => {
+      if (notifiedNotComputable) {
+        return;
+      }
+
+      if (!event.lengthComputable) {
+        notifiedNotComputable = true;
+        progressCallback(-1);
+        return;
+      }
+
+      progressCallback(event.loaded / event.total);
+    };
+
+    xhr.onloadend = () => {
+      const body = xhr.response;
+
+      const options = {
+        status: xhr.status,
+        headers:
+          parseResponseHeaders(xhr.getAllResponseHeaders()),
+      };
+
+      resolve(new Response(body, options));
+    };
+
+    xhr.send();
+  });
+
+  function parseResponseHeaders(headers) {
+    return (
+      Object.fromEntries(
+        headers
+          .trim()
+          .split(/[\r\n]+/)
+          .map(line => line.match(/(.+?):\s*(.+)/))
+          .map(match => [match[1], match[2]])));
+  }
+}
diff --git a/src/static/icons.svg b/src/static/misc/icons.svg
index 8c9a80a9..87cb0169 100644
--- a/src/static/icons.svg
+++ b/src/static/misc/icons.svg
@@ -34,7 +34,9 @@
     <path d="M 21.67012,2.5595806 C 21.281556,2.6425058 21.182046,2.7041073 20.355164,3.3769857 20.042418,3.6304997 19.575668,4.0072168 19.319784,4.2109758 18.793802,4.6327094 18.656383,4.7724975 18.535549,5.0165344 L 18.452624,5.1894925 17.308257,5.9926819 C 16.678026,6.4357391 16.092811,6.8479955 16.005148,6.9095971 L 15.848774,7.0209537 15.69714,6.9190742 15.545505,6.8148255 13.022211,6.4428469 C 11.633807,6.239088 10.475225,6.0661298 10.449162,6.059022 10.408885,6.0519141 10.378084,5.9997897 10.321221,5.8434166 10.226449,5.585164 10.001367,5.1468453 9.9326572,5.0852438 9.8639478,5.0212729 9.3237497,4.7014188 9.2858411,4.7014188 9.2479324,4.7014188 9.2479324,4.7037881 9.2811025,4.6066472 9.3024261,4.5474149 9.3355962,4.5189835 9.4327371,4.4763363 9.5725252,4.4123654 9.5843716,4.3886725 9.6341267,4.0190633 9.6578196,3.8484744 9.6554503,3.8129351 9.6246495,3.76318 9.5938488,3.7181635 9.5891102,3.6684084 9.6009566,3.5072966 L 9.6175417,3.305907 9.5322472,3.2656291 C 9.4777536,3.2395669 9.4351063,3.1945504 9.4066749,3.1329488 9.3403347,2.9836836 9.1365758,2.7917711 8.9541405,2.7041073 8.7977673,2.6306593 8.7835516,2.6282901 8.4850211,2.6282901 8.2007063,2.6282901 8.1675362,2.6330286 8.0395945,2.6922609 7.7837112,2.8130947 7.5491515,3.0926709 7.5112429,3.3201227 7.4993964,3.3983093 7.4828114,3.4243715 7.430687,3.4456951 7.2885296,3.5049274 7.2766831,3.5404667 7.2766831,3.8792752 7.2766831,4.2346687 7.2885296,4.2725773 7.430687,4.3389174 7.4828114,4.3626103 7.5254586,4.3934111 7.5254586,4.4099961 7.5254586,4.4242119 7.5301972,4.4526434 7.5373051,4.4692284 7.5467822,4.4952906 7.4614878,4.5284606 7.1724344,4.6042779 6.9639369,4.6611409 6.7649165,4.7251117 6.7270079,4.7488046 6.6748835,4.7796054 6.6346056,4.8506841 6.5493111,5.071028 6.3858301,5.4785459 6.3052742,5.7770765 6.246042,6.1917022 6.1702247,6.712946 6.2010255,6.9356593 6.3905687,7.2223434 6.4308466,7.2839449 6.4616474,7.3360693 6.4569088,7.3408079 6.4521702,7.3455464 6.2081334,7.5113967 5.9143414,7.7104171 5.2106623,8.1890137 4.0354944,9.0680203 3.9738929,9.1627919 3.8909677,9.2907335 3.7535489,11.00373 3.7203789,12.370811 L 3.7037938,12.993934 3.3104917,13.344589 C 2.7157999,13.870571 2.2229876,14.344429 1.5548478,15.022046 0.79193642,15.796804 0.78008997,15.81102 0.73744275,16.017148 0.69716482,16.21143 0.64504044,16.801383 0.65925618,16.924586 0.66873334,16.998034 0.64977902,17.038312 0.51946807,17.227855 0.05982581,17.888887 -0.09180875,18.675491 0.05271794,19.620838 0.19013676,20.51406 0.77772068,21.321988 1.3842589,21.44993 1.4742919,21.468884 3.966785,21.475992 10.354391,21.475992 H 19.196581 L 19.362431,21.378851 C 19.748626,21.146661 20.293562,20.663326 20.646587,20.241592 21.184415,19.597145 21.698551,18.587827 21.961543,17.661435 22.205579,16.803752 22.309828,16.038471 22.321675,14.995984 L 22.331152,14.379968 22.66996,14.135932 C 23.07274,13.844509 23.108279,13.813708 23.127233,13.728414 23.148557,13.626534 23.10354,13.493854 22.913997,13.095813 22.473309,12.174159 21.947327,11.607899 21.288664,11.342539 20.926163,11.195643 19.736779,11.032162 18.739308,10.989514 18.29862,10.97056 18.33179,10.994253 18.327052,10.695723 18.324682,10.463532 18.286774,10.321375 18.210956,10.266881 18.168309,10.23608 18.056953,10.212387 17.83187,10.183956 17.656543,10.162632 17.509647,10.143678 17.504908,10.141309 17.502539,10.13657 17.488323,10.044168 17.476477,9.9351804 17.443307,9.6698199 17.348535,9.2788871 17.237178,8.9448172 17.185054,8.7955519 17.144776,8.6699796 17.144776,8.6676103 17.144776,8.6628717 17.258502,8.5989009 17.400659,8.5254529 17.540447,8.4496356 18.128031,8.13452 18.706138,7.824143 L 19.760472,7.260252 H 19.959493 C 20.06848,7.260252 20.222484,7.243667 20.30304,7.2223434 20.419135,7.1915426 21.146507,6.8906428 22.890304,6.1514243 23.018246,6.0969306 23.243328,5.9476653 23.394963,5.8197237 23.641369,5.6088569 23.859344,5.2558327 23.949377,4.9170242 24.010978,4.6824645 24.018086,4.2180836 23.961223,3.9858932 23.781157,3.2466747 23.129603,2.6496137 22.395123,2.5477342 22.151086,2.5121948 21.859663,2.5169334 21.67012,2.5595806 Z M 22.357214,2.9268206 C 22.786055,2.99553 23.179358,3.2703676 23.418656,3.6636698 23.631892,4.0214326 23.688755,4.5331992 23.553705,4.9146549 23.40444,5.3387578 23.006399,5.7273214 22.594143,5.8505245 22.414077,5.9050181 22.018406,5.9239724 21.831232,5.8884331 21.317096,5.7865536 20.838499,5.3671893 20.672649,4.874377 20.644217,4.7843439 20.615786,4.6232322 20.606309,4.490552 L 20.592093,4.2631001 20.53523,4.4052576 C 20.504429,4.4834441 20.471259,4.6137551 20.464151,4.6966802 L 20.449936,4.8435762 19.620684,5.4145751 18.791433,5.9832047 18.772478,5.8813252 C 18.717985,5.5899026 18.810387,5.2202933 18.99993,4.9928415 19.158672,4.800929 20.33621,3.8105658 21.018565,3.2964298 21.475838,2.9481442 21.854925,2.8438954 22.357214,2.9268206 Z M 8.9778334,3.2822141 C 9.2147624,3.3177534 9.4303678,3.3746164 9.4493221,3.4054172 9.4801229,3.452803 9.4706457,3.6447155 9.4303678,3.7750264 9.3948284,3.8982295 9.3948284,3.8982295 9.3071647,3.8911216 9.2289781,3.8840138 9.2171317,3.8745366 9.1815923,3.7773957 9.1436837,3.6778855 9.1342065,3.6684084 9.032327,3.649454 8.8878004,3.6233919 8.8238295,3.6423462 8.7717051,3.7300099 8.7480122,3.7702878 8.7029957,3.8129351 8.6698257,3.8271508 8.6034856,3.8508437 8.3404944,3.8555823 8.2078141,3.8342587 8.1296276,3.8200429 8.1154118,3.8081965 8.0940882,3.7323792 8.0822418,3.6849934 8.0703953,3.5760061 8.0703953,3.4883423 8.0703953,3.2585212 8.0751339,3.2561519 8.4755439,3.2561519 8.6579792,3.2561519 8.8854311,3.2679984 8.9778334,3.2822141 Z M 9.0773436,3.943246 C 9.0962979,4.000109 9.1270986,4.0380176 9.179223,4.0593412 9.269256,4.0996191 9.269256,4.1043577 9.2052852,4.310486 9.1602687,4.4550126 9.1484223,4.4715977 9.0583892,4.5142449 8.925709,4.5734771 8.7574894,4.5711079 8.6129627,4.5095063 8.4755439,4.4502741 8.4281581,4.3768261 8.4092038,4.1920215 L 8.394988,4.0688184 8.5466226,4.0522333 C 8.7385351,4.0285404 8.8309374,3.9906318 8.8735846,3.9100759 8.9020161,3.8603209 8.9233397,3.8484744 8.9802027,3.853213 9.0370656,3.8579516 9.0560199,3.8769059 9.0773436,3.943246 Z M 7.8287277,5.0497044 C 8.3120629,5.5306703 9.0844514,5.6207033 9.326119,5.2274012 9.3971977,5.111306 9.4019363,5.1089367 9.4753843,5.1302603 9.6791432,5.1871233 9.6815125,5.1894925 9.6815125,5.3624507 9.6815125,5.5093467 9.7123133,5.7794458 9.743114,5.8979103 9.7549605,5.9500346 9.7502219,5.9500346 9.4753843,5.9642504 9.2976875,5.9737275 9.1318372,5.9974204 9.0181113,6.0305905 8.9209704,6.059022 8.5466226,6.2248723 8.1912291,6.3978304 7.6747239,6.6489752 7.5349358,6.7105767 7.5136121,6.6868838 7.4662263,6.6347594 7.210343,6.2153951 7.2198202,6.2059179 7.2269281,6.2011794 7.2885296,6.2343494 7.3596083,6.2793659 L 7.4899192,6.3622911 7.4970271,6.1419471 C 7.5088736,5.8647402 7.4685956,5.5140853 7.3927784,5.2250319 7.3619776,5.1018288 7.340654,4.9952108 7.3477618,4.9881029 7.357239,4.980995 7.4330563,4.9620407 7.52072,4.947825 7.6083837,4.9312399 7.6818317,4.9193935 7.6865703,4.9170242 7.6889396,4.9170242 7.7529104,4.9762564 7.8287277,5.0497044 Z M 18.542657,5.7249521 C 18.542657,5.895541 18.59952,6.1419471 18.668229,6.2864738 L 18.722723,6.4049383 16.448205,7.790973 C 15.19485,8.5515151 14.16184,9.1675305 14.152363,9.155684 14.140516,9.1462068 14.126301,9.0727589 14.119193,8.992203 14.102608,8.8216141 14.149993,8.6747181 14.251873,8.5775772 14.320582,8.5112371 18.495271,5.6064876 18.526072,5.6041183 18.535549,5.601749 18.542657,5.658612 18.542657,5.7249521 Z M 12.595739,6.7982405 C 14.100238,7.0138458 15.353593,7.1939119 15.379655,7.2010198 15.405717,7.2057583 15.445995,7.2247127 15.469688,7.2412977 15.507597,7.2697292 15.476796,7.2981606 15.166419,7.5161353 L 14.822872,7.7601722 13.25914,7.5445668 C 12.273516,7.4095173 11.662239,7.3360693 11.610115,7.3455464 11.517712,7.3621315 11.328169,7.480596 11.306845,7.5350896 11.299738,7.554044 11.3258,7.6914628 11.363708,7.8407281 11.456111,8.1984908 11.52482,8.6841953 11.543774,9.1130368 11.560359,9.4471067 11.55799,9.4636917 11.515343,9.4636917 11.437156,9.4636917 10.195648,9.2978414 10.183802,9.2883642 10.179063,9.2812564 10.160109,9.1296218 10.141155,8.9519251 10.079553,8.3193246 9.9326572,7.6914628 9.6886204,7.0019994 9.6175417,6.7982405 9.5535708,6.6015894 9.546463,6.56605 9.5275087,6.4618013 9.5914795,6.4073076 9.7383755,6.4073076 9.8047156,6.4073076 11.09124,6.5826351 12.595739,6.7982405 Z M 13.247294,7.8881139 C 13.870417,7.9734083 14.384553,8.0468563 14.389292,8.0515949 14.39403,8.0563334 14.292151,8.1321507 14.164209,8.2198145 14.02916,8.3122168 13.915434,8.4046191 13.898849,8.4425277 13.839616,8.5681001 13.711675,8.9400786 13.602687,9.2978414 13.541086,9.4992311 13.484223,9.6721892 13.474746,9.6816664 13.467638,9.6911435 13.164369,9.6650814 12.801867,9.6248034 12.230869,9.5584633 12.145574,9.5442476 12.15979,9.5134468 12.169267,9.4921232 12.211914,9.3736587 12.256931,9.2480863 12.42515,8.7576433 12.380134,8.3027396 12.121881,7.9023296 12.069757,7.8217737 12.02711,7.750695 12.02711,7.7459565 12.02711,7.7246328 12.117143,7.73411 13.247294,7.8881139 Z M 16.846245,9.0111573 C 16.933909,9.2338706 17.040527,9.6698199 17.073697,9.9328111 17.087913,10.053645 17.09739,10.157894 17.092652,10.160263 17.085544,10.16974 16.405558,10.086815 16.322632,10.067861 16.272877,10.056014 16.272877,10.053645 16.310786,9.9446576 16.339217,9.8617324 16.348695,9.7290522 16.351064,9.4423681 L 16.353433,9.0561738 16.542976,8.9519251 C 16.644856,8.8950621 16.741997,8.8476763 16.758582,8.8476763 16.772797,8.8476763 16.813075,8.9211243 16.846245,9.0111573 Z M 15.983824,9.6224341 C 15.971977,9.7290522 15.950654,9.8617324 15.934069,9.9138568 15.910376,9.9920434 15.89616,10.008628 15.844036,10.008628 15.737418,10.006259 14.938967,9.8949025 14.922382,9.8783175 14.884473,9.8427781 14.986353,9.776438 15.46258,9.5252932 L 15.971977,9.2575635 15.988562,9.3428579 C 15.99804,9.3902437 15.99567,9.5158161 15.983824,9.6224341 Z M 14.06233,10.162632 C 16.152043,10.416146 17.872148,10.624644 17.883994,10.624644 17.893472,10.624644 17.902949,10.693353 17.902949,10.778648 V 10.932652 H 17.635219 C 17.29878,10.932652 17.23007,10.965822 17.038158,11.209858 16.943386,11.333062 16.888893,11.382817 16.853353,11.382817 16.824922,11.382817 16.052533,11.30463 15.140357,11.207489 L 13.477115,11.034531 13.339696,10.911328 C 12.915593,10.532241 12.406196,10.297682 11.768857,10.186325 11.337646,10.112877 10.366237,10.008628 10.103246,10.008628 10.065337,10.008628 10.060599,9.9849355 10.060599,9.8404088 V 9.6745585 L 10.162478,9.6887743 C 10.216972,9.6958821 11.972616,9.9091182 14.06233,10.162632 Z M 10.084292,10.420885 C 11.261829,10.501441 11.59116,10.541719 12.055541,10.660183 12.380134,10.740739 12.522291,10.802341 12.716573,10.93739 13.081444,11.190904 13.446314,11.6387 13.66192,12.098342 13.749583,12.285516 13.853832,12.5722 13.841986,12.584047 13.82777,12.600632 10.003736,12.259454 9.9895202,12.242869 9.980043,12.233392 9.9445037,12.13862 9.9065951,12.034371 9.7146826,11.479957 9.3877205,11.046377 9.015742,10.849726 8.8190909,10.747847 8.8214602,10.747847 8.378403,10.890004 7.8026655,11.077178 6.956829,11.43968 6.1749633,11.835351 5.8172005,12.015417 5.6134416,12.122035 4.8718538,12.522445 4.7462814,12.591155 4.7439121,12.591155 4.8126215,12.531922 4.9002853,12.456105 5.5636865,12.008309 5.8669556,11.816397 6.776763,11.247767 7.7766033,10.762063 8.6508714,10.465901 L 8.9588791,10.361653 9.2550403,10.373499 C 9.4185213,10.380607 9.7904998,10.401931 10.084292,10.420885 Z M 18.779586,11.382817 C 19.575668,11.425464 20.70345,11.574729 21.042258,11.683716 21.478207,11.823505 21.883356,12.157574 22.212687,12.648017 22.33589,12.832822 22.577558,13.256925 22.615467,13.356435 22.63916,13.415667 22.63679,13.420406 22.575189,13.420406 22.489894,13.420406 19.556713,13.13846 19.551975,13.131353 19.549605,13.126614 19.523543,13.029473 19.495112,12.911009 19.334,12.266562 18.893312,11.745318 18.251234,11.43968 18.151724,11.392294 18.068799,11.352016 18.068799,11.347277 18.068799,11.344908 18.118554,11.344908 18.182525,11.349647 18.244127,11.354385 18.511856,11.368601 18.779586,11.382817 Z M 15.384394,11.643439 C 16.990772,11.797442 17.050004,11.80692 17.424352,11.991724 17.886364,12.216807 18.234649,12.541399 18.409977,12.90627 18.549765,13.197693 18.594781,13.403821 18.608997,13.832662 L 18.620844,14.185687 18.516595,14.171471 C 18.459732,14.164363 17.33195,14.060114 16.014625,13.939281 L 13.614534,13.718937 V 13.562563 13.40619 L 13.946235,13.091075 C 14.247134,12.80676 14.277935,12.768851 14.277935,12.695403 14.277935,12.517707 14.031529,11.875629 13.846724,11.577098 L 13.799339,11.501281 H 13.858571 C 13.891741,11.501281 14.578835,11.565252 15.384394,11.643439 Z M 9.0394349,12.8731 C 9.4114134,13.598103 9.5132929,14.531603 9.3237497,15.469842 9.1531608,16.31094 8.6840414,17.092805 8.124889,17.462415 7.8239891,17.663804 7.6486617,17.718298 7.3003761,17.720667 7.0658163,17.720667 6.9899991,17.71119 6.8596881,17.666174 6.5469418,17.559556 6.2531499,17.313149 6.0185902,16.960125 5.7011053,16.483898 5.5399936,15.960285 5.4997157,15.27556 L 5.4831306,15.019677 5.6205494,14.924905 C 5.6963667,14.872781 5.7674454,14.825395 5.7769226,14.823026 5.7863997,14.818287 5.7958769,14.927274 5.7958769,15.062324 5.7958769,15.199743 5.8029848,15.327684 5.8100926,15.349008 5.8219391,15.379809 5.8645863,15.386917 6.0209595,15.386917 6.1275775,15.386917 6.2649963,15.394025 6.3242286,15.401132 L 6.4308466,15.415348 6.4687553,15.574091 C 6.4877096,15.659385 6.5137718,15.756526 6.5208796,15.787327 L 6.5374647,15.84419 6.2270877,15.832343 C 5.878802,15.818127 5.8906485,15.81102 5.9427729,15.981608 L 5.9688351,16.074011 H 6.2957971 6.6227591 L 6.7080536,16.24223 C 6.8691653,16.564454 7.0895092,16.820337 7.3216997,16.950648 L 7.4377949,17.019358 7.3453926,17.038312 C 7.210343,17.066743 6.8644267,17.059635 6.7672858,17.026465 6.6914685,17.000403 6.6890992,17.002772 6.7293772,17.033573 6.8170409,17.099913 7.0397541,17.170992 7.2056045,17.180469 7.3998862,17.194685 7.684201,17.128345 7.8524206,17.031204 7.994578,16.948279 8.2386149,16.713719 8.3760337,16.528914 8.9138625,15.801542 9.1128829,14.64059 8.8617382,13.702352 8.8048752,13.491485 8.6485021,13.133722 8.5466226,12.979718 L 8.4968675,12.90627 8.6816721,12.77359 C 8.7811823,12.700142 8.8759539,12.64091 8.8925389,12.63854 8.9067547,12.63854 8.9730948,12.745158 9.0394349,12.8731 Z M 11.778334,13.396713 C 12.581523,13.448837 13.25914,13.491485 13.285203,13.491485 13.32785,13.491485 13.330219,13.512808 13.330219,13.977189 V 14.465263 L 13.218862,14.451047 C 13.154892,14.443939 12.941656,14.427354 12.740266,14.413139 12.446474,14.391815 12.373026,14.394184 12.356441,14.417877 12.346964,14.434462 12.330379,14.526864 12.320902,14.624005 12.309055,14.759055 12.313794,14.806441 12.337487,14.830134 12.365918,14.853826 13.022211,14.913059 13.268618,14.913059 13.311265,14.913059 13.313634,14.927274 13.299418,15.202112 13.282833,15.47695 13.1928,16.287247 13.173846,16.308571 13.169107,16.313309 12.955871,16.303832 12.699988,16.289616 12.318532,16.265923 12.22613,16.265923 12.202437,16.291985 12.185852,16.308571 12.157421,16.400973 12.140836,16.493375 12.117143,16.630794 12.117143,16.671072 12.140836,16.694765 12.162159,16.716088 12.323271,16.735043 12.619432,16.751628 12.868208,16.765843 13.074336,16.782429 13.079074,16.787167 13.098029,16.806121 12.92744,17.483738 12.818452,17.820178 L 12.707096,18.158986 12.562569,18.156617 C 12.484383,18.156617 12.264039,18.14714 12.074495,18.135293 11.820981,18.121077 11.719102,18.123447 11.688301,18.142401 11.633807,18.17794 11.503497,18.47884 11.520082,18.526226 11.539036,18.573612 11.603007,18.58072 12.081603,18.599674 12.311424,18.606782 12.500968,18.623367 12.500968,18.632844 12.500968,18.687338 12.140836,19.355477 11.984462,19.592406 L 11.799658,19.871983 11.25709,19.867244 10.712154,19.862506 10.593689,20.009401 C 10.363868,20.293716 10.370976,20.312671 10.686091,20.317409 10.804556,20.319778 11.008315,20.326886 11.136257,20.336364 L 11.373186,20.350579 11.200227,20.499845 C 10.998838,20.672803 10.759539,20.836284 10.508395,20.976072 L 10.328329,21.073213 8.6911493,21.068474 7.0516006,21.061366 7.2624674,20.900255 C 8.4708053,19.983339 9.4635378,18.17794 9.8686864,16.156936 9.9989974,15.50775 10.053491,14.995984 10.08903,14.053006 L 10.117462,13.301942 H 10.216972 C 10.273835,13.301942 10.975145,13.344589 11.778334,13.396713 Z M 20.608678,14.216487 21.880987,14.330213 21.892833,14.424985 C 21.899941,14.479479 21.909418,14.631113 21.916526,14.763793 L 21.926003,15.00783 H 21.817016 C 21.755414,15.00783 21.556394,14.995984 21.376328,14.981768 21.110967,14.962814 21.039889,14.962814 21.018565,14.988876 20.973549,15.036262 20.952225,15.351377 20.990134,15.384547 21.006719,15.396394 21.222324,15.422456 21.46873,15.439041 L 21.914157,15.467473 21.897572,15.735202 C 21.883356,16.005301 21.833601,16.419927 21.783846,16.671072 L 21.757784,16.808491 H 21.589564 C 21.497162,16.806121 21.279187,16.796644 21.10386,16.784798 20.928532,16.775321 20.772159,16.770582 20.755574,16.77769 20.715296,16.791906 20.630002,17.118868 20.653694,17.166253 20.672649,17.206531 20.722404,17.21127 21.381067,17.249179 21.584825,17.261025 21.646427,17.272872 21.646427,17.298934 21.646427,17.355797 21.373959,18.161355 21.298141,18.324836 L 21.229432,18.47884 20.933271,18.462255 C 20.772159,18.455147 20.53523,18.44567 20.407288,18.44567 L 20.177467,18.443301 20.089803,18.618628 C 20.042418,18.7134 20.011617,18.808171 20.018725,18.829495 20.032941,18.867404 20.115866,18.87925 20.70345,18.910051 L 21.025673,18.929005 20.859823,19.21332 C 20.76979,19.369693 20.613417,19.61373 20.513906,19.755887 L 20.331471,20.011771 19.786534,20.009401 19.241598,20.007032 19.104179,20.177621 C 19.028362,20.270023 18.969129,20.362426 18.973868,20.383749 18.980976,20.41455 19.040208,20.424027 19.348216,20.438243 19.549605,20.44772 19.758103,20.459567 19.807858,20.461936 L 19.90263,20.469044 19.70124,20.637263 C 19.592253,20.732035 19.419294,20.867084 19.315046,20.940532 L 19.130241,21.073213 H 17.829501 16.53113 L 16.941017,20.663326 C 17.376966,20.229745 17.639958,19.902783 17.917165,19.44551 18.53318,18.433824 18.969129,17.116498 19.144457,15.74231 19.196581,15.334792 19.236859,14.671391 19.227382,14.358645 L 19.217905,14.081438 19.277137,14.093284 C 19.310307,14.100392 19.909737,14.154886 20.608678,14.216487 Z M 4.9997955,16.074011 C 5.0211191,16.168782 5.0329655,16.254077 5.0282269,16.261185 5.0187498,16.2754 4.7249578,16.47679 3.6895781,17.1781 L 3.2654752,17.464784 3.1446414,17.303672 C 3.0783013,17.213639 2.9740525,17.102283 2.9100817,17.052528 2.8484801,17.002772 2.7963558,16.950648 2.7939865,16.938802 2.7916172,16.924586 3.2725831,16.5763 3.860167,16.164044 L 4.931086,15.412979 4.9476711,15.657016 C 4.9571482,15.792065 4.9808411,15.979239 4.9997955,16.074011 Z M 2.5309953,17.481369 C 2.6589369,17.642481 2.822418,17.983659 2.8840195,18.218218 2.985899,18.618628 2.9645753,19.237013 2.8318951,19.684809 2.6897377,20.161036 2.3296056,20.639633 1.9884279,20.803114 1.8770712,20.855238 1.8083618,20.871823 1.6377729,20.878931 1.5193084,20.883669 1.3795203,20.876562 1.3250267,20.862346 1.1710228,20.819699 0.95304814,20.660956 0.82984506,20.497475 0.59054677,20.17999 0.45075866,19.78195 0.42469647,19.343631 0.41285002,19.125656 0.44602008,18.637583 0.47919014,18.554657 0.4886673,18.526226 0.68294908,18.393546 0.69479553,18.405392 0.69953411,18.410131 0.68768766,18.481209 0.66636405,18.564135 0.62608612,18.737093 0.59291606,19.234644 0.61660896,19.329415 0.6308247,19.388648 0.63556328,19.391017 0.82747577,19.391017 H 1.0217575 L 1.0596662,19.533174 1.0975748,19.675332 H 0.88670802 C 0.6782105,19.675332 0.67584121,19.675332 0.69005695,19.727456 0.71611914,19.81275 0.73033488,19.817489 0.94594027,19.834074 L 1.1520685,19.84829 1.2255165,19.98097 C 1.3084416,20.125497 1.5358935,20.376641 1.6046029,20.397965 1.6306651,20.407442 1.6496194,20.421658 1.6496194,20.433504 1.6496194,20.466674 1.3937361,20.487998 1.2942259,20.461936 1.1615457,20.428766 1.1994543,20.469044 1.343981,20.51643 1.5027234,20.570923 1.7467603,20.554338 1.8960256,20.483259 2.0310751,20.416919 2.2419419,20.208422 2.3627757,20.025987 2.7252771,19.462095 2.7797707,18.58072 2.4859788,17.971812 2.4054229,17.803592 2.1874482,17.550078 2.0666144,17.483738 2.0287058,17.462415 2.038183,17.448199 2.161386,17.360535 L 2.3011741,17.263394 2.3675143,17.31078 C 2.4054229,17.336842 2.4788709,17.41266 2.5309953,17.481369 Z M 4.7154807,17.502693 4.8268373,17.640111 4.4785517,17.805962 C 4.2866392,17.895995 4.1255274,17.967074 4.1231582,17.962335 4.0994653,17.941011 4.0686645,17.696974 4.0852495,17.680389 4.1089424,17.656697 4.5970162,17.355797 4.6017547,17.360535 4.604124,17.362905 4.6538791,17.426875 4.7154807,17.502693 Z M 6.6867299,18.296405 C 6.8762731,18.393546 7.0563392,18.670753 7.136895,18.988237 7.1961273,19.21332 7.1890194,19.630315 7.1250486,19.900414 7.0113227,20.376641 6.7222693,20.798375 6.3976766,20.957117 6.262627,21.023458 6.2318263,21.030565 6.0612374,21.023458 5.8148312,21.011611 5.6868896,20.935794 5.5328857,20.710711 5.3575582,20.457197 5.3101725,20.277131 5.3101725,19.864875 5.3101725,19.44551 5.3528197,19.239382 5.4997157,18.94559 5.682151,18.585458 5.9143414,18.348529 6.172594,18.265604 6.3218593,18.218218 6.5611576,18.232434 6.6867299,18.296405 Z M 4.4003651,18.827126 C 4.6491405,18.966914 4.8007751,19.31283 4.8007751,19.739302 4.8007751,20.172882 4.6799413,20.533015 4.440643,20.798375 4.1658054,21.106383 3.7819804,21.139553 3.54979,20.871823 3.3744625,20.675172 3.2844295,20.395596 3.2844295,20.063895 3.2844295,19.616099 3.4171097,19.260706 3.6872088,18.983499 3.860167,18.808171 3.9241378,18.77974 4.1586975,18.777371 4.2724234,18.775001 4.3316557,18.786848 4.4003651,18.827126 Z"/>
   </symbol>
 
+  <symbol id="icon-nintendoMusic" viewBox="0 0 114 114"><path d="m 25.732087,113.50416 c -6.588515,-0.68012 -8.695543,-1.22214 -12.553652,-3.22936 -1.762772,-0.9171 -2.993406,-1.82849 -4.819732,-3.56942 -4.002744,-3.81559 -6.122812,-7.644635 -7.313171,-13.208305 -0.57951298,-2.7086 -0.57982798,-2.7284 -0.57982798,-36.44445 0,-33.62928 0.0018,-33.74274 0.57492598,-36.41917 1.686613,-7.87615 6.06013,-13.7685635 12.652915,-17.0471985 6.30684,-3.13643301 6.476747,-3.14880001 43.259379,-3.14880001 36.83125,0 36.91674,0.0063 43.334176,3.17739101 2.30211,1.13757 3.12687,1.734291 5.36135,3.879006 3.97494,3.8152415 5.99397,7.4652015 7.1956,13.0080215 0.53982,2.4901 0.5558,3.30337 0.66081,33.64033 0.0716,20.68964 0.0125,32.32756 -0.17666,34.79271 -0.60064,7.82649 -2.45786,12.343825 -6.99853,17.022595 -2.97211,3.0625 -5.98498,4.91141 -10.136116,6.22023 -4.18488,1.31945 -4.22496,1.32091 -37.91772,1.37306 -17.389737,0.0269 -32.034424,0.006 -32.543747,-0.0466 z M 50.073754,90.083395 c 5.28821,-1.00894 11.01156,-4.94764 13.30048,-9.15315 1.62746,-2.99019 1.61562,-2.84326 1.71693,-21.30579 l 0.0926,-16.87504 1.51367,0.80678 c 3.87465,2.06517 6.93228,5.07249 7.98053,7.84925 1.15152,3.0503 0.16775,6.87834 -2.63159,10.24005 -1.08169,1.29899 -1.22105,1.64952 -0.86804,2.18341 0.30452,0.46055 5.11665,4.07164 5.42586,4.07164 0.99334,0 4.56281,-5.3344 5.63772,-8.42531 2.66837,-7.67291 -1.02275,-15.80495 -10.79188,-23.77593 -4.79047,-3.90873 -5.90889,-5.43704 -6.2796,-8.58104 -0.10769,-0.91337 -0.34912,-1.69723 -0.56478,-1.83372 -0.20718,-0.13112 -1.37934,-0.24022 -2.60481,-0.24245 -3.55907,-0.006 -3.19583,-2.32457 -3.19583,20.39499 v 19.52328 l -1.19679,-0.52939 c -7.671865,-3.39363 -19.019411,0.11167 -23.831713,7.36171 -3.779121,5.69349 -2.626548,12.55586 2.702974,16.0934 3.63067,2.4099 8.421397,3.18425 13.594279,2.19731 z"/></symbol>
   <symbol id="icon-soundcloud" viewBox="0 0 40 40"><path d="M13.8,27.4l0.3-4.2L13.8,14c0-0.1-0.1-0.2-0.1-0.3c-0.1-0.1-0.2-0.1-0.3-0.1c-0.1,0-0.2,0-0.3,0.1C13.1,13.8,13,13.9,13,14 l-0.2,9.2l0.2,4.2c0,0.1,0.1,0.2,0.1,0.3c0.1,0.1,0.2,0.1,0.3,0.1C13.7,27.8,13.8,27.7,13.8,27.4z M18.8,26.9l0.2-3.7l-0.2-10.3 c0-0.2-0.1-0.3-0.2-0.4c-0.1-0.1-0.2-0.1-0.3-0.1s-0.2,0-0.3,0.1c-0.1,0.1-0.2,0.2-0.2,0.4l0,0.1l-0.2,10.1c0,0,0.1,1.4,0.2,4.1v0 c0,0.1,0,0.2,0.1,0.3c0.1,0.1,0.2,0.2,0.4,0.2c0.1,0,0.2-0.1,0.3-0.2c0.1-0.1,0.2-0.2,0.2-0.4L18.8,26.9z M1.2,20.9l0.3,2.2 l-0.3,2.2c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.1-0.1-0.2-0.2l-0.3-2.2l0.3-2.2c0-0.1,0.1-0.2,0.2-0.2S1.2,20.8,1.2,20.9z M2.7,19.5 l0.4,3.6l-0.4,3.6c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L2,23.2l0.4-3.6c0-0.1,0.1-0.2,0.2-0.2C2.6,19.4,2.7,19.4,2.7,19.5z M4.2,18.9l0.4,4.3l-0.4,4.2c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2l-0.4-4.2l0.4-4.3c0-0.1,0.1-0.2,0.2-0.2 C4.2,18.7,4.2,18.7,4.2,18.9z M5.8,18.8l0.4,4.4l-0.4,4.3c0,0.2-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L5,23.2l0.4-4.4 c0-0.2,0.1-0.2,0.2-0.2C5.7,18.5,5.8,18.6,5.8,18.8z M7.4,19.1l0.4,4.1l-0.4,4.3c0,0.2-0.1,0.3-0.3,0.3c-0.1,0-0.1,0-0.2-0.1 c-0.1-0.1-0.1-0.1-0.1-0.2l-0.3-4.3l0.3-4.1c0-0.1,0-0.1,0.1-0.2C7,18.8,7,18.8,7.1,18.8C7.3,18.8,7.4,18.9,7.4,19.1L7.4,19.1z M9,16.5l0.4,6.7L9,27.5c0,0.1,0,0.2-0.1,0.2c-0.1,0.1-0.1,0.1-0.2,0.1c-0.2,0-0.3-0.1-0.3-0.3l-0.3-4.3l0.3-6.7 c0-0.2,0.1-0.3,0.3-0.3c0.1,0,0.1,0,0.2,0.1S9,16.4,9,16.5z M10.5,15l0.3,8.2l-0.3,4.3c0,0.1,0,0.2-0.1,0.2 c-0.1,0.1-0.1,0.1-0.2,0.1c-0.2,0-0.3-0.1-0.3-0.3l-0.3-4.3L9.9,15c0-0.2,0.1-0.3,0.3-0.3c0.1,0,0.2,0,0.2,0.1 C10.5,14.8,10.5,14.9,10.5,15z M12.2,14.3l0.3,8.9l-0.3,4.2c0,0.2-0.1,0.4-0.4,0.4c-0.2,0-0.3-0.1-0.4-0.4l-0.3-4.2l0.3-8.9 c0-0.1,0-0.2,0.1-0.3c0.1-0.1,0.2-0.1,0.2-0.1c0.1,0,0.2,0,0.3,0.1C12.1,14.1,12.2,14.2,12.2,14.3z M18.8,27.3L18.8,27.3L18.8,27.3z M15.4,14.2l0.3,8.9l-0.3,4.2c0,0.1,0,0.2-0.1,0.3c-0.1,0.1-0.2,0.1-0.3,0.1c-0.1,0-0.2,0-0.3-0.1s-0.1-0.2-0.1-0.3l-0.2-4.2 l0.2-8.9c0-0.1,0-0.2,0.1-0.3c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1C15.4,14,15.4,14.1,15.4,14.2L15.4,14.2z M17.1,14.6 l0.2,8.6l-0.2,4.1c0,0.1,0,0.2-0.1,0.3c-0.1,0.1-0.2,0.1-0.3,0.1c-0.1,0-0.2,0-0.3-0.1c-0.1-0.1-0.1-0.2-0.2-0.3L16,23.2l0.2-8.6 c0-0.1,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1C17.1,14.3,17.1,14.4,17.1,14.6z M20.7,23.2l-0.2,4 c0,0.2-0.1,0.3-0.2,0.4c-0.1,0.1-0.2,0.2-0.4,0.2c-0.1,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.2-0.2-0.2-0.4l-0.1-2l-0.1-2L19.4,12V12 c0-0.2,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1c0.2,0.1,0.2,0.2,0.3,0.5L20.7,23.2z M39.4,22.9 c0,1.4-0.5,2.5-1.4,3.5c-0.9,1-2,1.4-3.4,1.4H21.4c-0.1,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.2-0.2-0.2-0.4V11.5c0-0.3,0.2-0.5,0.5-0.6 c1-0.4,2-0.6,3-0.6c2.2,0,4.1,0.8,5.7,2.3c1.6,1.5,2.5,3.4,2.7,5.7c0.6-0.3,1.2-0.4,1.8-0.4c1.3,0,2.4,0.5,3.4,1.5 C38.9,20.3,39.4,21.5,39.4,22.9L39.4,22.9z"/></symbol>
+  <symbol id="icon-steam" viewBox="-4 -4 96.32 96.47"><path d="M 44.084,0 C 20.846,0 1.809,17.918 0,40.689 l 23.71,9.803 c 2.009,-1.374 4.436,-2.179 7.047,-2.179 0.234,0 0.467,0.01 0.698,0.021 L 41.999,33.051 c 0,-0.073 0,-0.144 0,-0.216 0,-9.199 7.483,-16.683 16.683,-16.683 9.199,0 16.682,7.484 16.682,16.683 0,9.199 -7.483,16.684 -16.682,16.684 -0.127,0 -0.253,0 -0.379,-0.01 l -15.038,10.73 c 0.01,0.195 0.015,0.394 0.015,0.592 0,6.906 -5.617,12.522 -12.522,12.522 -6.061,0 -11.129,-4.326 -12.277,-10.055 L 1.524,56.292 c 5.25,18.568 22.309,32.181 42.56,32.181 24.432,0 44.237,-19.806 44.237,-44.235 C 88.321,19.805 68.515,0 44.084,0" /><path d="m 27.721,67.122 -5.434,-2.245 c 0.963,2.005 2.629,3.684 4.841,4.606 4.782,1.992 10.295,-0.277 12.288,-5.063 0.965,-2.314 0.971,-4.869 0.014,-7.189 C 38.475,54.91 36.673,53.1 34.356,52.134 32.057,51.177 29.594,51.212 27.43,52.029 l 5.613,2.321 c 3.527,1.47 5.195,5.52 3.725,9.047 -1.467,3.528 -5.52,5.196 -9.047,3.725" /><path d="m 69.796,32.835 c 0,-6.129 -4.986,-11.116 -11.116,-11.116 -6.129,0 -11.116,4.987 -11.116,11.116 0,6.13 4.987,11.115 11.116,11.115 6.13,0 11.116,-4.986 11.116,-11.115 M 50.348,32.816 c 0,-4.612 3.739,-8.35 8.351,-8.35 4.612,0 8.351,3.738 8.351,8.35 0,4.612 -3.739,8.35 -8.351,8.35 -4.612,0 -8.351,-3.739 -8.351,-8.35" /></symbol>
   <symbol id="icon-tiktok" viewBox="0 0 5 5.292"><path fill-rule="evenodd" clip-rule="evenodd" d="M 3.0056593,3.7402047 C 2.9917683,4.1032048 2.686077,4.394531 2.3113304,4.394531 c -0.08567,0 -0.1676888,-0.015266 -0.2434637,-0.043152 0.075775,0.027886 0.1578202,0.043152 0.2434902,0.043152 0.3747466,0 0.6804387,-0.2913262 0.6943554,-0.6542999 l 0.00132,-3.24141643 h 0.6058809 c 0.058393,0.30815327 0.2455538,0.57257183 0.5048391,0.73777393 7.93e-5,1.058e-4 1.852e-4,2.117e-4 2.645e-4,3.175e-4 0.1804944,0.1149589 0.3957012,0.1820292 0.6267297,0.1820292 v 0.1800449 c 0,0 0,0 2.65e-5,2.65e-5 V 2.227616 c -0.4291437,0 -0.8268027,-0.1341672 -1.1513855,-0.3618624 v 1.6436603 c 0,0.8208777 -0.6833226,1.4886974 -1.5232747,1.4886974 -0.3245565,0 -0.6255391,-0.1000367 -0.8729448,-0.269816 C 1.1970357,4.728163 1.1969034,4.7280043 1.1967446,4.727872 0.80416835,4.4583206 0.54689375,4.0127459 0.54689375,3.5092552 c 0,-0.8208513 0.68332265,-1.4886974 1.52327485,-1.4886974 0.069689,0 0.1380032,0.00561 0.2052587,0.014526 V 2.22669 C 1.5091597,2.2441785 0.88092205,2.817015 0.79752745,3.5485978 0.88100145,2.8170944 1.5091863,2.2443373 2.2754008,2.2268487 v 0.6340861 c -0.06498,-0.01987 -0.1336642,-0.031432 -0.2052851,-0.031432 -0.3835836,0 -0.6956521,0.3050312 -0.6956521,0.6799109 0,0.2610585 0.1515497,0.4878806 0.3729741,0.6017548 0,2.64e-5 0,2.64e-5 0,2.64e-5 0.096544,0.049661 0.2062111,0.078103 0.3226514,0.078103 0.3747467,0 0.6804387,-0.2913261 0.6943554,-0.6542997 l 0.00132,-3.24144299 H 3.593361 c 0,0.0701124 0.00691,0.13863846 0.019525,0.20525906 H 3.0070087 Z" /></symbol>
   <symbol id="icon-tumblr" viewBox="6 2 40 34"><path d="m 27.827893,29.747564 c 0,2.493786 1.258711,3.356328 3.263194,3.356328 h 2.843742 v 6.339769 h -5.384094 c -4.847873,0 -8.461022,-2.494139 -8.461022,-8.461022 v -9.555691 h -4.405136 v -5.174192 c 4.847872,-1.258711 6.875638,-5.43066 7.109177,-9.04381 h 5.034139 v 8.204552 h 5.873397 v 6.01345 h -5.873397 v 8.320616"/></symbol>
   <symbol id="icon-twitch" viewBox="0 0 2400 2800"><g><path d="M500,0L0,500v1800h600v500l500-500h400l900-900V0H500z M2200,1300l-400,400h-400l-350,350v-350H600V200h1600 V1300z"/><rect x="1700" y="550" width="200" height="600"/><rect x="1150" y="550" width="200" height="600"/></g></symbol>
diff --git a/src/static/misc/image.svg b/src/static/misc/image.svg
new file mode 100644
index 00000000..a251b373
--- /dev/null
+++ b/src/static/misc/image.svg
@@ -0,0 +1,11 @@
+<!-- Copyright © (c) 2019-2023 The Bootstrap authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+The Software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the Software or the use or other dealings in the Software. -->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-image-fill" viewBox="0 0 16 16">
+  <path d="M.002 3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-12a2 2 0 0 1-2-2V3zm1 9v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12zm5-6.5a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0z"/>
+</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 4b38b60d..7a40bd0d 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -57,6 +57,16 @@ count:
       many: ""
       other: "{ALBUMS} albums"
 
+  artTags:
+    _: "{TAGS}"
+    withUnit:
+      zero: ""
+      one: "{TAGS} tag"
+      two: ""
+      few: ""
+      many: ""
+      other: "{TAGS} tags"
+
   artworks:
     _: "{ARTWORKS}"
     withUnit:
@@ -139,6 +149,16 @@ count:
       many: ""
       other: "{MONTHS} months"
 
+  timesFeatured:
+    _: "{TIMES_FEATURED}"
+    withUnit:
+      zero: ""
+      one: "featured {TIMES_FEATURED} time"
+      two: ""
+      few: ""
+      many: ""
+      other: "featured {TIMES_FEATURED} times"
+
   timesReferenced:
     _: "{TIMES_REFERENCED}"
     withUnit:
@@ -249,41 +269,71 @@ releaseInfo:
 
   # Descriptions
 
-  by: "By {ARTISTS}."
+  by:
+    _: "By {ARTISTS}."
+    featuring: "By {ARTISTS}, featuring {FEATURING}."
+
   from: "From {ALBUM}."
 
-  coverArtBy: "Cover art by {ARTISTS}."
-  wallpaperArtBy: "Wallpaper art by {ARTISTS}."
-  bannerArtBy: "Banner art by {ARTISTS}."
+  wallpaperArtBy: "Wallpaper by {ARTISTS}"
+  bannerArtBy: "Banner by {ARTISTS}"
 
   released: "Released {DATE}."
-  artReleased: "Art released {DATE}."
+  albumReleased: "Album released {DATE}."
+  trackReleased: "Track released {DATE}."
   addedToWiki: "Added to wiki {DATE}."
 
   duration: "Duration: {DURATION}."
 
   contributors: "Contributors:"
-  lyrics: "Lyrics:"
+
+  lyrics:
+    _: "Lyrics:"
+
+    switcher: "({ENTRIES})"
+
   note: "Context notes:"
 
-  alsoReleasedAs:
-    _: "Also released as:"
+  alsoReleasedOn: "Also released on {ALBUMS}."
 
-    item:
-      _: "{TRACK} ({ALBUM})"
-      withYear: "({YEAR}) {TRACK} ({ALBUM})"
+  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}:"
 
-  tracksReferenced: "Tracks that {TRACK} references:"
-  tracksThatReference: "Tracks that reference {TRACK}:"
-  tracksSampled: "Tracks that {TRACK} samples:"
-  tracksThatSample: "Tracks that sample {TRACK}:"
+    sticky:
+      _: "Tracks that reference this one:"
+      fromGroup: "Tracks that reference this one — from {GROUP}:"
+      fromOther: "Tracks that reference this one — from somewhere else:"
+
+  tracksThatSample:
+    _: "Tracks that sample {TRACK}:"
+
+    sticky:
+      _: "Tracks that sample this one:"
+      fromGroup: "Tracks that sample this one — from {GROUP}:"
+      fromOther: "Tracks that sample this one — from somewhere else:"
 
   flashesThatFeature:
     _: "Flashes & games that feature {TRACK}:"
+    sticky: "Flashes & games that feature this track:"
+
     item:
       _: "{FLASH}"
       asDifferentRelease: "{FLASH} (as {TRACK})"
 
+  referencesArtworks: "References {ARTWORKS}."
+  referencedByArtworks: "Referenced by {ARTWORKS}."
+
+  # 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
@@ -320,8 +370,12 @@ releaseInfo:
     _: "Read {LINK}."
     link: "artist commentary"
 
+  readCreditSources:
+    _: "Read {LINK}."
+    link: "crediting sources"
+
   additionalFiles:
-    heading: "View or download {ADDITIONAL_FILES}:"
+    heading: "View or download additional files:"
 
     entry:
       _: "{TITLE}"
@@ -334,8 +388,8 @@ releaseInfo:
       withSize: "{FILE} ({SIZE})"
 
     shortcut:
-      _: "View {ANCHOR_LINK}: {TITLES}"
-      anchorLink: "additional files"
+      _: "View {LINK}."
+      link: "additional files"
 
   sheetMusicFiles:
     heading: "Print or download sheet music files:"
@@ -364,25 +418,36 @@ trackList:
   section:
     _: "{SECTION}:"
     withDuration: "{SECTION}: ({DURATION})"
+    sticky: "{SECTION}:"
 
-  group:
-    _: "From {GROUP}:"
-    fromOther: "From somewhere else:"
+  fromGroup: "From {GROUP}:"
+  fromOther: "From somewhere else:"
 
   item:
     _: "{TRACK}"
-    withDuration: "{DURATION} {TRACK}"
-    withArtists: "{TRACK} {BY}"
-    withDuration.withArtists: "{DURATION} {TRACK} {BY}"
 
-    withDuration.duration:
-      _: "({DURATION})"
-      missing: "_:__"
-      missing.info: "no duration provided; treated as zero seconds long"
+    withDuration:
+      _: >-
+        {DURATION} {TRACK}
+
+      duration:
+        _: "({DURATION})"
+        missing: "_:__"
+        missing.info: "no duration provided; treated as zero seconds long"
+
+    withArtists:
+      _: >-
+        {TRACK} {BY}
+
+      by: "by {ARTISTS}"
+      featuring: "feat. {ARTISTS}"
+      by.featuring: "by {ARTISTS} feat. {FEATURING}"
 
-    withArtists.by: "by {ARTISTS}"
+    withDuration.withArtists: >-
+      {DURATION} {TRACK} {BY}
 
-    rerelease: "{TRACK} (rerelease)"
+    rerelease: >-
+      {TRACK} (rerelease)
 
 #
 # misc:
@@ -408,8 +473,6 @@ misc:
 
       accent:
         withAnnotation: "({ANNOTATION})"
-        withAlbums: "(on {ALBUMS})"
-        withAnnotations.withAlbums: "({ANNOTATION}; on {ALBUMS})"
 
   # alt:
   #   Fallback text for the alt text of images and artworks - these
@@ -430,14 +493,51 @@ misc:
     entry:
       title:
         _: "{ARTISTS}:"
+
         noArtists: "Unknown artist"
+
         withAccent: "{ARTISTS}: {ACCENT}"
+
         accent:
           withAnnotation: "({ANNOTATION})"
-          withDate: ({DATE})"
-          withAnnotation.withDate: "({ANNOTATION}, {DATE})"
 
-      seeOriginalRelease: "See {ORIGINAL}!"
+        date: "{DATE}"
+        date.range: "{DATE_RANGE}"
+
+        date.accessed: "accessed {DATE}"
+        date.captured: "captured {DATE}"
+
+        date.around: "around {DATE}"
+        date.around.range: "around {DATE_RANGE}"
+
+        date.sometime.range: "sometime {DATE_RANGE}"
+        date.throughout.range: "throughout {DATE_RANGE}"
+
+    info:
+      fromMainRelease: >-
+        This commentary is properly placed on this track's main release, {ALBUM}.
+
+      fromMainRelease.namedDifferently: >-
+        This commentary is properly placed on this track's main release, {ALBUM}, where it's named {NAME}.
+
+      releaseSpecific: >-
+        This commentary is specific to this release, {ALBUM}.
+
+      seeSpecificReleases: >-
+        For release-specific commentary, check out: {ALBUMS}.
+
+      seeSpecificReleases.withMainCommentary: >-
+        For release-specific commentary, see also: {ALBUMS}.
+
+  artistCredit:
+    withNormalArtists: >-
+      {NORMAL}
+
+    withFeaturingArtists: >-
+      feat. {FEATURING}
+
+    withNormalArtists.withFeaturingArtists: >-
+      {NORMAL} feat. {FEATURING}
 
   # artistLink:
   #   Artist links have special accents which are made conditionally
@@ -449,16 +549,41 @@ 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})"
+    # Contributions that are annotated "edits for wiki" are
+    # displayed differently from normal contributions, in a
+    # tooltip next to the rest of the credits on that line.
+    withEditsForWiki:
+      _: "{ARTISTS} ({EDITS})"
+      edits: "+ edits"
+      editsLine: "Edits for wiki by {ARTISTS}"
 
     # 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 +615,11 @@ 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"
+
+  creditSources:
+    _: "Crediting sources:"
 
   # external:
   #   Links which will generally bring you somewhere off of the wiki.
@@ -514,6 +644,7 @@ misc:
       _: "{LINK} ({ANNOTATION})"
       annotation: "invalid URL"
 
+    amazonMusic: "Amazon Music"
     appleMusic: "Apple Music"
     artstation: "ArtStation"
     bandcamp: "Bandcamp"
@@ -560,10 +691,12 @@ misc:
     mspfa: "MSPFA"
     neocities: "Neocities"
     newgrounds: "Newgrounds"
+    nintendoMusic: "Nintendo Music"
     patreon: "Patreon"
     poetryFoundation: "Poetry Foundation"
     soundcloud: "SoundCloud"
     spotify: "Spotify"
+    steam: "Steam"
     tiktok: "TikTok"
     toyhouse: "Toyhouse"
     tumblr: "Tumblr"
@@ -595,6 +728,40 @@ misc:
 
   missingLinkContent: "(Missing link content)"
 
+  # navAccent:
+  #   Accent shown in the nav bar for navigating to pages that are
+  #   related to the current one.
+
+  navAccent: "({LINKS})"
+
+  # 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:
+    readMore: "Read more on {LINKS}."
+
+    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.
@@ -610,8 +777,46 @@ misc:
   #   displayed in the browser tab bar, bookmarks list, etc.
 
   pageTitle:
-    _: "{TITLE}"
-    withWikiName: "{TITLE} | {WIKI_NAME}"
+    _: >-
+      {TITLE}
+
+    withSubtitle: >-
+      {TITLE} - {SUBTITLE}
+
+    withWikiName: >-
+      {TITLE} | {WIKI_NAME}
+
+    withSubtitle.withWikiName: >-
+      {TITLE} - {SUBTITLE} | {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:
   #
@@ -637,11 +842,17 @@ misc:
       left: "Sidebar (left)"
       right: "Sidebar (right)"
 
+    # Displayed on various info pages.
+
+    artistCommentary: "Artist commentary"
+    creditSources: "Crediting sources"
+
     # Displayed on artist info page.
 
     tracks: "Tracks"
     artworks: "Artworks"
     flashes: "Flashes & Games"
+    commentary: "Commentary"
 
     # Displayed on track and flash info pages.
 
@@ -663,9 +874,6 @@ misc:
 
     # Displayed on track and album info pages.
 
-    commentary: "Commentary"
-
-    artistCommentary: "Commentary"
     additionalFiles: "Additional files"
 
   # socialEmbed:
@@ -692,20 +900,43 @@ misc:
     warnings: "{WARNINGS}"
     reveal: "click to show"
 
-  # albumGrid:
+  # coverArtwork:
+  #   Generic or particular strings for artworks outside a grid
+  #   context, when just one cover is being spotlighted.
+
+  coverArtwork:
+    artworkBy: >-
+      Artwork by {ARTISTS}
+
+    artworkBy.customLabel: >-
+      {LABEL} by {ARTISTS}
+
+    artworkBy.withYear: >-
+      Artwork ({YEAR}) by {ARTISTS}
+
+    artworkBy.customLabel.withYear: >-
+      {LABEL} ({YEAR}) by {ARTISTS}
+
+    source: "Via {SOURCE}"
+
+    trackArtFromAlbum: "Album cover for {ALBUM}"
+
+  # coverGrid:
   #   Generic strings for various sorts of gallery grids, displayed
   #   on the homepage, album galleries, artist artwork galleries, and
   #   so on. These get the name of the thing being represented and,
   #   often, a bit of text providing pertinent extra details about
   #   that thing.
 
-  albumGrid:
+  coverGrid:
     noCoverArt: "{ALBUM}"
 
     details:
-      _: "({TRACKS}, {TIME})"
-      coverArtists: "(Illust. {ARTISTS})"
-      otherCoverArtists: "(With {ARTISTS})"
+      accent: "({DETAILS})"
+
+      albumLength: "{TRACKS}, {TIME}"
+      coverArtists: "Artwork by {ARTISTS}"
+      otherCoverArtists: "With {ARTISTS}"
 
   albumGalleryGrid:
     noCoverArt: "{NAME}"
@@ -763,7 +994,9 @@ albumSidebar:
 
     group:
       _: "{GROUP}"
-      withRange: "{GROUP} ({RANGE})"
+
+      withRange: "{GROUP} {RANGE_PART}"
+      withRange.rangePart: "({RANGE})"
 
   # groupBox:
   #   This is the box for groups. Apart from the next and previous
@@ -775,6 +1008,32 @@ albumSidebar:
     next: "Next: {ALBUM}"
     previous: "Previous: {ALBUM}"
 
+  # releaseBox:
+  #   This is the narrow box for alternate releases of the
+  #   current track.
+
+  releaseBox:
+    title: "{ALBUM}"
+
+#
+# albumSecondaryNav:
+#   The secondary nav bar is shown in medium and thin layouts,
+#   and provides access to the same navigational features present
+#   in the sidebar (with a more compact view).
+#
+albumSecondaryNav:
+  group: >-
+    {GROUP}
+
+  group.withPreviousNext: >-
+    {GROUP} ({PREVIOUS_NEXT})
+
+  series: >-
+    {SERIES}
+
+  series.withPreviousNext: >-
+    {SERIES} ({PREVIOUS_NEXT})
+
 #
 # albumPage:
 #
@@ -792,6 +1051,9 @@ albumPage:
 
   nav:
     album: "{ALBUM}"
+
+    backToAlbum: "Return to album page"
+
     randomTrack: "Random Track"
     gallery: "Gallery"
     commentary: "Commentary"
@@ -851,6 +1113,15 @@ albumGalleryPage:
   noTrackArtworksLine: >-
     This album doesn't have any track artwork.
 
+  # setSwitcher:
+  #   This is displayed if multiple sets of artwork are available
+  #   across the album.
+
+  setSwitcher:
+    _: "({SETS})"
+
+    unlabeledSet: "Main album art"
+
 #
 # albumCommentaryPage:
 #   The album commentary page is a more minimal layout that brings
@@ -865,13 +1136,24 @@ albumCommentaryPage:
   nav:
     album: "Album: {ALBUM}"
 
+  sidebar:
+    noTrackCommentary: >-
+      No track commentary.
+
+    noCommentary: >-
+      No album or track commentary.
+
   infoLine: >-
     {WORDS} across {ENTRIES}.
 
+  infoLine.withoutCommentary: >-
+    This album does not have any commentary.
+
   entry:
     title:
       albumCommentary:
         _: "{ALBUM}"
+        sticky: "{ALBUM} (album commentary)"
         accent: "Listen on: {LISTENING_LINKS}"
 
       trackCommentary:
@@ -892,6 +1174,16 @@ artistPage:
   nav:
     artist: "Artist: {ARTIST}"
 
+  closelyLinkedGroups:
+    one: "This artist has a group page: {GROUP}."
+    multiple: "This artist has group pages: {GROUPS}."
+
+    group:
+      _: "{GROUP}"
+      withAnnotation: "{GROUP} ({ANNOTATION})"
+
+    alias: "This artist is an alias of {GROUPS}."
+
   creditList:
 
     # album:
@@ -943,7 +1235,19 @@ artistPage:
       #   artists or contributors, and get dimmed a little compared
       #   to original release track entries.
 
-      rerelease: "{ENTRY} (rerelease)"
+      rerelease:
+        _: "{ENTRY} ({RERELEASE})"
+        term: "rerelease"
+
+        firstRelease: >-
+          First released on {ALBUM}
+
+      firstRelease:
+        _: "{ENTRY} ({FIRST_RELEASE})"
+        term: "first release"
+
+        rerelease: >-
+          Also released on {ALBUM}
 
       # track:
       #   The string without duration is used in both the artist's
@@ -1021,6 +1325,14 @@ artistPage:
     orBrowseList: "View {LINK}! Or browse the list:"
     link: "art gallery"
 
+  wikiEditArtworks:
+    _: "{ARTIST} has edited these artworks for this wiki:"
+    sticky: "Artworks — edited for this wiki"
+
+  wikiEditorCommentary:
+    _: "{ARTIST} has written these commentary entries as an editor of this wiki:"
+    sticky: "Commentary — written for this wiki"
+
 #
 # artistGalleryPage:
 #   The artist gallery page shows a neat grid of all of the album and
@@ -1034,6 +1346,111 @@ artistGalleryPage:
     Contributed to {COVER_ARTS}.
 
 #
+# artTagPage:
+#   Stuff that's common between art tag pages.
+#
+artTagPage:
+  nav:
+    tag: "Tag: {TAG}"
+
+  sidebar:
+    otherTagsExempt: "(…another {TAGS}…)"
+
+#
+# artTagInfoPage:
+#   The art tag info page displays general information about a tag,
+#   including details about how it's networked with other tags in
+#   particular.
+#
+artTagInfoPage:
+  title: "{TAG}"
+
+  viewArtGallery:
+    _: "View this tag's {LINK}!"
+    link: "art gallery"
+
+  readMoreOn: "Read more about '{TAG}' on {LINKS}."
+
+  seeAlso:
+    _: "See also: {TAGS}"
+    tagWithAnnotation: "{TAG} ({ANNOTATION})"
+
+  featuredIn:
+    notFeatured: >-
+      This tag hasn't been featured in any artworks yet.
+
+    directlyOnly: >-
+      This tag is featured in {ARTWORKS}.
+
+    indirectlyOnly: >-
+      This tag is featured in {ARTWORKS}, but only indirectly.
+
+    directlyAndIndirectly: >-
+      This tag is directly featured in {ARTWORKS_DIRECTLY}, and indirectly in {ARTWORKS_INDIRECTLY} more, for a total of {ARTWORKS_TOTAL}.
+
+  descendsFromTags:
+    _: "Tags which '{TAG}' falls under:"
+    item: "{TAG}"
+
+  descendantTags:
+    _: "Tags which fall under '{TAG}':"
+
+    item:
+      _: "{TAG}"
+
+      withGallery:
+        _: "{TAG} ({GALLERY})"
+        gallery: "view gallery"
+
+      withTimesUsed: "{TAG} ({TIMES_USED})"
+      withGallery.withTimesUsed: "{TAG} ({GALLERY}; {TIMES_USED})"
+
+#
+# artTagGalleryPage:
+#   The tag gallery page displays all the artworks that a tag has
+#   been featured in, in one neat grid, with each artwork displaying
+#   its illustrators, as well as a short info line that indicates
+#   how many artworks the tag's been featured in, whether directly,
+#   indirectly (via descendant tags), both, or neither. If a tag's
+#   been featured both directly and indirectly, there are buttons
+#   to switch what's being shown.
+#
+artTagGalleryPage:
+  title: "{TAG}"
+
+  descendsFrom: "Up: {TAGS}."
+  descendants: "Down: {TAGS}."
+
+  featuredLine:
+    all: >-
+      Featured in {COVER_ARTS}.
+
+    direct: >-
+      Featured directly in {COVER_ARTS}.
+
+    indirect: >-
+      Featured indirectly in {COVER_ARTS}.
+
+    notFeatured:
+      _: >-
+        This tag hasn't been featured in any artworks yet.
+
+      callToAction: >-
+        Maybe your track will be the first!
+
+  showingLine:
+    _: "({SHOWING})"
+
+    all: >-
+      Showing all artworks.
+
+    indirect: >-
+      Showing artworks where this tag is only featured indirectly.
+
+    direct: >-
+      Showing artworks where this tag is featured directly.
+
+#
 # commentaryIndex:
 #   The commentary index page shows a summary of all the commentary
 #   across the entire wiki, with a list linking to each album's
@@ -1107,6 +1524,13 @@ groupPage:
   nav:
     group: "Group: {GROUP}"
 
+  secondaryNav:
+    category: >-
+      {CATEGORY}
+
+    category.withPreviousNext: >-
+      {CATEGORY} ({PREVIOUS_NEXT})
+
 #
 # groupInfoPage:
 #   The group info page shows visiting links, the group's full
@@ -1115,10 +1539,32 @@ groupPage:
 groupInfoPage:
   title: "{GROUP}"
 
+  closelyLinkedArtists:
+    one: "View artist page: {ARTIST}."
+    multiple: "View artist pages: {ARTISTS}."
+
+    aliases: "View alias pages: {ALIASES}."
+
+    artist:
+      _: "{ARTIST}"
+      withAnnotation: "{ARTIST} ({ANNOTATION})"
+
   viewAlbumGallery:
-    _: "View {LINK}! Or browse the list:"
+    _: >-
+      View {LINK}! Or browse the list:
+
     link: "album gallery"
 
+    withViewSwitcher:
+      _: >-
+        View {LINK}! Or browse the list: {VIEW_SWITCHER}
+
+  viewSwitcher:
+    _: "({OPTIONS})"
+
+    bySeries: "By series"
+    byDate: "By date"
+
   # albumList:
   #   Many albums are present under multiple groups, and these get an
   #   accent indicating what other group is highest on the album's
@@ -1127,21 +1573,40 @@ groupInfoPage:
   albumList:
     title: "Albums"
 
+    series:
+      _: >-
+        {SERIES}:
+
     item:
       _: >-
         {ALBUM}
 
-      withYear: >-
-        {YEAR_ACCENT} {ALBUM}
+      withYear:
+        _: >-
+          {YEAR_ACCENT} {ALBUM}
+
+        accent: "({YEAR})"
+
+      withOtherGroup:
+        _: >-
+          {ALBUM} {OTHER_GROUP_ACCENT}
 
-      withOtherGroup: >-
-        {ALBUM} {OTHER_GROUP_ACCENT}
+        accent: "(from {GROUPS})"
+        notFromThisGroup: "(not from {GROUP})"
+
+      withArtists:
+        _: >-
+          {ALBUM} {BY}
+
+        by: "by {ARTISTS}"
+        featuring: "feat. {ARTISTS}"
+        by.featuring: "by {ARTISTS} feat. {FEATURING}"
 
       withYear.withOtherGroup: >-
         {YEAR_ACCENT} {ALBUM} {OTHER_GROUP_ACCENT}
 
-      yearAccent: "({YEAR})"
-      otherGroupAccent:  "(from {GROUPS})"
+      withYear.withArtists: >-
+        {YEAR_ACCENT} {ALBUM} {BY}
 
 #
 # groupGalleryPage:
@@ -1283,6 +1748,79 @@ listingPage:
         title: "{DATE}"
         item: "{ALBUM}"
 
+  listArtTags:
+
+    # listArtTags.byName:
+    #   List art tags alphabetically without sorting or chunking by
+    #   any other criteria. Also displays the number of times each
+    #   art tag has been featured.
+
+    byName:
+      title: "Tags - by Name"
+      title.short: "...by Name"
+      item: "{TAG} ({TIMES_USED})"
+
+    # listArtTags.byUses:
+    #   List art tags by number of times used, falling back to an
+    #   alphabetical sort if two art tags have been featured the same
+    #   number of times. Art tags which haven't haven't been featured
+    #   at all yet are totally excluded from the list.
+
+    byUses:
+      title: "Tags - by Uses"
+      title.short: "...by Uses"
+      item: "{TAG} ({TIMES_USED})"
+
+    # listArtTags.network:
+    #   List art tags in a custom networked fashion, showing all
+    #   connections between ancestors and descendants. Each top-level
+    #   tag gets one section. Descendants are generally nested directly
+    #   under their ancestors, except if they have two or more direct
+    #   ancestors *and* at least one direct descendant, in which case
+    #   they're moved to a dedicated section further down the page.
+    #   "Orphan" art tags, if any - tags which don't have any ancestors
+    #   nor any descendants - are displayed in a section at the bottom.
+
+    network:
+      title: "Art Tag Network"
+      title.short: "Art Tag Network"
+
+      jumpToRoot:
+        title: "Jump to one of the upper-most tags:"
+        item: "{TAG}"
+
+      statLine:
+        _: "Displaying tag info: {STAT}"
+        none: "name only"
+        totalUses: "uses (total)"
+        directUses: "uses (direct only)"
+        descendants: "descendants (total)"
+        leaves: "descendants (leaves only)"
+
+      root:
+        _: "{TAG}"
+
+      root.jumpToTop:
+        _: "{TAG} ({LINK})"
+        link: "Jump back to top"
+
+      root.withAncestors:
+        _: "{TAG} (descends from {ANCESTORS})"
+
+      tag:
+        _: "{TAG}"
+
+        jumpToRoot: "Jump to: {TAG}"
+
+        withStat:
+          _: "{STAT} {TAG}"
+          stat: "({STAT})"
+          notApplicable: "-"
+
+      orphanArtTags:
+        title: "These tags don't have any descendants or ancestors:"
+        item: "{TAG}"
+
   listArtists:
 
     # listArtists.byName:
@@ -1610,29 +2148,6 @@ listingPage:
         title.withDate: "{ALBUM} ({DATE})"
         item: "{TRACK}"
 
-  listTags:
-
-    # listTags.byName:
-    #   List art tags alphabetically without sorting or chunking by
-    #   any other criteria. Also displays the number of times each
-    #   art tag has been featured.
-
-    byName:
-      title: "Tags - by Name"
-      title.short: "...by Name"
-      item: "{TAG} ({TIMES_USED})"
-
-    # listTags.byUses:
-    #   List art tags by number of times used, falling back to an
-    #   alphabetical sort if two art tags have been featured the same
-    #   number of times. Art tags which haven't haven't been featured
-    #   at all yet are totally excluded from the list.
-
-    byUses:
-      title: "Tags - by Uses"
-      title.short: "...by Uses"
-      item: "{TAG} ({TIMES_USED})"
-
   other:
 
     # other.allSheetMusic:
@@ -1801,20 +2316,26 @@ redirectPage:
     This page has been moved to {TARGET}.
 
 #
-# tagPage:
-#   The tag gallery page displays all the artworks that a tag has
-#   been featured in, in one neat grid, with each artwork displaying
-#   its illustrators, as well as a short info line that indicates
-#   how many artworks the tag's part of.
+# referencedArtworksPage:
+#   The "referenced artworks" page shows a gallery of all the artworks
+#   which some artwork references.
 #
-tagPage:
-  title: "{TAG}"
+referencedArtworksPage:
+  subtitle: "Referenced Artworks"
 
-  nav:
-    tag: "Tag: {TAG}"
+  statsLine: >-
+    References {ARTWORKS}.
 
-  infoLine: >-
-    Appears in {COVER_ARTS}.
+#
+# referencingArtworksPage:
+#   The "referencing artworks" page shows a gallery of all the artworks
+#   which reference some artwork.
+#
+referencingArtworksPage:
+  subtitle: "Referencing Artworks"
+
+  statsLine: >-
+    Referenced by {ARTWORKS}.
 
 #
 # trackPage:
@@ -1836,10 +2357,18 @@ trackPage:
   nav:
     random: "Random"
 
+    backToTrack: "Return to track page"
+
     track:
       _: "{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 6bd52da9..86ecab69 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -31,38 +31,28 @@
 // Oh yeah, like. Just run this through some relatively recent version of
 // node.js and you'll 8e fine. ...Within the project root. O8viously.
 
+import '#import-heck';
+
 import {execSync} from 'node:child_process';
-import {readdir, readFile} from 'node:fs/promises';
+import {readdir, readFile, stat, writeFile} from 'node:fs/promises';
 import * as path from 'node:path';
 import {fileURLToPath} from 'node:url';
 
 import wrap from 'word-wrap';
 
-// Due to import time shenanigans, these imports have to come in the specified
-// order. This obviously needs fixing up.
-
-/* precede #find */
-import {
-  filterReferenceErrors,
-  reportDuplicateDirectories,
-  reportContentTextErrors,
-} from '#data-checks';
-
-import {bindFind, getAllFindSpecs} from '#find';
-
-// End of import time shenanigans (hopefully)
-
-import {showAggregate} from '#aggregate';
+import {mapAggregate, openAggregate, showAggregate} from '#aggregate';
 import CacheableObject from '#cacheable-object';
+import {stringifyCache} from '#cli';
 import {displayCompositeCacheAnalysis} from '#composite';
+import find, {bindFind, getAllFindSpecs} from '#find';
 import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile}
   from '#language';
 import {isMain, traverse} from '#node-utils';
+import {bindReverse} from '#reverse';
+import {writeSearchData} from '#search';
 import {sortByName} from '#sort';
-import {empty, withEntries} from '#sugar';
-import {generateURLs, urlSpec} from '#urls';
-import {linkWikiDataArrays, loadAndProcessDataDocuments, sortWikiDataArrays}
-  from '#yaml';
+import thingConstructors from '#things';
+import {identifyAllWebRoutes} from '#web-routes';
 
 import {
   colors,
@@ -73,8 +63,24 @@ import {
   logError,
   parseOptions,
   progressCallAll,
+  showHelpForOptions as unboundShowHelpForOptions,
 } from '#cli';
 
+import {
+  filterReferenceErrors,
+  reportContentTextErrors,
+  reportDirectoryErrors,
+  reportOrphanedArtworks,
+} from '#data-checks';
+
+import {
+  bindOpts,
+  empty,
+  filterMultipleArrays,
+  indentWrap as unboundIndentWrap,
+  withEntries,
+} from '#sugar';
+
 import genThumbs, {
   CACHE_FILE as thumbsCacheFile,
   defaultMagickThreads,
@@ -84,14 +90,30 @@ import genThumbs, {
   verifyImagePaths,
 } from '#thumbs';
 
+import {
+  applyLocalizedWithBaseDirectory,
+  applyURLSpecOverriding,
+  generateURLs,
+  getOrigin,
+  internalDefaultURLSpecFile,
+  processURLSpecFromFile,
+} from '#urls';
+
+import {
+  getAllDataSteps,
+  linkWikiDataArrays,
+  loadYAMLDocumentsFromDataSteps,
+  processThingsFromDataSteps,
+  saveThingsFromDataSteps,
+  sortWikiDataArrays,
+} from '#yaml';
+
 import FileSizePreloader from './file-size-preloader.js';
 import {listingSpec, listingTargetSpec} from './listing-spec.js';
 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();
@@ -113,71 +135,138 @@ const defaultStepStatus = {status: STATUS_NOT_STARTED, annotation: null};
 // Defined globally for quick access outside the main() function's contents.
 // This will be initialized and mutated over the course of main().
 let stepStatusSummary;
-let showStepStatusSummary = false;
+let shouldShowStepStatusSummary = false;
+let shouldShowStepMemoryInSummary = 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`},
+    loadOfflineThumbnailCache:
+      {...defaultStepStatus, name: `load offline 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`,
+        for: ['verify']},
 
-    reportDuplicateDirectories:
-      {...defaultStepStatus, name: `report duplicate directories`},
+    reportOrphanedArtworks:
+      {...defaultStepStatus, name: `report orphaned artworks`,
+        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']},
+
+    sortWikiDataSourceFiles:
+      {...defaultStepStatus, name: `apply sorting rules to wiki data files`,
+        for: ['build']},
+
+    checkWikiDataSourceFileSorting:
+      {...defaultStepStatus, name: `check sorting rules against wiki data files`},
+
+    loadURLFiles:
+      {...defaultStepStatus, name: `load internal & custom url spec files`,
+        for: ['build']},
+
+    loadOnlineThumbnailCache:
+      {...defaultStepStatus, name: `load online thumbnail cache file`,
+        for: ['thumbs', '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']},
+
+    loadOnlineFileSizeCache:
+      {...defaultStepStatus, name: `load online file size cache file`,
+        for: ['build']},
+
+    buildSearchIndex:
+      {...defaultStepStatus, name: `generate search index`,
+        for: ['build', 'search']},
+
+    identifyWebRoutes:
+      {...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 = (
@@ -194,21 +283,53 @@ async function main() {
     }));
 
   let selectedBuildModeFlag;
-  let usingDefaultBuildMode;
+  let sortInAdditionToBuild = false;
+
+  // As an exception, --sort can be combined with another build mode.
+  if (selectedBuildModeFlags.length >= 2 && selectedBuildModeFlags.includes('sort')) {
+    sortInAdditionToBuild = true;
+    selectedBuildModeFlags.splice(selectedBuildModeFlags.indexOf('sort'), 1);
+  }
+
+  if (sortInAdditionToBuild) {
+    Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
+      status: STATUS_NOT_STARTED,
+      annotation: `--sort provided with another build mode`,
+    });
+
+    Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `--sort provided, dry run not applicable`,
+    });
+  } else {
+    Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `--sort not provided, dry run only`,
+    });
+
+    Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, {
+      status: STATUS_NOT_STARTED,
+      annotation: `--sort not provided, dry run applicable`,
+    });
+  }
 
   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 = {
@@ -216,7 +337,10 @@ async function main() {
     listingTargetSpec,
   };
 
-  const buildOptions = selectedBuildMode.getCLIOptions();
+  const buildOptions =
+    (selectedBuildMode
+      ? selectedBuildMode.getCLIOptions()
+      : {});
 
   const commonOptions = {
     'help': {
@@ -228,7 +352,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',
     },
 
@@ -236,12 +360,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 by adding "-cache" to the end of the media directory`,
+      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\nAlways required for wiki building; may be provided via the HSMUSIC_CACHE environment varaible`,
       type: 'value',
     },
 
@@ -260,11 +389,36 @@ async function main() {
       type: 'value',
     },
 
+    'urls': {
+      help: `Specify which optional URL specs to use for this build, customizing where pages are generated or resources are accessed from`,
+      type: 'value',
+    },
+
+    'show-url-spec': {
+      help: `Displays the entire computed URL spec, after the data folder's default override and optional specs are applied. This is mostly useful for progammer debugging!`,
+      type: 'flag',
+    },
+
+    'skip-directory-validation': {
+      help: `Skips checking for duplicated directories, which speeds up the build but may cause the wiki to catch on fire`,
+      type: 'flag',
+    },
+
+    'skip-orphaned-artwork-validation': {
+      help: `Skips checking for internally orphaned artworks, which is a bad idea, unless you're debugging those in particular`,
+      type: 'flag',
+    },
+
     'skip-reference-validation': {
       help: `Skips checking and reporting reference errors, which speeds up the build but may silently allow erroneous data to pass through`,
       type: 'flag',
     },
 
+    'skip-content-text-validation': {
+      help: `Skips checking and reporting content text errors, which speeds up the build but may silently allow misformatted or mislinked content to pass through`,
+      type: 'flag',
+    },
+
     // Thum8nail gener8tion is *usually* something you want, 8ut it can 8e
     // kinda a pain to run every time, since it does necessit8te reading
     // every media file at run time. Pass this to skip it.
@@ -285,16 +439,46 @@ async function main() {
       type: 'flag',
     },
 
+    'new-thumbs': {
+      help: `Repair a media cache that's completely missing its index file by starting clean and not reusing any existing thumbnails`,
+      type: 'flag',
+    },
+
+    'refresh-online-thumbs': {
+      help: `Downloads a fresh copy of the online file size cache, so changes there are immediately reflected`,
+      type: 'flag',
+    },
+
     'skip-file-sizes': {
       help: `Skips preloading file sizes for images and additional files, which will be left blank in the build`,
       type: 'flag',
     },
 
+    'refresh-online-file-sizes': {
+      help: `Downloads a fresh copy of the online file size cache, so changes there are immediately reflected`,
+      type: 'flag',
+    },
+
+    'skip-sorting-validation': {
+      help: `Skips checking the if custom sorting rules for this wiki are satisfied`,
+      type: 'flag',
+    },
+
     'skip-media-validation': {
       help: `Skips checking and reporting missing and misplaced media files, which isn't necessary if you aren't adding or removing data or updating directories`,
       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.
@@ -329,6 +513,11 @@ async function main() {
       type: 'flag',
     },
 
+    'show-step-memory': {
+      help: `Include total process memory usage traces at the time each top-level build step ends. Use with --show-step-summary. This is mostly useful for programmer debugging!`,
+      type: 'flag',
+    },
+
     'queue-size': {
       help: `Process more or fewer disk files at once to optimize performance or avoid I/O errors, unlimited if set to 0 (between 500 and 700 is usually a safe range for building HSMusic on Windows machines)\nDefaults to ${defaultQueueSize}`,
       type: 'value',
@@ -351,14 +540,6 @@ async function main() {
     },
     magick: {alias: 'magick-threads'},
 
-    // This option is super slow and has the potential for bugs! It puts
-    // CacheableObject in a mode where every instance is a Proxy which will
-    // keep track of invalid property accesses.
-    'show-invalid-property-accesses': {
-      help: `Report accesses at runtime to nonexistant properties on wiki data objects, at a dramatic performance cost\n(Internal/development use only)`,
-      type: 'flag',
-    },
-
     'precache-mode': {
       help:
         `Change the way certain runtime-computed values are preemptively evaluated and cached\n\n` +
@@ -374,6 +555,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.
@@ -384,89 +577,72 @@ 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(``);
-      }
-    };
+  shouldShowStepStatusSummary = cliOptions['show-step-summary'] ?? false;
+  shouldShowStepMemoryInSummary = cliOptions['show-step-memory'] ?? 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;
@@ -474,17 +650,18 @@ async function main() {
 
   const dataPath = cliOptions['data-path'] || process.env.HSMUSIC_DATA;
   const mediaPath = cliOptions['media-path'] || process.env.HSMUSIC_MEDIA;
+  const wikiCachePath = cliOptions['cache-path'] || process.env.HSMUSIC_CACHE;
   const langPath = cliOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset!
 
   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';
-  const showInvalidPropertyAccesses = cliOptions['show-invalid-property-accesses'] ?? false;
+
+  const wantedURLSpecKeys = cliOptions['urls'] ?? [];
+  const showURLSpec = cliOptions['show-url-spec'] ?? false;
 
   // Makes writing nicer on the CPU and file I/O parts of the OS, with a
   // marginal performance deficit while waiting for file writes to finish
@@ -501,7 +678,11 @@ async function main() {
     logError`${`Expected --media-path option or HSMUSIC_MEDIA to be set`}`;
   }
 
-  if (!dataPath || !mediaPath) {
+  if (!wikiCachePath) {
+    logError`${`Expected --cache-path option or HSMUSIC_CACHE to be set`}`;
+  }
+
+  if (!dataPath || !mediaPath || !wikiCachePath) {
     return false;
   }
 
@@ -512,60 +693,96 @@ 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;
 
-    cli: {
+    const cliEntries =
+      (cliArg === null || cliArg === undefined
+        ? []
+     : Array.isArray(cliArg)
+        ? cliArg
+        : [cliArg]);
+
+    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 {
-        if (cliFlagNegates) {
-          step.status = STATUS_NOT_APPLICABLE;
-          step.annotation = `--${cliFlag} provided`;
+      }
+
+      step.status =
+        (cliFlagNegates
+          ? STATUS_NOT_APPLICABLE
+          : STATUS_NOT_STARTED);
+
+      step.annotation = `--${cliFlag} provided`;
+
+      if (cliFlagWarning) {
+        if (!paragraph) console.log('');
+
+        for (const line of cliFlagWarning.split('\n')) {
+          logWarn(line);
         }
-        if (cliFlagWarning) {
-          for (const line of cliFlagWarning.split('\n')) {
-            logWarn(line);
-          }
+
+        paragraph = false;
+      }
+
+      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) {
+      step.status = STATUS_NOT_STARTED;
+      step.annotation = `required for --${selectedBuildModeFlag}`;
+      return;
     }
 
     if (buildConfig?.applicable === false) {
@@ -580,13 +797,29 @@ async function main() {
       return;
     }
 
+    if (buildConfig?.default === 'perform') {
+      step.status = STATUS_NOT_STARTED;
+      step.annotation = `default for --${selectedBuildModeFlag}`;
+      return;
+    }
+
     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;
@@ -609,32 +842,67 @@ async function main() {
       }
     };
 
+    fallbackStep('reportDirectoryErrors', {
+      default: 'perform',
+      cli: {
+        flag: 'skip-directory-validation',
+        negate: true,
+        warn:
+          `Skipping directory validation. If any directories are duplicated\n` +
+          `in data, the build will probably fail in unpredictable ways.`,
+      },
+    });
+
+    fallbackStep('reportOrphanedArtworks', {
+      default: 'perform',
+      cli: {
+        flag: 'skip-orphaned-artwork-validation',
+        negate: true,
+        warn:
+          `Skipping orphaned artwork validation. Hopefully you're debugging!`,
+      },
+    });
+
     fallbackStep('filterReferenceErrors', {
       default: 'perform',
-      buildConfig: null,
       cli: {
         flag: 'skip-reference-validation',
         negate: true,
         warn:
           `Skipping reference validation. If any reference errors are present\n` +
           `in data, they will be silently passed along to the build.`,
-      }
+      },
     });
 
-    fallbackStep('generateThumbnails', {
+    fallbackStep('reportContentTextErrors', {
       default: 'perform',
-      buildConfig: 'thumbs',
       cli: {
-        flag: 'skip-thumbs',
+        flag: 'skip-content-text-validation',
         negate: true,
+        warn:
+          `Skipping content text validation. If any commentary or other content\n` +
+          `is misformatted or has bad links, it will be silently passed along\n` +
+          `to the build.`,
       },
     });
 
+    fallbackStep('generateThumbnails', {
+      default: 'perform',
+      buildConfig: 'thumbs',
+      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',
+        ],
       },
     });
 
@@ -647,6 +915,111 @@ async function main() {
       },
     });
 
+    fallbackStep('identifyWebRoutes', {
+      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`,
+        });
+      } 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`,
+        });
+      }
+
+      paragraph = false;
+    }
+
+    fallbackStep('checkWikiDataSourceFileSorting', {
+      default: 'perform',
+      buildConfig: 'sort',
+      cli: {
+        flag: 'skip-sorting-validation',
+        negate: true,
+        warning:
+          `Skipping sorting validation. If any of this wiki's sorting rules are not\n` +
+          `satisfied, those errors will be silently passed along to the build.`,
+      },
+    });
+
     fallbackStep('verifyImagePaths', {
       default: 'perform',
       buildConfig: 'mediaValidation',
@@ -674,7 +1047,7 @@ async function main() {
   }
 
   if (stepStatusSummary.generateThumbnails.status === STATUS_NOT_STARTED) {
-    Object.assign(stepStatusSummary.loadThumbnailCache, {
+    Object.assign(stepStatusSummary.loadOfflineThumbnailCache, {
       status: STATUS_NOT_APPLICABLE,
       annotation: `using cache from thumbnail generation`,
     });
@@ -687,33 +1060,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;
   }
@@ -735,45 +1118,171 @@ 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(),
   });
 
+  const regenerateMissingThumbnailCache =
+    cliOptions['new-thumbs'] ?? false;
+
   const {mediaCachePath, annotation: mediaCachePathAnnotation} =
     await determineMediaCachePath({
       mediaPath,
+      wikiCachePath,
+
       providedMediaCachePath:
         cliOptions['media-cache-path'] || process.env.HSMUSIC_MEDIA_CACHE,
+
+      regenerateMissingThumbnailCache,
+
       disallowDoubling:
         stepStatusSummary.migrateThumbnails.status === STATUS_NOT_STARTED,
     });
 
+  if (regenerateMissingThumbnailCache) {
+    if (
+      mediaCachePathAnnotation !== `contained path will regenerate missing cache` &&
+      mediaCachePathAnnotation !== `adjacent path will regenerate missing cache`
+    ) {
+      if (mediaCachePath) {
+        logError`Determined a media cache path. (${mediaCachePathAnnotation})`;
+        console.error('');
+        logWarn`By using ${'--new-thumbs'}, you requested to generate completely`;
+        logWarn`new thumbnails, but there's already a ${'thumbnail-cache.json'}`;
+        logWarn`file where it's expected, within this media cache:`;
+        logWarn`${path.resolve(mediaCachePath)}`;
+        console.error('');
+        logWarn`If you really do want to completely regenerate all thumbnails`;
+        logWarn`and not reuse any existing ones, move aside ${'thumbnail-cache.json'}`;
+        logWarn`and run with ${'--new-thumbs'} again.`;
+
+        Object.assign(stepStatusSummary.determineMediaCachePath, {
+          status: STATUS_FATAL_ERROR,
+          annotation: `--new-thumbs provided but regeneration not needed`,
+          timeEnd: Date.now(),
+          memory: process.memoryUsage(),
+        });
+
+        return false;
+      } else {
+        logError`Couldn't determine a media cache path. (${mediaCachePathAnnotation})`;
+        console.error('');
+        logWarn`You requested to generate completely new thumbnails, but`;
+        logWarn`the media cache wasn't readable or just couldn't be found.`;
+        logWarn`Run again without ${'--new-thumbs'} - you should investigate`;
+        logWarn`what's going on before continuing.`;
+
+        Object.assign(stepStatusSummary.determineMediaCachePath, {
+          status: STATUS_FATAL_ERROR,
+          annotation: mediaCachePathAnnotation,
+          timeEnd: Date.now(),
+          memory: process.memoryUsage(),
+        });
+
+        return false;
+      }
+    }
+  }
+
   if (!mediaCachePath) {
     logError`Couldn't determine a media cache path. (${mediaCachePathAnnotation})`;
 
     switch (mediaCachePathAnnotation) {
-      case 'inferred path does not have cache':
-        logError`If you're certain this is the right path, you can provide it via`;
-        logError`${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}, and it should work.`;
+      case `contained path does not have cache`:
+        console.error('');
+        logError`You've provided a ${'--cache-path'} or ${'HSMUSIC_CACHE_PATH'},`;
+        logError`${path.resolve(wikiCachePath)}`;
+        console.error('');
+        logError`It contains a ${'media-cache'} folder, but this folder is`;
+        logError`missing its ${'thumbnail-cache.json'} file. This means there's`;
+        logError`no information available to reuse. If you use this cache,`;
+        logError`hsmusic will generate any existing thumbnails over again.`;
+        console.error('');
+        logError`* Try to see if you can recover or locate a copy of your`;
+        logError`  ${'thumbnail-cache.json'} file and put it back in place;`;
+        logError`* Or, generate all-new thumbnails with ${'--new-thumbs'}.`;
+        break;
+
+      case 'adjacent path does not have cache':
+        console.error('');
+        logError`You have an existing ${'media-cache'} folder next to your media path,`;
+        logError`${path.resolve(mediaPath)}`;
+        console.error('');
+        logError`The ${'media-cache'} folder is missing its ${'thumbnail-cache.json'}`;
+        logError`file. This means there's no information available to reuse,`;
+        logError`and if you use this cache, hsmusic will generate any existing`;
+        logError`thumbnails over again.`;
+        console.error('');
+        logError`* Try to see if you can recover or locate a copy of your`;
+        logError`  ${'thumbnail-cache.json'} file and put it back in place;`;
+        logError`* Or, generate all-new thumbnails with ${'--new-thumbs'}.`;
         break;
 
-      case 'inferred path not readable':
+      case `contained path not readable`:
+      case `adjacent path not readable`:
+        console.error('');
         logError`The folder couldn't be read, which usually indicates`;
         logError`a permissions error. Try to resolve this, or provide`;
         logError`a new path with ${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}.`;
         break;
 
-      case 'media path not provided': /* unreachable */
+      case `media path not provided`: /* unreachable */
+        console.error('');
         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 `cache path not provided`: /* unreachable */
+        console.error('');
+        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;
     }
 
     Object.assign(stepStatusSummary.determineMediaCachePath, {
       status: STATUS_FATAL_ERROR,
       annotation: mediaCachePathAnnotation,
       timeEnd: Date.now(),
+      memory: process.memoryUsage(),
     });
 
     return false;
@@ -785,6 +1294,7 @@ async function main() {
     status: STATUS_DONE_CLEAN,
     annotation: mediaCachePathAnnotation,
     timeEnd: Date.now(),
+    memory: process.memoryUsage(),
   });
 
   if (stepStatusSummary.migrateThumbnails.status === STATUS_NOT_STARTED) {
@@ -804,6 +1314,7 @@ async function main() {
         status: STATUS_FATAL_ERROR,
         annotation: `view log for details`,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
 
       return false;
@@ -815,6 +1326,7 @@ async function main() {
     Object.assign(stepStatusSummary.migrateThumbnails, {
       status: STATUS_DONE_CLEAN,
       timeEnd: Date.now(),
+      memory: process.memoryUsage(),
     });
 
     return true;
@@ -829,16 +1341,17 @@ async function main() {
   };
 
   if (
-    stepStatusSummary.loadThumbnailCache.status === STATUS_NOT_STARTED &&
+    stepStatusSummary.loadOfflineThumbnailCache.status === STATUS_NOT_STARTED &&
     stepStatusSummary.generateThumbnails.status === STATUS_NOT_STARTED
   ) {
-    throw new Error(`Unable to continue with both loadThumbnailCache and generateThumbnails`);
+    throw new Error(`Unable to continue with both loadOfflineThumbnailCache and generateThumbnails`);
   }
 
   let thumbsCache;
 
-  if (stepStatusSummary.loadThumbnailCache.status === STATUS_NOT_STARTED) {
-    Object.assign(stepStatusSummary.loadThumbnailCache, {
+  // TODO: Skip this step if we're using online thumbs
+  if (stepStatusSummary.loadOfflineThumbnailCache.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.loadOfflineThumbnailCache, {
       status: STATUS_STARTED_NOT_DONE,
       timeStart: Date.now(),
     });
@@ -854,10 +1367,11 @@ async function main() {
         logError`that you'll be good to go and don't need to process thumbnails`
         logError`again!`;
 
-        Object.assign(stepStatusSummary.loadThumbnailCache, {
+        Object.assign(stepStatusSummary.loadOfflineThumbnailCache, {
           status: STATUS_FATAL_ERROR,
           annotation: `cache does not exist`,
           timeEnd: Date.now(),
+          memory: process.memoryUsage(),
         });
 
         return false;
@@ -871,10 +1385,11 @@ async function main() {
         logError`to help you out with troubleshooting!`;
         logError`${'https://hsmusic.wiki/discord/'}`;
 
-        Object.assign(stepStatusSummary.loadThumbnailCache, {
+        Object.assign(stepStatusSummary.loadOfflineThumbnailCache, {
           status: STATUS_FATAL_ERROR,
           annotation: `cache malformed or unreadable`,
           timeEnd: Date.now(),
+          memory: process.memoryUsage(),
         });
 
         return false;
@@ -883,9 +1398,10 @@ async function main() {
 
     logInfo`Thumbnail cache file successfully read.`;
 
-    Object.assign(stepStatusSummary.loadThumbnailCache, {
+    Object.assign(stepStatusSummary.loadOfflineThumbnailCache, {
       status: STATUS_DONE_CLEAN,
       timeEnd: Date.now(),
+      memory: process.memoryUsage(),
     });
 
     logInfo`Skipping thumbnail generation.`;
@@ -913,6 +1429,7 @@ async function main() {
         status: STATUS_FATAL_ERROR,
         annotation: `view log for details`,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
 
       return false;
@@ -921,6 +1438,7 @@ async function main() {
     Object.assign(stepStatusSummary.generateThumbnails, {
       status: STATUS_DONE_CLEAN,
       timeEnd: Date.now(),
+      memory: process.memoryUsage(),
     });
 
     if (thumbsOnly) {
@@ -932,36 +1450,102 @@ async function main() {
     thumbsCache = {};
   }
 
-  if (showInvalidPropertyAccesses) {
-    CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true;
-  }
-
   Object.assign(stepStatusSummary.loadDataFiles, {
     status: STATUS_STARTED_NOT_DONE,
     timeStart: Date.now(),
   });
 
-  let processDataAggregate, wikiDataResult;
+  let yamlDataSteps;
+  let yamlDocumentProcessingAggregate;
 
-  try {
-    ({aggregate: processDataAggregate, result: wikiDataResult} =
-        await loadAndProcessDataDocuments({dataPath}));
-  } catch (error) {
-    console.error(error);
+  {
+    const whoops = (error, stage) => {
+      if (!paragraph) console.log('');
 
-    logError`There was a JavaScript error loading data files.`;
-    fileIssue();
+      console.error(error);
+      niceShowAggregate(error);
 
-    Object.assign(stepStatusSummary.loadDataFiles, {
-      status: STATUS_FATAL_ERROR,
-      annotation: `javascript error - view log for details`,
-      timeEnd: Date.now(),
-    });
+      logError`There was a JavaScript error ${stage}.`;
+      fileIssue();
 
-    return false;
-  }
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `javascript error - view log for details`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      return false;
+    };
 
-  Object.assign(wikiData, wikiDataResult);
+    let loadAggregate, loadResult;
+    let processAggregate, processResult;
+    let saveAggregate, saveResult;
+
+    const dataSteps = getAllDataSteps();
+
+    try {
+      ({aggregate: loadAggregate, result: loadResult} =
+          await loadYAMLDocumentsFromDataSteps(
+            dataSteps,
+            {dataPath}));
+    } catch (error) {
+      return whoops(error, `loading data files`);
+    }
+
+    try {
+      loadAggregate.close();
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
+
+      logError`The above errors were detected while loading data files.`;
+      logError`Since this indicates some files weren't able to load at all,`;
+      logError`there would probably be pretty bad reference errors if the`;
+      logError`build were to continue. Please resolve these errors and`;
+      logError`then give it another go.`;
+
+      paragraph = true;
+      console.log('');
+
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `error loading data files`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      return false;
+    }
+
+    try {
+      ({aggregate: processAggregate, result: processResult} =
+          await processThingsFromDataSteps(
+            loadResult.documentLists,
+            loadResult.fileLists,
+            dataSteps,
+            {dataPath}));
+    } catch (error) {
+      return whoops(error, `processing data files`);
+    }
+
+    try {
+      ({aggregate: saveAggregate, result: saveResult} =
+          saveThingsFromDataSteps(
+            processResult,
+            dataSteps));
+
+      saveAggregate.close();
+      saveAggregate = undefined;
+    } catch (error) {
+      return whoops(error, `finalizing data files`);
+    }
+
+    yamlDataSteps = dataSteps;
+    yamlDocumentProcessingAggregate = processAggregate;
+
+    Object.assign(wikiData, saveResult);
+  }
 
   {
     const logThings = (prop, label) => {
@@ -970,14 +1554,24 @@ async function main() {
           ? prop
           : wikiData[prop]);
 
+      if (array && empty(array)) {
+        return;
+      }
+
       logInfo` - ${array?.length ?? colors.red('(Missing!)')} ${colors.normal(colors.dim(label))}`;
     }
 
     try {
+      if (!paragraph) console.log('');
+
       logInfo`Loaded data and processed objects:`;
       logThings('albumData', 'albums');
       logThings('trackData', 'tracks');
-      logThings(wikiData.artistData.filter(artist => !artist.isAlias), 'artists');
+      logThings(
+        (wikiData.artistData
+          ? wikiData.artistData.filter(artist => !artist.isAlias)
+          : null),
+        'artists');
       if (wikiData.flashData) {
         logThings('flashData', 'flashes');
         logThings('flashActData', 'flash acts');
@@ -990,34 +1584,47 @@ async function main() {
         logThings('newsData', 'news entries');
       }
       logThings('staticPageData', 'static pages');
+      logThings('sortingRules', 'sorting rules');
       if (wikiData.homepageLayout) {
         logInfo` - ${1} homepage layout (${
-          wikiData.homepageLayout.rows.length
+          wikiData.homepageLayout.sections.length
+        } sections, ${
+          wikiData.homepageLayout.sections
+            .flatMap(section => section.rows)
+            .length
         } rows)`;
       }
       if (wikiData.wikiInfo) {
         logInfo` - ${1} wiki config file`;
       }
+
+      console.log('');
+      paragraph = true;
     } catch (error) {
       console.error(`Error showing data summary:`, error);
+      paragraph = false;
     }
 
     let errorless = true;
     try {
-      processDataAggregate.close();
+      yamlDocumentProcessingAggregate.close();
     } catch (error) {
+      if (!paragraph) console.log('');
       niceShowAggregate(error);
+
       logWarn`The above errors were detected while processing data files.`;
+
       errorless = false;
     }
 
     if (!wikiData.wikiInfo) {
-      logError`Can't proceed without wiki info file successfully loading`;
+      logError`Can't proceed without wiki info file successfully loading.`;
 
       Object.assign(stepStatusSummary.loadDataFiles, {
         status: STATUS_FATAL_ERROR,
         annotation: `wiki info object not available`,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
 
       return false;
@@ -1025,20 +1632,27 @@ async function main() {
 
     if (errorless) {
       logInfo`All data files processed without any errors - nice!`;
+      paragraph = false;
 
       Object.assign(stepStatusSummary.loadDataFiles, {
         status: STATUS_DONE_CLEAN,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
     } else {
-      logWarn`If the remaining valid data is complete enough, the wiki will`;
-      logWarn`still build - but all errored data will be skipped.`;
-      logWarn`(Resolve errors for more complete output!)`;
+      logWarn`This might indicate some fields in the YAML data weren't formatted`;
+      logWarn`correctly, for example. The build should still work, but invalid`;
+      logWarn`fields will be skipped. Take a look at the report above to see`;
+      logWarn`what needs fixing up, for a more complete build!`;
+
+      console.log('');
+      paragraph = true;
 
       Object.assign(stepStatusSummary.loadDataFiles, {
         status: STATUS_HAS_WARNINGS,
         annotation: `view log for details`,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
     }
   }
@@ -1052,11 +1666,12 @@ async function main() {
     timeStart: Date.now(),
   });
 
-  linkWikiDataArrays(wikiData);
+  linkWikiDataArrays(wikiData, {bindFind, bindReverse});
 
   Object.assign(stepStatusSummary.linkWikiDataArrays, {
     status: STATUS_DONE_CLEAN,
     timeEnd: Date.now(),
+    memory: process.memoryUsage(),
   });
 
   if (precacheMode === 'common') {
@@ -1106,57 +1721,119 @@ 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(),
+        memory: process.memoryUsage(),
+      });
+
+      return false;
     }
 
     Object.assign(stepStatusSummary.precacheCommonData, {
       status: STATUS_DONE_CLEAN,
       timeEnd: Date.now(),
+      memory: process.memoryUsage(),
     });
   }
 
-  // Filter out any things with duplicate directories throughout the data,
-  // warning about them too.
+  // Check for things with duplicate directories throughout the data,
+  // and halt if any are found.
 
-  Object.assign(stepStatusSummary.reportDuplicateDirectories, {
-    status: STATUS_STARTED_NOT_DONE,
-    timeStart: Date.now(),
-  });
+  if (stepStatusSummary.reportDirectoryErrors.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.reportDirectoryErrors, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-  try {
-    reportDuplicateDirectories(wikiData, {getAllFindSpecs});
-    logInfo`No duplicate directories found - nice!`;
+    try {
+      reportDirectoryErrors(wikiData, {getAllFindSpecs});
+      logInfo`No duplicate directories found - nice!`;
+      paragraph = false;
 
-    Object.assign(stepStatusSummary.reportDuplicateDirectories, {
-      status: STATUS_DONE_CLEAN,
-      timeEnd: Date.now(),
-    });
-  } catch (aggregate) {
-    niceShowAggregate(aggregate);
+      Object.assign(stepStatusSummary.reportDirectoryErrors, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    } catch (aggregate) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(aggregate);
 
-    logWarn`The above duplicate directories were detected while reviewing data files.`;
-    logWarn`Since it's impossible to automatically determine which one's directory is`;
-    logWarn`correct, the build can't continue. Specify unique 'Directory' fields in`;
-    logWarn`some or all of these data entries to resolve the errors.`;
+      logWarn`The above duplicate directories were detected while reviewing data files.`;
+      logWarn`Since it's impossible to automatically determine which one's directory is`;
+      logWarn`correct, the build can't continue. Specify unique 'Directory' fields in`;
+      logWarn`some or all of these data entries to resolve the errors.`;
 
-    Object.assign(stepStatusSummary.reportDuplicateDirectories, {
-      status: STATUS_FATAL_ERROR,
-      annotation: `duplicate directories found`,
-      timeEnd: Date.now(),
+      console.log('');
+      paragraph = true;
+
+      Object.assign(stepStatusSummary.reportDirectoryErrors, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `duplicate directories found`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      return false;
+    }
+  }
+
+  // Check for artwork objects which have been orphaned from their things,
+  // and halt if any are found.
+
+  if (stepStatusSummary.reportOrphanedArtworks.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.reportOrphanedArtworks, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
     });
 
-    return false;
+    try {
+      reportOrphanedArtworks(wikiData, {getAllFindSpecs});
+
+      Object.assign(stepStatusSummary.reportOrphanedArtworks, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    } catch (aggregate) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(aggregate);
+
+      logError`Failed to initialize artwork data connections properly.`;
+      fileIssue();
+
+      Object.assign(stepStatusSummary.reportOrphanedArtworks, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `orphaned artworks found`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      return false;
+    }
   }
 
-  // Filter out any reference errors throughout the data, warning about them
-  // too.
+  // Filter out any reference errors throughout the data, warning about these.
 
   if (stepStatusSummary.filterReferenceErrors.status === STATUS_NOT_STARTED) {
     Object.assign(stepStatusSummary.filterReferenceErrors, {
@@ -1165,28 +1842,36 @@ async function main() {
     });
 
     const filterReferenceErrorsAggregate =
-      filterReferenceErrors(wikiData, {bindFind});
+      filterReferenceErrors(wikiData, {find, bindFind});
 
     try {
       filterReferenceErrorsAggregate.close();
 
       logInfo`All references validated without any errors - nice!`;
+      paragraph = false;
 
       Object.assign(stepStatusSummary.filterReferenceErrors, {
         status: STATUS_DONE_CLEAN,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
     } catch (error) {
+      if (!paragraph) console.log('');
       niceShowAggregate(error);
 
       logWarn`The above errors were detected while validating references in data files.`;
-      logWarn`The wiki will still build, but these connections between data objects`;
-      logWarn`will be completely skipped. Resolve the errors for more complete output.`;
+      logWarn`The wiki should still build, but these connections between data objects`;
+      logWarn`will be skipped, which might have unexpected consequences. Take a look at`;
+      logWarn`the report above to see what needs fixing up, for a more complete build!`;
+
+      console.log('');
+      paragraph = true;
 
       Object.assign(stepStatusSummary.filterReferenceErrors, {
         status: STATUS_HAS_WARNINGS,
         annotation: `view log for details`,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
     }
   }
@@ -1199,23 +1884,31 @@ async function main() {
 
     try {
       reportContentTextErrors(wikiData, {bindFind});
+
       logInfo`All content text validated without any errors - nice!`;
+      paragraph = false;
 
       Object.assign(stepStatusSummary.reportContentTextErrors, {
         status: STATUS_DONE_CLEAN,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
     } catch (error) {
+      if (!paragraph) console.log('');
       niceShowAggregate(error);
 
       logWarn`The above errors were detected while processing content text in data files.`;
       logWarn`The wiki will still build, but placeholders will be displayed in these spots.`;
       logWarn`Resolve the errors for more complete output.`;
 
+      console.log('');
+      paragraph = true;
+
       Object.assign(stepStatusSummary.reportContentTextErrors, {
         status: STATUS_HAS_WARNINGS,
         annotation: `view log for details`,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
     }
   }
@@ -1228,11 +1921,12 @@ async function main() {
     timeStart: Date.now(),
   });
 
-  sortWikiDataArrays(wikiData);
+  sortWikiDataArrays(yamlDataSteps, wikiData, {bindFind, bindReverse});
 
   Object.assign(stepStatusSummary.sortWikiDataArrays, {
     status: STATUS_DONE_CLEAN,
     timeEnd: Date.now(),
+    memory: process.memoryUsage(),
   });
 
   if (precacheMode === 'all') {
@@ -1256,9 +1950,81 @@ async function main() {
     Object.assign(stepStatusSummary.precacheAllData, {
       status: STATUS_DONE_CLEAN,
       timeEnd: Date.now(),
+      memory: process.memoryUsage(),
     });
   }
 
+  if (stepStatusSummary.sortWikiDataSourceFiles.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    const {SortingRule} = thingConstructors;
+    const results =
+      await Array.fromAsync(SortingRule.go({dataPath, wikiData}));
+
+    if (results.some(result => result.changed)) {
+      logInfo`Updated data files to satisfy sorting.`;
+      logInfo`Restarting automatically, since that's now needed!`;
+
+      Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
+        status: STATUS_DONE_CLEAN,
+        annotation: `changes cueing restart`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      return 'restart';
+    } else {
+      logInfo`All sorting rules are satisfied. Nice!`;
+      paragraph = false;
+
+      Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
+        status: STATUS_DONE_CLEAN,
+        annotation: `no changes needed`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    }
+  } else if (stepStatusSummary.checkWikiDataSourceFileSorting.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    const {SortingRule} = thingConstructors;
+    const results =
+      await Array.fromAsync(SortingRule.go({dataPath, wikiData, dry: true}));
+
+    const needed = results.filter(result => result.changed);
+
+    if (empty(needed)) {
+      logInfo`All sorting rules are satisfied. Nice!`;
+      paragraph = false;
+
+      Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    } else {
+      logWarn`Some of this wiki's sorting rules currently aren't satisfied:`;
+      for (const {rule} of needed) {
+        logWarn`- ${rule.message}`;
+      }
+      logWarn`Run ${'hsmusic --sort'} to automatically update data files.`;
+      paragraph = false;
+
+      Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `not all rules satisfied`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    }
+  }
+
   if (stepStatusSummary.performBuild.status === STATUS_NOT_APPLICABLE) {
     displayCompositeCacheAnalysis();
 
@@ -1267,6 +2033,354 @@ async function main() {
     }
   }
 
+  Object.assign(stepStatusSummary.loadURLFiles, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
+
+  let internalURLSpec = {};
+
+  try {
+    let aggregate;
+    ({aggregate, result: internalURLSpec} =
+      await processURLSpecFromFile(internalDefaultURLSpecFile));
+
+    aggregate.close();
+  } catch (error) {
+    niceShowAggregate(error);
+    logError`Couldn't load internal default URL spec.`;
+    logError`This is required to build the wiki, so stopping here.`;
+    fileIssue();
+
+    Object.assign(stepStatusSummary.loadURLFiles, {
+      status: STATUS_FATAL_ERROR,
+      annotation: `see log for details`,
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
+
+    return false;
+  }
+
+  // We'll mutate this as we load other url spec files.
+  const urlSpec = structuredClone(internalURLSpec);
+
+  const allURLSpecDataFiles =
+    (await readdir(dataPath))
+      .filter(name =>
+        name.startsWith('urls') &&
+        ['.json', '.yaml'].includes(path.extname(name)))
+      .sort() /* Just in case... */
+      .map(name => path.join(dataPath, name));
+
+  const getURLSpecKeyFromFile = file => {
+    const base = path.basename(file, path.extname(file));
+    if (base === 'urls') {
+      return base;
+    } else {
+      return base.replace(/^urls-/, '');
+    }
+  };
+
+  const isDefaultURLSpecFile = file =>
+    getURLSpecKeyFromFile(file) === 'urls';
+
+  const overrideDefaultURLSpecFile =
+    allURLSpecDataFiles.find(file => isDefaultURLSpecFile(file));
+
+  const optionalURLSpecDataFiles =
+    allURLSpecDataFiles.filter(file => !isDefaultURLSpecFile(file));
+
+  const optionalURLSpecDataKeys =
+    optionalURLSpecDataFiles.map(file => getURLSpecKeyFromFile(file));
+
+  const selectedURLSpecDataKeys = optionalURLSpecDataKeys.slice();
+  const selectedURLSpecDataFiles = optionalURLSpecDataFiles.slice();
+
+  const {removed: [unusedURLSpecDataKeys]} =
+    filterMultipleArrays(
+      selectedURLSpecDataKeys,
+      selectedURLSpecDataFiles,
+      (key, _file) => wantedURLSpecKeys.includes(key));
+
+  if (!empty(selectedURLSpecDataKeys)) {
+    logInfo`Using these optional URL specs: ${selectedURLSpecDataKeys.join(', ')}`;
+    if (!empty(unusedURLSpecDataKeys)) {
+      logInfo`Other available optional URL specs: ${unusedURLSpecDataKeys.join(', ')}`;
+    }
+  } else if (!empty(unusedURLSpecDataKeys)) {
+    logInfo`Not using any optional URL specs.`;
+    logInfo`These are available with --urls: ${unusedURLSpecDataKeys.join(', ')}`;
+  }
+
+  if (overrideDefaultURLSpecFile) {
+    try {
+      let aggregate;
+      let overrideDefaultURLSpec;
+
+      ({aggregate, result: overrideDefaultURLSpec} =
+          await processURLSpecFromFile(overrideDefaultURLSpecFile));
+
+      aggregate.close();
+
+      ({aggregate} =
+          applyURLSpecOverriding(overrideDefaultURLSpec, urlSpec));
+
+      aggregate.close();
+    } catch (error) {
+      niceShowAggregate(error);
+      logError`Errors loading this data repo's ${'urls.yaml'} file.`;
+      logError`This provides essential overrides for this wiki,`;
+      logError`so stopping here. Debug the errors to continue.`;
+
+      Object.assign(stepStatusSummary.loadURLFiles, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `see log for details`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      return false;
+    }
+  }
+
+  const processURLSpecsAggregate =
+    openAggregate({message: `Errors processing URL specs`});
+
+  const selectedURLSpecs =
+    processURLSpecsAggregate.receive(
+      await Promise.all(
+        selectedURLSpecDataFiles
+          .map(file => processURLSpecFromFile(file))));
+
+  for (const selectedURLSpec of selectedURLSpecs) {
+    processURLSpecsAggregate.receive(
+      applyURLSpecOverriding(selectedURLSpec, urlSpec));
+  }
+
+  try {
+    processURLSpecsAggregate.close();
+  } catch (error) {
+    niceShowAggregate(error);
+    logWarn`There were errors loading the optional URL specs you`;
+    logWarn`selected using ${'--urls'}. Since they might misfunction,`;
+    logWarn`debug the errors or remove the failing ones from ${'--urls'}.`;
+
+    Object.assign(stepStatusSummary.loadURLFiles, {
+      status: STATUS_FATAL_ERROR,
+      annotation: `see log for details`,
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
+
+    return false;
+  }
+
+  if (showURLSpec) {
+    if (!paragraph) console.log('');
+
+    logInfo`Here's the final URL spec, via ${'--show-url-spec'}:`
+    console.log(urlSpec);
+    console.log('');
+
+    paragraph = true;
+  }
+
+  Object.assign(stepStatusSummary.loadURLFiles, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+    memory: process.memoryUsage(),
+  });
+
+  if (!getOrigin(urlSpec.thumb.prefix)) {
+    Object.assign(stepStatusSummary.loadOnlineThumbnailCache, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `using offline thumbs`,
+    });
+  }
+
+  if (getOrigin(urlSpec.media.prefix)) {
+    Object.assign(stepStatusSummary.preloadFileSizes, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `using online media`,
+    });
+  } else {
+    Object.assign(stepStatusSummary.loadOnlineFileSizeCache, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `using offline media`,
+    });
+  }
+
+  applyLocalizedWithBaseDirectory(urlSpec);
+
+  const urls = generateURLs(urlSpec);
+
+  if (stepStatusSummary.loadOnlineThumbnailCache.status === STATUS_NOT_STARTED) loadOnlineThumbnailCache: {
+    Object.assign(stepStatusSummary.loadOnlineThumbnailCache, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    let onlineThumbsCache = null;
+
+    const cacheFile = path.join(wikiCachePath, 'online-thumbnail-cache.json');
+
+    let readError = null;
+    let writeError = null;
+
+    if (!cliOptions['refresh-online-thumbs']) {
+      try {
+        onlineThumbsCache = JSON.parse(await readFile(cacheFile));
+      } catch (caughtError) {
+        readError = caughtError;
+      }
+    }
+
+    if (onlineThumbsCache) obliterateLocalCopy: {
+      if (!onlineThumbsCache._urlPrefix) {
+        // Well, it doesn't even count.
+        onlineThumbsCache = null;
+        break obliterateLocalCopy;
+      }
+
+      if (onlineThumbsCache._urlPrefix !== urlSpec.thumb.prefix) {
+        logInfo`Local copy of online thumbs cache is for a different prefix.`;
+        logInfo`It'll be downloaded and replaced, for reuse next time.`;
+        paragraph = false;
+
+        onlineThumbsCache = null;
+        break obliterateLocalCopy;
+      }
+
+      let stats;
+      try {
+        stats = await stat(cacheFile);
+      } catch {
+        logInfo`Unable to get the stats of local copy of online thumbs cache...`;
+        logInfo`This is really weird, since we *were* able to read it...`;
+        logInfo`We're just going to try writing to it and download fresh!`;
+        paragraph = false;
+
+        onlineThumbsCache = null;
+        break obliterateLocalCopy;
+      }
+
+      const delta = Date.now() - stats.mtimeMs;
+      const minute = 60 * 1000;
+      const delay = 60 * minute;
+
+      const whenst = duration => `~${Math.ceil(duration / minute)} min`;
+
+      if (delta < delay) {
+        logInfo`Online thumbs cache was downloaded recently, skipping for this build.`;
+        logInfo`Next scheduled is in ${whenst(delay - delta)}, or by using ${'--refresh-online-thumbs'}.`;
+        paragraph = false;
+
+        Object.assign(stepStatusSummary.loadOnlineThumbnailCache, {
+          status: STATUS_DONE_CLEAN,
+          annotation: `reusing local copy, earlier than scheduled`,
+          timeEnd: Date.now(),
+          memory: process.memoryUsage(),
+        });
+
+        thumbsCache = onlineThumbsCache;
+
+        break loadOnlineThumbnailCache;
+      } else {
+        logInfo`Online thumbs cache hasn't been downloaded for a little while.`;
+        logInfo`It'll be downloaded this build, then again in ${whenst(delay)}.`;
+        onlineThumbsCache = null;
+        paragraph = false;
+      }
+    }
+
+    try {
+      await writeFile(cacheFile, stringifyCache(onlineThumbsCache));
+    } catch (caughtError) {
+      writeError = caughtError;
+    }
+
+    if (readError && writeError && readError.code !== 'ENOENT') {
+      console.error(readError);
+      logWarn`Wasn't able to read the local copy of the`;
+      logWarn`online thumbs cache file...`;
+      console.error(writeError);
+      logWarn`...or write to it, either.`;
+      logWarn`The online thumbs cache will be downloaded`;
+      logWarn`for every build until you investigate this path:`;
+      logWarn`${cacheFile}`;
+      paragraph = false;
+    } else if (readError && readError.code === 'ENOENT' && !writeError) {
+      logInfo`No local copy of online thumbs cache.`;
+      logInfo`It'll be downloaded this time and reused next time.`;
+      paragraph = false;
+    } else if (readError && readError.code === 'ENOENT' && writeError) {
+      console.error(writeError);
+      logWarn`Doesn't look like we can write a local copy of`;
+      logWarn`the offline thumbs cache, at this path:`;
+      logWarn`${cacheFile}`;
+      logWarn`The online thumbs cache will be downloaded`;
+      logWarn`for every build until you investigate that.`;
+      paragraph = false;
+    }
+
+    const url = new URL(urlSpec.thumb.prefix);
+    url.pathname = path.posix.join(url.pathname, 'thumbnail-cache.json');
+
+    try {
+      onlineThumbsCache = await fetch(url).then(res => res.json());
+    } catch (error) {
+      console.error(error);
+      logWarn`There was an error downloading the online thumbnail cache.`;
+      logWarn`The wiki will act as though no thumbs are available at all.`;
+      paragraph = false;
+
+      Object.assign(stepStatusSummary.loadOnlineThumbnailCache, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `failed to download`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      onlineThumbsCache = {};
+      thumbsCache = {};
+
+      break loadOnlineThumbnailCache;
+    }
+
+    onlineThumbsCache._urlPrefix = urlSpec.thumb.prefix;
+
+    thumbsCache = onlineThumbsCache;
+
+    if (onlineThumbsCache && !writeError) {
+      try {
+        await writeFile(cacheFile, stringifyCache(onlineThumbsCache));
+      } catch (error) {
+        console.error(error);
+        logWarn`There was an error saving a local copy of the`;
+        logWarn`online thumbnail cache. It'll be fetched again`;
+        logWarn`next time.`;
+        paragraph = false;
+
+        Object.assign(stepStatusSummary.loadOnlineThumbnailCache, {
+          status: STATUS_HAS_WARNINGS,
+          annotation: `failed to download`,
+          timeEnd: Date.now(),
+          memory: process.memoryUsage(),
+        });
+
+        break loadOnlineThumbnailCache;
+      }
+    }
+
+    Object.assign(stepStatusSummary.loadOnlineThumbnailCache, {
+      status: STATUS_DONE_CLEAN,
+      timeStart: Date.now(),
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
+  }
+
   const languageReloading =
     stepStatusSummary.watchLanguageFiles.status === STATUS_NOT_STARTED;
 
@@ -1329,6 +2443,7 @@ async function main() {
       status: STATUS_FATAL_ERROR,
       annotation: `see log for details`,
       timeEnd: Date.now(),
+      memory: process.memoryUsage(),
     });
 
     return false;
@@ -1342,6 +2457,7 @@ async function main() {
   Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
     status: STATUS_DONE_CLEAN,
     timeEnd: Date.now(),
+    memory: process.memoryUsage(),
   });
 
   let customLanguageWatchers;
@@ -1421,6 +2537,7 @@ async function main() {
             status: STATUS_FATAL_ERROR,
             annotation: `see log for details`,
             timeEnd: Date.now(),
+            memory: process.memoryUsage(),
           });
 
           errorLoadingCustomLanguages = true;
@@ -1452,6 +2569,7 @@ async function main() {
       Object.assign(stepStatusSummary.watchLanguageFiles, {
         status: STATUS_DONE_CLEAN,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
     } else {
       languages = {};
@@ -1475,11 +2593,13 @@ async function main() {
           status: STATUS_FATAL_ERROR,
           annotation: `see log for details`,
           timeEnd: Date.now(),
+          memory: process.memoryUsage(),
         });
       } else {
         Object.assign(stepStatusSummary.loadLanguageFiles, {
           status: STATUS_DONE_CLEAN,
           timeEnd: Date.now(),
+          memory: process.memoryUsage(),
         });
       }
     }
@@ -1517,12 +2637,14 @@ async function main() {
         status: STATUS_FATAL_ERROR,
         annotation: `wiki specifies default language whose file is not available`,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
 
       return false;
     }
 
     logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`;
+    paragraph = false;
 
     finalDefaultLanguage = customDefaultLanguage;
     finalDefaultLanguageAnnotation = `using wiki-specified custom default language`;
@@ -1603,15 +2725,15 @@ async function main() {
   }
 
   logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
+  paragraph = false;
 
   Object.assign(stepStatusSummary.initializeDefaultLanguage, {
     status: STATUS_DONE_CLEAN,
     annotation: finalDefaultLanguageAnnotation,
     timeEnd: Date.now(),
+    memory: process.memoryUsage(),
   });
 
-  const urls = generateURLs(urlSpec);
-
   let missingImagePaths;
 
   if (stepStatusSummary.verifyImagePaths.status === STATUS_NOT_APPLICABLE) {
@@ -1632,85 +2754,225 @@ async function main() {
       Object.assign(stepStatusSummary.verifyImagePaths, {
         status: STATUS_DONE_CLEAN,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
     } else if (empty(missingImagePaths)) {
       Object.assign(stepStatusSummary.verifyImagePaths, {
         status: STATUS_HAS_WARNINGS,
         annotation: `misplaced images detected`,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
     } else if (empty(misplacedImagePaths)) {
       Object.assign(stepStatusSummary.verifyImagePaths, {
         status: STATUS_HAS_WARNINGS,
         annotation: `missing images detected`,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
     } else {
       Object.assign(stepStatusSummary.verifyImagePaths, {
         status: STATUS_HAS_WARNINGS,
         annotation: `missing and misplaced images detected`,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
     }
   }
 
-  let getSizeOfAdditionalFile;
-  let getSizeOfImagePath;
+  let getSizeOfMediaFile = () => null;
+
+  const fileSizePreloader =
+    new FileSizePreloader({
+      prefix: mediaPath,
+    });
+
+  if (stepStatusSummary.loadOnlineFileSizeCache.status === STATUS_NOT_STARTED) loadOnlineFileSizeCache: {
+    Object.assign(stepStatusSummary.loadOnlineFileSizeCache, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    let onlineFileSizeCache = null;
+
+    const makeFileSizeCacheAvailable = () => {
+      fileSizePreloader.loadFromCache(onlineFileSizeCache);
+
+      getSizeOfMediaFile = p =>
+        fileSizePreloader.getSizeOfPath(
+          path.resolve(
+            mediaPath,
+            decodeURIComponent(p).split('/').join(path.sep)));
+    };
+
+    const cacheFile = path.join(wikiCachePath, 'online-file-size-cache.json');
+
+    let readError = null;
+    let writeError = null;
+
+    if (!cliOptions['refresh-online-file-sizes']) {
+      try {
+        onlineFileSizeCache = JSON.parse(await readFile(cacheFile));
+      } catch (caughtError) {
+        readError = caughtError;
+      }
+    }
+
+    if (onlineFileSizeCache) obliterateLocalCopy: {
+      if (!onlineFileSizeCache._urlPrefix) {
+        // Well, it doesn't even count.
+        onlineFileSizeCache = null;
+        break obliterateLocalCopy;
+      }
+
+      if (onlineFileSizeCache._urlPrefix !== urlSpec.media.prefix) {
+        logInfo`Local copy of online file size cache is for a different prefix.`;
+        logInfo`It'll be downloaded and replaced, for reuse next time.`;
+        paragraph = false;
+
+        onlineFileSizeCache = null;
+        break obliterateLocalCopy;
+      }
+
+      let stats;
+      try {
+        stats = await stat(cacheFile);
+      } catch {
+        logInfo`Unable to get the stats of local copy of online file size cache...`;
+        logInfo`This is really weird, since we *were* able to read it...`;
+        logInfo`We're just going to try writing to it and download fresh!`;
+        paragraph = false;
+
+        onlineFileSizeCache = null;
+        break obliterateLocalCopy;
+      }
+
+      const delta = Date.now() - stats.mtimeMs;
+      const minute = 60 * 1000;
+      const delay = 60 * minute;
+
+      const whenst = duration => `~${Math.ceil(duration / minute)} min`;
+
+      if (delta < delay) {
+        logInfo`Online file size cache was downloaded recently, skipping for this build.`;
+        logInfo`Next scheduled is in ${whenst(delay - delta)}, or by using ${'--refresh-online-file-sizes'}.`;
+        paragraph = false;
+
+        Object.assign(stepStatusSummary.loadOnlineFileSizeCache, {
+          status: STATUS_DONE_CLEAN,
+          annotation: `reusing local copy, earlier than scheduled`,
+          timeEnd: Date.now(),
+          memory: process.memoryUsage(),
+        });
+
+        delete onlineFileSizeCache._urlPrefix;
+
+        makeFileSizeCacheAvailable();
+
+        break loadOnlineFileSizeCache;
+      } else {
+        logInfo`Online file size hasn't been downloaded for a little while.`;
+        logInfo`It'll be downloaded this build, then again in ${whenst(delay)}.`;
+        onlineFileSizeCache = null;
+        paragraph = false;
+      }
+    }
+
+    try {
+      await writeFile(cacheFile, stringifyCache(onlineFileSizeCache));
+    } catch (caughtError) {
+      writeError = caughtError;
+    }
+
+    if (readError && writeError && readError.code !== 'ENOENT') {
+      console.error(readError);
+      logWarn`Wasn't able to read the local copy of the`;
+      logWarn`online file size cache file...`;
+      console.error(writeError);
+      logWarn`...or write to it, either.`;
+      logWarn`The online file size cache will be downloaded`;
+      logWarn`for every build until you investigate this path:`;
+      logWarn`${cacheFile}`;
+      paragraph = false;
+    } else if (readError && readError.code === 'ENOENT' && !writeError) {
+      logInfo`No local copy of online file size cache.`;
+      logInfo`It'll be downloaded this time and reused next time.`;
+      paragraph = false;
+    } else if (readError && readError.code === 'ENOENT' && writeError) {
+      console.error(writeError);
+      logWarn`Doesn't look like we can write a local copy of`;
+      logWarn`the offline file size cache, at this path:`;
+      logWarn`${cacheFile}`;
+      logWarn`The online file size cache will be downloaded`;
+      logWarn`for every build until you investigate that.`;
+      paragraph = false;
+    }
+
+    const url = new URL(urlSpec.media.prefix);
+    url.pathname = path.posix.join(url.pathname, 'file-size-cache.json');
+
+    try {
+      onlineFileSizeCache = await fetch(url).then(res => res.json());
+    } catch (error) {
+      console.error(error);
+      logWarn`There was an error downloading the online file size cache.`;
+      logWarn`The wiki will act as though no file sizes are available at all.`;
+      paragraph = false;
+
+      Object.assign(stepStatusSummary.loadOnlineFileSizeCache, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `failed to download`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
 
-  if (stepStatusSummary.preloadFileSizes.status === STATUS_NOT_APPLICABLE) {
-    getSizeOfAdditionalFile = () => null;
-    getSizeOfImagePath = () => null;
-  } else if (stepStatusSummary.preloadFileSizes.status === STATUS_NOT_STARTED) {
+      break loadOnlineFileSizeCache;
+    }
+
+    makeFileSizeCacheAvailable();
+
+    onlineFileSizeCache._urlPrefix = urlSpec.media.prefix;
+
+    if (onlineFileSizeCache && !writeError) {
+      try {
+        await writeFile(cacheFile, stringifyCache(onlineFileSizeCache));
+      } catch (error) {
+        console.error(error);
+        logWarn`There was an error saving a local copy of the`;
+        logWarn`online file size cache. It'll be fetched again`;
+        logWarn`next time.`;
+        paragraph = false;
+
+        Object.assign(stepStatusSummary.loadOnlineFileSizeCache, {
+          status: STATUS_HAS_WARNINGS,
+          annotation: `failed to download`,
+          timeEnd: Date.now(),
+          memory: process.memoryUsage(),
+        });
+
+        break loadOnlineFileSizeCache;
+      }
+    }
+
+    Object.assign(stepStatusSummary.loadOnlineFileSizeCache, {
+      status: STATUS_DONE_CLEAN,
+      timeStart: Date.now(),
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
+  }
+
+  if (stepStatusSummary.preloadFileSizes.status === STATUS_NOT_STARTED) {
     Object.assign(stepStatusSummary.preloadFileSizes, {
       status: STATUS_STARTED_NOT_DONE,
       timeStart: Date.now(),
     });
 
-    const fileSizePreloader = new FileSizePreloader();
-
-    // File sizes of additional files need to be precalculated before we can
-    // actually reference 'em in site building, so get those loading right
-    // away. We actually need to keep track of two things here - the on-device
-    // file paths we're actually reading, and the corresponding on-site media
-    // paths that will be exposed in site build code. We'll build a mapping
-    // function between them so that when site code requests a site path,
-    // it'll get the size of the file at the corresponding device path.
-    const additionalFilePaths = [
-      ...wikiData.albumData.flatMap((album) =>
-        [
-          ...(album.additionalFiles ?? []),
-          ...album.tracks.flatMap((track) => [
-            ...(track.additionalFiles ?? []),
-            ...(track.sheetMusicFiles ?? []),
-            ...(track.midiProjectFiles ?? []),
-          ]),
-        ]
-          .flatMap((fileGroup) => fileGroup.files ?? [])
-          .map((file) => ({
-            device: path.join(
-              mediaPath,
-              urls
-                .from('media.root')
-                .toDevice('media.albumAdditionalFile', album.directory, file)
-            ),
-            media: urls
-              .from('media.root')
-              .to('media.albumAdditionalFile', album.directory, file),
-          }))
-      ),
-    ];
-
-    // Same dealio for images. Since just about any image can be embedded and
-    // we can't super easily know which ones are referenced at runtime, just
-    // cheat and get file sizes for all images under media. (This includes
-    // additional files which are images.)
-    const imageFilePaths =
+    const mediaFilePaths =
       await traverse(mediaPath, {
         pathStyle: 'device',
         filterDir: dir => dir !== '.git',
-        filterFile: file =>
-          ['.png', '.gif', '.jpg'].includes(path.extname(file)) &&
-          !isThumb(file),
+        filterFile: file => !isThumb(file),
       }).then(files => files
           .map(file => ({
             device: file,
@@ -1720,27 +2982,19 @@ async function main() {
                 .to('media.path', path.relative(mediaPath, file).split(path.sep).join('/')),
           })));
 
-    const getSizeOfMediaFileHelper = paths => (mediaPath) => {
-      const pair = paths.find(({media}) => media === mediaPath);
+    getSizeOfMediaFile = mediaPath => {
+      const pair = mediaFilePaths.find(({media}) => media === mediaPath);
       if (!pair) return null;
       return fileSizePreloader.getSizeOfPath(pair.device);
     };
 
-    getSizeOfAdditionalFile = getSizeOfMediaFileHelper(additionalFilePaths);
-    getSizeOfImagePath = getSizeOfMediaFileHelper(imageFilePaths);
-
-    logInfo`Preloading filesizes for ${additionalFilePaths.length} additional files...`;
-
-    fileSizePreloader.loadPaths(...additionalFilePaths.map((path) => path.device));
-    await fileSizePreloader.waitUntilDoneLoading();
-
-    logInfo`Preloading filesizes for ${imageFilePaths.length} full-resolution images...`;
+    logInfo`Preloading file sizes for ${mediaFilePaths.length} media files...`;
 
-    fileSizePreloader.loadPaths(...imageFilePaths.map((path) => path.device));
+    fileSizePreloader.loadPaths(...mediaFilePaths.map(path => path.device));
     await fileSizePreloader.waitUntilDoneLoading();
 
     if (fileSizePreloader.hasErrored) {
-      logWarn`Some media files couldn't be read for preloading filesizes.`;
+      logWarn`Some media files couldn't be read for preloading file sizes.`;
       logWarn`This means the wiki won't display file sizes for these files.`;
       logWarn`Investigate missing or unreadable files to get that fixed!`;
 
@@ -1748,17 +3002,160 @@ async function main() {
         status: STATUS_HAS_WARNINGS,
         annotation: `see log for details`,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
     } else {
-      logInfo`Done preloading filesizes without any errors - nice!`;
+      logInfo`Done preloading file sizes without any errors - nice!`;
+      paragraph = false;
 
       Object.assign(stepStatusSummary.preloadFileSizes, {
         status: STATUS_DONE_CLEAN,
         timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    }
+
+    // TODO: kinda jank that this is out of band of any particular step,
+    // even though it's operationally a follow-up to preloadFileSizes
+
+    let oopsCache = false;
+    saveFileSizeCache: {
+      let cache;
+      try {
+        cache = fileSizePreloader.saveAsCache();
+      } catch (error) {
+        console.error(error);
+        logWarn`Couldn't compute file size preloader's cache.`;
+        oopsCache = true;
+        break saveFileSizeCache;
+      }
+
+      const cacheFile = path.join(mediaPath, 'file-size-cache.json');
+
+      try {
+        await writeFile(cacheFile, stringifyCache(cache));
+      } catch (error) {
+        console.error(error);
+        logWarn`Couldn't save preloaded file sizes to a cache file:`;
+        logWarn`${cacheFile}`;
+        oopsCache = true;
+      }
+    }
+
+    if (oopsCache) {
+      logWarn`This won't affect the build, but this build should not be used`;
+      logWarn`as a model for another build accessing its media files online.`;
+    }
+  }
+
+  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(),
+        memory: process.memoryUsage(),
+      });
+    } 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(),
+        memory: process.memoryUsage(),
+      });
+    }
+  }
+
+  let webRouteSources = null;
+  let preparedWebRoutes = null;
+
+  if (stepStatusSummary.identifyWebRoutes.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.identifyWebRoutes, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    const fromRoot = urls.from('shared.root');
+
+    try {
+      webRouteSources = await identifyAllWebRoutes({
+        mediaCachePath,
+        mediaPath,
+        wikiCachePath,
+      });
+
+      const {aggregate, result} =
+        mapAggregate(
+          webRouteSources,
+          ({to, ...rest}) => ({
+            ...rest,
+            to: fromRoot.to(...to),
+          }),
+          {message: `Errors computing effective web route paths`},);
+
+      aggregate.close();
+      preparedWebRoutes = result;
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
+
+      logError`There was an issue identifying web routes!`;
+      fileIssue();
+
+      console.log('');
+      paragraph = true;
+
+      Object.assign(stepStatusSummary.identifyWebRoutes, {
+        status: STATUS_FATAL_ERROR,
+        message: `JavaScript error - view log for details`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
       });
+
+      return false;
     }
+
+    logInfo`Successfully determined web routes - nice!`;
+    paragraph = false;
+
+    Object.assign(stepStatusSummary.identifyWebRoutes, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
   }
 
+  wikiData.wikiInfo.searchDataAvailable =
+    (webRouteSources
+      ? webRouteSources
+          .some(({to}) => to[0].startsWith('searchData'))
+      : null);
+
   if (stepStatusSummary.performBuild.status === STATUS_NOT_APPLICABLE) {
     return true;
   }
@@ -1800,28 +3197,39 @@ async function main() {
 
   let buildModeResult;
 
+  logInfo`Passing control over to build mode: ${selectedBuildModeFlag}`;
+  console.log('');
+
+  const universalUtilities = {
+    getSizeOfMediaFile,
+
+    defaultLanguage: finalDefaultLanguage,
+    developersComment,
+    languages,
+    missingImagePaths,
+    thumbsCache,
+    urlSpec,
+    urls,
+    wikiData,
+  };
+
   try {
     buildModeResult = await selectedBuildMode.go({
       cliOptions,
+      queueSize,
+
+      universalUtilities,
+      ...universalUtilities,
+
       dataPath,
       mediaPath,
       mediaCachePath,
-      queueSize,
+      wikiCachePath,
       srcRootPath: __dirname,
 
-      defaultLanguage: finalDefaultLanguage,
-      languages,
-      missingImagePaths,
-      thumbsCache,
-      urls,
-      urlSpec,
-      wikiData,
+      webRoutes: preparedWebRoutes,
 
-      cachebust: '?' + CACHEBUST,
       closeLanguageWatchers,
-      developersComment,
-      getSizeOfAdditionalFile,
-      getSizeOfImagePath,
       niceShowAggregate,
     });
   } catch (error) {
@@ -1834,6 +3242,7 @@ async function main() {
       status: STATUS_FATAL_ERROR,
       message: `javascript error - view log for details`,
       timeEnd: Date.now(),
+      memory: process.memoryUsage(),
     });
 
     return false;
@@ -1844,6 +3253,7 @@ async function main() {
       status: STATUS_HAS_WARNINGS,
       annotation: `may not have completed - view log for details`,
       timeEnd: Date.now(),
+      memory: process.memoryUsage(),
     });
 
     return false;
@@ -1852,6 +3262,7 @@ async function main() {
   Object.assign(stepStatusSummary.performBuild, {
     status: STATUS_DONE_CLEAN,
     timeEnd: Date.now(),
+    memory: process.memoryUsage(),
   });
 
   return true;
@@ -1862,126 +3273,67 @@ async function main() {
 if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmusic') {
   (async () => {
     let result;
+    let numRestarts = 0;
 
     const totalTimeStart = Date.now();
 
-    try {
-      result = await main();
-    } catch (error) {
-      if (error instanceof AggregateError) {
-        showAggregate(error);
-      } else if (error.cause) {
-        console.error(error);
-        showAggregate(error);
-      } else {
-        console.error(error);
-      }
-    }
-
-    const totalTimeEnd = Date.now();
-
-    const formatDuration = timeDelta => {
-      const seconds = timeDelta / 1000;
-
-      if (seconds > 90) {
-        const modSeconds = Math.floor(seconds % 60);
-        const minutes = Math.floor(seconds - seconds % 60) / 60;
-        return `${minutes}m${modSeconds}s`;
-      }
-
-      if (seconds < 0.1) {
-        return 'instant';
+    while (true) {
+      try {
+        result = await main();
+      } catch (error) {
+        if (error instanceof AggregateError) {
+          showAggregate(error);
+        } else if (error.cause) {
+          console.error(error);
+          showAggregate(error);
+        } else {
+          console.error(error);
+        }
       }
 
-      const precision = (seconds > 1 ? 3 : 2);
-      return `${seconds.toPrecision(precision)}s`;
-    };
-
-    if (showStepStatusSummary) {
-      const totalDuration = formatDuration(totalTimeEnd - totalTimeStart);
-
-      console.error(colors.bright(`Step summary:`));
-
-      const longestNameLength =
-        Math.max(...
-          Object.values(stepStatusSummary)
-            .map(({name}) => name.length));
-
-      const stepsNotClean =
-        Object.values(stepStatusSummary)
-          .map(({status}) =>
-            status === STATUS_HAS_WARNINGS ||
-            status === STATUS_FATAL_ERROR ||
-            status === STATUS_STARTED_NOT_DONE);
-
-      const anyStepsNotClean =
-        stepsNotClean.includes(true);
-
-      const stepDetails = Object.values(stepStatusSummary);
-
-      const stepDurations =
-        stepDetails.map(({status, timeStart, timeEnd}) => {
-          if (
-            status === STATUS_NOT_APPLICABLE ||
-            status === STATUS_NOT_STARTED ||
-            status === STATUS_STARTED_NOT_DONE
-          ) {
-            return '-';
-          }
+      if (result === 'restart') {
+        console.log('');
 
-          if (typeof timeStart !== 'number' || typeof timeEnd !== 'number') {
-            return 'unknown';
+        if (shouldShowStepStatusSummary) {
+          if (numRestarts >= 1) {
+            console.error(colors.bright(`Step summary since latest restart:`));
+          } else {
+            console.error(colors.bright(`Step summary before restart:`));
           }
 
-          return formatDuration(timeEnd - timeStart);
-        });
-
-      const longestDurationLength =
-        Math.max(...stepDurations.map(duration => duration.length));
-
-      for (let index = 0; index < stepDetails.length; index++) {
-        const {name, status, annotation} = stepDetails[index];
-        const duration = stepDurations[index];
-
-        let message =
-          (stepsNotClean[index]
-            ? `!! `
-            : ` - `);
-
-        message += `(${duration})`.padStart(longestDurationLength + 2, ' ');
-        message += ` `;
-        message += `${name}: `.padEnd(longestNameLength + 4, '.');
-        message += ` `;
-        message += status;
-
-        if (annotation) {
-          message += ` (${annotation})`;
+          showStepStatusSummary();
+          console.log('');
         }
 
-        switch (status) {
-          case STATUS_DONE_CLEAN:
-            console.error(colors.green(message));
-            break;
-
-          case STATUS_NOT_STARTED:
-          case STATUS_NOT_APPLICABLE:
-            console.error(colors.dim(message));
-            break;
+        if (numRestarts > 5) {
+          logError`A restart was cued, but we've restarted a bunch already.`;
+          logError`Exiting because this is probably a bug!`;
+          console.log('');
+          break;
+        } else {
+          console.log('');
+          logInfo`A restart was cued. This is probably normal, and required`;
+          logInfo`to load updated data files. Restarting automatically now!`;
+          console.log('');
+          numRestarts++;
+        }
+      } else {
+        break;
+      }
+    }
 
-          case STATUS_HAS_WARNINGS:
-          case STATUS_STARTED_NOT_DONE:
-            console.error(colors.yellow(message));
-            break;
+    if (shouldShowStepStatusSummary)  {
+      if (numRestarts >= 1) {
+        console.error(colors.bright(`Step summary after final restart:`));
+      } else {
+        console.error(colors.bright(`Step summary:`));
+      }
 
-          case STATUS_FATAL_ERROR:
-            console.error(colors.red(message));
-            break;
+      const {anyStepsNotClean} =
+        showStepStatusSummary();
 
-          default:
-            console.error(message);
-            break;
-        }
-      }
+      const totalTimeEnd = Date.now();
+      const totalDuration = formatDuration(totalTimeEnd - totalTimeStart);
 
       console.error(colors.bright(`Done in ${totalDuration}.`));
 
@@ -2006,8 +3358,124 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus
     }
 
     decorateTime.displayTime();
-    CacheableObject.showInvalidAccesses();
 
     process.exit(0);
   })();
 }
+
+function formatDuration(timeDelta) {
+  const seconds = timeDelta / 1000;
+
+  if (seconds > 90) {
+    const modSeconds = Math.floor(seconds % 60);
+    const minutes = Math.floor(seconds - seconds % 60) / 60;
+    return `${minutes}m${modSeconds}s`;
+  }
+
+  if (seconds < 0.1) {
+    return 'instant';
+  }
+
+  const precision = (seconds > 1 ? 3 : 2);
+  return `${seconds.toPrecision(precision)}s`;
+}
+
+function showStepStatusSummary() {
+  const longestNameLength =
+    Math.max(...
+      Object.values(stepStatusSummary)
+        .map(({name}) => name.length));
+
+  const stepsNotClean =
+    Object.values(stepStatusSummary)
+      .map(({status}) =>
+        status === STATUS_HAS_WARNINGS ||
+        status === STATUS_FATAL_ERROR ||
+        status === STATUS_STARTED_NOT_DONE);
+
+  const anyStepsNotClean =
+    stepsNotClean.includes(true);
+
+  const stepDetails = Object.values(stepStatusSummary);
+
+  const stepDurations =
+    stepDetails.map(({status, timeStart, timeEnd}) => {
+      if (
+        status === STATUS_NOT_APPLICABLE ||
+        status === STATUS_NOT_STARTED ||
+        status === STATUS_STARTED_NOT_DONE
+      ) {
+        return '-';
+      }
+
+      if (typeof timeStart !== 'number' || typeof timeEnd !== 'number') {
+        return 'unknown';
+      }
+
+      return formatDuration(timeEnd - timeStart);
+    });
+
+  const longestDurationLength =
+    Math.max(...stepDurations.map(duration => duration.length));
+
+  const stepMemories =
+    stepDetails.map(({memory}) =>
+      (memory
+        ? Math.round(memory["heapUsed"] / 1024 / 1024) + 'MB'
+        : '-'));
+
+  const longestMemoryLength =
+    Math.max(...stepMemories.map(memory => memory.length));
+
+  for (let index = 0; index < stepDetails.length; index++) {
+    const {name, status, annotation} = stepDetails[index];
+    const duration = stepDurations[index];
+    const memory = stepMemories[index];
+
+    let message =
+      (stepsNotClean[index]
+        ? `!! `
+        : ` - `);
+
+    message += `(${duration} `.padStart(longestDurationLength + 2, ' ');
+
+    if (shouldShowStepMemoryInSummary) {
+      message += ` ${memory})`.padStart(longestMemoryLength + 2, ' ');
+    }
+
+    message += ` `;
+    message += `${name}: `.padEnd(longestNameLength + 4, '.');
+    message += ` `;
+    message += status;
+
+    if (annotation) {
+      message += ` (${annotation})`;
+    }
+
+    switch (status) {
+      case STATUS_DONE_CLEAN:
+        console.error(colors.green(message));
+        break;
+
+      case STATUS_NOT_STARTED:
+      case STATUS_NOT_APPLICABLE:
+        console.error(colors.dim(message));
+        break;
+
+      case STATUS_HAS_WARNINGS:
+      case STATUS_STARTED_NOT_DONE:
+        console.error(colors.yellow(message));
+        break;
+
+      case STATUS_FATAL_ERROR:
+        console.error(colors.red(message));
+        break;
+
+      default:
+        console.error(message);
+        break;
+    }
+  }
+
+  return {anyStepsNotClean};
+}
diff --git a/src/url-spec.js b/src/url-spec.js
index ea5337a2..75cd8006 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -1,114 +1,220 @@
-import {withEntries} from '#sugar';
-
-const urlSpec = {
-  data: {
-    prefix: 'data/',
-
-    paths: {
-      root: '',
-      path: '<>',
-
-      album: 'album/<>',
-      artist: 'artist/<>',
-      track: 'track/<>',
-    },
-  },
-
-  localized: {
-    // TODO: Implement this.
-    // prefix: '_languageCode',
-
-    paths: {
-      root: '',
-      path: '<>',
-      page: '<>/',
-
-      home: '',
-
-      album: 'album/<>/',
-      albumCommentary: 'commentary/album/<>/',
-      albumGallery: 'album/<>/gallery/',
-
-      artist: 'artist/<>/',
-      artistGallery: 'artist/<>/gallery/',
-
-      commentaryIndex: 'commentary/',
-
-      flashIndex: 'flash/',
-
-      flash: 'flash/<>/',
-
-      flashActGallery: 'flash-act/<>/',
-
-      groupInfo: 'group/<>/',
-      groupGallery: 'group/<>/gallery/',
-
-      listingIndex: 'list/',
-
-      listing: 'list/<>/',
-
-      newsIndex: 'news/',
-
-      newsEntry: 'news/<>/',
-
-      staticPage: '<>/',
-
-      tag: 'tag/<>/',
-
-      track: 'track/<>/',
-    },
-  },
-
-  shared: {
-    paths: {
-      root: '',
-      path: '<>',
-
-      utilityRoot: 'util',
-      staticRoot: 'static',
-
-      utilityFile: 'util/<>',
-      staticFile: 'static/<>?<>',
-
-      staticIcon: 'static/icons.svg#icon-<>',
-    },
-  },
-
-  media: {
-    prefix: 'media/',
-
-    paths: {
-      root: '',
-      path: '<>',
-
-      albumAdditionalFile: 'album-additional/<>/<>',
-      albumBanner: 'album-art/<>/banner.<>',
-      albumCover: 'album-art/<>/cover.<>',
-      albumWallpaper: 'album-art/<>/bg.<>',
-
-      artistAvatar: 'artist-avatar/<>.<>',
-
-      flashArt: 'flash-art/<>.<>',
-
-      trackCover: 'album-art/<>/<>.<>',
-    },
-  },
-
-  thumb: {
-    prefix: 'thumb/',
-
-    paths: {
-      root: '',
-      path: '<>',
-    },
-  },
-};
-
-// This gets automatically switched in place when working from a baseDirectory,
-// so it should never be referenced manually.
-urlSpec.localizedWithBaseDirectory = {
-  paths: withEntries(urlSpec.localized.paths, (entries) =>
-    entries.map(([key, path]) => [key, '<>/' + path])),
-};
-
-export default urlSpec;
+// Exports defined here are re-exported through urls.js,
+// so they're generally imported from '#urls'.
+
+import {readFile} from 'node:fs/promises';
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+import yaml from 'js-yaml';
+
+import {annotateError, annotateErrorWithFile, openAggregate} from '#aggregate';
+import {empty, typeAppearance, withEntries} from '#sugar';
+
+export const DEFAULT_URL_SPEC_FILE = 'urls-default.yaml';
+
+export const internalDefaultURLSpecFile =
+  path.resolve(
+    path.dirname(fileURLToPath(import.meta.url)),
+    DEFAULT_URL_SPEC_FILE);
+
+function processStringToken(key, token) {
+  const oops = appearance =>
+    new Error(
+      `Expected ${key} to be a string or an array of strings, ` +
+      `got ${appearance}`);
+
+  if (typeof token === 'string') {
+    return token;
+  } else if (Array.isArray(token)) {
+    if (empty(token)) {
+      throw oops(`empty array`);
+    } else if (token.every(item => typeof item !== 'string')) {
+      throw oops(`array of non-strings`);
+    } else if (token.some(item => typeof item !== 'string')) {
+      throw oops(`array of mixed strings and non-strings`);
+    } else {
+      return token.join('');
+    }
+  } else {
+    throw oops(typeAppearance(token));
+  }
+}
+
+function processObjectToken(key, token) {
+  const oops = appearance =>
+    new Error(
+      `Expected ${key} to be an object or an array of objects, ` +
+      `got ${appearance}`);
+
+  const looksLikeObject = value =>
+    typeof value === 'object' &&
+    value !== null &&
+    !Array.isArray(value);
+
+  if (looksLikeObject(token)) {
+    return {...token};
+  } else if (Array.isArray(token)) {
+    if (empty(token)) {
+      throw oops(`empty array`);
+    } else if (token.every(item => !looksLikeObject(item))) {
+      throw oops(`array of non-objects`);
+    } else if (token.some(item => !looksLikeObject(item))) {
+      throw oops(`array of mixed objects and non-objects`);
+    } else {
+      return Object.assign({}, ...token);
+    }
+  }
+}
+
+function makeProcessToken(aggregate) {
+  return (object, key, processFn) => {
+    if (key in object) {
+      const value = aggregate.call(processFn, key, object[key]);
+      if (value === null) {
+        delete object[key];
+      } else {
+        object[key] = value;
+      }
+    }
+  };
+}
+
+export function processGroupSpec(groupKey, groupSpec) {
+  const aggregate =
+    openAggregate({message: `Errors processing group "${groupKey}"`});
+
+  const processToken = makeProcessToken(aggregate);
+
+  groupSpec.key = groupKey;
+
+  processToken(groupSpec, 'prefix', processStringToken);
+  processToken(groupSpec, 'paths', processObjectToken);
+
+  return {aggregate, result: groupSpec};
+}
+
+export function processURLSpec(sourceSpec) {
+  const aggregate =
+    openAggregate({message: `Errors processing URL spec`});
+
+  sourceSpec ??= {};
+
+  const urlSpec = structuredClone(sourceSpec);
+
+  delete urlSpec.yamlAliases;
+  delete urlSpec.localizedWithBaseDirectory;
+
+  aggregate.nest({message: `Errors processing groups`}, groupsAggregate => {
+    Object.assign(urlSpec,
+      withEntries(urlSpec, entries =>
+        entries.map(([groupKey, groupSpec]) => [
+          groupKey,
+          groupsAggregate.receive(
+            processGroupSpec(groupKey, groupSpec)),
+        ])));
+  });
+
+  switch (sourceSpec.localizedWithBaseDirectory) {
+    case '<auto>': {
+      if (!urlSpec.localized) {
+        aggregate.push(new Error(
+          `Not ready for 'localizedWithBaseDirectory' group, ` +
+          `'localized' not available`));
+      } else if (!urlSpec.localized.paths) {
+        aggregate.push(new Error(
+          `Not ready for 'localizedWithBaseDirectory' group, ` +
+          `'localized' group's paths not available`));
+      }
+
+      break;
+    }
+
+    case undefined:
+      break;
+
+    default:
+      aggregate.push(new Error(
+        `Expected 'localizedWithBaseDirectory' group to have value '<auto>' ` +
+        `or not be set`));
+
+      break;
+  }
+
+  return {aggregate, result: urlSpec};
+}
+
+export function applyURLSpecOverriding(overrideSpec, baseSpec) {
+  const aggregate = openAggregate({message: `Errors applying URL spec`});
+
+  for (const [groupKey, overrideGroupSpec] of Object.entries(overrideSpec)) {
+    const baseGroupSpec = baseSpec[groupKey];
+
+    if (!baseGroupSpec) {
+      aggregate.push(new Error(`Group key "${groupKey}" not available on base spec`));
+      continue;
+    }
+
+    if (overrideGroupSpec.prefix) {
+      baseGroupSpec.prefix = overrideGroupSpec.prefix;
+    }
+
+    if (overrideGroupSpec.paths) {
+      for (const [pathKey, overridePathValue] of Object.entries(overrideGroupSpec.paths)) {
+        if (!baseGroupSpec.paths[pathKey]) {
+          aggregate.push(new Error(`Path key "${groupKey}.${pathKey}" not available on base spec`));
+          continue;
+        }
+
+        baseGroupSpec.paths[pathKey] = overridePathValue;
+      }
+    }
+  }
+
+  return {aggregate};
+}
+
+export function applyLocalizedWithBaseDirectory(urlSpec) {
+  const paths =
+    withEntries(urlSpec.localized.paths, entries =>
+      entries.map(([key, path]) => [key, '<>/' + path]));
+
+  urlSpec.localizedWithBaseDirectory =
+    Object.assign(
+      structuredClone(urlSpec.localized),
+      {paths});
+}
+
+export async function processURLSpecFromFile(file) {
+  let contents;
+
+  try {
+    contents = await readFile(file, 'utf-8');
+  } catch (caughtError) {
+    throw annotateError(
+      new Error(`Failed to read URL spec file`, {cause: caughtError}),
+      error => annotateErrorWithFile(error, file));
+  }
+
+  let sourceSpec;
+  let parseLanguage;
+
+  try {
+    if (path.extname(file) === '.yaml') {
+      parseLanguage = 'YAML';
+      sourceSpec = yaml.load(contents);
+    } else {
+      parseLanguage = 'JSON';
+      sourceSpec = JSON.parse(contents);
+    }
+  } catch (caughtError) {
+    throw annotateError(
+      new Error(`Failed to parse URL spec file as valid ${parseLanguage}`, {cause: caughtError}),
+      error => annotateErrorWithFile(error, file));
+  }
+
+  try {
+    return processURLSpec(sourceSpec);
+  } catch (caughtError) {
+    throw annotateErrorWithFile(caughtError, file);
+  }
+}
diff --git a/src/urls-default.yaml b/src/urls-default.yaml
new file mode 100644
index 00000000..7fcccae8
--- /dev/null
+++ b/src/urls-default.yaml
@@ -0,0 +1,144 @@
+# These are variables which are used to make expressing this
+# YAML file more convenient. They are not exposed externally.
+# (Stuff which uses this YAML file can't even see the names
+# for each variable!)
+yamlAliases:
+  - &genericPaths
+      root: ''
+      path: '<>'
+
+  # 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.
+  - &staticVersion 5r2
+
+data:
+  prefix: 'data/'
+
+  paths:
+  - *genericPaths
+
+  - album: 'album/<>'
+    artist: 'artist/<>'
+    track: 'track/<>'
+
+localized:
+  paths:
+  - *genericPaths
+  - page: '<>/'
+
+    home: ''
+
+    album: 'album/<>/'
+    albumCommentary: 'commentary/album/<>/'
+    albumGallery: 'album/<>/gallery/'
+    albumReferencedArtworks: 'album/<>/referenced-art/'
+    albumReferencingArtworks: 'album/<>/referencing-art/'
+
+    artTagInfo: 'tag/<>/info/'
+    artTagGallery: 'tag/<>/'
+
+    artist: 'artist/<>/'
+    artistGallery: 'artist/<>/gallery/'
+
+    commentaryIndex: 'commentary/'
+
+    flashIndex: 'flash/'
+
+    flash: 'flash/<>/'
+
+    flashActGallery: 'flash-act/<>/'
+
+    groupInfo: 'group/<>/'
+    groupGallery: 'group/<>/gallery/'
+
+    listingIndex: 'list/'
+
+    listing: 'list/<>/'
+
+    newsIndex: 'news/'
+
+    newsEntry: 'news/<>/'
+
+    staticPage: '<>/'
+
+    track: 'track/<>/'
+    trackReferencedArtworks: 'track/<>/referenced-art/'
+    trackReferencingArtworks: 'track/<>/referencing-art/'
+
+# This gets automatically switched in place when working from
+# a baseDirectory, so it should never be referenced manually.
+# It's also filled in externally to this YAML spec.
+localizedWithBaseDirectory: '<auto>'
+
+shared:
+  paths: *genericPaths
+
+staticCSS:
+  prefix:
+  - 'static-'
+  - *staticVersion
+  - '/css/'
+
+  paths: *genericPaths
+
+staticJS:
+  prefix:
+  - 'static-'
+  - *staticVersion
+  - '/js/'
+
+  paths: *genericPaths
+
+staticLib:
+  prefix:
+  - 'static-'
+  - *staticVersion
+  - '/lib/'
+
+  paths: *genericPaths
+
+staticMisc:
+  prefix:
+  - 'static-'
+  - *staticVersion
+  - '/misc/'
+
+  paths:
+  - *genericPaths
+  - icon: 'icons.svg#icon-<>'
+
+staticSharedUtil:
+  prefix:
+  - 'static-'
+  - *staticVersion
+  - '/shared-util/'
+
+  paths: *genericPaths
+
+media:
+  prefix: 'media/'
+
+  paths:
+  - *genericPaths
+
+  - albumAdditionalFile: 'album-additional/<>/<>'
+    albumBanner: 'album-art/<>/banner.<>'
+    albumCover: 'album-art/<>/cover.<>'
+    albumWallpaper: 'album-art/<>/bg.<>'
+    albumWallpaperPart: 'album-art/<>/<>'
+
+    artistAvatar: 'artist-avatar/<>.<>'
+
+    flashArt: 'flash-art/<>.<>'
+
+    trackCover: 'album-art/<>/<>.<>'
+
+thumb:
+  prefix: 'thumb/'
+  paths: *genericPaths
+
+searchData:
+  prefix: 'search-data/'
+  paths: *genericPaths
diff --git a/src/util/urls.js b/src/urls.js
index 11b9b8b0..9cc4a554 100644
--- a/src/util/urls.js
+++ b/src/urls.js
@@ -8,17 +8,16 @@ import * as path from 'node:path';
 
 import {withEntries} from '#sugar';
 
-// This export is only provided for convenience, i.e. to enable the following:
-//
-//   import {urlSpec} from '#urls';
-//
-// It's not actually defined in this module's variable scope, and functions
-// exported here require a urlSpec (whether this default one or another) to be
-// passed directly.
-//
-export {default as urlSpec} from '../url-spec.js';
+export * from './url-spec.js';
 
 export function generateURLs(urlSpec) {
+  if (
+    typeof urlSpec.localized === 'object' &&
+    typeof urlSpec.localizedWithBaseDirectory !== 'object'
+  ) {
+    throw new Error(`Provided urlSpec missing localizedWithBaseDirectory`);
+  }
+
   const getValueForFullKey = (obj, fullKey) => {
     const [groupKey, subKey] = fullKey.split('.');
     if (!groupKey || !subKey) {
@@ -49,8 +48,12 @@ export function generateURLs(urlSpec) {
   const generateTo = (fromPath, fromGroup) => {
     const A = trimLeadingSlash(fromPath);
 
-    const rebasePrefix = '../'
-      .repeat((fromGroup.prefix || '').split('/').filter(Boolean).length);
+    const fromPrefix = fromGroup.prefix || '';
+
+    const rebasePrefix =
+      '../'.repeat(fromPrefix.split('/').filter(Boolean).length);
+
+    const fromOrigin = getOrigin(fromPrefix);
 
     const pathHelper = (toPath, toGroup) => {
       let B = trimLeadingSlash(toPath);
@@ -58,40 +61,106 @@ export function generateURLs(urlSpec) {
       let argIndex = 0;
       B = B.replaceAll('<>', () => `<${argIndex++}>`);
 
-      if (toGroup.prefix !== fromGroup.prefix) {
-        // TODO: Handle differing domains in prefixes.
-        B = rebasePrefix + (toGroup.prefix || '') + B;
-      }
-
       const suffix = toPath.endsWith('/') ? '/' : '';
 
-      return {
-        posix: path.posix.relative(A, B) + suffix,
-        device: path.relative(A, B) + suffix,
-      };
-    };
+      const toPrefix = toGroup.prefix;
+
+      if (toPrefix !== fromPrefix) {
+        // Compare origins. Note that getOrigin() can
+        // be null for both prefixes.
+        const toOrigin = getOrigin(toPrefix);
+        if (fromOrigin === toOrigin) {
+          // Go to the root, add the to-group's prefix, then
+          // continue with normal path.relative() behavior.
+          B = rebasePrefix + (toGroup.prefix || '') + B;
+        } else {
+          // Crossing origins never conceptually represents
+          // something you can interpret on-`.device()`.
+          return {
+            posix: toGroup.prefix + B + suffix,
+            device: null,
+          };
+        }
+      }
 
-    const groupSymbol = Symbol();
+      // If we're coming from a qualified origin (domain),
+      // then at this point, A and B represent paths on the
+      // same origin. We can use normal path.relative() behavior.
+      if (fromOrigin) {
+        // If we're working on an origin, there's no meaning to
+        // a `.device()`-local relative path.
+        return {
+          posix: path.posix.relative(A, B) + suffix,
+          device: null,
+        };
+      } else {
+        return {
+          posix: path.posix.relative(A, B) + suffix,
+          device: path.relative(A, B) + suffix,
+        };
+      }
+    };
 
-    const groupHelper = (urlGroup) => ({
-      [groupSymbol]: urlGroup,
-      ...withEntries(urlGroup.paths, (entries) =>
-        entries.map(([key, path]) => [key, pathHelper(path, urlGroup)])
-      ),
-    });
+    const groupHelper = urlGroup =>
+      withEntries(urlGroup.paths, entries =>
+        entries.map(([key, path]) => [
+          key,
+          pathHelper(path, urlGroup),
+        ]));
 
-    const relative = withEntries(urlSpec, (entries) =>
-      entries.map(([key, urlGroup]) => [key, groupHelper(urlGroup)])
-    );
+    const relative =
+      withEntries(urlSpec, entries =>
+        entries.map(([key, urlGroup]) => [
+          key,
+          groupHelper(urlGroup),
+        ]));
 
     const toHelper =
       ({device}) =>
       (key, ...args) => {
-        const {
-          value: {
-            [device ? 'device' : 'posix']: template,
-          },
-        } = getValueForFullKey(relative, key);
+        const templateKey = (device ? 'device' : 'posix');
+
+        const {value: {[templateKey]: template}} =
+          getValueForFullKey(relative, key);
+
+        // If we got past getValueForFullKey(), we've already ruled out
+        // the common errors, i.e. incorrectly formatted key or invalid
+        // group key or subkey.
+        if (template === null) {
+          // Self-diagnose, brutally.
+
+          const otherTemplateKey = (device ? 'posix' : 'device');
+
+          const {value: {[templateKey]: otherTemplate}} =
+            getValueForFullKey(relative, key);
+
+          const effectiveMode =
+            (otherTemplate
+              ? `${templateKey} mode`
+              : `either mode`);
+
+          const toGroupKey = key.split('.')[0];
+
+          const anyOthers =
+            Object.values(relative[toGroupKey])
+              .find(templates =>
+                (otherTemplate
+                  ? templates[templateKey]
+                  : templates.posix || templates.device));
+
+          const effectiveTo =
+            (anyOthers
+              ? key
+              : `${toGroupKey}.*`);
+
+          if (anyOthers) {
+            console.log(relative[toGroupKey]);
+          }
+
+          throw new Error(
+            `from(${fromGroup.key}.*).to(${effectiveTo}) ` +
+            `not available in ${effectiveMode} with this url spec`);
+        }
 
         let missing = 0;
         let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => {
@@ -111,19 +180,31 @@ export function generateURLs(urlSpec) {
 
         if (missing) {
           throw new Error(
-            `Expected ${missing + args.length} arguments, got ${
-              args.length
-            } (key ${key}, args [${args}])`
-          );
+            `Expected ${missing + args.length} arguments, ` +
+            `got ${args.length} (key ${key}, args [${args}])`);
         }
 
         return result;
       };
 
-    return {
-      to: toHelper({device: false}),
-      toDevice: toHelper({device: true}),
-    };
+    const toAvailableHelper =
+      ({device}) =>
+      (key) => {
+        const templateKey = (device ? 'device' : 'posix');
+
+        const {value: {[templateKey]: template}} =
+          getValueForFullKey(relative, key);
+
+        return !!template;
+      };
+
+    const to = toHelper({device: false});
+    const toDevice = toHelper({device: true});
+
+    to.available = toAvailableHelper({device: false});
+    toDevice.available = toAvailableHelper({device: true});
+
+    return {to, toDevice};
   };
 
   const generateFrom = () => {
@@ -144,6 +225,14 @@ export function generateURLs(urlSpec) {
   return generateFrom();
 }
 
+export function getOrigin(prefix) {
+  try {
+    return new URL(prefix).origin;
+  } catch {
+    return null;
+  }
+}
+
 const thumbnailHelper = (name) => (file) =>
   file.replace(/\.(jpg|png)$/, name + '.jpg');
 
@@ -194,9 +283,14 @@ export function getURLsFrom({
       to = targetFullKey;
     }
 
-    return (
-      subdirectoryPrefix +
-      urls.from(from).to(to, ...args));
+    const toResult =
+      urls.from(from).to(to, ...args);
+
+    if (getOrigin(toResult)) {
+      return toResult;
+    } else {
+      return subdirectoryPrefix + toResult;
+    }
   };
 }
 
@@ -211,16 +305,18 @@ export function getURLsFromRoot({
 
   return (targetFullKey, ...args) => {
     const [groupKey, subKey] = targetFullKey.split('.');
-    return (
-      '/' +
+    const toResult =
       (groupKey === 'localized' && baseDirectory
-        ? to(
-            'localizedWithBaseDirectory.' + subKey,
-            baseDirectory,
-            ...args
-          )
-        : to(targetFullKey, ...args))
-    );
+        ? to('localizedWithBaseDirectory.' + subKey, baseDirectory, ...args)
+     : groupKey === 'localizedDefaultLanguage'
+        ? to('localized.' + subKey, ...args)
+        : to(targetFullKey, ...args));
+
+    if (getOrigin(toResult)) {
+      return toResult;
+    } else {
+      return '/' + toResult;
+    }
   };
 }
 
diff --git a/src/data/validators.js b/src/validators.js
index 987f806d..6badc93a 100644
--- a/src/data/validators.js
+++ b/src/validators.js
@@ -3,8 +3,12 @@ import {inspect as nodeInspect} from 'node:util';
 import {openAggregate, withAggregate} from '#aggregate';
 import {colors, ENABLE_COLOR} from '#cli';
 import {cut, empty, matchMultiline, typeAppearance} from '#sugar';
-import {commentaryRegexCaseInsensitive, commentaryRegexCaseSensitiveOneShot}
-  from '#wiki-data';
+
+import {
+  commentaryRegexCaseInsensitive,
+  commentaryRegexCaseSensitiveOneShot,
+  oldStyleLyricsDetectionRegex,
+} from '#wiki-data';
 
 function inspect(value) {
   return nodeInspect(value, {colors: ENABLE_COLOR});
@@ -288,69 +292,108 @@ export function isColor(color) {
   throw new TypeError(`Unknown color format`);
 }
 
-export function isCommentary(commentaryText) {
-  isContentString(commentaryText);
+export function validateContentEntries({
+  headingPhrase,
+  entryPhrase,
 
-  const rawMatches =
-    Array.from(commentaryText.matchAll(commentaryRegexCaseInsensitive));
+  caseInsensitiveRegex,
+  caseSensitiveOneShotRegex,
+}) {
+  return content => {
+    isContentString(content);
 
-  if (empty(rawMatches)) {
-    throw new TypeError(`Expected at least one commentary heading`);
-  }
+    const rawMatches =
+      Array.from(content.matchAll(caseInsensitiveRegex));
 
-  const niceMatches =
-    rawMatches.map(match => ({
-      position: match.index,
-      length: match[0].length,
-    }));
-
-  validateArrayItems(({position, length}, index) => {
-    if (index === 0 && position > 0) {
-      throw new TypeError(`Expected first commentary heading to be at top`);
+    if (empty(rawMatches)) {
+      throw new TypeError(`Expected at least one ${headingPhrase}`);
     }
 
-    const ownInput = commentaryText.slice(position, position + length);
-    const restOfInput = commentaryText.slice(position + length);
+    const niceMatches =
+      rawMatches.map(match => ({
+        position: match.index,
+        length: match[0].length,
+      }));
 
-    const upToNextLineBreak =
-      (restOfInput.includes('\n')
-        ? restOfInput.slice(0, restOfInput.indexOf('\n'))
-        : restOfInput);
+    validateArrayItems(({position, length}, index) => {
+      if (index === 0 && position > 0) {
+        throw new TypeError(`Expected first ${headingPhrase} to be at top`);
+      }
 
-    if (/\S/.test(upToNextLineBreak)) {
-      throw new TypeError(
-        `Expected commentary heading to occupy entire line, got extra text:\n` +
-        `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` +
-        `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` +
-        `(Check for missing "|-" in YAML, or a misshapen annotation)`);
-    }
+      const ownInput = content.slice(position, position + length);
+      const restOfInput = content.slice(position + length);
 
-    if (!commentaryRegexCaseSensitiveOneShot.test(ownInput)) {
-      throw new TypeError(
-        `Miscapitalization in commentary heading:\n` +
-        `${colors.red(`"${cut(ownInput, 60)}"`)}\n` +
-        `(Check for ${colors.red(`"<I>"`)} instead of ${colors.green(`"<i>"`)})`);
-    }
+      const upToNextLineBreak =
+        (restOfInput.includes('\n')
+          ? restOfInput.slice(0, restOfInput.indexOf('\n'))
+          : restOfInput);
+
+      if (/\S/.test(upToNextLineBreak)) {
+        throw new TypeError(
+          `Expected ${headingPhrase} to occupy entire line, got extra text:\n` +
+          `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` +
+          `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` +
+          `(Check for missing "|-" in YAML, or a misshapen annotation)`);
+      }
 
-    const nextHeading =
-      (index === niceMatches.length - 1
-        ? commentaryText.length
-        : niceMatches[index + 1].position);
+      if (!caseSensitiveOneShotRegex.test(ownInput)) {
+        throw new TypeError(
+          `Miscapitalization in ${headingPhrase}:\n` +
+          `${colors.red(`"${cut(ownInput, 60)}"`)}\n` +
+          `(Check for ${colors.red(`"<I>"`)} instead of ${colors.green(`"<i>"`)})`);
+      }
 
-    const upToNextHeading =
-      commentaryText.slice(position + length, nextHeading);
+      const nextHeading =
+        (index === niceMatches.length - 1
+          ? content.length
+          : niceMatches[index + 1].position);
 
-    if (!/\S/.test(upToNextHeading)) {
-      throw new TypeError(
-        `Expected commentary entry to have body text, only got a heading`);
-    }
+      const upToNextHeading =
+        content.slice(position + length, nextHeading);
+
+      if (!/\S/.test(upToNextHeading)) {
+        throw new TypeError(
+          `Expected ${entryPhrase} to have body text, only got a heading`);
+      }
+
+      return true;
+    })(niceMatches);
 
     return true;
-  })(niceMatches);
+  };
+}
+
+export const isCommentary =
+  validateContentEntries({
+    headingPhrase: `commentary heading`,
+    entryPhrase: `commentary entry`,
+
+    caseInsensitiveRegex: commentaryRegexCaseInsensitive,
+    caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot,
+  });
+
+export function isOldStyleLyrics(content) {
+  isContentString(content);
+
+  if (oldStyleLyricsDetectionRegex.test(content)) {
+    throw new TypeError(
+      `Expected old-style lyrics block not to include "<i> ... :</i>" at start of any line`);
+  }
 
   return true;
 }
 
+export const isLyrics =
+  anyOf(
+    isOldStyleLyrics,
+    validateContentEntries({
+      headingPhrase: `lyrics heading`,
+      entryPhrase: `lyrics entry`,
+
+      caseInsensitiveRegex: commentaryRegexCaseInsensitive,
+      caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot,
+    }));
+
 const isArtistRef = validateReference('artist');
 
 export function validateProperties(spec) {
@@ -443,24 +486,23 @@ for (const entry of illegalContentSpec) {
   }
 }
 
-const illegalContentRegexp =
-  new RegExp(
-    illegalContentSpec
-      .map(entry => entry.illegal)
-      .map(illegal => `${illegal}+`)
-      .join('|'),
-    'g');
-
-const illegalCharactersInContent =
+const illegalSequencesInContent =
   illegalContentSpec
     .map(entry => entry.illegal)
-    .join('');
+    .map(illegal =>
+      (illegal.length === 1
+        ? `${illegal}+`
+        : `(?:${illegal})+`))
+    .join('|');
+
+const illegalContentRegexp =
+  new RegExp(illegalSequencesInContent, 'g');
 
 const legalContentNearEndRegexp =
-  new RegExp(`[^\n${illegalCharactersInContent}]+$`);
+  new RegExp(`(?<=^|${illegalSequencesInContent})(?:(?!${illegalSequencesInContent}).)+$`);
 
 const legalContentNearStartRegexp =
-  new RegExp(`^[^\n${illegalCharactersInContent}]+`);
+  new RegExp(`^(?:(?!${illegalSequencesInContent}).)+`);
 
 const trimWhitespaceNearBothSidesRegexp =
   /^ +| +$/gm;
@@ -469,7 +511,7 @@ const trimWhitespaceNearEndRegexp =
   / +$/gm;
 
 export function isContentString(content) {
-  isStringNonEmpty(content);
+  isString(content);
 
   const mainAggregate = openAggregate({
     message: `Errors validating content string`,
@@ -544,7 +586,7 @@ export function isContentString(content) {
     const parts = [
       actionPart,
       surroundings,
-      `(${where})`,
+      `(${colors.yellow(where)})`,
     ].filter(Boolean);
 
     illegalAggregate.push(new TypeError(parts.join(` `)));
@@ -606,20 +648,96 @@ export function isContentString(content) {
 export function isThingClass(thingClass) {
   isFunction(thingClass);
 
-  if (!Object.hasOwn(thingClass, Symbol.for('Thing.referenceType'))) {
-    throw new TypeError(`Expected a Thing constructor, missing Thing.referenceType`);
+  // This is *expressly* no faster than an instanceof check, because it's
+  // deliberately still walking the prototype chain for the provided object.
+  // (This is necessary because the symbol we're checking is defined only on
+  // the Thing constructor, and not directly on each subclass.) However, it's
+  // preferred over an instanceof check anyway, because instanceof would
+  // require that the #validators module has access to #thing, which it
+  // currently doesn't!
+  if (!(Symbol.for('Thing.isThingConstructor') in thingClass)) {
+    throw new TypeError(`Expected a Thing constructor, missing Thing.isThingConstructor`);
+  }
+
+  return true;
+}
+
+export function isThing(thing) {
+  isObject(thing);
+
+  // This *is* faster than an instanceof check, because it doesn't walk the
+  // prototype chain. It works because this property is set as part of every
+  // Thing subclass's inherited "public class fields" - it's set directly on
+  // every constructed Thing.
+  if (!Object.hasOwn(thing, Symbol.for('Thing.isThing'))) {
+    throw new TypeError(`Expected a Thing, missing Thing.isThing`);
   }
 
   return true;
 }
 
 export const isContribution = validateProperties({
-  who: isArtistRef,
-  what: optional(isStringNonEmpty),
+  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),
@@ -638,6 +756,24 @@ export const isTrackSection = validateProperties({
 
 export const isTrackSectionList = validateArrayItems(isTrackSection);
 
+export const isSeries = validateProperties({
+  name: isName,
+  description: optional(isContentString),
+  albums: optional(validateReferenceList('album')),
+
+  showAlbumArtists:
+    optional(is('all', 'differing', 'none')),
+});
+
+export const isSeriesList = validateArrayItems(isSeries);
+
+export const isWallpaperPart = validateProperties({
+  asset: optional(isString),
+  style: optional(isString),
+});
+
+export const isWallpaperPartList = validateArrayItems(isWallpaperPart);
+
 export function isDimensions(dimensions) {
   isArray(dimensions);
 
@@ -705,7 +841,7 @@ export function isURL(string) {
   return true;
 }
 
-export function validateReference(type = 'track') {
+export function validateReference(type) {
   return (ref) => {
     isStringNonEmpty(ref);
 
@@ -718,8 +854,17 @@ export function validateReference(type = 'track') {
     const {groups: {typePart, directoryPart}} = match;
 
     if (typePart) {
-      if (typePart !== type)
-        throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`);
+      if (Array.isArray(type)) {
+        if (!type.includes(typePart)) {
+          throw new TypeError(
+            `Expected ref to begin with one of ` +
+            type.map(type => `"${type}:"`).join(', ') +
+            `, got "${typePart}:"`);
+        }
+      } else if (typePart !== type) {
+        throw new TypeError(
+          `Expected ref to begin with "${type}:", got "${typePart}:"`);
+      }
 
       isDirectory(directoryPart);
     }
@@ -730,16 +875,35 @@ export function validateReference(type = 'track') {
   };
 }
 
-export function validateReferenceList(type = '') {
+export function validateReferenceList(type) {
   return validateArrayItems(validateReference(type));
 }
 
+export function validateThing({
+  referenceType: expectedReferenceType = '',
+} = {}) {
+  return (thing) => {
+    isThing(thing);
+
+    if (expectedReferenceType) {
+      const {[Symbol.for('Thing.referenceType')]: referenceType} =
+        thing.constructor;
+
+      if (referenceType !== expectedReferenceType) {
+        throw new TypeError(`Expected only ${expectedReferenceType}, got other type: ${referenceType}`);
+      }
+    }
+
+    return true;
+  };
+}
+
 const validateWikiData_cache = {};
 
 export function validateWikiData({
   referenceType = '',
   allowMixedTypes = false,
-}) {
+} = {}) {
   if (referenceType && allowMixedTypes) {
     throw new TypeError(`Don't specify both referenceType and allowMixedTypes`);
   }
@@ -768,25 +932,22 @@ export function validateWikiData({
       let foundOtherObject = false;
 
       for (const object of array) {
-        const {[Symbol.for('Thing.referenceType')]: referenceType} = object.constructor;
-
-        if (referenceType === undefined) {
-          foundOtherObject = true;
-
-          // Early-exit if a Thing has been found - nothing more can be learned.
-          if (foundThing) {
-            throw new TypeError(`Expected array of wiki data objects, got mixed items`);
-          }
-        } else {
-          foundThing = true;
-
+        if (Object.hasOwn(object, Symbol.for('Thing.isThing'))) {
           // Early-exit if a non-Thing object has been found - nothing more can
           // be learned.
           if (foundOtherObject) {
             throw new TypeError(`Expected array of wiki data objects, got mixed items`);
           }
 
-          allRefTypes.add(referenceType);
+          foundThing = true;
+          allRefTypes.add(object.constructor[Symbol.for('Thing.referenceType')]);
+        } else {
+          // Early-exit if a Thing has been found - nothing more can be learned.
+          if (foundThing) {
+            throw new TypeError(`Expected array of wiki data objects, got mixed items`);
+          }
+
+          foundOtherObject = true;
         }
       }
 
@@ -827,19 +988,8 @@ export function validateWikiData({
 }
 
 export const isAdditionalName = validateProperties({
-  name: isName,
+  name: isContentString,
   annotation: optional(isContentString),
-
-  // TODO: This only allows indicating sourcing from a track.
-  // That's okay for the current limited use of "from", but
-  // could be expanded later.
-  from:
-    // Double TODO: Explicitly allowing both references and
-    // live objects to co-exist is definitely weird, and
-    // altogether questions the way we define validators...
-    optional(anyOf(
-      validateReferenceList('track'),
-      validateWikiData({referenceType: 'track'}))),
 });
 
 export const isAdditionalNameList = validateArrayItems(isAdditionalName);
diff --git a/src/web-routes.js b/src/web-routes.js
new file mode 100644
index 00000000..b93607d6
--- /dev/null
+++ b/src/web-routes.js
@@ -0,0 +1,140 @@
+import {readdir} from 'node:fs/promises';
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const codeSrcPath = __dirname;
+const codeRootPath = path.resolve(codeSrcPath, '..');
+
+function getNodeDependencyRootPath(dependencyName) {
+  return (
+    path.dirname(
+      fileURLToPath(
+        import.meta.resolve(dependencyName))));
+}
+
+export const stationaryCodeRoutes = [
+  {
+    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, 'common-util'),
+    to: ['staticSharedUtil.root'],
+    statically: 'copy',
+  },
+];
+
+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,
+  ...dependencyRoutes,
+];
+
+export async function identifyDynamicWebRoutes({
+  mediaPath,
+  mediaCachePath,
+  wikiCachePath,
+}) {
+  const routeFunctions = [
+    () => Promise.resolve([
+      {
+        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 =
+    routeFunctions.map(fn => fn());
+
+  const routeCheckResults =
+    await Promise.all(routeCheckPromises);
+
+  return routeCheckResults.flat();
+}
+
+export async function identifyAllWebRoutes(opts) {
+  return [
+    ...allStaticWebRoutes,
+    ...await identifyDynamicWebRoutes(opts),
+  ];
+}
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
index 3d4ecc7a..d55ab215 100644
--- a/src/write/bind-utilities.js
+++ b/src/write/bind-utilities.js
@@ -19,14 +19,13 @@ import {
 
 export function bindUtilities({
   absoluteTo,
-  cachebust,
   defaultLanguage,
-  getSizeOfAdditionalFile,
-  getSizeOfImagePath,
+  getSizeOfMediaFile,
   language,
   languages,
   missingImagePaths,
   pagePath,
+  pagePathStringFromRoot,
   thumbsCache,
   to,
   urls,
@@ -36,16 +35,15 @@ export function bindUtilities({
 
   Object.assign(bound, {
     absoluteTo,
-    cachebust,
     defaultLanguage,
-    getSizeOfAdditionalFile,
-    getSizeOfImagePath,
+    getSizeOfMediaFile,
     getThumbnailsAvailableForDimensions,
     html,
     language,
     languages,
     missingImagePaths,
     pagePath,
+    pagePathStringFromRoot,
     thumb,
     to,
     urls,
diff --git a/src/write/build-modes/index.js b/src/write/build-modes/index.js
index 3ae2cfc6..4b61619d 100644
--- a/src/write/build-modes/index.js
+++ b/src/write/build-modes/index.js
@@ -1,3 +1,4 @@
 export * as 'live-dev-server' from './live-dev-server.js';
 export * as 'repl' from './repl.js';
+export * as 'sort' from './sort.js';
 export * as 'static-build' from './static-build.js';
diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js
index 24e18320..ecb9df21 100644
--- a/src/write/build-modes/live-dev-server.js
+++ b/src/write/build-modes/live-dev-server.js
@@ -1,10 +1,14 @@
 import {spawn} from 'node:child_process';
 import * as http from 'node:http';
-import {readFile, stat} from 'node:fs/promises';
+import {open, stat} from 'node:fs/promises';
+import * as os from 'node:os';
 import * as path from 'node:path';
+import {pipeline} from 'node:stream/promises';
 import {inspect as nodeInspect} from 'node:util';
 
-import {ENABLE_COLOR, logInfo, logWarn, progressCallAll} from '#cli';
+import {openAggregate} from '#aggregate';
+import {ENABLE_COLOR, colors, fileIssue, logInfo, logWarn, progressCallAll}
+  from '#cli';
 import {watchContentDependencies} from '#content-dependencies';
 import {quickEvaluate} from '#content-function';
 import * as html from '#html';
@@ -23,23 +27,27 @@ 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: {
-    default: true,
+    default: 'perform',
   },
 
   languageReloading: {
-    default: true,
+    default: 'perform',
   },
 
   mediaValidation: {
-    default: true,
+    default: 'perform',
   },
 
   thumbs: {
-    default: true,
+    default: 'perform',
+  },
+
+  webRoutes: {
+    required: true,
   },
 };
 
@@ -86,24 +94,49 @@ export function getCLIOptions() {
   };
 }
 
+const getContentType = extname => ({
+  // BRB covering all my bases
+  'aac': 'audio/aac',
+  'bmp': 'image/bmp',
+  'css': 'text/css',
+  'csv': 'text/csv',
+  'gif': 'image/gif',
+  'ico': 'image/vnd.microsoft.icon',
+  'jpg': 'image/jpeg',
+  'jpeg': 'image/jpeg',
+  'js': 'text/javascript',
+  'mjs': 'text/javascript',
+  'mp3': 'audio/mpeg',
+  'mp4': 'video/mp4',
+  'oga': 'audio/ogg',
+  'ogg': 'audio/ogg',
+  'ogv': 'video/ogg',
+  'opus': 'audio/opus',
+  'png': 'image/png',
+  'pdf': 'application/pdf',
+  'svg': 'image/svg+xml',
+  'ttf': 'font/ttf',
+  'txt': 'text/plain',
+  'wav': 'audio/wav',
+  'weba': 'audio/webm',
+  'webm': 'video/webm',
+  'woff': 'font/woff',
+  'woff2': 'font/woff2',
+  'xml': 'application/xml',
+  'zip': 'application/zip',
+})[extname];
+
 export async function go({
   cliOptions,
-  _dataPath,
-  mediaPath,
-  mediaCachePath,
+
+  universalUtilities,
 
   defaultLanguage,
   languages,
-  missingImagePaths,
-  srcRootPath,
-  thumbsCache,
   urls,
+  webRoutes,
   wikiData,
 
-  cachebust,
-  developersComment: _developersComment,
-  getSizeOfAdditionalFile,
-  getSizeOfImagePath,
   niceShowAggregate,
 }) {
   const showError = (error) => {
@@ -133,21 +166,49 @@ export async function go({
   contentDependenciesWatcher.on('error', () => {});
   await new Promise(resolve => contentDependenciesWatcher.once('ready', resolve));
 
+  const commonUtilities = {...universalUtilities};
+
+  const pathAggregate = openAggregate({message: `Errors computing page paths`});
+
   let targetSpecPairs = getPageSpecsWithTargets({wikiData});
-  const pages = progressCallAll(`Computing page data & paths for ${targetSpecPairs.length} targets.`,
+  const pages = progressCallAll(`Computing page paths for ${targetSpecPairs.length} targets.`,
     targetSpecPairs.flatMap(({
       pageSpec,
       target,
       targetless,
     }) => () => {
-      if (targetless) {
-        const result = pageSpec.pathsTargetless({wikiData});
-        return Array.isArray(result) ? result : [result];
-      } else {
-        return pageSpec.pathsForTarget(target);
+      try {
+        if (targetless) {
+          const result = pageSpec.pathsTargetless({wikiData});
+          return Array.isArray(result) ? result : [result];
+        } else {
+          return pageSpec.pathsForTarget(target);
+        }
+      } catch (caughtError) {
+        if (targetless) {
+          pathAggregate.push(new Error(
+            `Failed to compute targetless paths for ` +
+            inspect(pageSpec, {compact: true}),
+            {cause: caughtError}));
+        } else {
+          pathAggregate.push(new Error(
+            `Failed to compute paths for ` +
+            inspect(target),
+            {cause: caughtError}));
+        }
+        return [];
       }
     })).flat();
 
+  try {
+    pathAggregate.close();
+  } catch (error) {
+    niceShowAggregate(error);
+    logWarn`Failed to compute page paths for some targets.`;
+    logWarn`This means some pages that normally exist will be 404s.`;
+    fileIssue();
+  }
+
   logInfo`Will be serving a total of ${pages.length} pages.`;
 
   const urlToPageMap = Object.fromEntries(pages
@@ -211,7 +272,7 @@ export async function go({
 
         response.writeHead(200, contentTypeJSON);
         response.end(json);
-        if (loudResponses) console.log(`${requestHead} [200] ${pathname}`);
+        if (loudResponses) console.log(`${requestHead} [200] ${pathname} (${colors.yellow(`special`)})`);
       } catch (error) {
         response.writeHead(500, contentTypeJSON);
         response.end(`Internal error serializing wiki JSON`);
@@ -221,30 +282,27 @@ export async function go({
       return;
     }
 
-    const {
-      area: localFileArea,
-      path: localFilePath
-    } = pathname.match(/^\/(?<area>static|util|media|thumb)\/(?<path>.*)/)?.groups ?? {};
+    const matchedWebRoute =
+      webRoutes
+        .find(({to}) => pathname.startsWith('/' + to));
+
+    if (matchedWebRoute) {
+      const localFilePath = pathname.slice(1 + matchedWebRoute.to.length);
 
-    if (localFileArea) {
       // Not security tested, man, this is a dev server!!
-      const safePath = path.posix.resolve('/', localFilePath).replace(/^\//, '');
-
-      let localDirectory;
-      if (localFileArea === 'static' || localFileArea === 'util') {
-        localDirectory = path.join(srcRootPath, localFileArea);
-      } else if (localFileArea === 'media') {
-        localDirectory = mediaPath;
-      } else if (localFileArea === 'thumb') {
-        localDirectory = mediaCachePath;
-      }
+      const safePath =
+        path.posix
+          .resolve('/', localFilePath)
+          .replace(/^\//, '');
+
+      const localDirectory = matchedWebRoute.from;
 
       let filePath;
       try {
         filePath = path.resolve(localDirectory, decodeURI(safePath.split('/').join(path.sep)));
       } catch (error) {
         response.writeHead(404, contentTypePlain);
-        response.end(`No ${localFileArea} file found for: ${safePath}`);
+        response.end(`File not found for: ${safePath}`);
         console.log(`${requestHead} [404] ${pathname}`);
         console.log(`Failed to decode request pathname`);
       }
@@ -254,12 +312,12 @@ export async function go({
       } catch (error) {
         if (error.code === 'ENOENT') {
           response.writeHead(404, contentTypePlain);
-          response.end(`No ${localFileArea} file found for: ${safePath}`);
+          response.end(`File not found for: ${safePath}`);
           console.log(`${requestHead} [404] ${pathname}`);
           console.log(`ENOENT for stat: ${filePath}`);
         } else {
           response.writeHead(500, contentTypePlain);
-          response.end(`Internal error accessing ${localFileArea} file for: ${safePath}`);
+          response.end(`Internal error accessing file for: ${safePath}`);
           console.error(`${requestHead} [500] ${pathname}`);
           showError(error);
         }
@@ -267,66 +325,79 @@ export async function go({
       }
 
       const extname = path.extname(safePath).slice(1).toLowerCase();
+      const contentType = getContentType(extname);
+
+      let fd, size;
+      try {
+        ({size} = await stat(filePath));
+        fd = await open(filePath);
+      } catch (error) {
+        if (error.code === 'EISDIR') {
+          response.writeHead(404, contentTypePlain);
+          response.end(`File not found for: ${safePath}`);
+          console.error(`${requestHead} [404] ${pathname} (is directory)`);
+        } else {
+          response.writeHead(500, contentTypePlain);
+          response.end(`Failed during file-to-response pipeline`);
+          console.error(`${requestHead} [500] ${pathname}`);
+          showError(error);
+        }
+        return;
+      }
 
-      const contentType = {
-        // BRB covering all my bases
-        'aac': 'audio/aac',
-        'bmp': 'image/bmp',
-        'css': 'text/css',
-        'csv': 'text/csv',
-        'gif': 'image/gif',
-        'ico': 'image/vnd.microsoft.icon',
-        'jpg': 'image/jpeg',
-        'jpeg': 'image/jpeg',
-        'js': 'text/javascript',
-        'mjs': 'text/javascript',
-        'mp3': 'audio/mpeg',
-        'mp4': 'video/mp4',
-        'oga': 'audio/ogg',
-        'ogg': 'audio/ogg',
-        'ogv': 'video/ogg',
-        'opus': 'audio/opus',
-        'png': 'image/png',
-        'pdf': 'application/pdf',
-        'svg': 'image/svg+xml',
-        'ttf': 'font/ttf',
-        'txt': 'text/plain',
-        'wav': 'audio/wav',
-        'weba': 'audio/webm',
-        'webm': 'video/webm',
-        'woff': 'font/woff',
-        'woff2': 'font/woff2',
-        'xml': 'application/xml',
-        'zip': 'application/zip',
-      }[extname];
+      response.writeHead(200, {
+        ...contentType ? {'Content-Type': contentType} : {},
+        'Content-Length': size,
+      });
 
       try {
-        const {size} = await stat(filePath);
-        const buffer = await readFile(filePath)
-        response.writeHead(200, contentType ? {
-          'Content-Type': contentType,
-          'Content-Length': size,
-        } : {});
-        response.end(buffer);
-        if (loudResponses) console.log(`${requestHead} [200] ${pathname}`);
+        await pipeline(fd.createReadStream(), response);
       } catch (error) {
-        response.writeHead(500, contentTypePlain);
-        response.end(`Failed during file-to-response pipeline`);
-        console.error(`${requestHead} [500] ${pathname}`);
-        showError(error);
+        if (error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
+          // Connection was dropped, this is OK.
+          return;
+        } else {
+          throw error;
+        }
       }
+
+      if (loudResponses) console.log(`${requestHead} [200] ${pathname} (${colors.magenta(`web route`)})`);
+
       return;
     }
 
     // Other routes determined by page and URL specs
 
+    const startTiming = () => {
+      if (!showTimings) {
+        return () => '';
+      }
+
+      const timeStart = Date.now();
+
+      return () => {
+        const timeEnd = Date.now();
+        const timeDelta = timeEnd - timeStart;
+
+        if (timeDelta > 100) {
+          return `${(timeDelta / 1000).toFixed(2)}s`;
+        } else {
+          return `${timeDelta}ms`;
+        }
+      };
+    };
+
     // URL to page map expects trailing slash but no leading slash.
     const pathnameKey = pathname.replace(/^\//, '') + (pathname.endsWith('/') ? '' : '/');
 
-    if (!Object.hasOwn(urlToPageMap, pathnameKey)) {
+    const is404 =
+      !Object.hasOwn(urlToPageMap, pathnameKey) ||
+      !(urlToPageMap[pathnameKey].page.condition?.() ?? true);
+
+    if (is404) {
       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;
     }
 
@@ -383,22 +454,16 @@ export async function go({
         return;
       }
 
-      const timeStart = Date.now();
+      const timing = startTiming();
 
       const bound = bindUtilities({
+        ...commonUtilities,
+
         absoluteTo,
-        cachebust,
-        defaultLanguage,
-        getSizeOfAdditionalFile,
-        getSizeOfImagePath,
         language,
-        languages,
-        missingImagePaths,
         pagePath: servePath,
-        thumbsCache,
-        to,
-        urls,
-        wikiData,
+        pagePathStringFromRoot: pathname.replace(/^\//, ''),
+        to: page.absoluteLinks ? absoluteTo : to,
       });
 
       const topLevelResult =
@@ -412,18 +477,10 @@ export async function go({
 
       const {pageHTML} = html.resolve(topLevelResult);
 
-      const timeEnd = Date.now();
-      const timeDelta = timeEnd - timeStart;
-
-      if (showTimings) {
-        const timeString =
-          (timeDelta > 100
-            ? `${(timeDelta / 1000).toFixed(2)}s`
-            : `${timeDelta}ms`);
-
-        console.log(`${requestHead} [200, ${timeString}] ${pathname}`);
-      } else if (loudResponses) {
-        console.log(`${requestHead} [200] ${pathname}`);
+      const timeString = timing();
+      const status = (timeString ? `200 ${timeString}` : `200`);
+      if (showTimings || loudResponses) {
+        console.log(`${requestHead} [${status}] ${pathname} (${colors.blue(`page`)})`);
       }
 
       response.writeHead(200, contentTypeHTML);
@@ -436,7 +493,13 @@ export async function go({
     }
   });
 
-  const address = `http://${host}:${port}/`;
+  const addresses =
+    (host === '0.0.0.0'
+      ? [`http://localhost:${port}/`,
+         `http://${os.hostname()}:${port}/`]
+   : host === '127.0.0.1'
+      ? [`http://localhost:${port}/`]
+      : [`http://${host}:${port}/`]);
 
   server.on('error', error => {
     if (error.code === 'EADDRINUSE') {
@@ -453,7 +516,15 @@ export async function go({
   });
 
   server.on('listening', () => {
-    logInfo`${'All done!'} Listening at: ${address}`;
+    if (addresses.length === 1) {
+      logInfo`${'All done!'} Listening at: ${addresses[0]}`;
+    } else {
+      logInfo`${`All done!`} Listening at:`;
+      for (const address of addresses) {
+        logInfo`- ${address}`;
+      }
+    }
+
     logInfo`Press ^C here (control+C) to stop the server and exit.`;
     if (showTimings && loudResponses) {
       logInfo`Printing all HTTP responses, plus page generation timings.`;
diff --git a/src/write/build-modes/repl.js b/src/write/build-modes/repl.js
index 20985595..920ad9f7 100644
--- a/src/write/build-modes/repl.js
+++ b/src/write/build-modes/repl.js
@@ -6,13 +6,17 @@ export const config = {
   },
 
   languageReloading: {
-    default: true,
+    default: 'perform',
   },
 
   mediaValidation: {
     default: 'skip',
   },
 
+  search: {
+    default: 'skip',
+  },
+
   thumbs: {
     applicable: false,
   },
@@ -32,6 +36,7 @@ import * as path from 'node:path';
 import * as repl from 'node:repl';
 
 import _find, {bindFind} from '#find';
+import _reverse, {bindReverse} from '#reverse';
 import CacheableObject from '#cacheable-object';
 import {logWarn} from '#cli';
 import {debugComposite} from '#composite';
@@ -46,16 +51,12 @@ export async function getContextAssignments({
   mediaPath,
   mediaCachePath,
 
+  universalUtilities,
+
   defaultLanguage,
-  languages,
-  missingImagePaths,
-  thumbsCache,
-  urls,
   wikiData,
 
-  getSizeOfAdditionalFile,
-  getSizeOfImagePath,
-  niceShowAggregate,
+  niceShowAggregate: showAggregate,
 }) {
   let find;
   try {
@@ -66,19 +67,25 @@ export async function getContextAssignments({
     logWarn`\`find\` variable will be missing`;
   }
 
+  let reverse;
+  try {
+    reverse = bindReverse(wikiData);
+  } catch (error) {
+    console.error(error);
+    logWarn`Failed to prepare wikiData-bound reverse() functions`;
+    logWarn`\`reverse\` variable will be missing`;
+  }
+
   const replContext = {
+    universalUtilities,
+    ...universalUtilities,
+
     dataPath,
     mediaPath,
     mediaCachePath,
 
-    languages,
-    defaultLanguage,
     language: defaultLanguage,
 
-    missingImagePaths,
-    thumbsCache,
-    urls,
-
     wikiData,
     ...wikiData,
     WD: wikiData,
@@ -98,9 +105,11 @@ export async function getContextAssignments({
     find,
     bindFind,
 
-    getSizeOfAdditionalFile,
-    getSizeOfImagePath,
-    showAggregate: niceShowAggregate,
+    _reverse,
+    reverse,
+    bindReverse,
+
+    showAggregate,
   };
 
   replContext.replContext = replContext;
diff --git a/src/write/build-modes/sort.js b/src/write/build-modes/sort.js
new file mode 100644
index 00000000..1a738ac8
--- /dev/null
+++ b/src/write/build-modes/sort.js
@@ -0,0 +1,76 @@
+export const description = `Update data files in-place to satisfy custom sorting rules`;
+
+import {logInfo} from '#cli';
+import {empty} from '#sugar';
+import thingConstructors from '#things';
+
+export const config = {
+  fileSizes: {
+    applicable: false,
+  },
+
+  languageReloading: {
+    applicable: false,
+  },
+
+  mediaValidation: {
+    applicable: false,
+  },
+
+  search: {
+    applicable: false,
+  },
+
+  thumbs: {
+    applicable: false,
+  },
+
+  webRoutes: {
+    applicable: false,
+  },
+
+  sort: {
+    applicable: false,
+  },
+};
+
+export function getCLIOptions() {
+  return {};
+}
+
+export async function go({wikiData, dataPath}) {
+  if (empty(wikiData.sortingRules)) {
+    logInfo`There aren't any sorting rules in for this wiki.`;
+    return true;
+  }
+
+  const {SortingRule} = thingConstructors;
+
+  let numUpdated = 0;
+  let numActive = 0;
+
+  for await (const result of SortingRule.go({wikiData, dataPath})) {
+    numActive++;
+
+    const niceMessage = `"${result.rule.message}"`;
+
+    if (result.changed) {
+      numUpdated++;
+      logInfo`Updating to satisfy ${niceMessage}.`;
+    } else {
+      logInfo`Already good: ${niceMessage}`;
+    }
+  }
+
+  if (numUpdated > 1) {
+    logInfo`Updated data files to satisfy ${numUpdated} sorting rules.`;
+  } else if (numUpdated === 1) {
+    logInfo`Updated data files to satisfy ${1} sorting rule.`
+  } else if (numActive >= 1) {
+    logInfo`All sorting rules were already satisfied. Good to go!`;
+  } else {
+    logInfo`No sorting rules are currently active.`;
+  }
+
+  return true;
+}
diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js
index a355a002..2baed816 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';
@@ -24,6 +27,7 @@ import {
 } from '#cli';
 
 import {
+  getOrigin,
   getPagePathname,
   getURLsFrom,
   getURLsFromRoot,
@@ -38,7 +42,7 @@ export const description = `Generates all page content in one build (according t
 
 export const config = {
   fileSizes: {
-    default: true,
+    default: 'perform',
   },
 
   languageReloading: {
@@ -46,11 +50,19 @@ export const config = {
   },
 
   mediaValidation: {
-    default: true,
+    default: 'perform',
+  },
+
+  search: {
+    default: 'perform',
   },
 
   thumbs: {
-    default: true,
+    default: 'perform',
+  },
+
+  webRoutes: {
+    required: true,
   },
 };
 
@@ -99,23 +111,18 @@ export function getCLIOptions() {
 
 export async function go({
   cliOptions,
-  _dataPath,
-  mediaPath,
-  mediaCachePath,
   queueSize,
 
+  universalUtilities,
+
+  mediaPath,
+
   defaultLanguage,
   languages,
-  missingImagePaths,
-  srcRootPath,
-  thumbsCache,
   urls,
+  webRoutes,
   wikiData,
 
-  cachebust,
-  developersComment: _developersComment,
-  getSizeOfAdditionalFile,
-  getSizeOfImagePath,
   niceShowAggregate,
 }) {
   const outputPath = cliOptions['out-path'] || process.env.HSMUSIC_OUT;
@@ -148,12 +155,14 @@ export async function go({
 
   await mkdir(outputPath, {recursive: true});
 
-  await writeSymlinks({
-    srcRootPath,
-    mediaPath,
-    mediaCachePath,
+  await writeWebRouteSymlinks({
     outputPath,
-    urls,
+    webRoutes,
+  });
+
+  await writeWebRouteCopies({
+    outputPath,
+    webRoutes,
   });
 
   if (writeAll) {
@@ -186,7 +195,7 @@ export async function go({
           return null;
         }
 
-        const paths = [];
+        let paths = [];
 
         if (pageSpec.pathsTargetless) {
           const result = pageSpec.pathsTargetless({wikiData});
@@ -216,6 +225,9 @@ export async function go({
           // TODO: Validate each pathsForTargets entry
         }
 
+        paths =
+          paths.filter(path => path.condition?.() ?? true);
+
         return paths;
       })
       .filter(Boolean)
@@ -277,6 +289,8 @@ export async function go({
     showAggregate: niceShowAggregate,
   });
 
+  const commonUtilities = {...universalUtilities};
+
   const perLanguageFn = async (language, i, entries) => {
     const baseDirectory =
       language === defaultLanguage ? '' : language.code;
@@ -305,19 +319,13 @@ export async function go({
         });
 
         const bound = bindUtilities({
+          ...commonUtilities,
+
           absoluteTo,
-          cachebust,
-          defaultLanguage,
-          getSizeOfAdditionalFile,
-          getSizeOfImagePath,
           language,
-          languages,
-          missingImagePaths,
           pagePath,
-          thumbsCache,
-          to,
-          urls,
-          wikiData,
+          pagePathStringFromRoot: pathname,
+          to: page.absoluteLinks ? absoluteTo : to,
         });
 
         let pageHTML, oEmbedJSON;
@@ -432,41 +440,158 @@ async function writePage({
   ].filter(Boolean));
 }
 
-function writeSymlinks({
-  srcRootPath,
-  mediaPath,
-  mediaCachePath,
+function filterNoOrigin(route) {
+  return !getOrigin(route.to);
+}
+
+function writeWebRouteSymlinks({
   outputPath,
-  urls,
+  webRoutes,
 }) {
-  return progressPromiseAll('Writing site symlinks.', [
-    link(path.join(srcRootPath, 'util'), 'shared.utilityRoot'),
-    link(path.join(srcRootPath, 'static'), 'shared.staticRoot'),
-    link(mediaPath, 'media.root'),
-    link(mediaCachePath, 'thumb.root'),
-  ]);
-
-  async function link(directory, urlKey) {
-    const pathname = urls.from('shared.root').toDevice(urlKey);
-    const file = path.join(outputPath, pathname);
-
-    try {
-      await unlink(file);
-    } catch (error) {
-      if (error.code !== 'ENOENT') {
-        throw error;
+  const symlinkRoutes =
+    webRoutes
+      .filter(route => route.statically === 'symlink')
+      .filter(filterNoOrigin);
+
+  const promises =
+    symlinkRoutes.map(async route => {
+      const parts = route.to.split('/');
+      const parentDirectoryParts = parts.slice(0, -1);
+      const symlinkNamePart = parts.at(-1);
+
+      const parentDirectory = path.join(outputPath, ...parentDirectoryParts);
+      const symlinkPath = path.join(parentDirectory, symlinkNamePart);
+
+      try {
+        await unlink(symlinkPath);
+      } catch (error) {
+        if (error.code !== 'ENOENT') {
+          throw error;
+        }
       }
-    }
 
-    try {
-      await symlink(path.resolve(directory), file);
-    } catch (error) {
-      if (error.code === 'EPERM') {
-        await symlink(path.resolve(directory), file, 'junction');
-      } else {
-        throw error;
+      await mkdir(parentDirectory, {recursive: true});
+
+      try {
+        await symlink(route.from, symlinkPath);
+      } catch (error) {
+        if (error.code === 'EPERM') {
+          await symlink(route.from, symlinkPath, 'junction');
+        } else {
+          throw error;
+        }
       }
-    }
+    });
+
+  return progressPromiseAll(`Writing web route symlinks.`, promises);
+}
+
+async function writeWebRouteCopies({
+  outputPath,
+  webRoutes,
+}) {
+  const copyRoutes =
+    webRoutes
+      .filter(route => route.statically === 'copy')
+      .filter(filterNoOrigin);
+
+  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")`);
   }
 }
 
diff --git a/tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs b/tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs
deleted file mode 100644
index e166140a..00000000
--- a/tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs
+++ /dev/null
@@ -1,14 +0,0 @@
-/* IMPORTANT
- * This snapshot file is auto-generated, but designed for humans.
- * It should be checked into source control and tracked carefully.
- * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
- * Make sure to inspect the output below.  Do not ignore changes!
- */
-'use strict'
-exports[`test/snapshot/generateAdditionalFilesShortcut.js > TAP > generateAdditionalFilesShortcut (snapshot) > basic behavior 1`] = `
-View <a href="#additional-files">additional files</a>: SBURB Wallpaper, Alternate Covers
-`
-
-exports[`test/snapshot/generateAdditionalFilesShortcut.js > TAP > generateAdditionalFilesShortcut (snapshot) > no additional files 1`] = `
-
-`
diff --git a/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs
index d8f1e974..4f09569d 100644
--- a/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs
@@ -52,5 +52,5 @@ exports[`test/snapshot/generateAlbumAdditionalFilesList.js > TAP > generateAlbum
 `
 
 exports[`test/snapshot/generateAlbumAdditionalFilesList.js > TAP > generateAlbumAdditionalFilesList (snapshot) > no additional files 1`] = `
-<ul class="additional-files-list"></ul>
+
 `
diff --git a/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs
deleted file mode 100644
index 71d9c55d..00000000
--- a/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs
+++ /dev/null
@@ -1,37 +0,0 @@
-/* IMPORTANT
- * This snapshot file is auto-generated, but designed for humans.
- * It should be checked into source control and tracked carefully.
- * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
- * Make sure to inspect the output below.  Do not ignore changes!
- */
-'use strict'
-exports[`test/snapshot/generateAlbumCoverArtwork.js > TAP > generateAlbumCoverArtwork (snapshot) > display: primary 1`] = `
-[mocked: image
- args: [
-   [
-     { name: 'Damara', directory: 'damara', isContentWarning: false },
-     { name: 'Cronus', directory: 'cronus', isContentWarning: false },
-     { name: 'Bees', directory: 'bees', isContentWarning: false },
-     { name: 'creepy crawlies', isContentWarning: true }
-   ]
- ]
- slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#f28514', thumb: 'medium', reveal: true, link: true, square: true }]
-<ul class="image-details">
-    <li><a href="tag/damara/">Damara</a></li>
-    <li><a href="tag/cronus/">Cronus</a></li>
-    <li><a href="tag/bees/">Bees</a></li>
-</ul>
-`
-
-exports[`test/snapshot/generateAlbumCoverArtwork.js > TAP > generateAlbumCoverArtwork (snapshot) > display: thumbnail 1`] = `
-[mocked: image
- args: [
-   [
-     { name: 'Damara', directory: 'damara', isContentWarning: false },
-     { name: 'Cronus', directory: 'cronus', isContentWarning: false },
-     { name: 'Bees', directory: 'bees', isContentWarning: false },
-     { name: 'creepy crawlies', isContentWarning: true }
-   ]
- ]
- slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#f28514', thumb: 'small', reveal: false, link: false, square: true }]
-`
diff --git a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
index f9fc025b..14cce64e 100644
--- a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
@@ -7,16 +7,17 @@
 'use strict'
 exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > basic behavior 1`] = `
 <p>
-    By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (music probably)</span> and <span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/tensei/">Tensei</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://tenseimusic.bandcamp.com/">
-                        <svg><use href="static/icons.svg#icon-bandcamp"></use></svg>
-                        <span class="icon-text">tenseimusic</span>
-                    </a></span></span></span> (hot jams)</span>.
+    By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (music probably)</span> and <span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/tensei/">Tensei</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://tenseimusic.bandcamp.com/">
+                        <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg></span>
+                        <span class="external-handle">tenseimusic</span>
+                    </a>
+                    <span class="external-platform">Bandcamp</span></span></span></span> (hot jams)</span>.
     <br>
-    Cover art by <a href="artist/hb/">Hanni Brosh</a>.
+    Cover art by <span class="contribution nowrap"><a href="artist/hb/">Hanni Brosh</a></span>.
     <br>
-    Wallpaper art by <a href="artist/hb/">Hanni Brosh</a> and <span class="contribution nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
+    Wallpaper art by <span class="contribution nowrap"><a href="artist/hb/">Hanni Brosh</a></span> and <span class="contribution nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
     <br>
-    Banner art by <a href="artist/hb/">Hanni Brosh</a> and <span class="contribution nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
+    Banner art by <span class="contribution nowrap"><a href="artist/hb/">Hanni Brosh</a></span> and <span class="contribution nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
     <br>
     Released 3/14/2011.
     <br>
@@ -24,7 +25,7 @@ exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseI
     <br>
     Duration: ~10:25.
 </p>
-<p>Listen on <a href="https://homestuck.bandcamp.com/album/alterniabound-with-alternia" class="nowrap">Bandcamp</a>, <a href="https://www.youtube.com/playlist?list=PLnVpmehyaOFZWO9QOZmD6A3TIK0wZ6xE2" class="nowrap">YouTube (playlist)</a>, or <a href="https://www.youtube.com/watch?v=HO5V2uogkYc" class="nowrap">YouTube (full album)</a>.</p>
+<p>Listen on <a class="external-link" href="https://homestuck.bandcamp.com/album/alterniabound-with-alternia">Bandcamp</a>, <a class="external-link" href="https://www.youtube.com/playlist?list=PLnVpmehyaOFZWO9QOZmD6A3TIK0wZ6xE2">YouTube (playlist)</a>, or <a class="external-link" href="https://www.youtube.com/watch?v=HO5V2uogkYc">YouTube (full album)</a>.</p>
 `
 
 exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > equal cover art date 1`] = `
@@ -36,5 +37,5 @@ exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseI
 `
 
 exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > URLs only 1`] = `
-<p>Listen on <a href="https://homestuck.bandcamp.com/foo" class="nowrap">Bandcamp</a> or <a href="https://soundcloud.com/bar" class="nowrap">SoundCloud</a>.</p>
+<p>Listen on <a class="external-link" href="https://homestuck.bandcamp.com/foo">Bandcamp</a> or <a class="external-link" href="https://soundcloud.com/bar">SoundCloud</a>.</p>
 `
diff --git a/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs
index f2b51cb5..145dd0f7 100644
--- a/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs
@@ -6,28 +6,29 @@
  */
 'use strict'
 exports[`test/snapshot/generateAlbumSecondaryNav.js > TAP > generateAlbumSecondaryNav (snapshot) > basic behavior, mode: album 1`] = `
-<nav id="secondary-nav" class="nav-links-groups">
-    <span style="--primary-color: #abcdef">
-        <a href="group/vcg/">VCG</a>
-        (<a title="First" href="album/first/">Previous</a>, <a title="Last" href="album/last/">Next</a>)
-    </span>
-    <span style="--primary-color: #123456">
-        <a href="group/bepis/">Bepis</a>
-        (<a title="Second" href="album/second/">Next</a>)
+<nav id="secondary-nav" class="album-secondary-nav with-previous-next">
+    <span class="group-with-series">
+        <span style="--primary-color: #abcdef"><a href="group/vcg/">VCG</a> (<span class="dot-switcher interpage"><span><a title="First" href="album/first/">Previous</a></span><span><a title="Last" href="album/last/">Next</a></span></span>)</span>
+        <span class="series-nav-link" style="--primary-color: #abcdef"><a class="series" href="group/vcg/">Series</a> (<span class="dot-switcher interpage"><span><a class="inert-previous-next-link">Previous</a></span><span><a title="Last" href="album/last/">Next</a></span></span>)</span>
     </span>
+    <span style="--primary-color: #123456"><a href="group/bepis/">Bepis</a> (<span class="dot-switcher interpage"><span><a class="inert-previous-next-link">Previous</a></span><span><a title="Second" href="album/second/">Next</a></span></span>)</span>
 </nav>
 `
 
 exports[`test/snapshot/generateAlbumSecondaryNav.js > TAP > generateAlbumSecondaryNav (snapshot) > basic behavior, mode: track 1`] = `
-<nav id="secondary-nav" class="nav-links-groups">
-    <a style="--primary-color: #abcdef" href="group/vcg/">VCG</a>
-    <a style="--primary-color: #123456" href="group/bepis/">Bepis</a>
+<nav id="secondary-nav" class="album-secondary-nav">
+    <span class="dot-switcher"><span>
+            <span class="group-with-series">
+                <span style="--primary-color: #abcdef"><a href="group/vcg/">VCG</a></span>
+                <span class="series-nav-link" style="--primary-color: #abcdef"><a class="series" href="group/vcg/">Series</a></span>
+            </span>
+        </span><span><span style="--primary-color: #123456"><a href="group/bepis/">Bepis</a></span></span></span>
 </nav>
 `
 
 exports[`test/snapshot/generateAlbumSecondaryNav.js > TAP > generateAlbumSecondaryNav (snapshot) > dateless album in mixed group 1`] = `
-<nav id="secondary-nav" class="nav-links-groups">
-    <a style="--primary-color: #abcdef" href="group/vcg/">VCG</a>
-    <a style="--primary-color: #123456" href="group/bepis/">Bepis</a>
+<nav id="secondary-nav" class="album-secondary-nav with-previous-next">
+    <span style="--primary-color: #abcdef"><a href="group/vcg/">VCG</a></span>
+    <span style="--primary-color: #123456"><a href="group/bepis/">Bepis</a></span>
 </nav>
 `
diff --git a/tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs
index 0b7a0f77..6502f719 100644
--- a/tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs
@@ -6,7 +6,7 @@
  */
 'use strict'
 exports[`test/snapshot/generateAlbumSidebarGroupBox.js > TAP > generateAlbumSidebarGroupBox (snapshot) > basic behavior, mode: album 1`] = `
-<div class="sidebar individual-group-sidebar-box">
+<div class="sidebar collapsible individual-group-sidebar-box">
     <h1><a href="group/vcg/">VCG</a></h1>
     Very cool group.
     <p>Visit on <a class="external-link" href="https://vcg.bandcamp.com/">Bandcamp</a> or <a class="external-link" href="https://youtube.com/@vcg">YouTube</a>.</p>
@@ -16,14 +16,14 @@ exports[`test/snapshot/generateAlbumSidebarGroupBox.js > TAP > generateAlbumSide
 `
 
 exports[`test/snapshot/generateAlbumSidebarGroupBox.js > TAP > generateAlbumSidebarGroupBox (snapshot) > basic behavior, mode: track 1`] = `
-<div class="sidebar individual-group-sidebar-box">
+<div class="sidebar collapsible individual-group-sidebar-box">
     <h1><a href="group/vcg/">VCG</a></h1>
     <p>Visit on <a class="external-link" href="https://vcg.bandcamp.com/">Bandcamp</a> or <a class="external-link" href="https://youtube.com/@vcg">YouTube</a>.</p>
 </div>
 `
 
 exports[`test/snapshot/generateAlbumSidebarGroupBox.js > TAP > generateAlbumSidebarGroupBox (snapshot) > dateless album in mixed group 1`] = `
-<div class="sidebar individual-group-sidebar-box">
+<div class="sidebar collapsible individual-group-sidebar-box">
     <h1><a href="group/vcg/">VCG</a></h1>
     Very cool group.
     <p>Visit on <a class="external-link" href="https://vcg.bandcamp.com/">Bandcamp</a> or <a class="external-link" href="https://youtube.com/@vcg">YouTube</a>.</p>
diff --git a/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
index 3b6676f8..40a8017d 100644
--- a/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
@@ -10,13 +10,41 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 `
 
+exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > basic behavior, with descriptions 1`] = `
+<dl class="album-group-list">
+    <dt class="content-heading" tabindex="0">
+        <span class="content-heading-main-title">First section: (~1:00)</span>
+        <template class="content-heading-sticky-title">First section:</template>
+    </dt>
+    <dd>
+        <blockquote><p>Why yes!</p></blockquote>
+        <ul>
+            <li>(0:20) <a href="track/t1/">Track 1</a></li>
+            <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
+            <li>(0:40) <a href="track/t3/">Track 3</a></li>
+        </ul>
+    </dd>
+    <dt class="content-heading" tabindex="0">
+        <span class="content-heading-main-title">Second section:</span>
+        <template class="content-heading-sticky-title">Second section:</template>
+    </dt>
+    <dd>
+        <blockquote><p>How <em>contentful,</em> this is.</p></blockquote>
+        <ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li></ul>
+    </dd>
+</dl>
+`
+
 exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > basic behavior, with track sections 1`] = `
 <dl class="album-group-list">
-    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">First section: (~1:00)</span></dt>
+    <dt class="content-heading" tabindex="0">
+        <span class="content-heading-main-title">First section: (~1:00)</span>
+        <template class="content-heading-sticky-title">First section:</template>
+    </dt>
     <dd>
         <ul>
             <li>(0:20) <a href="track/t1/">Track 1</a></li>
@@ -24,14 +52,20 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
             <li>(0:40) <a href="track/t3/">Track 3</a></li>
         </ul>
     </dd>
-    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
-    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+    <dt class="content-heading" tabindex="0">
+        <span class="content-heading-main-title">Second section:</span>
+        <template class="content-heading-sticky-title">Second section:</template>
+    </dt>
+    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li></ul></dd>
 </dl>
 `
 
 exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > collapseDurationScope: album 1`] = `
 <dl class="album-group-list">
-    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">First section: (~1:00)</span></dt>
+    <dt class="content-heading" tabindex="0">
+        <span class="content-heading-main-title">First section: (~1:00)</span>
+        <template class="content-heading-sticky-title">First section:</template>
+    </dt>
     <dd>
         <ul>
             <li>(0:20) <a href="track/t1/">Track 1</a></li>
@@ -39,24 +73,30 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
             <li>(0:40) <a href="track/t3/">Track 3</a></li>
         </ul>
     </dd>
-    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
-    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+    <dt class="content-heading" tabindex="0">
+        <span class="content-heading-main-title">Second section:</span>
+        <template class="content-heading-sticky-title">Second section:</template>
+    </dt>
+    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li></ul></dd>
 </dl>
 <ul>
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 <ul>
     <li><a href="track/t2/">Track 2</a></li>
-    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 `
 
 exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > collapseDurationScope: never 1`] = `
 <dl class="album-group-list">
-    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">First section: (~1:00)</span></dt>
+    <dt class="content-heading" tabindex="0">
+        <span class="content-heading-main-title">First section: (~1:00)</span>
+        <template class="content-heading-sticky-title">First section:</template>
+    </dt>
     <dd>
         <ul>
             <li>(0:20) <a href="track/t1/">Track 1</a></li>
@@ -64,24 +104,30 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
             <li>(0:40) <a href="track/t3/">Track 3</a></li>
         </ul>
     </dd>
-    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
-    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+    <dt class="content-heading" tabindex="0">
+        <span class="content-heading-main-title">Second section:</span>
+        <template class="content-heading-sticky-title">Second section:</template>
+    </dt>
+    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li></ul></dd>
 </dl>
 <ul>
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 <ul>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 `
 
 exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > collapseDurationScope: section 1`] = `
 <dl class="album-group-list">
-    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">First section: (~1:00)</span></dt>
+    <dt class="content-heading" tabindex="0">
+        <span class="content-heading-main-title">First section: (~1:00)</span>
+        <template class="content-heading-sticky-title">First section:</template>
+    </dt>
     <dd>
         <ul>
             <li>(0:20) <a href="track/t1/">Track 1</a></li>
@@ -89,24 +135,30 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
             <li>(0:40) <a href="track/t3/">Track 3</a></li>
         </ul>
     </dd>
-    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
-    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+    <dt class="content-heading" tabindex="0">
+        <span class="content-heading-main-title">Second section:</span>
+        <template class="content-heading-sticky-title">Second section:</template>
+    </dt>
+    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li></ul></dd>
 </dl>
 <ul>
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 <ul>
     <li><a href="track/t2/">Track 2</a></li>
-    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 `
 
 exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > collapseDurationScope: track 1`] = `
 <dl class="album-group-list">
-    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">First section: (~1:00)</span></dt>
+    <dt class="content-heading" tabindex="0">
+        <span class="content-heading-main-title">First section: (~1:00)</span>
+        <template class="content-heading-sticky-title">First section:</template>
+    </dt>
     <dd>
         <ul>
             <li>(0:20) <a href="track/t1/">Track 1</a></li>
@@ -114,17 +166,20 @@ exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList
             <li>(0:40) <a href="track/t3/">Track 3</a></li>
         </ul>
     </dd>
-    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
-    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+    <dt class="content-heading" tabindex="0">
+        <span class="content-heading-main-title">Second section:</span>
+        <template class="content-heading-sticky-title">Second section:</template>
+    </dt>
+    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li></ul></dd>
 </dl>
 <ul>
     <li>(0:20) <a href="track/t1/">Track 1</a></li>
     <li><a href="track/t2/">Track 2</a></li>
     <li>(0:40) <a href="track/t3/">Track 3</a></li>
-    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 <ul>
     <li><a href="track/t2/">Track 2</a></li>
-    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by"><span class="chunkwrap">by <span class="contribution nowrap"><a href="artist/apricot/">Apricot</a></span>,</span> <span class="chunkwrap"><span class="contribution nowrap"><a href="artist/peach/">Peach</a></span>,</span> <span class="chunkwrap">and <span class="contribution nowrap"><a href="artist/cerise/">Cerise</a></span></span></span></li>
 </ul>
 `
diff --git a/tap-snapshots/test/snapshot/generateCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateCoverArtwork.js.test.cjs
deleted file mode 100644
index f16923d0..00000000
--- a/tap-snapshots/test/snapshot/generateCoverArtwork.js.test.cjs
+++ /dev/null
@@ -1,37 +0,0 @@
-/* IMPORTANT
- * This snapshot file is auto-generated, but designed for humans.
- * It should be checked into source control and tracked carefully.
- * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
- * Make sure to inspect the output below.  Do not ignore changes!
- */
-'use strict'
-exports[`test/snapshot/generateCoverArtwork.js > TAP > generateCoverArtwork (snapshot) > display: primary 1`] = `
-[mocked: image
- args: [
-   [
-     { name: 'Damara', directory: 'damara', isContentWarning: false },
-     { name: 'Cronus', directory: 'cronus', isContentWarning: false },
-     { name: 'Bees', directory: 'bees', isContentWarning: false },
-     { name: 'creepy crawlies', isContentWarning: true }
-   ]
- ]
- slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'medium', reveal: true, link: true, square: true }]
-<ul class="image-details">
-    <li><a href="tag/damara/">Damara</a></li>
-    <li><a href="tag/cronus/">Cronus</a></li>
-    <li><a href="tag/bees/">Bees</a></li>
-</ul>
-`
-
-exports[`test/snapshot/generateCoverArtwork.js > TAP > generateCoverArtwork (snapshot) > display: thumbnail 1`] = `
-[mocked: image
- args: [
-   [
-     { name: 'Damara', directory: 'damara', isContentWarning: false },
-     { name: 'Cronus', directory: 'cronus', isContentWarning: false },
-     { name: 'Bees', directory: 'bees', isContentWarning: false },
-     { name: 'creepy crawlies', isContentWarning: true }
-   ]
- ]
- slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'small', reveal: false, link: false, square: true }]
-`
diff --git a/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs b/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs
deleted file mode 100644
index b2c4c647..00000000
--- a/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs
+++ /dev/null
@@ -1,28 +0,0 @@
-/* IMPORTANT
- * This snapshot file is auto-generated, but designed for humans.
- * It should be checked into source control and tracked carefully.
- * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
- * Make sure to inspect the output below.  Do not ignore changes!
- */
-'use strict'
-exports[`test/snapshot/generatePreviousNextLinks.js > TAP > generatePreviousNextLinks (snapshot) > basic behavior 1`] = `
-previous: { tooltipStyle: 'browser', color: false, attributes: { id: 'previous-button' }, content: Tag (no name, 1 items) }
-next: { tooltipStyle: 'browser', color: false, attributes: { id: 'next-button' }, content: Tag (no name, 1 items) }
-`
-
-exports[`test/snapshot/generatePreviousNextLinks.js > TAP > generatePreviousNextLinks (snapshot) > disable id 1`] = `
-previous: { tooltipStyle: 'browser', color: false, attributes: { id: false }, content: Tag (no name, 1 items) }
-next: { tooltipStyle: 'browser', color: false, attributes: { id: false }, content: Tag (no name, 1 items) }
-`
-
-exports[`test/snapshot/generatePreviousNextLinks.js > TAP > generatePreviousNextLinks (snapshot) > neither link present 1`] = `
-
-`
-
-exports[`test/snapshot/generatePreviousNextLinks.js > TAP > generatePreviousNextLinks (snapshot) > next missing 1`] = `
-previous: { tooltipStyle: 'browser', color: false, attributes: { id: 'previous-button' }, content: Tag (no name, 1 items) }
-`
-
-exports[`test/snapshot/generatePreviousNextLinks.js > TAP > generatePreviousNextLinks (snapshot) > previous missing 1`] = `
-next: { tooltipStyle: 'browser', color: false, attributes: { id: 'next-button' }, content: Tag (no name, 1 items) }
-`
diff --git a/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs
deleted file mode 100644
index 3a22266e..00000000
--- a/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs
+++ /dev/null
@@ -1,99 +0,0 @@
-/* IMPORTANT
- * This snapshot file is auto-generated, but designed for humans.
- * It should be checked into source control and tracked carefully.
- * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
- * Make sure to inspect the output below.  Do not ignore changes!
- */
-'use strict'
-exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > inferred additional names only 1`] = `
-[mocked: generateAdditionalNamesBox
- args: [
-   [
-     { name: 'Baz Baz', from: [ { directory: 'the-pyrenees' } ] }
-   ]
- ]
- slots: {}]
-`
-
-exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > multiple own 1`] = `
-[mocked: generateAdditionalNamesBox
- args: [
-   [
-     { name: 'Apple Time!' },
-     { name: 'Pterodactyl Time!' },
-     { name: 'Banana Time!' }
-   ]
- ]
- slots: {}]
-`
-
-exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > no additional names 1`] = `
-
-`
-
-exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own additional names only 1`] = `
-[mocked: generateAdditionalNamesBox
- args: [ [ { name: 'Foo Bar', annotation: 'the Alps' } ] ]
- slots: {}]
-`
-
-exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and inferred, some overlap 1`] = `
-[mocked: generateAdditionalNamesBox
- args: [
-   [
-     { name: 'Ke$halo Strike Back', annotation: 'own annotation' },
-     { name: 'Ironic Mania', annotation: 'own annotation' },
-     {
-       name: 'ANARCHY::MEGASTRIFE',
-       from: [ { directory: 'inferred-from' } ]
-     }
-   ]
- ]
- slots: {}]
-`
-
-exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and shared and inferred, various overlap 1`] = `
-[mocked: generateAdditionalNamesBox
- args: [
-   [
-     { name: 'Own!', annotation: 'own annotation' },
-     { name: 'Own! Shared!', annotation: 'own annotation' },
-     { name: 'Own! Inferred!', annotation: 'own annotation' },
-     { name: 'Own! Shared! Inferred!', annotation: 'own annotation' },
-     { name: 'Shared!', annotation: 'shared annotation' },
-     { name: 'Shared! Inferred!', annotation: 'shared annotation' },
-     { name: 'Inferred!', from: [ { directory: 'inferred-from' } ] }
-   ]
- ]
- slots: {}]
-`
-
-exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and shared, some overlap 1`] = `
-[mocked: generateAdditionalNamesBox
- args: [
-   [
-     { name: 'weed dreams..', annotation: 'own annotation' },
-     { name: '夜間のMOON汗', annotation: 'own annotation' },
-     { name: 'GAMINGブラザー96', annotation: 'shared annotation' }
-   ]
- ]
- slots: {}]
-`
-
-exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > shared additional names only 1`] = `
-[mocked: generateAdditionalNamesBox
- args: [ [ { name: 'Bar Foo', annotation: 'the Rockies' } ] ]
- slots: {}]
-`
-
-exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > shared and inferred, some overlap 1`] = `
-[mocked: generateAdditionalNamesBox
- args: [
-   [
-     { name: 'Coruscate', annotation: 'shared annotation' },
-     { name: 'Arbroath', annotation: 'shared annotation' },
-     { name: 'Prana Ferox', from: [ { directory: 'inferred-from' } ] }
-   ]
- ]
- slots: {}]
-`
diff --git a/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs
deleted file mode 100644
index 1d21e47d..00000000
--- a/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs
+++ /dev/null
@@ -1,50 +0,0 @@
-/* IMPORTANT
- * This snapshot file is auto-generated, but designed for humans.
- * It should be checked into source control and tracked carefully.
- * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
- * Make sure to inspect the output below.  Do not ignore changes!
- */
-'use strict'
-exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverArtwork (snapshot) > display: primary - no unique art 1`] = `
-[mocked: image
- args: [
-   [
-     { name: 'Damara', directory: 'damara', isContentWarning: false },
-     { name: 'Cronus', directory: 'cronus', isContentWarning: false },
-     { name: 'Bees', directory: 'bees', isContentWarning: false },
-     { name: 'creepy crawlies', isContentWarning: true }
-   ]
- ]
- slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#abcdef', thumb: 'medium', reveal: true, link: true, square: true }]
-<ul class="image-details">
-    <li><a href="tag/damara/">Damara</a></li>
-    <li><a href="tag/cronus/">Cronus</a></li>
-    <li><a href="tag/bees/">Bees</a></li>
-</ul>
-`
-
-exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverArtwork (snapshot) > display: primary - unique art 1`] = `
-[mocked: image
- args: [ [ { name: 'Bees', directory: 'bees', isContentWarning: false } ] ]
- slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], color: '#f28514', thumb: 'medium', reveal: true, link: true, square: true }]
-<ul class="image-details"><li><a href="tag/bees/">Bees</a></li></ul>
-`
-
-exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverArtwork (snapshot) > display: thumbnail - no unique art 1`] = `
-[mocked: image
- args: [
-   [
-     { name: 'Damara', directory: 'damara', isContentWarning: false },
-     { name: 'Cronus', directory: 'cronus', isContentWarning: false },
-     { name: 'Bees', directory: 'bees', isContentWarning: false },
-     { name: 'creepy crawlies', isContentWarning: true }
-   ]
- ]
- slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#abcdef', thumb: 'small', reveal: false, link: false, square: true }]
-`
-
-exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverArtwork (snapshot) > display: thumbnail - unique art 1`] = `
-[mocked: image
- args: [ [ { name: 'Bees', directory: 'bees', isContentWarning: false } ] ]
- slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], color: '#f28514', thumb: 'small', reveal: false, link: false, square: true }]
-`
diff --git a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
index 3d988dce..098fe145 100644
--- a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
+++ b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
@@ -7,23 +7,23 @@
 'use strict'
 exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseInfo (snapshot) > basic behavior 1`] = `
 <p>
-    By <a href="artist/toby-fox/">Toby Fox</a>.
+    By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a></span>.
     <br>
     Released 11/29/2011.
     <br>
     Duration: 0:58.
 </p>
-<p>Listen on <a href="https://soundcloud.com/foo" class="nowrap">SoundCloud</a> or <a href="https://youtube.com/watch?v=bar" class="nowrap">YouTube</a>.</p>
+<p>Listen on <a class="external-link" href="https://soundcloud.com/foo">SoundCloud</a> or <a class="external-link" href="https://youtube.com/watch?v=bar">YouTube</a>.</p>
 `
 
 exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseInfo (snapshot) > cover artist contribs, non-unique 1`] = `
-<p>By <a href="artist/toby-fox/">Toby Fox</a>.</p>
+<p>By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a></span>.</p>
 <p>This wiki doesn't have any listening links for <i>Suspicious Track</i>.</p>
 `
 
 exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseInfo (snapshot) > cover artist contribs, unique 1`] = `
 <p>
-    By <a href="artist/toby-fox/">Toby Fox</a>.
+    By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a></span>.
     <br>
     Cover art by <span class="contribution nowrap"><a href="artist/alpaca/">Alpaca</a> (&#x1F525;)</span>.
 </p>
@@ -31,6 +31,6 @@ exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseI
 `
 
 exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseInfo (snapshot) > reduced details 1`] = `
-<p>By <a href="artist/toby-fox/">Toby Fox</a>.</p>
+<p>By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a></span>.</p>
 <p>This wiki doesn't have any listening links for <i>Suspicious Track</i>.</p>
 `
diff --git a/tap-snapshots/test/snapshot/image.js.test.cjs b/tap-snapshots/test/snapshot/image.js.test.cjs
index ddfb3e6c..283f4352 100644
--- a/tap-snapshots/test/snapshot/image.js.test.cjs
+++ b/tap-snapshots/test/snapshot/image.js.test.cjs
@@ -12,7 +12,7 @@ exports[`test/snapshot/image.js > TAP > image (snapshot) > content warnings via
             <img class="image" src="media/album-art/beyond-canon/cover.png">
             <span class="reveal-text-container">
                 <span class="reveal-text">
-                    <img class="reveal-symbol" src="static/warning.svg?413">
+                    <img class="reveal-symbol" src="static/misc/warning.svg">
                     <br>
                     <span class="reveal-warnings">too cool for school</span>
                     <br>
@@ -24,6 +24,14 @@ exports[`test/snapshot/image.js > TAP > image (snapshot) > content warnings via
 </div>
 `
 
+exports[`test/snapshot/image.js > TAP > image (snapshot) > dimensions 1`] = `
+<div class="image-container"><div class="image-outer-area"><div class="image-inner-area"><img class="image" width="600" height="400" src="foobar"></div></div></div>
+`
+
+exports[`test/snapshot/image.js > TAP > image (snapshot) > dimensions with square 1`] = `
+<div class="image-container square"><div class="image-outer-area square-content"><div class="image-inner-area"><img class="image" width="600" height="400" src="foobar"></div></div></div>
+`
+
 exports[`test/snapshot/image.js > TAP > image (snapshot) > lazy with square 1`] = `
 <noscript><div class="image-container square"><div class="image-outer-area square-content"><div class="image-inner-area"><img class="image" src="foobar"></div></div></div></noscript>
 <div class="image-container square js-hide"><div class="image-outer-area square-content"><div class="image-inner-area"><img class="image lazy" data-original="foobar"></div></div></div>
@@ -64,7 +72,3 @@ exports[`test/snapshot/image.js > TAP > image (snapshot) > thumb requested but s
 exports[`test/snapshot/image.js > TAP > image (snapshot) > thumbnail details 1`] = `
 <div class="image-container"><div class="image-outer-area"><div class="image-inner-area"><img class="image" data-original-length="1200" data-thumbs="voluminous:1200 middling:900 petite:20" src="thumb/album-art/beyond-canon/cover.voluminous.jpg"></div></div></div>
 `
-
-exports[`test/snapshot/image.js > TAP > image (snapshot) > width & height 1`] = `
-<div class="image-container"><div class="image-outer-area"><div class="image-inner-area"><img class="image" width="600" height="400" src="foobar"></div></div></div>
-`
diff --git a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
index 20f5adcb..0c44a85c 100644
--- a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
+++ b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
@@ -5,159 +5,114 @@
  * Make sure to inspect the output below.  Do not ignore changes!
  */
 'use strict'
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (inline) 1`] = `
-<span class="contribution nowrap"><a href="artist/lorem-ipsum-lover/">Lorem Ipsum Lover</a> (<span class="icons icons-inline"><a class="icon" href="https://loremipsum.io">
-            <svg>
-                <title>External (loremipsum.io)</title>
-                <use href="static/icons.svg#icon-globe"></use>
-            </svg>
-        </a>, <a class="icon" href="https://loremipsum.io/generator/">
-            <svg>
-                <title>External (loremipsum.io)</title>
-                <use href="static/icons.svg#icon-globe"></use>
-            </svg>
-        </a>, <a class="icon" href="https://loremipsum.io/#meaning">
-            <svg>
-                <title>External (loremipsum.io)</title>
-                <use href="static/icons.svg#icon-globe"></use>
-            </svg>
-        </a>, <a class="icon" href="https://loremipsum.io/#usage-and-examples">
-            <svg>
-                <title>External (loremipsum.io)</title>
-                <use href="static/icons.svg#icon-globe"></use>
-            </svg>
-        </a></span>)</span>
-`
-
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (tooltip) 1`] = `
-<span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/lorem-ipsum-lover/">Lorem Ipsum Lover</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://loremipsum.io">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/generator/">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/#meaning">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/#usage-and-examples">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/#controversy">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/#when-to-use-lorem-ipsum">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/#lorem-ipsum-all-the-things">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a><a class="icon has-text" href="https://loremipsum.io/#original-source">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">loremipsum.io</span>
-                </a></span></span></span></span>
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links 1`] = `
+<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/lorem-ipsum-lover/">Lorem Ipsum Lover</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://loremipsum.io">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/generator/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/#meaning">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/#usage-and-examples">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/#controversy">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/#when-to-use-lorem-ipsum">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/#lorem-ipsum-all-the-things">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span>
+                <a class="external-link" href="https://loremipsum.io/#original-source">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">loremipsum.io</span>
+                </a>
+                <span class="external-platform">Other</span></span></span></span></span>
 `
 
 exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > no accents 1`] = `
-<a href="artist/clark-powell/">Clark Powell</a>
-<a href="artist/the-big-baddies/">Grounder &amp; Scratch</a>
-<a href="artist/toby-fox/">Toby Fox</a>
+<span class="contribution nowrap"><a href="artist/clark-powell/">Clark Powell</a></span>
+<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a></span>
+<span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a></span>
 `
 
 exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > no preventWrapping 1`] = `
-<span class="contribution"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons icons-inline"><a class="icon" href="https://soundcloud.com/plazmataz">
-            <svg>
-                <title>SoundCloud</title>
-                <use href="static/icons.svg#icon-soundcloud"></use>
-            </svg>
-        </a></span>)</span>
+<span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://soundcloud.com/plazmataz">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-soundcloud"></use></svg></span>
+                    <span class="external-handle">plazmataz</span>
+                </a>
+                <span class="external-platform">SoundCloud</span></span></span></span></span>
 <span class="contribution"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
-<span class="contribution"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement) (<span class="icons icons-inline"><a class="icon" href="https://tobyfox.bandcamp.com/">
-            <svg>
-                <title>Bandcamp</title>
-                <use href="static/icons.svg#icon-bandcamp"></use>
-            </svg>
-        </a>, <a class="icon" href="https://toby.fox/">
-            <svg>
-                <title>External (toby.fox)</title>
-                <use href="static/icons.svg#icon-globe"></use>
-            </svg>
-        </a></span>)</span>
+<span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://tobyfox.bandcamp.com/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg></span>
+                    <span class="external-handle">tobyfox</span>
+                </a>
+                <span class="external-platform">Bandcamp</span>
+                <a class="external-link" href="https://toby.fox/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">toby.fox</span>
+                </a>
+                <span class="external-platform">Other</span></span></span></span> (Arrangement)</span>
 `
 
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showContribution 1`] = `
-<a href="artist/clark-powell/">Clark Powell</a>
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showAnnotation 1`] = `
+<span class="contribution nowrap"><a href="artist/clark-powell/">Clark Powell</a></span>
 <span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
 <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement)</span>
 `
 
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (inline) 1`] = `
-<span class="contribution nowrap"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons icons-inline"><a class="icon" href="https://soundcloud.com/plazmataz">
-            <svg>
-                <title>SoundCloud</title>
-                <use href="static/icons.svg#icon-soundcloud"></use>
-            </svg>
-        </a></span>)</span>
-<a href="artist/the-big-baddies/">Grounder &amp; Scratch</a>
-<span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (<span class="icons icons-inline"><a class="icon" href="https://tobyfox.bandcamp.com/">
-            <svg>
-                <title>Bandcamp</title>
-                <use href="static/icons.svg#icon-bandcamp"></use>
-            </svg>
-        </a>, <a class="icon" href="https://toby.fox/">
-            <svg>
-                <title>External (toby.fox)</title>
-                <use href="static/icons.svg#icon-globe"></use>
-            </svg>
-        </a></span>)</span>
-`
-
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (tooltip) 1`] = `
-<span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://soundcloud.com/plazmataz">
-                    <svg><use href="static/icons.svg#icon-soundcloud"></use></svg>
-                    <span class="icon-text">plazmataz</span>
-                </a></span></span></span></span>
-<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
-<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://tobyfox.bandcamp.com/">
-                    <svg><use href="static/icons.svg#icon-bandcamp"></use></svg>
-                    <span class="icon-text">tobyfox</span>
-                </a><a class="icon has-text" href="https://toby.fox/">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">toby.fox</span>
-                </a></span></span></span> (Arrangement)</span>
-`
-
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (inline) 1`] = `
-<span class="contribution nowrap"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons icons-inline"><a class="icon" href="https://soundcloud.com/plazmataz">
-            <svg>
-                <title>SoundCloud</title>
-                <use href="static/icons.svg#icon-soundcloud"></use>
-            </svg>
-        </a></span>)</span>
-<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
-<span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement) (<span class="icons icons-inline"><a class="icon" href="https://tobyfox.bandcamp.com/">
-            <svg>
-                <title>Bandcamp</title>
-                <use href="static/icons.svg#icon-bandcamp"></use>
-            </svg>
-        </a>, <a class="icon" href="https://toby.fox/">
-            <svg>
-                <title>External (toby.fox)</title>
-                <use href="static/icons.svg#icon-globe"></use>
-            </svg>
-        </a></span>)</span>
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showExternalLinks 1`] = `
+<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://soundcloud.com/plazmataz">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-soundcloud"></use></svg></span>
+                    <span class="external-handle">plazmataz</span>
+                </a>
+                <span class="external-platform">SoundCloud</span></span></span></span></span>
+<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a></span>
+<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://tobyfox.bandcamp.com/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg></span>
+                    <span class="external-handle">tobyfox</span>
+                </a>
+                <span class="external-platform">Bandcamp</span>
+                <a class="external-link" href="https://toby.fox/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">toby.fox</span>
+                </a>
+                <span class="external-platform">Other</span></span></span></span></span>
 `
 
-exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (tooltip) 1`] = `
-<span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://soundcloud.com/plazmataz">
-                    <svg><use href="static/icons.svg#icon-soundcloud"></use></svg>
-                    <span class="icon-text">plazmataz</span>
-                </a></span></span></span></span>
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showAnnotation & showExternalLinks 1`] = `
+<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://soundcloud.com/plazmataz">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-soundcloud"></use></svg></span>
+                    <span class="external-handle">plazmataz</span>
+                </a>
+                <span class="external-platform">SoundCloud</span></span></span></span></span>
 <span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
-<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://tobyfox.bandcamp.com/">
-                    <svg><use href="static/icons.svg#icon-bandcamp"></use></svg>
-                    <span class="icon-text">tobyfox</span>
-                </a><a class="icon has-text" href="https://toby.fox/">
-                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
-                    <span class="icon-text">toby.fox</span>
-                </a></span></span></span> (Arrangement)</span>
+<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip contribution-tooltip"><span class="tooltip-content"><a class="external-link" href="https://tobyfox.bandcamp.com/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-bandcamp"></use></svg></span>
+                    <span class="external-handle">tobyfox</span>
+                </a>
+                <span class="external-platform">Bandcamp</span>
+                <a class="external-link" href="https://toby.fox/">
+                    <span class="external-icon"><svg><use href="static/misc/icons.svg#icon-globe"></use></svg></span>
+                    <span class="external-handle">toby.fox</span>
+                </a>
+                <span class="external-platform">Other</span></span></span></span> (Arrangement)</span>
 `
diff --git a/tap-snapshots/test/snapshot/transformContent.js.test.cjs b/tap-snapshots/test/snapshot/transformContent.js.test.cjs
index 8d29d967..ac23a1bf 100644
--- a/tap-snapshots/test/snapshot/transformContent.js.test.cjs
+++ b/tap-snapshots/test/snapshot/transformContent.js.test.cjs
@@ -5,6 +5,11 @@
  * Make sure to inspect the output below.  Do not ignore changes!
  */
 'use strict'
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > absorb punctuation 1`] = `
+<p>Don&#39;t you think this is an <a class="external-link from-content indicate-external" href="https://github.com/hsmusic/hsmusic-wiki/pull/567" title="github.com (opens in new tab)" target="_blank">interesting pull request<span class="normal-content">,</span></a> Steve?</p>
+<p>Aren&#39;t you <a class="external-link from-content indicate-external" href="https://github.com/hsmusic/hsmusic-wiki/pull/567" title="github.com (opens in new tab)" target="_blank">interested<span class="normal-content">...</span></a> in <a class="external-link from-content indicate-external" href="https://github.com/hsmusic/hsmusic-wiki/pull/567" title="github.com (opens in new tab)" target="_blank">checking it out<span class="normal-content">?!!</span></a></p>
+`
+
 exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > basic markdown 1`] = `
 <p>Hello <em>world!</em> This is <strong>SO COOL.</strong></p>
 `
@@ -14,6 +19,11 @@ exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) >
 <p>Very nice: <time datetime="Fri, 25 Oct 2413 03:00:00 GMT">10/25/2413</time></p>
 `
 
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > emails 1`] = `
+<p>Email cute dogs to qznebula@protonmail.com please.</p>
+<p>Just kidding... <a class="external-link from-content indicate-external" href="mailto:qznebula@protonmail.com" title="External (opens in new tab)" target="_blank">unless?</a></p>
+`
+
 exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > escape end of tag 1`] = `
 <p>My favorite album is <a style="--primary-color: #123456" href="to-localized.album/cool-album">[Tactical Omission]</a>.</p>
 <p>Your favorite album is <a style="--primary-color: #123456" href="to-localized.album/cool-album">[Tactical Wha-Huh-Now</a>].</p>
diff --git a/test/lib/composite.js b/test/lib/composite.js
new file mode 100644
index 00000000..359d364d
--- /dev/null
+++ b/test/lib/composite.js
@@ -0,0 +1,33 @@
+import {compositeFrom} from '#composite';
+
+export function quickCheckCompositeOutputs(t, dependencies) {
+  return (step, outputDict) => {
+    t.same(
+      Object.keys(step.toDescription().outputs),
+      Object.keys(outputDict));
+
+    const composite = compositeFrom({
+      compose: false,
+      steps: [
+        step,
+
+        {
+          dependencies: Object.keys(outputDict),
+
+          // Access all dependencies by their expected keys -
+          // the composition runner actually provides a proxy
+          // and is checking that *we* access the dependencies
+          // we've specified.
+          compute: dependencies =>
+            Object.fromEntries(
+              Object.keys(outputDict)
+                .map(key => [key, dependencies[key]])),
+        },
+      ],
+    });
+
+    t.same(
+      composite.expose.compute(dependencies),
+      outputDict);
+  };
+}
diff --git a/test/lib/content-function.js b/test/lib/content-function.js
index 7bc62139..a46d18c9 100644
--- a/test/lib/content-function.js
+++ b/test/lib/content-function.js
@@ -17,8 +17,23 @@ import mock from './generic-mock.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
+function cleanURLSpec(reference) {
+  const prepared = structuredClone(reference);
+
+  for (const spec of Object.values(prepared)) {
+    if (spec.prefix) {
+      // Strip out STATIC_VERSION. This updates fairly regularly and we
+      // don't want it to affect snapshot tests.
+      spec.prefix = spec.prefix
+        .replace(/static-\d+[a-z]\d+/i, 'static');
+    }
+  }
+
+  return prepared;
+}
+
 export function testContentFunctions(t, message, fn) {
-  const urls = generateURLs(urlSpec);
+  const urls = generateURLs(cleanURLSpec(urlSpec));
 
   t.test(message, async t => {
     let loadedContentDependencies;
@@ -52,7 +67,6 @@ export function testContentFunctions(t, message, fn) {
             to,
             urls,
 
-            cachebust: 413,
             pagePath: ['home'],
             appendIndexHTML: false,
             getColors: c => getColors(c, {chroma}),
diff --git a/test/lib/index.js b/test/lib/index.js
index 5fb5bf78..4c9ee23f 100644
--- a/test/lib/index.js
+++ b/test/lib/index.js
@@ -1,5 +1,6 @@
 Error.stackTraceLimit = Infinity;
 
+export * from './composite.js';
 export * from './content-function.js';
 export * from './generic-mock.js';
 export * from './wiki-data.js';
diff --git a/test/lib/wiki-data.js b/test/lib/wiki-data.js
index d2d860ce..7c3d2147 100644
--- a/test/lib/wiki-data.js
+++ b/test/lib/wiki-data.js
@@ -1,6 +1,8 @@
 import CacheableObject from '#cacheable-object';
 import find from '#find';
 import {withEntries} from '#sugar';
+import Thing from '#thing';
+import thingConstructors from '#things';
 import {linkWikiDataArrays} from '#yaml';
 
 export function linkAndBindWikiData(wikiData, {
@@ -12,23 +14,6 @@ export function linkAndBindWikiData(wikiData, {
         ? withEntries(wikiData, entries => entries
             .map(([key, value]) => [key, value.slice()]))
         : wikiData));
-
-    // If albumData is present, automatically set albums' ownTrackData values
-    // by resolving track sections' references against the full array. This is
-    // just a nicety for working with albums throughout tests.
-    if (inferAlbumsOwnTrackData && wikiData.albumData && wikiData.trackData) {
-      for (const album of wikiData.albumData) {
-        const trackSections =
-          CacheableObject.getUpdateValue(album, 'trackSections');
-
-        const trackRefs =
-          trackSections.flatMap(section => section.tracks);
-
-        album.ownTrackData =
-          trackRefs.map(ref =>
-            find.track(ref, wikiData.trackData, {mode: 'error'}));
-      }
-    }
   }
 
   customLinkWikiDataArrays(wikiData);
@@ -70,3 +55,99 @@ export function linkAndBindWikiData(wikiData, {
         .bind(null, wikiData, {XXX_decacheWikiData: true}),
   };
 }
+
+export function stubWikiData() {
+  return {
+    albumData: [],
+    artistData: [],
+    artTagData: [],
+    flashData: [],
+    flashActData: [],
+    flashSideData: [],
+    groupData: [],
+    groupCategoryData: [],
+    newsData: [],
+    staticPageData: [],
+    trackData: [],
+    trackSectionData: [],
+  };
+}
+
+export function stubThing(wikiData, constructor, properties = {}) {
+  const thing = Reflect.construct(constructor, []);
+  Object.assign(thing, properties);
+
+  const wikiDataSpec = {
+    Album: 'albumData',
+    Artist: 'artistData',
+    ArtTag: 'artTagData',
+    Flash: 'flashData',
+    FlashAct: 'flashActData',
+    FlashSide: 'flashSideData',
+    Group: 'groupData',
+    GroupCategory: 'groupCategoryData',
+    NewsEntry: 'newsData',
+    StaticPage: 'staticPageData',
+    Track: 'trackData',
+    TrackSection: 'trackSectionData',
+  };
+
+  const wikiDataMap =
+    new Map(
+      Object.entries(wikiDataSpec)
+        .map(([thingKey, wikiDataKey]) => [
+          thingConstructors[thingKey],
+          wikiData[wikiDataKey],
+        ]));
+
+  const wikiDataArray =
+    wikiDataMap.get(constructor);
+
+  wikiDataArray.push(thing);
+
+  return thing;
+}
+
+export function stubTrackAndAlbum(wikiData, trackDirectory = null, albumDirectory = null) {
+  const {Track, TrackSection, Album} = thingConstructors;
+
+  const track =
+    stubThing(wikiData, Track, {directory: trackDirectory});
+
+  const section =
+    stubThing(wikiData, TrackSection, {tracks: [track]});
+
+  const album =
+    stubThing(wikiData, Album, {directory: albumDirectory, trackSections: [section]});
+
+  return {track, album, section};
+}
+
+export function stubArtistAndContribs(wikiData, artistName = `Test Artist`) {
+  const {Artist} = thingConstructors;
+
+  const artist =
+    stubThing(wikiData, Artist, {name: artistName});
+
+  const contribs =
+    [{artist: artistName, annotation: null}];
+
+  const badContribs =
+    [{artist: `Figment of Your Imagination`, annotation: null}];
+
+  return {artist, contribs, badContribs};
+}
+
+export function stubFlashAndAct(wikiData, flashDirectory = null) {
+  const {Flash, FlashAct} = thingConstructors;
+
+  const flash =
+    stubThing(wikiData, Flash, {directory: flashDirectory});
+
+  const flashAct =
+    stubThing(wikiData, FlashAct, {
+      flashes: [Thing.getReference(flash)],
+    });
+
+  return {flash, flashAct};
+}
diff --git a/test/snapshot/generateAdditionalFilesShortcut.js b/test/snapshot/generateAdditionalFilesShortcut.js
deleted file mode 100644
index 9825efa1..00000000
--- a/test/snapshot/generateAdditionalFilesShortcut.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import t from 'tap';
-import {testContentFunctions} from '#test-lib';
-
-testContentFunctions(t, 'generateAdditionalFilesShortcut (snapshot)', async (t, evaluate) => {
-  await evaluate.load();
-
-  evaluate.snapshot('no additional files', {
-    name: 'generateAdditionalFilesShortcut',
-    args: [[]],
-  });
-
-  evaluate.snapshot('basic behavior', {
-    name: 'generateAdditionalFilesShortcut',
-    args: [
-      [
-        {
-          title: 'SBURB Wallpaper',
-          files: [
-            'sburbwp_1280x1024.jpg',
-            'sburbwp_1440x900.jpg',
-            'sburbwp_1920x1080.jpg',
-          ],
-        },
-        {
-          title: 'Alternate Covers',
-          description: 'This is just an example description.',
-          files: [
-            'Homestuck_Vol4_alt1.jpg',
-            'Homestuck_Vol4_alt2.jpg',
-            'Homestuck_Vol4_alt3.jpg',
-          ],
-        },
-      ],
-    ],
-  });
-});
diff --git a/test/snapshot/generateAlbumCoverArtwork.js b/test/snapshot/generateAlbumCoverArtwork.js
deleted file mode 100644
index 9244c034..00000000
--- a/test/snapshot/generateAlbumCoverArtwork.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import t from 'tap';
-
-import contentFunction from '#content-function';
-import {testContentFunctions} from '#test-lib';
-
-testContentFunctions(t, 'generateAlbumCoverArtwork (snapshot)', async (t, evaluate) => {
-  await evaluate.load({
-    mock: {
-      image: evaluate.stubContentFunction('image'),
-    },
-  });
-
-  const album = {
-    directory: 'bee-forus-seatbelt-safebee',
-    coverArtFileExtension: 'png',
-    color: '#f28514',
-    artTags: [
-      {name: 'Damara', directory: 'damara', isContentWarning: false},
-      {name: 'Cronus', directory: 'cronus', isContentWarning: false},
-      {name: 'Bees', directory: 'bees', isContentWarning: false},
-      {name: 'creepy crawlies', isContentWarning: true},
-    ],
-  };
-
-  evaluate.snapshot('display: primary', {
-    name: 'generateAlbumCoverArtwork',
-    args: [album],
-    slots: {mode: 'primary'},
-  });
-
-  evaluate.snapshot('display: thumbnail', {
-    name: 'generateAlbumCoverArtwork',
-    args: [album],
-    slots: {mode: 'thumbnail'},
-  });
-});
diff --git a/test/snapshot/generateAlbumReleaseInfo.js b/test/snapshot/generateAlbumReleaseInfo.js
index 3dea1196..f41e502d 100644
--- a/test/snapshot/generateAlbumReleaseInfo.js
+++ b/test/snapshot/generateAlbumReleaseInfo.js
@@ -8,22 +8,22 @@ testContentFunctions(t, 'generateAlbumReleaseInfo (snapshot)', async (t, evaluat
     name: 'generateAlbumReleaseInfo',
     args: [{
       artistContribs: [
-        {who: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, what: 'music probably'},
-        {who: {name: 'Tensei', directory: 'tensei', urls: ['https://tenseimusic.bandcamp.com/']}, what: 'hot jams'},
+        {artist: {name: 'Toby Fox', directory: 'toby-fox', urls: []}, annotation: 'music probably'},
+        {artist: {name: 'Tensei', directory: 'tensei', urls: ['https://tenseimusic.bandcamp.com/']}, annotation: 'hot jams'},
       ],
 
       coverArtistContribs: [
-        {who: {name: 'Hanni Brosh', directory: 'hb', urls: null}, what: null},
+        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: []}, annotation: null},
       ],
 
       wallpaperArtistContribs: [
-        {who: {name: 'Hanni Brosh', directory: 'hb', urls: null}, what: null},
-        {who: {name: 'Niklink', directory: 'niklink', urls: null}, what: 'edits'},
+        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: []}, annotation: null},
+        {artist: {name: 'Niklink', directory: 'niklink', urls: []}, annotation: 'edits'},
       ],
 
       bannerArtistContribs: [
-        {who: {name: 'Hanni Brosh', directory: 'hb', urls: null}, what: null},
-        {who: {name: 'Niklink', directory: 'niklink', urls: null}, what: 'edits'},
+        {artist: {name: 'Hanni Brosh', directory: 'hb', urls: []}, annotation: null},
+        {artist: {name: 'Niklink', directory: 'niklink', urls: []}, annotation: 'edits'},
       ],
 
       name: 'AlterniaBound',
diff --git a/test/snapshot/generateAlbumSecondaryNav.js b/test/snapshot/generateAlbumSecondaryNav.js
index 709b062e..2495bc4a 100644
--- a/test/snapshot/generateAlbumSecondaryNav.js
+++ b/test/snapshot/generateAlbumSecondaryNav.js
@@ -4,25 +4,60 @@ import {testContentFunctions} from '#test-lib';
 testContentFunctions(t, 'generateAlbumSecondaryNav (snapshot)', async (t, evaluate) => {
   await evaluate.load();
 
-  let album, group1, group2;
+  let album, anotherAlbum, group1, group2;
 
-  group1 = {name: 'VCG', directory: 'vcg', color: '#abcdef'};
-  group2 = {name: 'Bepis', directory: 'bepis', color: '#123456'};
+  group1 = {
+    name: 'VCG',
+    directory: 'vcg',
+    color: '#abcdef',
+    serieses: [],
+  };
+
+  group2 = {
+    name: 'Bepis',
+    directory: 'bepis',
+    color: '#123456',
+    serieses: [],
+  };
 
   album = {
+    name: 'Album',
+    directory: 'album',
     date: new Date('2010-04-13'),
     groups: [group1, group2],
   };
 
+  anotherAlbum = {
+    name: 'Last',
+    directory: 'last',
+    date: new Date('2010-06-12'),
+  };
+
   group1.albums = [
-    {name: 'First', directory: 'first', date: new Date('2010-04-10')},
+    {
+      name: 'First',
+      directory: 'first',
+      date: new Date('2010-04-10'),
+    },
     album,
-    {name: 'Last', directory: 'last', date: new Date('2010-06-12')},
+    anotherAlbum,
+  ];
+
+  group1.serieses = [
+    {
+      name: 'Series',
+      albums: [album, anotherAlbum],
+      group: group1,
+    },
   ];
 
   group2.albums = [
     album,
-    {name: 'Second', directory: 'second', date: new Date('2011-04-13')},
+    {
+      name: 'Second',
+      directory: 'second',
+      date: new Date('2011-04-13'),
+    },
   ];
 
   evaluate.snapshot('basic behavior, mode: album', {
diff --git a/test/snapshot/generateAlbumTrackList.js b/test/snapshot/generateAlbumTrackList.js
index 181cc1d2..e09f1c4a 100644
--- a/test/snapshot/generateAlbumTrackList.js
+++ b/test/snapshot/generateAlbumTrackList.js
@@ -6,16 +6,20 @@ testContentFunctions(t, 'generateAlbumTrackList (snapshot)', async (t, evaluate)
     mock: {
       generateAlbumTrackListMissingDuration:
         evaluate.stubContentFunction('generateAlbumTrackListMissingDuration'),
+
+      image:
+        evaluate.stubContentFunction('image'),
     },
   });
 
   const contribs1 = [
-    {who: {name: 'Apricot', directory: 'apricot', urls: null}},
+    {artist: {name: 'Apricot', directory: 'apricot', urls: []}},
   ];
 
   const contribs2 = [
-    {who: {name: 'Apricot', directory: 'apricot', urls: null}},
-    {who: {name: 'Peach', directory: 'peach', urls: ['https://peach.bandcamp.com/']}},
+    {artist: {name: 'Apricot', directory: 'apricot', urls: []}},
+    {artist: {name: 'Peach', directory: 'peach', urls: ['https://peach.bandcamp.com/']}},
+    {artist: {name: 'Cerise', directory: 'cerise', urls: []}},
   ];
 
   const color1 = '#fb07ff';
@@ -45,6 +49,16 @@ testContentFunctions(t, 'generateAlbumTrackList (snapshot)', async (t, evaluate)
     tracks,
   };
 
+  const albumWithTrackSectionDescriptions = {
+    color: color1,
+    artistContribs: contribs1,
+    trackSections: [
+      {name: 'First section', tracks: tracks.slice(0, 3), description: `Why yes!`},
+      {name: 'Second section', tracks: tracks.slice(3), description: `How *contentful,* this is.`},
+    ],
+    tracks,
+  };
+
   const albumWithNoDuration = {
     color: color1,
     artistContribs: contribs1,
@@ -62,6 +76,11 @@ testContentFunctions(t, 'generateAlbumTrackList (snapshot)', async (t, evaluate)
     args: [albumWithoutTrackSections],
   });
 
+  evaluate.snapshot(`basic behavior, with descriptions`, {
+    name: 'generateAlbumTrackList',
+    args: [albumWithTrackSectionDescriptions],
+  });
+
   evaluate.snapshot(`collapseDurationScope: never`, {
     name: 'generateAlbumTrackList',
     slots: {collapseDurationScope: 'never'},
diff --git a/test/snapshot/generateCoverArtwork.js b/test/snapshot/generateCoverArtwork.js
deleted file mode 100644
index e35dd8d0..00000000
--- a/test/snapshot/generateCoverArtwork.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import t from 'tap';
-import {testContentFunctions} from '#test-lib';
-
-testContentFunctions(t, 'generateCoverArtwork (snapshot)', async (t, evaluate) => {
-  await evaluate.load({
-    mock: {
-      image: evaluate.stubContentFunction('image', {mock: true}),
-    },
-  });
-
-  const artTags = [
-    {name: 'Damara', directory: 'damara', isContentWarning: false},
-    {name: 'Cronus', directory: 'cronus', isContentWarning: false},
-    {name: 'Bees', directory: 'bees', isContentWarning: false},
-    {name: 'creepy crawlies', isContentWarning: true},
-  ];
-
-  const path = ['media.albumCover', 'bee-forus-seatbelt-safebee', 'png'];
-
-  evaluate.snapshot('display: primary', {
-    name: 'generateCoverArtwork',
-    args: [artTags],
-    slots: {path, mode: 'primary'},
-  });
-
-  evaluate.snapshot('display: thumbnail', {
-    name: 'generateCoverArtwork',
-    args: [artTags],
-    slots: {path, mode: 'thumbnail'},
-  });
-});
diff --git a/test/snapshot/generatePreviousNextLinks.js b/test/snapshot/generatePreviousNextLinks.js
deleted file mode 100644
index 0d952f59..00000000
--- a/test/snapshot/generatePreviousNextLinks.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import t from 'tap';
-import * as html from '#html';
-import {testContentFunctions} from '#test-lib';
-
-testContentFunctions(t, 'generatePreviousNextLinks (snapshot)', async (t, evaluate) => {
-  await evaluate.load();
-
-  const quickSnapshot = (message, slots) =>
-    evaluate.snapshot(message, {
-      name: 'generatePreviousNextLinks',
-      slots,
-      postprocess: template => template.content.join('\n'),
-    });
-
-  quickSnapshot('basic behavior', {
-    previousLink: evaluate.stubTemplate('previous'),
-    nextLink: evaluate.stubTemplate('next'),
-  });
-
-  quickSnapshot('previous missing', {
-    nextLink: evaluate.stubTemplate('next'),
-  });
-
-  quickSnapshot('next missing', {
-    previousLink: evaluate.stubTemplate('previous'),
-  });
-
-  quickSnapshot('neither link present', {});
-
-  quickSnapshot('disable id', {
-    previousLink: evaluate.stubTemplate('previous'),
-    nextLink: evaluate.stubTemplate('next'),
-    id: false,
-  });
-});
diff --git a/test/snapshot/generateTrackAdditionalNamesBox.js b/test/snapshot/generateTrackAdditionalNamesBox.js
deleted file mode 100644
index 9c1e3598..00000000
--- a/test/snapshot/generateTrackAdditionalNamesBox.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import t from 'tap';
-
-import contentFunction from '#content-function';
-import {testContentFunctions} from '#test-lib';
-
-testContentFunctions(t, 'generateTrackAdditionalNamesBox (snapshot)', async (t, evaluate) => {
-  await evaluate.load({
-    mock: {
-      generateAdditionalNamesBox:
-        evaluate.stubContentFunction('generateAdditionalNamesBox'),
-    },
-  });
-
-  const stubTrack = {
-    additionalNames: [],
-    sharedAdditionalNames: [],
-    inferredAdditionalNames: [],
-  };
-
-  const quickSnapshot = (message, trackProperties) =>
-    evaluate.snapshot(message, {
-      name: 'generateTrackAdditionalNamesBox',
-      args: [{...stubTrack, ...trackProperties}],
-    });
-
-  quickSnapshot(`no additional names`, {});
-
-  quickSnapshot(`own additional names only`, {
-    additionalNames: [
-      {name: `Foo Bar`, annotation: `the Alps`},
-    ],
-  });
-
-  quickSnapshot(`shared additional names only`, {
-    sharedAdditionalNames: [
-      {name: `Bar Foo`, annotation: `the Rockies`},
-    ],
-  });
-
-  quickSnapshot(`inferred additional names only`, {
-    inferredAdditionalNames: [
-      {name: `Baz Baz`, from: [{directory: `the-pyrenees`}]},
-    ],
-  });
-
-  quickSnapshot(`multiple own`, {
-    additionalNames: [
-      {name: `Apple Time!`},
-      {name: `Pterodactyl Time!`},
-      {name: `Banana Time!`},
-    ],
-  });
-
-  quickSnapshot(`own and shared, some overlap`, {
-    additionalNames: [
-      {name: `weed dreams..`, annotation: `own annotation`},
-      {name: `夜間のMOON汗`, annotation: `own annotation`},
-    ],
-    sharedAdditionalNames: [
-      {name: `weed dreams..`, annotation: `shared annotation`},
-      {name: `GAMINGブラザー96`, annotation: `shared annotation`},
-    ],
-  });
-
-  quickSnapshot(`shared and inferred, some overlap`, {
-    sharedAdditionalNames: [
-      {name: `Coruscate`, annotation: `shared annotation`},
-      {name: `Arbroath`, annotation: `shared annotation`},
-    ],
-    inferredAdditionalNames: [
-      {name: `Arbroath`, from: [{directory: `inferred-from`}]},
-      {name: `Prana Ferox`, from: [{directory: `inferred-from`}]},
-    ],
-  });
-
-  quickSnapshot(`own and inferred, some overlap`, {
-    additionalNames: [
-      {name: `Ke$halo Strike Back`, annotation: `own annotation`},
-      {name: `Ironic Mania`, annotation: `own annotation`},
-    ],
-    inferredAdditionalNames: [
-      {name: `Ironic Mania`, from: [{directory: `inferred-from`}]},
-      {name: `ANARCHY::MEGASTRIFE`, from: [{directory: `inferred-from`}]},
-    ],
-  });
-
-  quickSnapshot(`own and shared and inferred, various overlap`, {
-    additionalNames: [
-      {name: `Own!`, annotation: `own annotation`},
-      {name: `Own! Shared!`, annotation: `own annotation`},
-      {name: `Own! Inferred!`, annotation: `own annotation`},
-      {name: `Own! Shared! Inferred!`, annotation: `own annotation`},
-    ],
-    sharedAdditionalNames: [
-      {name: `Shared!`, annotation: `shared annotation`},
-      {name: `Own! Shared!`, annotation: `shared annotation`},
-      {name: `Shared! Inferred!`, annotation: `shared annotation`},
-      {name: `Own! Shared! Inferred!`, annotation: `shared annotation`},
-    ],
-    inferredAdditionalNames: [
-      {name: `Inferred!`, from: [{directory: `inferred-from`}]},
-      {name: `Own! Inferred!`, from: [{directory: `inferred-from`}]},
-      {name: `Shared! Inferred!`, from: [{directory: `inferred-from`}]},
-      {name: `Own! Shared! Inferred!`, from: [{directory: `inferred-from`}]},
-    ],
-  });
-});
diff --git a/test/snapshot/generateTrackCoverArtwork.js b/test/snapshot/generateTrackCoverArtwork.js
deleted file mode 100644
index 1e651eb1..00000000
--- a/test/snapshot/generateTrackCoverArtwork.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import t from 'tap';
-import {testContentFunctions} from '#test-lib';
-
-testContentFunctions(t, 'generateTrackCoverArtwork (snapshot)', async (t, evaluate) => {
-  await evaluate.load({
-    mock: {
-      image: evaluate.stubContentFunction('image'),
-    },
-  });
-
-  const album = {
-    directory: 'bee-forus-seatbelt-safebee',
-    coverArtFileExtension: 'png',
-    artTags: [
-      {name: 'Damara', directory: 'damara', isContentWarning: false},
-      {name: 'Cronus', directory: 'cronus', isContentWarning: false},
-      {name: 'Bees', directory: 'bees', isContentWarning: false},
-      {name: 'creepy crawlies', isContentWarning: true},
-    ],
-  };
-
-  const track1 = {
-    directory: 'beesmp3',
-    hasUniqueCoverArt: true,
-    coverArtFileExtension: 'jpg',
-    color: '#f28514',
-    artTags: [{name: 'Bees', directory: 'bees', isContentWarning: false}],
-    album,
-  };
-
-  const track2 = {
-    directory: 'fake-bonus-track',
-    hasUniqueCoverArt: false,
-    color: '#abcdef',
-    album,
-  };
-
-  evaluate.snapshot('display: primary - unique art', {
-    name: 'generateTrackCoverArtwork',
-    args: [track1],
-    slots: {mode: 'primary'},
-  });
-
-  evaluate.snapshot('display: thumbnail - unique art', {
-    name: 'generateTrackCoverArtwork',
-    args: [track1],
-    slots: {mode: 'thumbnail'},
-  });
-
-  evaluate.snapshot('display: primary - no unique art', {
-    name: 'generateTrackCoverArtwork',
-    args: [track2],
-    slots: {mode: 'primary'},
-  });
-
-  evaluate.snapshot('display: thumbnail - no unique art', {
-    name: 'generateTrackCoverArtwork',
-    args: [track2],
-    slots: {mode: 'thumbnail'},
-  });
-});
diff --git a/test/snapshot/generateTrackReleaseInfo.js b/test/snapshot/generateTrackReleaseInfo.js
index c72344b9..931377c8 100644
--- a/test/snapshot/generateTrackReleaseInfo.js
+++ b/test/snapshot/generateTrackReleaseInfo.js
@@ -4,8 +4,8 @@ import {testContentFunctions} from '#test-lib';
 testContentFunctions(t, 'generateTrackReleaseInfo (snapshot)', async (t, evaluate) => {
   await evaluate.load();
 
-  const artistContribs = [{who: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, what: null}];
-  const coverArtistContribs = [{who: {name: 'Alpaca', directory: 'alpaca', urls: null}, what: '🔥'}];
+  const artistContribs = [{artist: {name: 'Toby Fox', directory: 'toby-fox', urls: []}, annotation: null}];
+  const coverArtistContribs = [{artist: {name: 'Alpaca', directory: 'alpaca', urls: []}, annotation: '🔥'}];
 
   evaluate.snapshot('basic behavior', {
     name: 'generateTrackReleaseInfo',
diff --git a/test/snapshot/image.js b/test/snapshot/image.js
index 447e7fa7..1985211f 100644
--- a/test/snapshot/image.js
+++ b/test/snapshot/image.js
@@ -38,11 +38,10 @@ testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => {
     },
   });
 
-  quickSnapshot('width & height', {
+  quickSnapshot('dimensions', {
     slots: {
       src: 'foobar',
-      width: 600,
-      height: 400,
+      dimensions: [600, 400],
     },
   });
 
@@ -53,6 +52,14 @@ testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => {
     },
   });
 
+  quickSnapshot('dimensions with square', {
+    slots: {
+      src: 'foobar',
+      dimensions: [600, 400],
+      square: true,
+    },
+  });
+
   quickSnapshot('lazy with square', {
     slots: {
       src: 'foobar',
diff --git a/test/snapshot/linkContribution.js b/test/snapshot/linkContribution.js
index ebd3be58..47ef1503 100644
--- a/test/snapshot/linkContribution.js
+++ b/test/snapshot/linkContribution.js
@@ -9,80 +9,49 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
       name: 'linkContribution',
       multiple: [
         {args: [
-          {who: {
+          {artist: {
             name: 'Clark Powell',
             directory: 'clark-powell',
             urls: ['https://soundcloud.com/plazmataz'],
-          }, what: null},
+          }, annotation: null},
         ]},
         {args: [
-          {who: {
+          {artist: {
             name: 'Grounder & Scratch',
             directory: 'the-big-baddies',
             urls: [],
-          }, what: 'Snooping'},
+          }, annotation: 'Snooping'},
         ]},
         {args: [
-          {who: {
+          {artist: {
             name: 'Toby Fox',
             directory: 'toby-fox',
             urls: ['https://tobyfox.bandcamp.com/', 'https://toby.fox/'],
-          }, what: 'Arrangement'},
+          }, annotation: 'Arrangement'},
         ]},
       ],
       slots,
     });
 
-  quickSnapshot('showContribution & showIcons (inline)', {
-    showContribution: true,
-    showIcons: true,
-    iconMode: 'inline',
+  quickSnapshot('showAnnotation & showExternalLinks', {
+    showAnnotation: true,
+    showExternalLinks: true,
   });
 
-  quickSnapshot('showContribution & showIcons (tooltip)', {
-    showContribution: true,
-    showIcons: true,
-    iconMode: 'tooltip',
+  quickSnapshot('only showAnnotation', {
+    showAnnotation: true,
   });
 
-  quickSnapshot('only showContribution', {
-    showContribution: true,
-  });
-
-  quickSnapshot('only showIcons (inline)', {
-    showIcons: true,
-    iconMode: 'inline',
-  });
-
-  quickSnapshot('only showIcons (tooltip)', {
-    showContribution: true,
-    showIcons: true,
-    iconMode: 'tooltip',
+  quickSnapshot('only showExternalLinks', {
+    showExternalLinks: true,
   });
 
   quickSnapshot('no accents', {});
 
-  evaluate.snapshot('loads of links (inline)', {
-    name: 'linkContribution',
-    args: [
-      {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
-        'https://loremipsum.io',
-        'https://loremipsum.io/generator/',
-        'https://loremipsum.io/#meaning',
-        'https://loremipsum.io/#usage-and-examples',
-        'https://loremipsum.io/#controversy',
-        'https://loremipsum.io/#when-to-use-lorem-ipsum',
-        'https://loremipsum.io/#lorem-ipsum-all-the-things',
-        'https://loremipsum.io/#original-source',
-      ]}, what: null},
-    ],
-    slots: {showIcons: true},
-  });
-
-  evaluate.snapshot('loads of links (tooltip)', {
+  evaluate.snapshot('loads of links', {
     name: 'linkContribution',
     args: [
-      {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
+      {artist: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
         'https://loremipsum.io',
         'https://loremipsum.io/generator/',
         'https://loremipsum.io/#meaning',
@@ -91,14 +60,14 @@ testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
         'https://loremipsum.io/#when-to-use-lorem-ipsum',
         'https://loremipsum.io/#lorem-ipsum-all-the-things',
         'https://loremipsum.io/#original-source',
-      ]}, what: null},
+      ]}, annotation: null},
     ],
-    slots: {showIcons: true, iconMode: 'tooltip'},
+    slots: {showExternalLinks: true},
   });
 
   quickSnapshot('no preventWrapping', {
-    showContribution: true,
-    showIcons: true,
+    showAnnotation: true,
+    showExternalLinks: true,
     preventWrapping: false,
   });
 });
diff --git a/test/snapshot/linkThing.js b/test/snapshot/linkThing.js
index 502db6d7..9b5cff33 100644
--- a/test/snapshot/linkThing.js
+++ b/test/snapshot/linkThing.js
@@ -20,7 +20,7 @@ testContentFunctions(t, 'linkThing (snapshot)', async (t, evaluate) => {
   });
 
   quickSnapshot('preferShortName', {
-    args: ['localized.tag', {
+    args: ['localized.artTagGallery', {
       directory: 'five-oceanfalls',
       name: 'Five (Oceanfalls)',
       nameShort: 'Five',
diff --git a/test/snapshot/transformContent.js b/test/snapshot/transformContent.js
index 87e337e4..63391681 100644
--- a/test/snapshot/transformContent.js
+++ b/test/snapshot/transformContent.js
@@ -156,6 +156,16 @@ testContentFunctions(t, 'transformContent (snapshot)', async (t, evaluate) => {
       `[[date:13 April 2004]], and don't ye forget it`,
       {mode: 'lyrics'});
 
+  quickSnapshot(
+    'emails',
+      `Email cute dogs to qznebula@protonmail.com please.\n` +
+      `Just kidding... [unless?](mailto:qznebula@protonmail.com)`);
+
+  quickSnapshot(
+    `absorb punctuation`,
+      `Don't you think this is an [interesting pull request](https://github.com/hsmusic/hsmusic-wiki/pull/567), Steve?\n` +
+      `Aren't you [interested](https://github.com/hsmusic/hsmusic-wiki/pull/567)... in [checking it out](https://github.com/hsmusic/hsmusic-wiki/pull/567)?!!`);
+
   // TODO: Snapshots for mode: inline
   // TODO: Snapshots for mode: single-link
 });
diff --git a/test/unit/content/dependencies/generateAlbumTrackList.js b/test/unit/content/dependencies/generateAlbumTrackList.js
index 7b3ecd33..988f8505 100644
--- a/test/unit/content/dependencies/generateAlbumTrackList.js
+++ b/test/unit/content/dependencies/generateAlbumTrackList.js
@@ -10,6 +10,9 @@ testContentFunctions(t, 'generateAlbumTrackList (unit)', async (t, evaluate) =>
         generate: (name, {html}) =>
           html.tag('li', `Item: ${name}`),
       },
+
+      image:
+        evaluate.stubContentFunction('image'),
     },
   });
 
diff --git a/test/unit/content/dependencies/linkContribution.js b/test/unit/content/dependencies/linkContribution.js
index 94908901..3ffd71d2 100644
--- a/test/unit/content/dependencies/linkContribution.js
+++ b/test/unit/content/dependencies/linkContribution.js
@@ -2,46 +2,48 @@ import t from 'tap';
 import {testContentFunctions} from '#test-lib';
 
 t.test('generateContributionLinks (unit)', async t => {
-  const who1 = {
+  const artist1 = {
     name: 'Clark Powell',
     directory: 'clark-powell',
     urls: ['https://soundcloud.com/plazmataz'],
   };
 
-  const who2 = {
+  const artist2 = {
     name: 'Grounder & Scratch',
     directory: 'the-big-baddies',
     urls: [],
   };
 
-  const who3 = {
+  const artist3 = {
     name: 'Toby Fox',
     directory: 'toby-fox',
     urls: ['https://tobyfox.bandcamp.com/', 'https://toby.fox/'],
   };
 
-  const what1 = null;
-  const what2 = 'Snooping';
-  const what3 = 'Arrangement';
+  const annotation1 = null;
+  const annotation2 = 'Snooping';
+  const annotation3 = 'Arrangement';
 
   await testContentFunctions(t, 'generateContributionLinks (unit 1)', async (t, evaluate) => {
     const slots = {
-      showContribution: true,
-      showIcons: true,
+      showAnnotation: true,
+      showExternalLinks: true,
     };
 
     await evaluate.load({
       mock: evaluate.mock(mock => ({
         linkArtist: {
-          relations: mock.function('linkArtist.relations', () => ({}))
-            .args([undefined, who1]).next()
-            .args([undefined, who2]).next()
-            .args([undefined, who3]),
-
-          data: mock.function('linkArtist.data', () => ({}))
-            .args([who1]).next()
-            .args([who2]).next()
-            .args([who3]),
+          relations: mock
+            .function('linkArtist.relations', () => ({}))
+            .args([undefined, artist1]).next()
+            .args([undefined, artist2]).next()
+            .args([undefined, artist3]),
+
+          data: mock
+            .function('linkArtist.data', () => ({}))
+            .args([artist1]).next()
+            .args([artist2]).next()
+            .args([artist3]),
 
           // This can be tweaked to return a specific (mocked) template
           // for each artist if we need to test for slots in the future.
@@ -49,13 +51,18 @@ t.test('generateContributionLinks (unit)', async t => {
             .repeat(3),
         },
 
-        linkExternalAsIcon: {
-          data: mock.function('linkExternalAsIcon.data', () => ({}))
-            .args([who1.urls[0]]).next()
-            .args([who3.urls[0]]).next()
-            .args([who3.urls[1]]),
-
-          generate: mock.function('linkExternalAsIcon.generate', () => 'icon')
+        generateExternalIcon: {
+          data: mock
+            .function('generateExternalIcon.data', () => ({}))
+            .args([artist1.urls[0]]).next()
+            .args([artist3.urls[0]]).next()
+            .args([artist3.urls[1]]),
+
+          generate: mock
+            .function('generateExternalIcon.generate', () => ({
+              toString: () => 'icon',
+              setSlot: () => {},
+            }))
             .repeat(3),
         }
       })),
@@ -64,9 +71,9 @@ t.test('generateContributionLinks (unit)', async t => {
     evaluate({
       name: 'linkContribution',
       multiple: [
-        {args: [{who: who1, what: what1}]},
-        {args: [{who: who2, what: what2}]},
-        {args: [{who: who3, what: what3}]},
+        {args: [{artist: artist1, annotation: annotation1}]},
+        {args: [{artist: artist2, annotation: annotation2}]},
+        {args: [{artist: artist3, annotation: annotation3}]},
       ],
       slots,
     });
@@ -74,24 +81,27 @@ t.test('generateContributionLinks (unit)', async t => {
 
   await testContentFunctions(t, 'generateContributionLinks (unit 2)', async (t, evaluate) => {
     const slots = {
-      showContribution: false,
-      showIcons: false,
+      showAnnotation: false,
+      showExternalLinks: false,
     };
 
     await evaluate.load({
       mock: evaluate.mock(mock => ({
         linkArtist: {
-          relations: mock.function('linkArtist.relations', () => ({}))
-            .args([undefined, who1]).next()
-            .args([undefined, who2]).next()
-            .args([undefined, who3]),
-
-          data: mock.function('linkArtist.data', () => ({}))
-            .args([who1]).next()
-            .args([who2]).next()
-            .args([who3]),
-
-          generate: mock.function(() => 'artist link')
+          relations: mock
+            .function('linkArtist.relations', () => ({}))
+            .args([undefined, artist1]).next()
+            .args([undefined, artist2]).next()
+            .args([undefined, artist3]),
+
+          data: mock
+            .function('linkArtist.data', () => ({}))
+            .args([artist1]).next()
+            .args([artist2]).next()
+            .args([artist3]),
+
+          generate: mock
+            .function(() => 'artist link')
             .repeat(3),
         },
 
@@ -99,11 +109,16 @@ t.test('generateContributionLinks (unit)', async t => {
         // tree is the same since whether or not the external icon links are
         // shown is dependent on a slot, which is undefined and arbitrary at
         // relations/data time (it might change on a whim at generate time).
-        linkExternalAsIcon: {
-          data: mock.function('linkExternalAsIcon.data', () => ({}))
+        generateExternalIcon: {
+          data: mock
+            .function('generateExternalIcon.data', () => ({}))
             .repeat(3),
 
-          generate: mock.function('linkExternalAsIcon.generate', () => 'icon')
+          generate: mock
+            .function('generateExternalIcon.generate', () => ({
+              toString: () => 'icon',
+              setSlot: () => {},
+            }))
             .repeat(3),
         },
       })),
@@ -112,9 +127,9 @@ t.test('generateContributionLinks (unit)', async t => {
     evaluate({
       name: 'linkContribution',
       multiple: [
-        {args: [{who: who1, what: what1}]},
-        {args: [{who: who2, what: what2}]},
-        {args: [{who: who3, what: what3}]},
+        {args: [{artist: artist1, annotation: annotation1}]},
+        {args: [{artist: artist2, annotation: annotation2}]},
+        {args: [{artist: artist3, annotation: annotation3}]},
       ],
       slots,
     });
diff --git a/test/unit/data/cacheable-object.js b/test/unit/data/cacheable-object.js
index 8c31a5bc..4b927248 100644
--- a/test/unit/data/cacheable-object.js
+++ b/test/unit/data/cacheable-object.js
@@ -4,7 +4,7 @@ import CacheableObject from '#cacheable-object';
 
 function newCacheableObject(PD) {
   return new (class extends CacheableObject {
-    static propertyDescriptors = PD;
+    static [CacheableObject.propertyDescriptors] = PD;
   });
 }
 
diff --git a/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js b/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js
index 2bcabb4f..9d588e4c 100644
--- a/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js
+++ b/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js
@@ -177,10 +177,11 @@ t.test(`withResultOfAvailabilityCheck: validate dynamic inputs`, t => {
       mode: 'banana',
     }),
     {message: `Error computing composition`, cause:
-      {message: `Error computing composition withResultOfAvailabilityCheck`, cause:
-        {message: `Errors in input values provided to withResultOfAvailabilityCheck`, errors: [
-          {message: `mode: Expected one of null empty falsy index, got banana`},
-        ]}}});
+      {message: `Error in step 1 of 2, withResultOfAvailabilityCheck`, cause:
+        {message: `Error computing composition withResultOfAvailabilityCheck`, cause:
+          {message: `Errors in input values provided to withResultOfAvailabilityCheck`, errors: [
+            {message: `mode: Expected one of null empty falsy index, got banana`},
+          ]}}}});
 
   t.throws(
     () => composite.expose.compute({
@@ -188,8 +189,9 @@ t.test(`withResultOfAvailabilityCheck: validate dynamic inputs`, t => {
       mode: null,
     }),
     {message: `Error computing composition`, cause:
-      {message: `Error computing composition withResultOfAvailabilityCheck`, cause:
-        {message: `Errors in input values provided to withResultOfAvailabilityCheck`, errors: [
-          {message: `mode: Expected a value, got null`},
-        ]}}});
+      {message: `Error in step 1 of 2, withResultOfAvailabilityCheck`, cause:
+        {message: `Error computing composition withResultOfAvailabilityCheck`, cause:
+          {message: `Errors in input values provided to withResultOfAvailabilityCheck`, errors: [
+            {message: `mode: Expected a value, got null`},
+          ]}}}});
 });
diff --git a/test/unit/data/composite/data/withPropertiesFromObject.js b/test/unit/data/composite/data/withPropertiesFromObject.js
index 750dc8c4..b81d51a5 100644
--- a/test/unit/data/composite/data/withPropertiesFromObject.js
+++ b/test/unit/data/composite/data/withPropertiesFromObject.js
@@ -1,4 +1,5 @@
 import t from 'tap';
+import {quickCheckCompositeOutputs} from '#test-lib';
 
 import {compositeFrom, input} from '#composite';
 import {exposeDependency} from '#composite/control-flow';
@@ -62,6 +63,8 @@ t.test(`withPropertiesFromObject: output shapes & values`, t => {
       ['foo', 'baz', 'missing3'],
   };
 
+  const qcco = quickCheckCompositeOutputs(t, dependencies);
+
   const mapLevel1 = [
     [input.value('prefix_value'), [
       ['object_dependency', [
@@ -153,28 +156,10 @@ t.test(`withPropertiesFromObject: output shapes & values`, t => {
           properties: propertiesInput,
         });
 
-        quickCheckOutputs(step, outputDict);
+        qcco(step, outputDict);
       }
     }
   }
-
-  function quickCheckOutputs(step, outputDict) {
-    t.same(
-      Object.keys(step.toDescription().outputs),
-      Object.keys(outputDict));
-
-    const composite = compositeFrom({
-      compose: false,
-      steps: [step, {
-        dependencies: Object.keys(outputDict),
-        compute: dependencies => dependencies,
-      }],
-    });
-
-    t.same(
-      composite.expose.compute(dependencies),
-      outputDict);
-  }
 });
 
 t.test(`withPropertiesFromObject: validate static inputs`, t => {
@@ -226,11 +211,12 @@ t.test(`withPropertiesFromObject: validate dynamic inputs`, t => {
       properties: 'onceMore',
     }),
     {message: `Error computing composition`, cause:
-      {message: `Error computing composition withPropertiesFromObject`, cause:
-        {message: `Errors in input values provided to withPropertiesFromObject`, errors: [
-          {message: `object: Expected an object, got string`},
-          {message: `properties: Expected an array, got string`},
-        ]}}});
+      {message: `Error in step 1 of 2, withPropertiesFromObject`, cause:
+        {message: `Error computing composition withPropertiesFromObject`, cause:
+          {message: `Errors in input values provided to withPropertiesFromObject`, errors: [
+            {message: `object: Expected an object, got string`},
+            {message: `properties: Expected an array, got string`},
+          ]}}}});
 
   t.throws(
     () => composite.expose.compute({
@@ -238,17 +224,18 @@ t.test(`withPropertiesFromObject: validate dynamic inputs`, t => {
       properties: ['abc', 'def', 123],
     }),
     {message: `Error computing composition`, cause:
-      {message: `Error computing composition withPropertiesFromObject`, cause:
-        {message: `Errors in input values provided to withPropertiesFromObject`, errors: [
-          {message: `object: Expected an object, got array`},
-          {message: `properties: Errors validating array items`, errors: [
-            {
-              [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: 2,
-              message: `Error at zero-index 2: 123`,
-              cause: {
-                message: `Expected a string, got number`,
+      {message: `Error in step 1 of 2, withPropertiesFromObject`, cause:
+        {message: `Error computing composition withPropertiesFromObject`, cause:
+          {message: `Errors in input values provided to withPropertiesFromObject`, errors: [
+            {message: `object: Expected an object, got array`},
+            {message: `properties: Errors validating array items`, errors: [
+              {
+                [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: 2,
+                message: `Error at zero-index 2: 123`,
+                cause: {
+                  message: `Expected a string, got number`,
+                },
               },
-            },
-          ]},
-        ]}}});
+            ]},
+          ]}}}});
 });
diff --git a/test/unit/data/composite/data/withPropertyFromObject.js b/test/unit/data/composite/data/withPropertyFromObject.js
index 6a772c36..912c924c 100644
--- a/test/unit/data/composite/data/withPropertyFromObject.js
+++ b/test/unit/data/composite/data/withPropertyFromObject.js
@@ -1,5 +1,7 @@
 import t from 'tap';
+import {quickCheckCompositeOutputs} from '#test-lib';
 
+import CacheableObject from '#cacheable-object';
 import {compositeFrom, input} from '#composite';
 import {exposeDependency} from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
@@ -42,6 +44,89 @@ t.test(`withPropertyFromObject: basic behavior`, t => {
   }), null);
 });
 
+t.test(`withPropertyFromObject: "internal" input`, t => {
+  t.plan(7);
+
+  const composite = compositeFrom({
+    compose: false,
+
+    steps: [
+      withPropertyFromObject({
+        object: 'object',
+        property: 'property',
+        internal: 'internal',
+      }),
+
+      exposeDependency({dependency: '#value'}),
+    ],
+  });
+
+  const thing = new (class extends CacheableObject {
+    static [CacheableObject.propertyDescriptors] = {
+      foo: {
+        flags: {update: true, expose: false},
+      },
+
+      bar: {
+        flags: {update: true, expose: true},
+      },
+
+      baz: {
+        flags: {update: true, expose: true},
+        expose: {
+          transform: baz => baz * 2,
+        },
+      },
+    };
+  });
+
+  thing.foo = 100;
+  thing.bar = 200;
+  thing.baz = 300;
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['object', 'property', 'internal'],
+    },
+  });
+
+  t.equal(composite.expose.compute({
+    object: thing,
+    property: 'foo',
+    internal: true,
+  }), 100);
+
+  t.equal(composite.expose.compute({
+    object: thing,
+    property: 'bar',
+    internal: true,
+  }), 200);
+
+  t.equal(composite.expose.compute({
+    object: thing,
+    property: 'baz',
+    internal: true,
+  }), 300);
+
+  t.equal(composite.expose.compute({
+    object: thing,
+    property: 'baz',
+    internal: false,
+  }), 600);
+
+  t.equal(composite.expose.compute({
+    object: thing,
+    property: 'bimbam',
+    internal: false,
+  }), null);
+
+  t.equal(composite.expose.compute({
+    object: null,
+    property: 'bambim',
+    internal: false,
+  }), null);
+});
+
 t.test(`withPropertyFromObject: output shapes & values`, t => {
   t.plan(2 * 3 ** 2);
 
@@ -56,6 +141,8 @@ t.test(`withPropertyFromObject: output shapes & values`, t => {
       'baz',
   };
 
+  const qcco = quickCheckCompositeOutputs(t, dependencies);
+
   const mapLevel1 = [
     ['object_dependency', [
       ['property_dependency', {
@@ -98,25 +185,7 @@ t.test(`withPropertyFromObject: output shapes & values`, t => {
         property: propertyInput,
       });
 
-      quickCheckOutputs(step, outputDict);
+      qcco(step, outputDict);
     }
   }
-
-  function quickCheckOutputs(step, outputDict) {
-    t.same(
-      Object.keys(step.toDescription().outputs),
-      Object.keys(outputDict));
-
-    const composite = compositeFrom({
-      compose: false,
-      steps: [step, {
-        dependencies: Object.keys(outputDict),
-        compute: dependencies => dependencies,
-      }],
-    });
-
-    t.same(
-      composite.expose.compute(dependencies),
-      outputDict);
-  }
 });
diff --git a/test/unit/data/composite/data/withUniqueItemsOnly.js b/test/unit/data/composite/data/withUniqueItemsOnly.js
index 965b14b5..50b16f43 100644
--- a/test/unit/data/composite/data/withUniqueItemsOnly.js
+++ b/test/unit/data/composite/data/withUniqueItemsOnly.js
@@ -1,4 +1,5 @@
 import t from 'tap';
+import {quickCheckCompositeOutputs} from '#test-lib';
 
 import {compositeFrom, input} from '#composite';
 import {exposeDependency} from '#composite/control-flow';
@@ -44,6 +45,8 @@ t.test(`withUniqueItemsOnly: output shapes & values`, t => {
       [8, 8, 7, 6, 6, 5, 'bar', true, true, 5],
   };
 
+  const qcco = quickCheckCompositeOutputs(t, dependencies);
+
   const mapLevel1 = [
     ['list_dependency', {
       '#list_dependency': [1, 2, 3, 4, 'foo', false],
@@ -61,24 +64,6 @@ t.test(`withUniqueItemsOnly: output shapes & values`, t => {
       list: listInput,
     });
 
-    quickCheckOutputs(step, outputDict);
-  }
-
-  function quickCheckOutputs(step, outputDict) {
-    t.same(
-      Object.keys(step.toDescription().outputs),
-      Object.keys(outputDict));
-
-    const composite = compositeFrom({
-      compose: false,
-      steps: [step, {
-        dependencies: Object.keys(outputDict),
-        compute: dependencies => dependencies,
-      }],
-    });
-
-    t.same(
-      composite.expose.compute(dependencies),
-      outputDict);
+    qcco(step, outputDict);
   }
 });
diff --git a/test/unit/data/composite/things/track/withAlbum.js b/test/unit/data/composite/things/track/withAlbum.js
index d822f318..6f50776b 100644
--- a/test/unit/data/composite/things/track/withAlbum.js
+++ b/test/unit/data/composite/things/track/withAlbum.js
@@ -1,5 +1,9 @@
 import t from 'tap';
 
+import '#import-heck';
+
+import Thing from '#thing';
+
 import {compositeFrom, input} from '#composite';
 import {exposeConstant, exposeDependency} from '#composite/control-flow';
 import {withAlbum} from '#composite/things/track';
@@ -21,9 +25,21 @@ t.test(`withAlbum: basic behavior`, t => {
     },
   });
 
-  const fakeTrack1 = {directory: 'foo'};
-  const fakeTrack2 = {directory: 'bar'};
-  const fakeAlbum = {directory: 'baz', tracks: [fakeTrack1]};
+  const fakeTrack1 = {
+    [Thing.isThing]: true,
+    directory: 'foo',
+  };
+
+  const fakeTrack2 = {
+    [Thing.isThing]: true,
+    directory: 'bar',
+  };
+
+  const fakeAlbum = {
+    [Thing.isThing]: true,
+    directory: 'baz',
+    tracks: [fakeTrack1],
+  };
 
   t.equal(
     composite.expose.compute({
@@ -53,9 +69,21 @@ t.test(`withAlbum: early exit conditions`, t => {
     ],
   });
 
-  const fakeTrack1 = {directory: 'foo'};
-  const fakeTrack2 = {directory: 'bar'};
-  const fakeAlbum = {directory: 'baz', tracks: [fakeTrack1]};
+  const fakeTrack1 = {
+    [Thing.isThing]: true,
+    directory: 'foo',
+  };
+
+  const fakeTrack2 = {
+    [Thing.isThing]: true,
+    directory: 'bar',
+  };
+
+  const fakeAlbum = {
+    [Thing.isThing]: true,
+    directory: 'baz',
+    tracks: [fakeTrack1],
+  };
 
   t.equal(
     composite.expose.compute({
diff --git a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js
index babe4fae..411fd11d 100644
--- a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js
+++ b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -28,12 +28,13 @@ function stubArtist(artistName = `Test Artist`) {
 }
 
 t.test(`withParsedCommentaryEntries: basic behavior`, t => {
-  t.plan(3);
+  t.plan(7);
 
   const artist1 = stubArtist(`Mobius Trip`);
   const artist2 = stubArtist(`Hadron Kaleido`);
+  const artist3 = stubArtist('Homestuck');
 
-  const artistData = [artist1, artist2];
+  const artistData = [artist1, artist2, artist3];
 
   t.match(composite, {
     expose: {
@@ -53,6 +54,10 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => {
       artistDisplayText: null,
       annotation: null,
       date: null,
+      accessDate: null,
+      accessKind: null,
+      secondDate: null,
+      dateKind: null,
       body: `Some commentary.\nVery cool.`,
     },
   ]);
@@ -76,6 +81,10 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => {
       annotation: `music, art`,
       date: new Date('12 January 2015'),
       body: `First commentary entry.\nVery cool.`,
+      secondDate: null,
+      dateKind: null,
+      accessDate: null,
+      accessKind: null,
     },
     {
       artists: [artist2],
@@ -83,6 +92,10 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => {
       annotation: `moral support`,
       date: new Date('4 April 2022'),
       body: `Second commentary entry. Yes. So cool.`,
+      secondDate: null,
+      dateKind: null,
+      accessDate: null,
+      accessKind: null,
     },
     {
       artists: [],
@@ -90,6 +103,10 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => {
       annotation: `pingas`,
       date: new Date('25 August 2023'),
       body: `Oh no.. Oh dear...`,
+      secondDate: null,
+      dateKind: null,
+      accessDate: null,
+      accessKind: null,
     },
     {
       artists: [artist1, artist2],
@@ -97,6 +114,260 @@ t.test(`withParsedCommentaryEntries: basic behavior`, t => {
       annotation: null,
       date: null,
       body: `And back around we go.`,
+      secondDate: null,
+      dateKind: null,
+      accessDate: null,
+      accessKind: null,
+    },
+  ]);
+
+  t.same(composite.expose.compute({
+    artistData,
+    from:
+      `<i>Homestuck:</i> ([Bandcamp credits blurb](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/track/sburban-countdown-3) on "Homestuck Vol. 1-4 (with Midnight Crew: Drawing Dead)", 10/25/2019)\n` +
+      `\n` +
+      `Written by [[artist:michael-guy-bowman|Michael Guy Bowman]]<br>\n` +
+      `Arrangement by [[artist:mark-j-hadley|Mark Hadley]]\n` +
+      `\n` +
+      `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), 7/20/2019 captured 4/13/2024)\n` +
+      `This isn't real!\n` +
+      `\n` +
+      `<i>Homestuck:</i> ([fake](https://homestuck.com/fake), 10/25/2011 accessed 10/27/2011)\n` +
+      `This isn't real either!\n` +
+      `\n` +
+      `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), 7/20/2019 accessed 4/13/2024)\n` +
+      `Not this one, neither!\n`
+  }), [
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: `[Bandcamp credits blurb](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/track/sburban-countdown-3) on "Homestuck Vol. 1-4 (with Midnight Crew: Drawing Dead)"`,
+      date: new Date('10/25/2019'),
+      body:
+        `Written by [[artist:michael-guy-bowman|Michael Guy Bowman]]<br>\n` +
+        `Arrangement by [[artist:mark-j-hadley|Mark Hadley]]`,
+      secondDate: null,
+      dateKind: null,
+      accessDate: new Date('10/24/2020'),
+      accessKind: 'captured',
+    },
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`,
+      date: new Date('7/20/2019'),
+      body: `This isn't real!`,
+      secondDate: null,
+      dateKind: null,
+      accessDate: new Date('4/13/2024'),
+      accessKind: 'captured',
+    },
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: `[fake](https://homestuck.com/fake)`,
+      date: new Date('10/25/2011'),
+      body: `This isn't real either!`,
+      secondDate: null,
+      dateKind: null,
+      accessDate: new Date('10/27/2011'),
+      accessKind: 'accessed',
+    },
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`,
+      date: new Date('7/20/2019'),
+      body: `Not this one, neither!`,
+      secondDate: null,
+      dateKind: null,
+      accessDate: new Date('4/13/2024'),
+      accessKind: 'accessed',
+    },
+  ]);
+
+  t.same(composite.expose.compute({
+    artistData,
+    from:
+      `<i>Homestuck:</i> ([MSPA sound credits](https://web.archive.org/web/20120805031705/http://www.mspaintadventures.com:80/soundcredits.html), sometime 6/21/2012 - 8/5/2012)\n` +
+      `\n` +
+      `[[flash:246|Page 2146]] - <b>"Sburban Countdown"</b><br>\n` +
+      `Available on Bandcamp in [[album:homestuck-vol-1-4|Homestuck Vol. 1-4]]<br>\n` +
+      `Written by [[artist:michael-guy-bowman|Michael Guy Bowman]]<br>\n` +
+      `Arrangement by [[artist:mark-j-hadley|Mark Hadley]]\n` +
+      `\n` +
+      `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), 7/20/2019 - 7/20/2022 captured 4/13/2024)\n` +
+      `It's goin' once.\n` +
+      `\n` +
+      `<i>Homestuck:</i> (10/25/2011 - 10/28/2011 accessed 10/27/2011)\n` +
+      `It's goin' twice.\n` +
+      `\n` +
+      `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), 7/20/2019 - 7/20/2022 accessed 4/13/2024)\n` +
+      `It's goin' thrice!\n`
+  }), [
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: `[MSPA sound credits](https://web.archive.org/web/20120805031705/http://www.mspaintadventures.com:80/soundcredits.html)`,
+      body:
+        `[[flash:246|Page 2146]] - <b>"Sburban Countdown"</b><br>\n` +
+        `Available on Bandcamp in [[album:homestuck-vol-1-4|Homestuck Vol. 1-4]]<br>\n` +
+        `Written by [[artist:michael-guy-bowman|Michael Guy Bowman]]<br>\n` +
+        `Arrangement by [[artist:mark-j-hadley|Mark Hadley]]`,
+      date: new Date('6/21/2012'),
+      secondDate: new Date('8/5/2012'),
+      dateKind: 'sometime',
+      accessDate: new Date('8/5/2012'),
+      accessKind: 'captured',
+    },
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`,
+      body: `It's goin' once.`,
+      date: new Date('7/20/2019'),
+      secondDate: new Date('7/20/2022'),
+      dateKind: null,
+      accessDate: new Date('4/13/2024'),
+      accessKind: 'captured',
+    },
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: '', // TODO: This should be null, but the regex isn't structured for that, at the moment.
+      body: `It's goin' twice.`,
+      date: new Date('10/25/2011'),
+      secondDate: new Date('10/28/2011'),
+      dateKind: null,
+      accessDate: new Date('10/27/2011'),
+      accessKind: 'accessed',
+
+    },
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`,
+      body: `It's goin' thrice!`,
+      date: new Date('7/20/2019'),
+      secondDate: new Date('7/20/2022'),
+      dateKind: null,
+      accessDate: new Date('4/13/2024'),
+      accessKind: 'accessed',
+    },
+  ]);
+
+  t.same(composite.expose.compute({
+    artistData,
+    from:
+      `<i>Homestuck:</i> ([MSPA sound credits](https://web.archive.org/web/20120805031705/http://www.mspaintadventures.com:80/soundcredits.html), sometime 6/21/2012 - 8/5/2012)\n` +
+      `\n` +
+      `[[flash:246|Page 2146]] - <b>"Sburban Countdown"</b><br>\n` +
+      `Available on Bandcamp in [[album:homestuck-vol-1-4|Homestuck Vol. 1-4]]<br>\n` +
+      `Written by [[artist:michael-guy-bowman|Michael Guy Bowman]]<br>\n` +
+      `Arrangement by [[artist:mark-j-hadley|Mark Hadley]]\n` +
+      `\n` +
+      `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), 7/20/2019 - 7/20/2022 captured 4/13/2024)\n` +
+      `It's goin' once.\n` +
+      `\n` +
+      `<i>Homestuck:</i> (10/25/2011 - 10/28/2011 accessed 10/27/2011)\n` +
+      `It's goin' twice.\n` +
+      `\n` +
+      `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), 7/20/2019 - 7/20/2022 accessed 4/13/2024)\n` +
+      `It's goin' thrice!\n`
+  }), [
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: `[MSPA sound credits](https://web.archive.org/web/20120805031705/http://www.mspaintadventures.com:80/soundcredits.html)`,
+      body:
+        `[[flash:246|Page 2146]] - <b>"Sburban Countdown"</b><br>\n` +
+        `Available on Bandcamp in [[album:homestuck-vol-1-4|Homestuck Vol. 1-4]]<br>\n` +
+        `Written by [[artist:michael-guy-bowman|Michael Guy Bowman]]<br>\n` +
+        `Arrangement by [[artist:mark-j-hadley|Mark Hadley]]`,
+      date: new Date('6/21/2012'),
+      secondDate: new Date('8/5/2012'),
+      dateKind: 'sometime',
+      accessDate: new Date('8/5/2012'),
+      accessKind: 'captured',
+    },
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`,
+      body: `It's goin' once.`,
+      date: new Date('7/20/2019'),
+      secondDate: new Date('7/20/2022'),
+      dateKind: null,
+      accessDate: new Date('4/13/2024'),
+      accessKind: 'captured',
+    },
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: '', // TODO: This should be null, but the regex isn't structured for that, at the moment.
+      body: `It's goin' twice.`,
+      date: new Date('10/25/2011'),
+      secondDate: new Date('10/28/2011'),
+      dateKind: null,
+      accessDate: new Date('10/27/2011'),
+      accessKind: 'accessed',
+
+    },
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`,
+      body: `It's goin' thrice!`,
+      date: new Date('7/20/2019'),
+      secondDate: new Date('7/20/2022'),
+      dateKind: null,
+      accessDate: new Date('4/13/2024'),
+      accessKind: 'accessed',
+    },
+  ]);
+
+  t.same(composite.expose.compute({
+    artistData,
+    from:
+      `<i>Homestuck:</i> ([Homestuck sound credits](https://web.archive.org/web/20180717171235/https://www.homestuck.com/credits/sound), excerpt, around 4/3/2018)\n` +
+      `blablabla\n` +
+      `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), around 7/20/2019 - 7/20/2022 captured 4/13/2024)\n` +
+      `Snoopin', snoopin', snoo,\n` +
+      `<i>Homestuck:</i> ([fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake), throughout 7/20/2019 - 7/20/2022 accessed 4/13/2024)\n` +
+      `~ pingas ~\n`
+  }), [
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: `[Homestuck sound credits](https://web.archive.org/web/20180717171235/https://www.homestuck.com/credits/sound), excerpt`,
+      body: `blablabla`,
+      date: new Date('4/3/2018'),
+      secondDate: null,
+      dateKind: 'around',
+      accessDate: new Date('7/17/2018'),
+      accessKind: 'captured',
+    },
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`,
+      body: `Snoopin', snoopin', snoo,`,
+      date: new Date('7/20/2019'),
+      secondDate: new Date('7/20/2022'),
+      dateKind: 'around',
+      accessDate: new Date('4/13/2024'),
+      accessKind: 'captured',
+    },
+    {
+      artists: [artist3],
+      artistDisplayText: null,
+      annotation: `[fake](https://web.archive.org/web/20201024170202/https://homestuck.bandcamp.com/fake)`,
+      body: `~ pingas ~`,
+      date: new Date('7/20/2019'),
+      secondDate: new Date('7/20/2022'),
+      dateKind: 'throughout',
+      accessDate: new Date('4/13/2024'),
+      accessKind: 'accessed',
     },
   ]);
 });
diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js
index 46ea83b0..a64488f7 100644
--- a/test/unit/data/things/album.js
+++ b/test/unit/data/things/album.js
@@ -1,52 +1,27 @@
 import t from 'tap';
 
-import {linkAndBindWikiData} from '#test-lib';
 import thingConstructors from '#things';
 
-const {
-  Album,
-  ArtTag,
-  Artist,
-  Track,
-} = thingConstructors;
-
-function stubArtTag(tagName = `Test Art Tag`) {
-  const tag = new ArtTag();
-  tag.name = tagName;
-
-  return tag;
-}
-
-function stubArtistAndContribs() {
-  const artist = new Artist();
-  artist.name = `Test Artist`;
-
-  const contribs = [{who: `Test Artist`, what: null}];
-  const badContribs = [{who: `Figment of Your Imagination`, what: null}];
-
-  return {artist, contribs, badContribs};
-}
-
-function stubTrack(directory = 'foo') {
-  const track = new Track();
-  track.directory = directory;
-
-  return track;
-}
+import {
+  linkAndBindWikiData,
+  stubArtistAndContribs,
+  stubThing,
+  stubWikiData,
+} from '#test-lib';
 
 t.test(`Album.artTags`, t => {
+  const {Album, ArtTag} = thingConstructors;
+
   t.plan(3);
 
-  const {artist, contribs} = stubArtistAndContribs();
-  const album = new Album();
-  const tag1 = stubArtTag(`Tag 1`);
-  const tag2 = stubArtTag(`Tag 2`);
+  const wikiData = stubWikiData();
+
+  const {contribs} = stubArtistAndContribs(wikiData);
+  const album = stubThing(wikiData, Album);
+  const tag1 = stubThing(wikiData, ArtTag, {name: `Tag 1`});
+  const tag2 = stubThing(wikiData, ArtTag, {name: `Tag 2`});
 
-  const {XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist],
-    artTagData: [tag1, tag2],
-  });
+  linkAndBindWikiData(wikiData);
 
   t.same(album.artTags, [],
     `artTags #1: defaults to empty array`);
@@ -63,15 +38,16 @@ t.test(`Album.artTags`, t => {
 });
 
 t.test(`Album.bannerDimensions`, t => {
+  const {Album} = thingConstructors;
+
   t.plan(4);
 
-  const album = new Album();
-  const {artist, contribs, badContribs} = stubArtistAndContribs();
+  const wikiData = stubWikiData();
 
-  linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist],
-  });
+  const album = stubThing(wikiData, Album);
+  const {contribs, badContribs} = stubArtistAndContribs(wikiData);
+
+  linkAndBindWikiData(wikiData);
 
   t.equal(album.bannerDimensions, null,
     `Album.bannerDimensions #1: defaults to null`);
@@ -93,15 +69,16 @@ t.test(`Album.bannerDimensions`, t => {
 });
 
 t.test(`Album.bannerFileExtension`, t => {
+  const {Album} = thingConstructors;
+
   t.plan(5);
 
-  const album = new Album();
-  const {artist, contribs, badContribs} = stubArtistAndContribs();
+  const wikiData = stubWikiData();
+
+  const album = stubThing(wikiData, Album);
+  const {contribs, badContribs} = stubArtistAndContribs(wikiData);
 
-  linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist],
-  });
+  linkAndBindWikiData(wikiData);
 
   t.equal(album.bannerFileExtension, null,
     `Album.bannerFileExtension #1: defaults to null`);
@@ -128,15 +105,16 @@ t.test(`Album.bannerFileExtension`, t => {
 });
 
 t.test(`Album.bannerStyle`, t => {
+  const {Album} = thingConstructors;
+
   t.plan(4);
 
-  const album = new Album();
-  const {artist, contribs, badContribs} = stubArtistAndContribs();
+  const wikiData = stubWikiData();
+
+  const album = stubThing(wikiData, Album);
+  const {contribs, badContribs} = stubArtistAndContribs(wikiData);
 
-  linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist],
-  });
+  linkAndBindWikiData(wikiData);
 
   t.equal(album.bannerStyle, null,
     `Album.bannerStyle #1: defaults to null`);
@@ -158,15 +136,16 @@ t.test(`Album.bannerStyle`, t => {
 });
 
 t.test(`Album.coverArtDate`, t => {
+  const {Album} = thingConstructors;
+
   t.plan(6);
 
-  const album = new Album();
-  const {artist, contribs, badContribs} = stubArtistAndContribs();
+  const wikiData = stubWikiData();
 
-  linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist],
-  });
+  const album = stubThing(wikiData, Album);
+  const {contribs, badContribs} = stubArtistAndContribs(wikiData);
+
+  linkAndBindWikiData(wikiData);
 
   t.equal(album.coverArtDate, null,
     `Album.coverArtDate #1: defaults to null`);
@@ -198,15 +177,16 @@ t.test(`Album.coverArtDate`, t => {
 });
 
 t.test(`Album.coverArtFileExtension`, t => {
+  const {Album} = thingConstructors;
+
   t.plan(5);
 
-  const album = new Album();
-  const {artist, contribs, badContribs} = stubArtistAndContribs();
+  const wikiData = stubWikiData();
+
+  const album = stubThing(wikiData, Album);
+  const {contribs, badContribs} = stubArtistAndContribs(wikiData);
 
-  linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist],
-  });
+  linkAndBindWikiData(wikiData);
 
   t.equal(album.coverArtFileExtension, null,
     `Album.coverArtFileExtension #1: is null if coverArtistContribs empty (1/2)`);
@@ -234,81 +214,105 @@ t.test(`Album.coverArtFileExtension`, t => {
 });
 
 t.test(`Album.tracks`, t => {
-  t.plan(5);
+  const {Album, Track, TrackSection} = thingConstructors;
+
+  t.plan(4);
 
-  const album = new Album();
-  const track1 = stubTrack('track1');
-  const track2 = stubTrack('track2');
-  const track3 = stubTrack('track3');
+  let wikiData = stubWikiData();
+
+  const album = stubThing(wikiData, Album);
+  album.directory = 'foo';
+
+  const track1 = stubThing(wikiData, Track, {directory: 'track1'});
+  const track2 = stubThing(wikiData, Track, {directory: 'track2'});
+  const track3 = stubThing(wikiData, Track, {directory: 'track3'});
   const tracks = [track1, track2, track3];
 
-  album.ownTrackData = tracks;
+  const section1 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section1'});
+  const section2 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section2'});
+  const section3 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section3'});
+  const section4 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section4'});
+  const section5 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section5'});
+  const section6 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section6'});
+  const sections = [section1, section2, section3, section4, section5, section6];
+
+  wikiData = null;
 
   for (const track of tracks) {
     track.albumData = [album];
   }
 
+  for (const section of sections) {
+    section.albumData = [album];
+  }
+
   t.same(album.tracks, [],
     `Album.tracks #1: defaults to empty array`);
 
-  album.trackSections = [
-    {tracks: ['track:track1', 'track:track2', 'track:track3']},
-  ];
+  section1.tracks = [track1, track2, track3];
+
+  album.trackSections = [section1];
 
   t.same(album.tracks, [track1, track2, track3],
     `Album.tracks #2: pulls tracks from one track section`);
 
-  album.trackSections = [
-    {tracks: ['track:track1']},
-    {tracks: ['track:track2', 'track:track3']},
-  ];
+  section1.tracks = [track1];
+  section2.tracks = [track2, track3];
+
+  album.trackSections = [section1, section2];
 
   t.same(album.tracks, [track1, track2, track3],
     `Album.tracks #3: pulls tracks from multiple track sections`);
 
-  album.trackSections = [
-    {tracks: ['track:track1', 'track:does-not-exist']},
-    {tracks: ['track:this-one-neither', 'track:track2']},
-    {tracks: ['track:effectively-empty-section']},
-    {tracks: ['track:track3']},
-  ];
-
-  t.same(album.tracks, [track1, track2, track3],
-    `Album.tracks #4: filters out references without matches`);
+  section1.tracks = [track1];
+  section2.tracks = [];
+  section3.tracks = [track2];
+  section4.tracks = [];
+  section5.tracks = [];
+  section6.tracks = [track3];
 
-  album.trackSections = [
-    {tracks: ['track:track1']},
-    {},
-    {tracks: ['track:track2']},
-    {},
-    {},
-    {tracks: ['track:track3']},
-  ];
+  album.trackSections = [section1, section2, section3, section4, section5, section6];
 
   t.same(album.tracks, [track1, track2, track3],
-    `Album.tracks #5: skips missing tracks property`);
+    `Album.tracks #4: skips empty track sections`);
 });
 
 t.test(`Album.trackSections`, t => {
+  const {Album, Track, TrackSection} = thingConstructors;
+
   t.plan(7);
 
-  const album = new Album();
-  const track1 = stubTrack('track1');
-  const track2 = stubTrack('track2');
-  const track3 = stubTrack('track3');
-  const track4 = stubTrack('track4');
+  let wikiData = stubWikiData();
+
+  const album = stubThing(wikiData, Album);
+
+  const track1 = stubThing(wikiData, Track, {directory: 'track1'});
+  const track2 = stubThing(wikiData, Track, {directory: 'track2'});
+  const track3 = stubThing(wikiData, Track, {directory: 'track3'});
+  const track4 = stubThing(wikiData, Track, {directory: 'track4'});
   const tracks = [track1, track2, track3, track4];
 
-  album.ownTrackData = tracks;
+  const section1 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section1'});
+  const section2 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section2'});
+  const section3 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section3'});
+  const section4 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section4'});
+  const section5 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section5'});
+  const sections = [section1, section2, section3, section4, section5];
+
+  wikiData = null;
 
   for (const track of tracks) {
     track.albumData = [album];
   }
 
-  album.trackSections = [
-    {tracks: ['track:track1', 'track:track2']},
-    {tracks: ['track:track3', 'track:track4']},
-  ];
+  for (const section of sections) {
+    section.albumData = [album];
+  }
+
+  section1.tracks = [track1, track2];
+  section2.tracks = [track3, track4];
+
+  album.trackSections = [section1, section2];
 
   t.match(album.trackSections, [
     {tracks: [track1, track2]},
@@ -320,11 +324,14 @@ t.test(`Album.trackSections`, t => {
     {tracks: [track3, track4], startIndex: 2},
   ], `Album.trackSections #2: exposes startIndex`);
 
-  album.trackSections = [
-    {name: 'First section', tracks: ['track:track1']},
-    {name: 'Second section', tracks: ['track:track2']},
-    {tracks: ['track:track3']},
-  ];
+  section1.tracks = [track1];
+  section2.tracks = [track2];
+  section3.tracks = [track3];
+
+  section1.name = 'First section';
+  section2.name = 'Second section';
+
+  album.trackSections = [section1, section2, section3];
 
   t.match(album.trackSections, [
     {name: 'First section', tracks: [track1]},
@@ -334,11 +341,11 @@ t.test(`Album.trackSections`, t => {
 
   album.color = '#123456';
 
-  album.trackSections = [
-    {tracks: ['track:track1'], color: null},
-    {tracks: ['track:track2'], color: '#abcdef'},
-    {tracks: ['track:track3'], color: null},
-  ];
+  section2.color = '#abcdef';
+
+  // XXX_decacheWikiData
+  album.trackSections = [];
+  album.trackSections = [section1, section2, section3];
 
   t.match(album.trackSections, [
     {tracks: [track1], color: '#123456'},
@@ -346,11 +353,11 @@ t.test(`Album.trackSections`, t => {
     {tracks: [track3], color: '#123456'},
   ], `Album.trackSections #4: exposes color, inherited from album`);
 
-  album.trackSections = [
-    {tracks: ['track:track1'], dateOriginallyReleased: null},
-    {tracks: ['track:track2'], dateOriginallyReleased: new Date('2009-04-11')},
-    {tracks: ['track:track3'], dateOriginallyReleased: null},
-  ];
+  section2.dateOriginallyReleased = new Date('2009-04-11');
+
+  // XXX_decacheWikiData
+  album.trackSections = [];
+  album.trackSections = [section1, section2, section3];
 
   t.match(album.trackSections, [
     {tracks: [track1], dateOriginallyReleased: null},
@@ -358,11 +365,12 @@ t.test(`Album.trackSections`, t => {
     {tracks: [track3], dateOriginallyReleased: null},
   ], `Album.trackSections #5: exposes dateOriginallyReleased, if present`);
 
-  album.trackSections = [
-    {tracks: ['track:track1'], isDefaultTrackSection: true},
-    {tracks: ['track:track2'], isDefaultTrackSection: false},
-    {tracks: ['track:track3'], isDefaultTrackSection: null},
-  ];
+  section1.isDefaultTrackSection = true;
+  section2.isDefaultTrackSection = false;
+
+  // XXX_decacheWikiData
+  album.trackSections = [];
+  album.trackSections = [section1, section2, section3];
 
   t.match(album.trackSections, [
     {tracks: [track1], isDefaultTrackSection: true},
@@ -370,31 +378,40 @@ t.test(`Album.trackSections`, t => {
     {tracks: [track3], isDefaultTrackSection: false},
   ], `Album.trackSections #6: exposes isDefaultTrackSection, defaults to false`);
 
-  album.trackSections = [
-    {tracks: ['track:track1', 'track:track2', 'track:snooping'], color: '#112233'},
-    {tracks: ['track:track3', 'track:as-usual'],                 color: '#334455'},
-    {tracks: [],                                                 color: '#bbbbba'},
-    {tracks: ['track:icy', 'track:chilly', 'track:frigid'],      color: '#556677'},
-    {tracks: ['track:track4'],                                   color: '#778899'},
-  ];
+  section1.tracks = [track1, track2];
+  section2.tracks = [track3];
+  section3.tracks = [];
+  section4.tracks = [];
+  section5.tracks = [track4];
+
+  section1.color = '#112233';
+  section2.color = '#334455';
+  section3.color = '#bbbbba';
+  section4.color = '#556677';
+  section5.color = '#778899';
+
+  album.trackSections = [section1, section2, section3, section4, section5];
 
   t.match(album.trackSections, [
     {tracks: [track1, track2], color: '#112233'},
     {tracks: [track3],         color: '#334455'},
+    {tracks: [],               color: '#bbbbba'},
+    {tracks: [],               color: '#556677'},
     {tracks: [track4],         color: '#778899'},
-  ], `Album.trackSections #7: filters out references without matches & empty sections`);
+  ], `Album.trackSections #7: keeps empty sections`);
 });
 
 t.test(`Album.wallpaperFileExtension`, t => {
+  const {Album} = thingConstructors;
+
   t.plan(5);
 
-  const album = new Album();
-  const {artist, contribs, badContribs} = stubArtistAndContribs();
+  const wikiData = stubWikiData();
 
-  linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist],
-  });
+  const album = stubThing(wikiData, Album);
+  const {contribs, badContribs} = stubArtistAndContribs(wikiData);
+
+  linkAndBindWikiData(wikiData);
 
   t.equal(album.wallpaperFileExtension, null,
     `Album.wallpaperFileExtension #1: defaults to null`);
@@ -421,15 +438,16 @@ t.test(`Album.wallpaperFileExtension`, t => {
 });
 
 t.test(`Album.wallpaperStyle`, t => {
+  const {Album} = thingConstructors;
+
   t.plan(4);
 
-  const album = new Album();
-  const {artist, contribs, badContribs} = stubArtistAndContribs();
+  const wikiData = stubWikiData();
+
+  const album = stubThing(wikiData, Album);
+  const {contribs, badContribs} = stubArtistAndContribs(wikiData);
 
-  linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist],
-  });
+  linkAndBindWikiData(wikiData);
 
   t.equal(album.wallpaperStyle, null,
     `Album.wallpaperStyle #1: defaults to null`);
diff --git a/test/unit/data/things/art-tag.js b/test/unit/data/things/art-tag.js
index 561c93ef..015acd0e 100644
--- a/test/unit/data/things/art-tag.js
+++ b/test/unit/data/things/art-tag.js
@@ -1,55 +1,10 @@
 import t from 'tap';
 
-import {linkAndBindWikiData} from '#test-lib';
 import thingConstructors from '#things';
 
-const {
-  Album,
-  Artist,
-  ArtTag,
-  Track,
-} = thingConstructors;
-
-function stubAlbum(tracks, directory = 'bar') {
-  const album = new Album();
-  album.directory = directory;
-
-  const trackRefs = tracks.map(t => Thing.getReference(t));
-  album.trackSections = [{tracks: trackRefs}];
-
-  return album;
-}
-
-function stubTrack(directory = 'foo') {
-  const track = new Track();
-  track.directory = directory;
-
-  return track;
-}
-
-function stubTrackAndAlbum(trackDirectory = 'foo', albumDirectory = 'bar') {
-  const track = stubTrack(trackDirectory);
-  const album = stubAlbum([track], albumDirectory);
-
-  return {track, album};
-}
-
-function stubArtist(artistName = `Test Artist`) {
-  const artist = new Artist();
-  artist.name = artistName;
-
-  return artist;
-}
-
-function stubArtistAndContribs(artistName = `Test Artist`) {
-  const artist = stubArtist(artistName);
-  const contribs = [{who: artistName, what: null}];
-  const badContribs = [{who: `Figment of Your Imagination`, what: null}];
-
-  return {artist, contribs, badContribs};
-}
-
 t.test(`ArtTag.nameShort`, t => {
+  const {ArtTag} = thingConstructors;
+
   t.plan(3);
 
   const artTag = new ArtTag();
diff --git a/test/unit/data/things/flash.js b/test/unit/data/things/flash.js
index 62059604..de39e80d 100644
--- a/test/unit/data/things/flash.js
+++ b/test/unit/data/things/flash.js
@@ -1,39 +1,24 @@
 import t from 'tap';
 
-import {linkAndBindWikiData} from '#test-lib';
 import thingConstructors from '#things';
 
-const {
-  Flash,
-  FlashAct,
-  Thing,
-} = thingConstructors;
-
-function stubFlash(directory = 'foo') {
-  const flash = new Flash();
-  flash.directory = directory;
-
-  return flash;
-}
-
-function stubFlashAct(flashes, directory = 'bar') {
-  const flashAct = new FlashAct();
-  flashAct.directory = directory;
-  flashAct.flashes = flashes.map(flash => Thing.getReference(flash));
-
-  return flashAct;
-}
+import {
+  linkAndBindWikiData,
+  stubThing,
+  stubWikiData,
+} from '#test-lib';
 
 t.test(`Flash.color`, t => {
+  const {Flash, FlashAct} = thingConstructors;
+
   t.plan(4);
 
-  const flash = stubFlash();
-  const flashAct = stubFlashAct([flash]);
+  const wikiData = stubWikiData();
+
+  const flash = stubThing(wikiData, Flash, {directory: 'my-flash'});
+  const flashAct = stubThing(wikiData, FlashAct, {flashes: ['flash:my-flash']});
 
-  const {XXX_decacheWikiData} = linkAndBindWikiData({
-    flashData: [flash],
-    flashActData: [flashAct],
-  });
+  const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
 
   t.equal(flash.color, null,
     `color #1: defaults to null`);
diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js
index b1c1611e..403dc064 100644
--- a/test/unit/data/things/track.js
+++ b/test/unit/data/things/track.js
@@ -1,75 +1,20 @@
 import t from 'tap';
 
-import {linkAndBindWikiData} from '#test-lib';
+import {bindFind} from '#find';
 import thingConstructors from '#things';
 
-const {
-  Album,
-  ArtTag,
-  Artist,
-  Flash,
-  FlashAct,
-  Thing,
-  Track,
-} = thingConstructors;
-
-function stubAlbum(tracks, directory = 'bar') {
-  const album = new Album();
-  album.directory = directory;
-
-  const trackRefs = tracks.map(t => Thing.getReference(t));
-  album.trackSections = [{tracks: trackRefs}];
-
-  return album;
-}
-
-function stubTrack(directory = 'foo') {
-  const track = new Track();
-  track.directory = directory;
-
-  return track;
-}
-
-function stubTrackAndAlbum(trackDirectory = 'foo', albumDirectory = 'bar') {
-  const track = stubTrack(trackDirectory);
-  const album = stubAlbum([track], albumDirectory);
-
-  return {track, album};
-}
-
-function stubArtist(artistName = `Test Artist`) {
-  const artist = new Artist();
-  artist.name = artistName;
-
-  return artist;
-}
-
-function stubArtistAndContribs(artistName = `Test Artist`) {
-  const artist = stubArtist(artistName);
-  const contribs = [{who: artistName, what: null}];
-  const badContribs = [{who: `Figment of Your Imagination`, what: null}];
-
-  return {artist, contribs, badContribs};
-}
-
-function stubArtTag(tagName = `Test Art Tag`) {
-  const tag = new ArtTag();
-  tag.name = tagName;
-
-  return tag;
-}
-
-function stubFlashAndAct(directory = 'zam') {
-  const flash = new Flash();
-  flash.directory = directory;
-
-  const flashAct = new FlashAct();
-  flashAct.flashes = [Thing.getReference(flash)];
-
-  return {flash, flashAct};
-}
+import {
+  linkAndBindWikiData,
+  stubArtistAndContribs,
+  stubFlashAndAct,
+  stubThing,
+  stubTrackAndAlbum,
+  stubWikiData,
+} from '#test-lib';
 
 t.test(`Track.album`, t => {
+  const {Album, Track, TrackSection} = thingConstructors;
+
   t.plan(6);
 
   // Note: These asserts use manual albumData/trackData relationships
@@ -77,20 +22,28 @@ t.test(`Track.album`, t => {
   // be relevant for this case. Other properties use the same underlying
   // get-album behavior as Track.album so aren't tested as aggressively.
 
-  const track1 = stubTrack('track1');
-  const track2 = stubTrack('track2');
-  const album1 = new Album();
-  const album2 = new Album();
+  let wikiData = stubWikiData();
+
+  const track1 = stubThing(wikiData, Track, {directory: 'track1'});
+  const track2 = stubThing(wikiData, Track, {directory: 'track2'});
+  const album1 = stubThing(wikiData, Album);
+  const album2 = stubThing(wikiData, Album);
+  const section1 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section1'});
+  const section2 = stubThing(wikiData, TrackSection, {unqualifiedDirectory: 'section2'});
+
+  wikiData = null;
 
   t.equal(track1.album, null,
     `album #1: defaults to null`);
 
   track1.albumData = [album1, album2];
   track2.albumData = [album1, album2];
-  album1.ownTrackData = [track1, track2];
-  album2.ownTrackData = [track1, track2];
-  album1.trackSections = [{tracks: ['track:track1']}];
-  album2.trackSections = [{tracks: ['track:track2']}];
+  section1.tracks = [track1];
+  section2.tracks = [track2];
+  section1.albumData = [album1];
+  section2.albumData = [album2];
+  album1.trackSections = [section1];
+  album2.trackSections = [section2];
 
   t.equal(track1.album, album1,
     `album #2: is album when album's trackSections matches track`);
@@ -105,37 +58,100 @@ t.test(`Track.album`, t => {
   t.equal(track1.album, null,
     `album #4: is null when track missing albumData`);
 
-  album1.ownTrackData = [];
-  track1.albumData = [album1, album2];
+  section1.tracks = [];
+
+  // XXX_decacheWikiData
+  album1.trackSections = [];
+  album1.trackSections = [section1];
+  track1.albumData = [];
+  track1.albumData = [album2, album1];
 
   t.equal(track1.album, null,
-    `album #5: is null when album missing ownTrackData`);
+    `album #5: is null when album track section missing tracks`);
 
-  album1.ownTrackData = [track1, track2];
-  album1.trackSections = [{tracks: ['track:track2']}];
+  section1.tracks = [track2];
 
   // XXX_decacheWikiData
+  album1.trackSections = [];
+  album1.trackSections = [section1];
   track1.albumData = [];
-  track1.albumData = [album1, album2];
+  track1.albumData = [album2, album1];
 
   t.equal(track1.album, null,
-    `album #6: is null when album's trackSections don't match track`);
+    `album #6: is null when album track section doesn't match track`);
+});
+
+t.test(`Track.alwaysReferenceByDirectory`, t => {
+  t.plan(7);
+
+  const wikiData = stubWikiData();
+
+  const {track: originalTrack} =
+    stubTrackAndAlbum(wikiData, 'original-track', 'original-album');
+
+  const {track: rereleaseTrack, album: rereleaseAlbum} =
+    stubTrackAndAlbum(wikiData, 'rerelease-track', 'rerelease-album');
+
+  originalTrack.name = 'Cowabunga';
+  rereleaseTrack.name = 'Cowabunga';
+
+  originalTrack.dataSourceAlbum = 'album:original-album';
+  rereleaseTrack.dataSourceAlbum = 'album:rerelease-album';
+
+  rereleaseTrack.originalReleaseTrack = 'track:original-track';
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
+
+  t.equal(originalTrack.alwaysReferenceByDirectory, false,
+    `alwaysReferenceByDirectory #1: defaults to false`);
+
+  t.equal(rereleaseTrack.alwaysReferenceByDirectory, true,
+    `alwaysReferenceByDirectory #2: is true if rerelease name matches original`);
+
+  rereleaseTrack.name = 'Foo Dog!';
+
+  t.equal(rereleaseTrack.alwaysReferenceByDirectory, false,
+    `alwaysReferenceByDirectory #3: is false if rerelease name doesn't match original`);
+
+  rereleaseTrack.name = `COWabunga`;
+
+  t.equal(rereleaseTrack.alwaysReferenceByDirectory, false,
+    `alwaysReferenceByDirectory #4: is false if rerelease name doesn't match original exactly`);
+
+  rereleaseAlbum.alwaysReferenceTracksByDirectory = true;
+  XXX_decacheWikiData();
+
+  t.equal(rereleaseTrack.alwaysReferenceByDirectory, true,
+    `alwaysReferenceByDirectory #5: is true if album's alwaysReferenceTracksByDirectory is true`);
+
+  rereleaseTrack.alwaysReferenceByDirectory = false;
+
+  t.equal(rereleaseTrack.alwaysReferenceByDirectory, false,
+    `alwaysReferenceByDirectory #6: doesn't inherit from album if set to false`);
+
+  rereleaseTrack.name = 'Cowabunga';
+
+  t.equal(rereleaseTrack.alwaysReferenceByDirectory, false,
+    `alwaysReferenceByDirectory #7: doesn't compare original release name if set to false`);
 });
 
 t.test(`Track.artTags`, t => {
+  const {ArtTag} = thingConstructors;
+
   t.plan(6);
 
-  const {track, album} = stubTrackAndAlbum();
-  const {artist, contribs} = stubArtistAndContribs();
-  const tag1 = stubArtTag(`Tag 1`);
-  const tag2 = stubArtTag(`Tag 2`);
+  const wikiData = stubWikiData();
 
-  const {XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist],
-    artTagData: [tag1, tag2],
-    trackData: [track],
-  });
+  const {track, album} = stubTrackAndAlbum(wikiData);
+  const {contribs} = stubArtistAndContribs(wikiData);
+
+  const tag1 =
+    stubThing(wikiData, ArtTag, {name: `Tag 1`});
+
+  const tag2 =
+    stubThing(wikiData, ArtTag, {name: `Tag 2`});
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
 
   t.same(track.artTags, [],
     `artTags #1: defaults to empty array`);
@@ -173,114 +189,106 @@ t.test(`Track.artTags`, t => {
 });
 
 t.test(`Track.artistContribs`, t => {
+  const {Album, Artist, Track, TrackSection} = thingConstructors;
+
   t.plan(4);
 
-  const {track, album} = stubTrackAndAlbum();
-  const artist1 = stubArtist(`Artist 1`);
-  const artist2 = stubArtist(`Artist 2`);
+  const wikiData = stubWikiData();
+
+  const track =
+    stubThing(wikiData, Track);
 
-  const {XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist1, artist2],
-    trackData: [track],
-  });
+  const section =
+    stubThing(wikiData, TrackSection, {tracks: [track]});
+
+  const album =
+    stubThing(wikiData, Album, {trackSections: [section]});
+
+  const artist1 =
+    stubThing(wikiData, Artist, {name: `Artist 1`});
+
+  const artist2 =
+    stubThing(wikiData, Artist, {name: `Artist 2`});
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
 
   t.same(track.artistContribs, [],
     `artistContribs #1: defaults to empty array`);
 
   album.artistContribs = [
-    {who: `Artist 1`, what: `composition`},
-    {who: `Artist 2`, what: null},
+    {artist: `Artist 1`, annotation: `composition`},
+    {artist: `Artist 2`, annotation: null},
   ];
 
   XXX_decacheWikiData();
 
-  t.same(track.artistContribs,
-    [{who: artist1, what: `composition`}, {who: artist2, what: null}],
+  t.match(track.artistContribs,
+    [{artist: artist1, annotation: `composition`}, {artist: artist2, annotation: null}],
     `artistContribs #2: inherits album artistContribs`);
 
   track.artistContribs = [
-    {who: `Artist 1`, what: `arrangement`},
+    {artist: `Artist 1`, annotation: `arrangement`},
   ];
 
-  t.same(track.artistContribs, [{who: artist1, what: `arrangement`}],
+  t.match(track.artistContribs, [{artist: artist1, annotation: `arrangement`}],
     `artistContribs #3: resolves from own value`);
 
   track.artistContribs = [
-    {who: `Artist 1`, what: `snooping`},
-    {who: `Artist 413`, what: `as`},
-    {who: `Artist 2`, what: `usual`},
+    {artist: `Artist 1`, annotation: `snooping`},
+    {artist: `Artist 413`, annotation: `as`},
+    {artist: `Artist 2`, annotation: `usual`},
   ];
 
-  t.same(track.artistContribs,
-    [{who: artist1, what: `snooping`}, {who: artist2, what: `usual`}],
+  t.match(track.artistContribs,
+    [{artist: artist1, annotation: `snooping`}, {artist: artist2, annotation: `usual`}],
     `artistContribs #4: filters out names without matches`);
 });
 
 t.test(`Track.color`, t => {
-  t.plan(5);
+  t.plan(4);
+
+  const wikiData = stubWikiData();
 
-  const {track, album} = stubTrackAndAlbum();
+  const {track, album, section} = stubTrackAndAlbum(wikiData);
 
-  const {XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album],
-    trackData: [track],
-  });
+  const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
 
   t.equal(track.color, null,
     `color #1: defaults to null`);
 
   album.color = '#abcdef';
-  album.trackSections = [{
-    color: '#beeeef',
-    tracks: [Thing.getReference(track)],
-  }];
+  section.color = '#beeeef';
+
+  album.trackSections = [section];
+
   XXX_decacheWikiData();
 
   t.equal(track.color, '#beeeef',
     `color #2: inherits from track section before album`);
 
-  // Replace the album with a completely fake one. This isn't realistic, since
-  // in correct data, Album.tracks depends on Albums.trackSections and so the
-  // track's album will always have a corresponding track section. But if that
-  // connection breaks for some future reason (with the album still present),
-  // Track.color should still inherit directly from the album.
-  track.albumData = [
-    {
-      constructor: {[Thing.referenceType]: 'album'},
-      color: '#abcdef',
-      tracks: [track],
-      trackSections: [
-        {color: '#baaaad', tracks: []},
-      ],
-    },
-  ];
-
-  t.equal(track.color, '#abcdef',
-    `color #3: inherits from album without matching track section`);
-
   track.color = '#123456';
 
   t.equal(track.color, '#123456',
-    `color #4: is own value`);
+    `color #3: is own value`);
 
   t.throws(() => { track.color = '#aeiouw'; },
     {cause: TypeError},
-    `color #5: must be set to valid color`);
+    `color #4: must be set to valid color`);
 });
 
 t.test(`Track.commentatorArtists`, t => {
+  const {Artist, Track} = thingConstructors;
+
   t.plan(8);
 
-  const track = new Track();
-  const artist1 = stubArtist(`SnooPING`);
-  const artist2 = stubArtist(`ASUsual`);
-  const artist3 = stubArtist(`Icy`);
+  const wikiData = stubWikiData();
 
-  linkAndBindWikiData({
-    trackData: [track],
-    artistData: [artist1, artist2, artist3],
-  });
+  const track = stubThing(wikiData, Track);
+  const artist1 = stubThing(wikiData, Artist, {name: `SnooPING`});
+  const artist2 = stubThing(wikiData, Artist, {name: `ASUsual`});
+  const artist3 = stubThing(wikiData, Artist, {name: `Icy`});
+
+  linkAndBindWikiData(wikiData);
 
   // Keep track of the last commentary string in a separate value, since
   // the track.commentary property exposes as a completely different format
@@ -303,7 +311,7 @@ t.test(`Track.commentatorArtists`, t => {
     `Track.commentatorArtists #2: works with two commentators`);
 
   track.commentary = commentary +=
-    `<i>Icy|<b>Icy What You Did There</b>:</i>\n` +
+    `<i>Icy|<b>Icy annotation You Did There</b>:</i>\n` +
     `Incredible.\n`;
 
   t.same(track.commentatorArtists, [artist1, artist2, artist3],
@@ -346,47 +354,47 @@ t.test(`Track.commentatorArtists`, t => {
 });
 
 t.test(`Track.coverArtistContribs`, t => {
+  const {Artist} = thingConstructors;
+
   t.plan(5);
 
-  const {track, album} = stubTrackAndAlbum();
-  const artist1 = stubArtist(`Artist 1`);
-  const artist2 = stubArtist(`Artist 2`);
+  const wikiData = stubWikiData();
 
-  const {XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist1, artist2],
-    trackData: [track],
-  });
+  const {track, album} = stubTrackAndAlbum(wikiData);
+  const artist1 = stubThing(wikiData, Artist, {name: `Artist 1`});
+  const artist2 = stubThing(wikiData, Artist, {name: `Artist 2`});
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
 
   t.same(track.coverArtistContribs, [],
     `coverArtistContribs #1: defaults to empty array`);
 
   album.trackCoverArtistContribs = [
-    {who: `Artist 1`, what: `lines`},
-    {who: `Artist 2`, what: null},
+    {artist: `Artist 1`, annotation: `lines`},
+    {artist: `Artist 2`, annotation: null},
   ];
 
   XXX_decacheWikiData();
 
-  t.same(track.coverArtistContribs,
-    [{who: artist1, what: `lines`}, {who: artist2, what: null}],
+  t.match(track.coverArtistContribs,
+    [{artist: artist1, annotation: `lines`}, {artist: artist2, annotation: null}],
     `coverArtistContribs #2: inherits album trackCoverArtistContribs`);
 
   track.coverArtistContribs = [
-    {who: `Artist 1`, what: `collage`},
+    {artist: `Artist 1`, annotation: `collage`},
   ];
 
-  t.same(track.coverArtistContribs, [{who: artist1, what: `collage`}],
+  t.match(track.coverArtistContribs, [{artist: artist1, annotation: `collage`}],
     `coverArtistContribs #3: resolves from own value`);
 
   track.coverArtistContribs = [
-    {who: `Artist 1`, what: `snooping`},
-    {who: `Artist 413`, what: `as`},
-    {who: `Artist 2`, what: `usual`},
+    {artist: `Artist 1`, annotation: `snooping`},
+    {artist: `Artist 413`, annotation: `as`},
+    {artist: `Artist 2`, annotation: `usual`},
   ];
 
-  t.same(track.coverArtistContribs,
-    [{who: artist1, what: `snooping`}, {who: artist2, what: `usual`}],
+  t.match(track.coverArtistContribs,
+    [{artist: artist1, annotation: `snooping`}, {artist: artist2, annotation: `usual`}],
     `coverArtistContribs #4: filters out names without matches`);
 
   track.disableUniqueCoverArt = true;
@@ -398,14 +406,12 @@ t.test(`Track.coverArtistContribs`, t => {
 t.test(`Track.coverArtDate`, t => {
   t.plan(8);
 
-  const {track, album} = stubTrackAndAlbum();
-  const {artist, contribs, badContribs} = stubArtistAndContribs();
+  const wikiData = stubWikiData();
+
+  const {track, album} = stubTrackAndAlbum(wikiData);
+  const {contribs, badContribs} = stubArtistAndContribs(wikiData);
 
-  const {XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist],
-    trackData: [track],
-  });
+  const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
 
   track.coverArtistContribs = contribs;
 
@@ -458,14 +464,12 @@ t.test(`Track.coverArtDate`, t => {
 t.test(`Track.coverArtFileExtension`, t => {
   t.plan(8);
 
-  const {track, album} = stubTrackAndAlbum();
-  const {artist, contribs} = stubArtistAndContribs();
+  const wikiData = stubWikiData();
+
+  const {track, album} = stubTrackAndAlbum(wikiData);
+  const {contribs} = stubArtistAndContribs(wikiData);
 
-  const {XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist],
-    trackData: [track],
-  });
+  const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
 
   t.equal(track.coverArtFileExtension, null,
     `coverArtFileExtension #1: defaults to null`);
@@ -518,12 +522,11 @@ t.test(`Track.coverArtFileExtension`, t => {
 t.test(`Track.date`, t => {
   t.plan(3);
 
-  const {track, album} = stubTrackAndAlbum();
+  const wikiData = stubWikiData();
 
-  const {XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album],
-    trackData: [track],
-  });
+  const {track, album} = stubTrackAndAlbum(wikiData);
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
 
   t.equal(track.date, null,
     `date #1: defaults to null`);
@@ -543,17 +546,13 @@ t.test(`Track.date`, t => {
 t.test(`Track.featuredInFlashes`, t => {
   t.plan(2);
 
-  const {track, album} = stubTrackAndAlbum('track1');
+  const wikiData = stubWikiData();
 
-  const {flash: flash1, flashAct: flashAct1} = stubFlashAndAct('flash1');
-  const {flash: flash2, flashAct: flashAct2} = stubFlashAndAct('flash2');
+  const {track} = stubTrackAndAlbum(wikiData, 'track1');
+  const {flash: flash1} = stubFlashAndAct(wikiData, 'flash1');
+  const {flash: flash2} = stubFlashAndAct(wikiData, 'flash2');
 
-  const {XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album],
-    trackData: [track],
-    flashData: [flash1, flash2],
-    flashActData: [flashAct1, flashAct2],
-  });
+  const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
 
   t.same(track.featuredInFlashes, [],
     `featuredInFlashes #1: defaults to empty array`);
@@ -569,14 +568,12 @@ t.test(`Track.featuredInFlashes`, t => {
 t.test(`Track.hasUniqueCoverArt`, t => {
   t.plan(7);
 
-  const {track, album} = stubTrackAndAlbum();
-  const {artist, contribs, badContribs} = stubArtistAndContribs();
+  const wikiData = stubWikiData();
+
+  const {track, album} = stubTrackAndAlbum(wikiData);
+  const {contribs, badContribs} = stubArtistAndContribs(wikiData);
 
-  const {XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album],
-    artistData: [artist],
-    trackData: [track],
-  });
+  const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
 
   t.equal(track.hasUniqueCoverArt, false,
     `hasUniqueCoverArt #1: defaults to false`);
@@ -621,13 +618,12 @@ t.test(`Track.hasUniqueCoverArt`, t => {
 t.test(`Track.originalReleaseTrack`, t => {
   t.plan(3);
 
-  const {track: track1, album: album1} = stubTrackAndAlbum('track1');
-  const {track: track2, album: album2} = stubTrackAndAlbum('track2');
+  const wikiData = stubWikiData();
 
-  const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album1, album2],
-    trackData: [track1, track2],
-  });
+  const {track: track1} = stubTrackAndAlbum(wikiData, 'track1');
+  const {track: track2} = stubTrackAndAlbum(wikiData, 'track2');
+
+  linkAndBindWikiData(wikiData);
 
   t.equal(track2.originalReleaseTrack, null,
     `originalReleaseTrack #1: defaults to null`);
@@ -646,15 +642,14 @@ t.test(`Track.originalReleaseTrack`, t => {
 t.test(`Track.otherReleases`, t => {
   t.plan(6);
 
-  const {track: track1, album: album1} = stubTrackAndAlbum('track1');
-  const {track: track2, album: album2} = stubTrackAndAlbum('track2');
-  const {track: track3, album: album3} = stubTrackAndAlbum('track3');
-  const {track: track4, album: album4} = stubTrackAndAlbum('track4');
+  const wikiData = stubWikiData();
+
+  const {track: track1} = stubTrackAndAlbum(wikiData, 'track1');
+  const {track: track2} = stubTrackAndAlbum(wikiData, 'track2');
+  const {track: track3} = stubTrackAndAlbum(wikiData, 'track3');
+  const {track: track4} = stubTrackAndAlbum(wikiData, 'track4');
 
-  const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album1, album2, album3, album4],
-    trackData: [track1, track2, track3, track4],
-  });
+  const {linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
 
   t.same(track1.otherReleases, [],
     `otherReleases #1: defaults to empty array`);
@@ -668,13 +663,13 @@ t.test(`Track.otherReleases`, t => {
     `otherReleases #2: otherReleases of original release are its rereleases`);
 
   wikiData.trackData = [track1, track3, track2, track4];
-  linkWikiDataArrays();
+  linkWikiDataArrays({bindFind});
 
   t.same(track1.otherReleases, [track3, track2, track4],
     `otherReleases #3: otherReleases matches trackData order`);
 
   wikiData.trackData = [track3, track2, track1, track4];
-  linkWikiDataArrays();
+  linkWikiDataArrays({bindFind});
 
   t.same(track2.otherReleases, [track1, track3, track4],
     `otherReleases #4: otherReleases of rerelease are original track then other rereleases (1/3)`);
@@ -689,15 +684,14 @@ t.test(`Track.otherReleases`, t => {
 t.test(`Track.referencedByTracks`, t => {
   t.plan(4);
 
-  const {track: track1, album: album1} = stubTrackAndAlbum('track1');
-  const {track: track2, album: album2} = stubTrackAndAlbum('track2');
-  const {track: track3, album: album3} = stubTrackAndAlbum('track3');
-  const {track: track4, album: album4} = stubTrackAndAlbum('track4');
+  const wikiData = stubWikiData();
 
-  const {XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album1, album2, album3, album4],
-    trackData: [track1, track2, track3, track4],
-  });
+  const {track: track1} = stubTrackAndAlbum(wikiData, 'track1');
+  const {track: track2} = stubTrackAndAlbum(wikiData, 'track2');
+  const {track: track3} = stubTrackAndAlbum(wikiData, 'track3');
+  const {track: track4} = stubTrackAndAlbum(wikiData, 'track4');
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
 
   t.same(track1.referencedByTracks, [],
     `referencedByTracks #1: defaults to empty array`);
@@ -725,15 +719,14 @@ t.test(`Track.referencedByTracks`, t => {
 t.test(`Track.sampledByTracks`, t => {
   t.plan(4);
 
-  const {track: track1, album: album1} = stubTrackAndAlbum('track1');
-  const {track: track2, album: album2} = stubTrackAndAlbum('track2');
-  const {track: track3, album: album3} = stubTrackAndAlbum('track3');
-  const {track: track4, album: album4} = stubTrackAndAlbum('track4');
+  const wikiData = stubWikiData();
+
+  const {track: track1} = stubTrackAndAlbum(wikiData, 'track1');
+  const {track: track2} = stubTrackAndAlbum(wikiData, 'track2');
+  const {track: track3} = stubTrackAndAlbum(wikiData, 'track3');
+  const {track: track4} = stubTrackAndAlbum(wikiData, 'track4');
 
-  const {XXX_decacheWikiData} = linkAndBindWikiData({
-    albumData: [album1, album2, album3, album4],
-    trackData: [track1, track2, track3, track4],
-  });
+  const {XXX_decacheWikiData} = linkAndBindWikiData(wikiData);
 
   t.same(track1.sampledByTracks, [],
     `sampledByTracks #1: defaults to empty array`);
diff --git a/test/unit/data/things/validators.js b/test/unit/data/validators.js
index 11134a90..3a217d6f 100644
--- a/test/unit/data/things/validators.js
+++ b/test/unit/data/validators.js
@@ -1,5 +1,5 @@
 import t from 'tap';
-import {showAggregate} from '#sugar';
+import {showAggregate} from '#aggregate';
 
 import {
   // Basic types
@@ -280,17 +280,17 @@ t.test('isContentString', t => {
 
 t.test('isContribution', t => {
   t.plan(4);
-  t.ok(isContribution({who: 'artist:toby-fox', what: 'Music'}));
-  t.ok(isContribution({who: 'Toby Fox'}));
-  t.throws(() => isContribution(({who: 'group:umspaf', what: 'Organizing'})),
-    {errors: /who/});
-  t.throws(() => isContribution(({who: 'artist:toby-fox', what: 123})),
-    {errors: /what/});
+  t.ok(isContribution({artist: 'artist:toby-fox', annotation: 'Music'}));
+  t.ok(isContribution({artist: 'Toby Fox'}));
+  t.throws(() => isContribution(({artist: 'group:umspaf', annotation: 'Organizing'})),
+    {errors: /artist/});
+  t.throws(() => isContribution(({artist: 'artist:toby-fox', annotation: 123})),
+    {errors: /annotation/});
 });
 
 t.test('isContributionList', t => {
   t.plan(4);
-  t.ok(isContributionList([{who: 'Beavis'}, {who: 'Butthead', what: 'Wrangling'}]));
+  t.ok(isContributionList([{artist: 'Beavis'}, {artist: 'Butthead', annotation: 'Wrangling'}]));
   t.ok(isContributionList([]));
   t.throws(() => isContributionList(2));
   t.throws(() => isContributionList(['Charlie', 'Woodstock']));