« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/aggregate.js752
-rw-r--r--src/cli.js515
-rw-r--r--src/common-util/colors.js44
-rw-r--r--src/common-util/search-spec.js259
-rw-r--r--src/common-util/serialize.js77
-rw-r--r--src/common-util/sort.js461
-rw-r--r--src/common-util/sugar.js885
-rw-r--r--src/common-util/wiki-data.js499
-rw-r--r--src/content-function.js683
-rw-r--r--src/content/dependencies/generateAbsoluteDatetimestamp.js53
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js26
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunk.js46
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunkItem.js30
-rw-r--r--src/content/dependencies/generateAdditionalNamesBox.js28
-rw-r--r--src/content/dependencies/generateAdditionalNamesBoxItem.js48
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js96
-rw-r--r--src/content/dependencies/generateAlbumArtInfoBox.js39
-rw-r--r--src/content/dependencies/generateAlbumArtworkColumn.js38
-rw-r--r--src/content/dependencies/generateAlbumBanner.js37
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js306
-rw-r--r--src/content/dependencies/generateAlbumCommentarySidebar.js73
-rw-r--r--src/content/dependencies/generateAlbumGalleryAlbumGrid.js90
-rw-r--r--src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js20
-rw-r--r--src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js7
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js167
-rw-r--r--src/content/dependencies/generateAlbumGalleryStatsLine.js38
-rw-r--r--src/content/dependencies/generateAlbumGalleryTrackGrid.js122
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js238
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js142
-rw-r--r--src/content/dependencies/generateAlbumReferencedArtworksPage.js58
-rw-r--r--src/content/dependencies/generateAlbumReferencingArtworksPage.js58
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js107
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNav.js127
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNavGroupPart.js94
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js94
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js171
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js126
-rw-r--r--src/content/dependencies/generateAlbumSidebarSeriesBox.js102
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackListBox.js31
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js167
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbed.js70
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbedDescription.js41
-rw-r--r--src/content/dependencies/generateAlbumStyleRules.js107
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js206
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js62
-rw-r--r--src/content/dependencies/generateArtTagAncestorDescendantMapList.js153
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js222
-rw-r--r--src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js23
-rw-r--r--src/content/dependencies/generateArtTagGalleryPageShowingLine.js22
-rw-r--r--src/content/dependencies/generateArtTagInfoPage.js281
-rw-r--r--src/content/dependencies/generateArtTagNavLinks.js81
-rw-r--r--src/content/dependencies/generateArtTagSidebar.js124
-rw-r--r--src/content/dependencies/generateArtistArtworkColumn.js13
-rw-r--r--src/content/dependencies/generateArtistCredit.js180
-rw-r--r--src/content/dependencies/generateArtistCreditWikiEditsPart.js55
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js108
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js234
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js401
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunk.js50
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js72
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js72
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunk.js114
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkItem.js91
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkedList.js20
-rw-r--r--src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js280
-rw-r--r--src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js75
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunk.js34
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js34
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js62
-rw-r--r--src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js30
-rw-r--r--src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js61
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunk.js67
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkItem.js146
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkedList.js81
-rw-r--r--src/content/dependencies/generateArtistNavLinks.js94
-rw-r--r--src/content/dependencies/generateBackToAlbumLink.js15
-rw-r--r--src/content/dependencies/generateBackToTrackLink.js15
-rw-r--r--src/content/dependencies/generateBanner.js33
-rw-r--r--src/content/dependencies/generateColorStyleAttribute.js37
-rw-r--r--src/content/dependencies/generateColorStyleRules.js42
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js91
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js112
-rw-r--r--src/content/dependencies/generateCommentaryEntryDate.js93
-rw-r--r--src/content/dependencies/generateCommentaryIndexPage.js104
-rw-r--r--src/content/dependencies/generateContentHeading.js61
-rw-r--r--src/content/dependencies/generateContributionList.js29
-rw-r--r--src/content/dependencies/generateContributionTooltip.js48
-rw-r--r--src/content/dependencies/generateContributionTooltipChronologySection.js129
-rw-r--r--src/content/dependencies/generateContributionTooltipExternalLinkSection.js70
-rw-r--r--src/content/dependencies/generateCoverArtwork.js121
-rw-r--r--src/content/dependencies/generateCoverArtworkArtTagDetails.js50
-rw-r--r--src/content/dependencies/generateCoverArtworkArtistDetails.js25
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js98
-rw-r--r--src/content/dependencies/generateCoverArtworkReferenceDetails.js60
-rw-r--r--src/content/dependencies/generateCoverCarousel.js55
-rw-r--r--src/content/dependencies/generateCoverGrid.js90
-rw-r--r--src/content/dependencies/generateDatetimestampTemplate.js40
-rw-r--r--src/content/dependencies/generateDotSwitcherTemplate.js41
-rw-r--r--src/content/dependencies/generateExternalHandle.js20
-rw-r--r--src/content/dependencies/generateExternalIcon.js26
-rw-r--r--src/content/dependencies/generateExternalPlatform.js20
-rw-r--r--src/content/dependencies/generateFlashActGalleryPage.js85
-rw-r--r--src/content/dependencies/generateFlashActNavAccent.js64
-rw-r--r--src/content/dependencies/generateFlashActSidebar.js30
-rw-r--r--src/content/dependencies/generateFlashActSidebarCurrentActBox.js64
-rw-r--r--src/content/dependencies/generateFlashActSidebarSideMapBox.js85
-rw-r--r--src/content/dependencies/generateFlashArtworkColumn.js11
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js144
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js202
-rw-r--r--src/content/dependencies/generateFlashNavAccent.js66
-rw-r--r--src/content/dependencies/generateFooterLocalizationLinks.js59
-rw-r--r--src/content/dependencies/generateGridActionLinks.js16
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js182
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js179
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js47
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js87
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListItem.js136
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsSection.js93
-rw-r--r--src/content/dependencies/generateGroupNavAccent.js53
-rw-r--r--src/content/dependencies/generateGroupNavLinks.js59
-rw-r--r--src/content/dependencies/generateGroupSecondaryNav.js20
-rw-r--r--src/content/dependencies/generateGroupSecondaryNavCategoryPart.js79
-rw-r--r--src/content/dependencies/generateGroupSidebar.js46
-rw-r--r--src/content/dependencies/generateGroupSidebarCategoryDetails.js81
-rw-r--r--src/content/dependencies/generateImageOverlay.js50
-rw-r--r--src/content/dependencies/generateInterpageDotSwitcher.js31
-rw-r--r--src/content/dependencies/generateIntrapageDotSwitcher.js49
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesChunk.js90
-rw-r--r--src/content/dependencies/generateListRandomPageLinksAlbumLink.js18
-rw-r--r--src/content/dependencies/generateListingIndexList.js131
-rw-r--r--src/content/dependencies/generateListingPage.js288
-rw-r--r--src/content/dependencies/generateListingSidebar.js37
-rw-r--r--src/content/dependencies/generateListingsIndexPage.js89
-rw-r--r--src/content/dependencies/generateLyricsEntry.js25
-rw-r--r--src/content/dependencies/generateLyricsSection.js81
-rw-r--r--src/content/dependencies/generateNewsEntryNavAccent.js40
-rw-r--r--src/content/dependencies/generateNewsEntryPage.js105
-rw-r--r--src/content/dependencies/generateNewsEntryReadAnotherLinks.js97
-rw-r--r--src/content/dependencies/generateNewsIndexPage.js94
-rw-r--r--src/content/dependencies/generateNextLink.js13
-rw-r--r--src/content/dependencies/generatePageLayout.js790
-rw-r--r--src/content/dependencies/generatePageSidebar.js90
-rw-r--r--src/content/dependencies/generatePageSidebarBox.js30
-rw-r--r--src/content/dependencies/generatePageSidebarConjoinedBox.js38
-rw-r--r--src/content/dependencies/generatePreviousLink.js13
-rw-r--r--src/content/dependencies/generatePreviousNextLink.js58
-rw-r--r--src/content/dependencies/generateQuickDescription.js134
-rw-r--r--src/content/dependencies/generateReferencedArtworksPage.js100
-rw-r--r--src/content/dependencies/generateReferencingArtworksPage.js100
-rw-r--r--src/content/dependencies/generateRelativeDatetimestamp.js69
-rw-r--r--src/content/dependencies/generateReleaseInfoContributionsLine.js31
-rw-r--r--src/content/dependencies/generateSearchSidebarBox.js62
-rw-r--r--src/content/dependencies/generateSecondaryNav.js30
-rw-r--r--src/content/dependencies/generateSecondaryNavParentSiblingsPart.js115
-rw-r--r--src/content/dependencies/generateSocialEmbed.js70
-rw-r--r--src/content/dependencies/generateStaticPage.js46
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js59
-rw-r--r--src/content/dependencies/generateTextWithTooltip.js71
-rw-r--r--src/content/dependencies/generateTooltip.js34
-rw-r--r--src/content/dependencies/generateTrackArtistCommentarySection.js157
-rw-r--r--src/content/dependencies/generateTrackArtworkColumn.js33
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js435
-rw-r--r--src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js63
-rw-r--r--src/content/dependencies/generateTrackInfoPageOtherReleasesList.js42
-rw-r--r--src/content/dependencies/generateTrackList.js28
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js145
-rw-r--r--src/content/dependencies/generateTrackListItem.js106
-rw-r--r--src/content/dependencies/generateTrackListMissingDuration.js35
-rw-r--r--src/content/dependencies/generateTrackNavLinks.js64
-rw-r--r--src/content/dependencies/generateTrackReferencedArtworksPage.js47
-rw-r--r--src/content/dependencies/generateTrackReferencingArtworksPage.js47
-rw-r--r--src/content/dependencies/generateTrackReleaseBox.js46
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js82
-rw-r--r--src/content/dependencies/generateTrackSocialEmbed.js68
-rw-r--r--src/content/dependencies/generateTrackSocialEmbedDescription.js39
-rw-r--r--src/content/dependencies/generateUnsafeMunchy.js10
-rw-r--r--src/content/dependencies/generateWikiHomepageActionsRow.js22
-rw-r--r--src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js22
-rw-r--r--src/content/dependencies/generateWikiHomepageAlbumGridRow.js78
-rw-r--r--src/content/dependencies/generateWikiHomepageNewsBox.js86
-rw-r--r--src/content/dependencies/generateWikiHomepagePage.js97
-rw-r--r--src/content/dependencies/generateWikiHomepageSection.js39
-rw-r--r--src/content/dependencies/image.js374
-rw-r--r--src/content/dependencies/index.js274
-rw-r--r--src/content/dependencies/linkAlbum.js8
-rw-r--r--src/content/dependencies/linkAlbumAdditionalFile.js24
-rw-r--r--src/content/dependencies/linkAlbumCommentary.js8
-rw-r--r--src/content/dependencies/linkAlbumDynamically.js61
-rw-r--r--src/content/dependencies/linkAlbumGallery.js8
-rw-r--r--src/content/dependencies/linkAlbumReferencedArtworks.js8
-rw-r--r--src/content/dependencies/linkAlbumReferencingArtworks.js8
-rw-r--r--src/content/dependencies/linkAnythingMan.js28
-rw-r--r--src/content/dependencies/linkArtTagDynamically.js14
-rw-r--r--src/content/dependencies/linkArtTagGallery.js8
-rw-r--r--src/content/dependencies/linkArtTagInfo.js8
-rw-r--r--src/content/dependencies/linkArtist.js8
-rw-r--r--src/content/dependencies/linkArtistGallery.js8
-rw-r--r--src/content/dependencies/linkArtwork.js20
-rw-r--r--src/content/dependencies/linkCommentaryIndex.js12
-rw-r--r--src/content/dependencies/linkContribution.js85
-rw-r--r--src/content/dependencies/linkExternal.js151
-rw-r--r--src/content/dependencies/linkFlash.js8
-rw-r--r--src/content/dependencies/linkFlashAct.js22
-rw-r--r--src/content/dependencies/linkFlashIndex.js12
-rw-r--r--src/content/dependencies/linkFlashSide.js22
-rw-r--r--src/content/dependencies/linkGroup.js8
-rw-r--r--src/content/dependencies/linkGroupDynamically.js14
-rw-r--r--src/content/dependencies/linkGroupExtra.js34
-rw-r--r--src/content/dependencies/linkGroupGallery.js8
-rw-r--r--src/content/dependencies/linkListing.js15
-rw-r--r--src/content/dependencies/linkListingIndex.js12
-rw-r--r--src/content/dependencies/linkNewsEntry.js8
-rw-r--r--src/content/dependencies/linkNewsIndex.js12
-rw-r--r--src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js62
-rw-r--r--src/content/dependencies/linkPathFromMedia.js64
-rw-r--r--src/content/dependencies/linkPathFromRoot.js13
-rw-r--r--src/content/dependencies/linkPathFromSite.js13
-rw-r--r--src/content/dependencies/linkReferencedArtworks.js24
-rw-r--r--src/content/dependencies/linkReferencingArtworks.js24
-rw-r--r--src/content/dependencies/linkStaticPage.js8
-rw-r--r--src/content/dependencies/linkStationaryIndex.js24
-rw-r--r--src/content/dependencies/linkTemplate.js87
-rw-r--r--src/content/dependencies/linkThing.js154
-rw-r--r--src/content/dependencies/linkTrack.js8
-rw-r--r--src/content/dependencies/linkTrackDynamically.js36
-rw-r--r--src/content/dependencies/linkTrackReferencedArtworks.js8
-rw-r--r--src/content/dependencies/linkTrackReferencingArtworks.js8
-rw-r--r--src/content/dependencies/linkWikiHomepage.js20
-rw-r--r--src/content/dependencies/listAlbumsByDate.js52
-rw-r--r--src/content/dependencies/listAlbumsByDateAdded.js60
-rw-r--r--src/content/dependencies/listAlbumsByDuration.js52
-rw-r--r--src/content/dependencies/listAlbumsByName.js50
-rw-r--r--src/content/dependencies/listAlbumsByTracks.js51
-rw-r--r--src/content/dependencies/listAllAdditionalFiles.js9
-rw-r--r--src/content/dependencies/listAllAdditionalFilesTemplate.js209
-rw-r--r--src/content/dependencies/listAllMidiProjectFiles.js9
-rw-r--r--src/content/dependencies/listAllSheetMusicFiles.js9
-rw-r--r--src/content/dependencies/listArtTagNetwork.js366
-rw-r--r--src/content/dependencies/listArtTagsByName.js57
-rw-r--r--src/content/dependencies/listArtTagsByUses.js54
-rw-r--r--src/content/dependencies/listArtistsByCommentaryEntries.js58
-rw-r--r--src/content/dependencies/listArtistsByContributions.js174
-rw-r--r--src/content/dependencies/listArtistsByDuration.js55
-rw-r--r--src/content/dependencies/listArtistsByGroup.js157
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js323
-rw-r--r--src/content/dependencies/listArtistsByName.js48
-rw-r--r--src/content/dependencies/listGroupsByAlbums.js51
-rw-r--r--src/content/dependencies/listGroupsByCategory.js76
-rw-r--r--src/content/dependencies/listGroupsByDuration.js56
-rw-r--r--src/content/dependencies/listGroupsByLatestAlbum.js72
-rw-r--r--src/content/dependencies/listGroupsByName.js49
-rw-r--r--src/content/dependencies/listGroupsByTracks.js55
-rw-r--r--src/content/dependencies/listRandomPageLinks.js197
-rw-r--r--src/content/dependencies/listTracksByAlbum.js48
-rw-r--r--src/content/dependencies/listTracksByDate.js91
-rw-r--r--src/content/dependencies/listTracksByDuration.js51
-rw-r--r--src/content/dependencies/listTracksByDurationInAlbum.js87
-rw-r--r--src/content/dependencies/listTracksByName.js36
-rw-r--r--src/content/dependencies/listTracksByTimesReferenced.js52
-rw-r--r--src/content/dependencies/listTracksInFlashesByAlbum.js82
-rw-r--r--src/content/dependencies/listTracksInFlashesByFlash.js69
-rw-r--r--src/content/dependencies/listTracksWithExtra.js85
-rw-r--r--src/content/dependencies/listTracksWithLyrics.js9
-rw-r--r--src/content/dependencies/listTracksWithMidiProjectFiles.js9
-rw-r--r--src/content/dependencies/listTracksWithSheetMusicFiles.js9
-rw-r--r--src/content/dependencies/transformContent.js756
-rw-r--r--src/data/cacheable-object.js262
-rw-r--r--src/data/checks.js861
-rw-r--r--src/data/composite.js1463
-rw-r--r--src/data/composite/control-flow/exitWithoutDependency.js35
-rw-r--r--src/data/composite/control-flow/exitWithoutUpdateValue.js24
-rw-r--r--src/data/composite/control-flow/exposeConstant.js26
-rw-r--r--src/data/composite/control-flow/exposeDependency.js28
-rw-r--r--src/data/composite/control-flow/exposeDependencyOrContinue.js34
-rw-r--r--src/data/composite/control-flow/exposeUpdateValueOrContinue.js40
-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.js16
-rw-r--r--src/data/composite/control-flow/inputAvailabilityCheckMode.js9
-rw-r--r--src/data/composite/control-flow/raiseOutputWithoutDependency.js39
-rw-r--r--src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js47
-rw-r--r--src/data/composite/control-flow/withAvailabilityFilter.js40
-rw-r--r--src/data/composite/control-flow/withResultOfAvailabilityCheck.js54
-rw-r--r--src/data/composite/data/excludeFromList.js50
-rw-r--r--src/data/composite/data/fillMissingListItems.js45
-rw-r--r--src/data/composite/data/index.js35
-rw-r--r--src/data/composite/data/withFilteredList.js50
-rw-r--r--src/data/composite/data/withFlattenedList.js41
-rw-r--r--src/data/composite/data/withIndexInList.js38
-rw-r--r--src/data/composite/data/withMappedList.js49
-rw-r--r--src/data/composite/data/withNearbyItemFromList.js73
-rw-r--r--src/data/composite/data/withPropertiesFromList.js86
-rw-r--r--src/data/composite/data/withPropertiesFromObject.js87
-rw-r--r--src/data/composite/data/withPropertyFromList.js94
-rw-r--r--src/data/composite/data/withPropertyFromObject.js89
-rw-r--r--src/data/composite/data/withSortedList.js115
-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.js66
-rw-r--r--src/data/composite/data/withUniqueItemsOnly.js40
-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/withTracks.js29
-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/index.js1
-rw-r--r--src/data/composite/things/flash-act/withFlashSide.js22
-rw-r--r--src/data/composite/things/flash/index.js1
-rw-r--r--src/data/composite/things/flash/withFlashAct.js22
-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/exitWithoutUniqueCoverArt.js26
-rw-r--r--src/data/composite/things/track/index.js17
-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/trackAdditionalNameList.js38
-rw-r--r--src/data/composite/things/track/withAllReleases.js47
-rw-r--r--src/data/composite/things/track/withAlwaysReferenceByDirectory.js97
-rw-r--r--src/data/composite/things/track/withContainingTrackSection.js20
-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.js108
-rw-r--r--src/data/composite/things/track/withMainRelease.js70
-rw-r--r--src/data/composite/things/track/withOtherReleases.js30
-rw-r--r--src/data/composite/things/track/withPropertyFromAlbum.js48
-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.js48
-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.js32
-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.js17
-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.js129
-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.js156
-rw-r--r--src/data/composite/wiki-data/withResolvedReference.js57
-rw-r--r--src/data/composite/wiki-data/withResolvedReferenceList.js80
-rw-r--r--src/data/composite/wiki-data/withResolvedSeriesList.js130
-rw-r--r--src/data/composite/wiki-data/withReverseReferenceList.js36
-rw-r--r--src/data/composite/wiki-data/withThingsSortedAlphabetically.js122
-rw-r--r--src/data/composite/wiki-data/withUniqueReferencingThing.js36
-rw-r--r--src/data/composite/wiki-properties/additionalFiles.js30
-rw-r--r--src/data/composite/wiki-properties/additionalNameList.js14
-rw-r--r--src/data/composite/wiki-properties/annotatedReferenceList.js64
-rw-r--r--src/data/composite/wiki-properties/color.js12
-rw-r--r--src/data/composite/wiki-properties/commentary.js34
-rw-r--r--src/data/composite/wiki-properties/commentatorArtists.js49
-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/contentString.js15
-rw-r--r--src/data/composite/wiki-properties/contribsPresent.js30
-rw-r--r--src/data/composite/wiki-properties/contributionList.js58
-rw-r--r--src/data/composite/wiki-properties/dimensions.js13
-rw-r--r--src/data/composite/wiki-properties/directory.js41
-rw-r--r--src/data/composite/wiki-properties/duration.js13
-rw-r--r--src/data/composite/wiki-properties/externalFunction.js11
-rw-r--r--src/data/composite/wiki-properties/fileExtension.js13
-rw-r--r--src/data/composite/wiki-properties/flag.js19
-rw-r--r--src/data/composite/wiki-properties/helpers/reference-list-helpers.js44
-rw-r--r--src/data/composite/wiki-properties/index.js38
-rw-r--r--src/data/composite/wiki-properties/lyrics.js36
-rw-r--r--src/data/composite/wiki-properties/name.js11
-rw-r--r--src/data/composite/wiki-properties/referenceList.js46
-rw-r--r--src/data/composite/wiki-properties/referencedArtworkList.js32
-rw-r--r--src/data/composite/wiki-properties/reverseReferenceList.js30
-rw-r--r--src/data/composite/wiki-properties/seriesList.js31
-rw-r--r--src/data/composite/wiki-properties/simpleDate.js14
-rw-r--r--src/data/composite/wiki-properties/simpleString.js12
-rw-r--r--src/data/composite/wiki-properties/singleReference.js46
-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/urls.js14
-rw-r--r--src/data/composite/wiki-properties/wallpaperParts.js9
-rw-r--r--src/data/composite/wiki-properties/wikiData.js27
-rw-r--r--src/data/language.js341
-rw-r--r--src/data/patches.js395
-rw-r--r--src/data/serialize.js48
-rw-r--r--src/data/thing.js125
-rw-r--r--src/data/things/album.js959
-rw-r--r--src/data/things/art-tag.js192
-rw-r--r--src/data/things/artist.js306
-rw-r--r--src/data/things/artwork.js399
-rw-r--r--src/data/things/contribution.js302
-rw-r--r--src/data/things/flash.js452
-rw-r--r--src/data/things/group.js242
-rw-r--r--src/data/things/homepage-layout.js338
-rw-r--r--src/data/things/index.js227
-rw-r--r--src/data/things/language.js913
-rw-r--r--src/data/things/news-entry.js73
-rw-r--r--src/data/things/sorting-rule.js386
-rw-r--r--src/data/things/static-page.js85
-rw-r--r--src/data/things/track.js753
-rw-r--r--src/data/things/wiki-info.js152
-rw-r--r--src/data/yaml.js1851
-rw-r--r--src/external-links.js1035
-rw-r--r--src/file-size-preloader.js155
-rw-r--r--src/find-reverse.js144
-rw-r--r--src/find.js426
-rw-r--r--src/gen-thumbs.js1551
-rw-r--r--src/html.js2017
-rw-r--r--src/import-heck.js9
-rw-r--r--src/listing-spec.js1139
-rw-r--r--src/misc-templates.js379
-rw-r--r--src/node-utils.js102
-rw-r--r--src/page/album-commentary.js143
-rw-r--r--src/page/album.js524
-rw-r--r--src/page/art-tag.js35
-rw-r--r--src/page/artist-alias.js46
-rw-r--r--src/page/artist.js593
-rw-r--r--src/page/flash-act.js23
-rw-r--r--src/page/flash.js277
-rw-r--r--src/page/group.js305
-rw-r--r--src/page/homepage.js137
-rw-r--r--src/page/index.js45
-rw-r--r--src/page/listing.js226
-rw-r--r--src/page/news.js141
-rw-r--r--src/page/static.js53
-rw-r--r--src/page/tag.js110
-rw-r--r--src/page/track.js365
-rw-r--r--src/replacer.js871
-rw-r--r--src/reverse.js141
-rw-r--r--src/search.js119
-rw-r--r--src/static/client.js415
-rw-r--r--src/static/css/site-basic.css19
-rw-r--r--src/static/css/site.css3640
-rw-r--r--src/static/icons.svg11
-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.js237
-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/lyrics-switcher.js70
-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.js54
-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.js513
-rw-r--r--src/static/js/search-worker.js621
-rw-r--r--src/static/js/xhr-util.js64
-rw-r--r--src/static/lazy-loading.js51
-rw-r--r--src/static/misc/icons.svg45
-rw-r--r--src/static/misc/image.svg11
-rw-r--r--src/static/misc/warning.svg93
-rw-r--r--src/static/shared-util/README.md11
-rw-r--r--src/static/site-basic.css19
-rw-r--r--src/static/site.css928
-rw-r--r--src/strings-default.json346
-rw-r--r--src/strings-default.yaml2379
-rw-r--r--src/thing/album.js62
-rw-r--r--src/thing/structures.js32
-rw-r--r--src/thing/thing.js66
-rwxr-xr-xsrc/upd8.js5796
-rw-r--r--src/url-spec.js275
-rw-r--r--src/urls-default.yaml144
-rw-r--r--src/urls.js347
-rw-r--r--src/util/cli.js210
-rw-r--r--src/util/colors.js21
-rw-r--r--src/util/find.js54
-rw-r--r--src/util/html.js94
-rw-r--r--src/util/link.js80
-rw-r--r--src/util/magic-constants.js11
-rw-r--r--src/util/node-utils.js27
-rw-r--r--src/util/replacer.js424
-rw-r--r--src/util/serialize.js71
-rw-r--r--src/util/strings.js287
-rw-r--r--src/util/sugar.js272
-rw-r--r--src/util/urls.js102
-rw-r--r--src/util/wiki-data.js283
-rw-r--r--src/validators.js1147
-rw-r--r--src/web-routes.js140
-rw-r--r--src/write/bind-utilities.js71
-rw-r--r--src/write/build-modes/index.js4
-rw-r--r--src/write/build-modes/live-dev-server.js576
-rw-r--r--src/write/build-modes/repl.js165
-rw-r--r--src/write/build-modes/sort.js76
-rw-r--r--src/write/build-modes/static-build.js632
-rw-r--r--src/write/common-templates.js57
532 files changed, 69431 insertions, 10541 deletions
diff --git a/src/aggregate.js b/src/aggregate.js
new file mode 100644
index 00000000..cb806e89
--- /dev/null
+++ b/src/aggregate.js
@@ -0,0 +1,752 @@
+import {colors} from '#cli';
+import {empty, typeAppearance} from '#sugar';
+
+// Utility function for providing useful interfaces to the JS AggregateError
+// class.
+//
+// Generally, this works by returning a set of interfaces which operate on
+// functions: wrap() takes a function and returns a new function which passes
+// its arguments through and appends any resulting error to the internal error
+// list; call() simplifies this process by wrapping the provided function and
+// then calling it immediately. Once the process for which errors should be
+// aggregated is complete, close() constructs and throws an AggregateError
+// object containing all caught errors (or doesn't throw anything if there were
+// no errors).
+export function openAggregate({
+  // Constructor to use, defaulting to the builtin AggregateError class.
+  // Anything passed here should probably extend from that! May be used for
+  // letting callers programatically distinguish between multiple aggregate
+  // errors.
+  //
+  // This should be provided using the aggregateThrows utility function.
+  [openAggregate.errorClassSymbol]: errorClass = AggregateError,
+
+  // Optional human-readable message to describe the aggregate error, if
+  // constructed.
+  message = '',
+
+  // Optional flag to indicate that this layer of the aggregate error isn't
+  // generally useful outside of developer debugging purposes - it will be
+  // skipped by default when using showAggregate, showing contained errors
+  // inline with other children of this aggregate's parent.
+  //
+  // If set to 'single', it'll be hidden only if there's a single error in the
+  // aggregate (so it's not grouping multiple errors together).
+  translucent = false,
+
+  // Value to return when a provided function throws an error. If this is a
+  // function, it will be called with the arguments given to the function.
+  // (This is primarily useful when wrapping a function and then providing it
+  // to another utility, e.g. array.map().)
+  returnOnFail = null,
+} = {}) {
+  const errors = [];
+
+  const aggregate = {};
+
+  aggregate.wrap =
+    (fn) =>
+    (...args) => {
+      try {
+        return fn(...args);
+      } catch (error) {
+        errors.push(error);
+        return typeof returnOnFail === 'function'
+          ? returnOnFail(...args)
+          : returnOnFail;
+      }
+    };
+
+  aggregate.wrapAsync =
+    (fn) =>
+    (...args) => {
+      return fn(...args).then(
+        (value) => value,
+        (error) => {
+          errors.push(error);
+          return typeof returnOnFail === 'function'
+            ? returnOnFail(...args)
+            : returnOnFail;
+        }
+      );
+    };
+
+  aggregate.push = (error) => {
+    errors.push(error);
+  };
+
+  aggregate.call = (fn, ...args) => {
+    return aggregate.wrap(fn)(...args);
+  };
+
+  aggregate.callAsync = (fn, ...args) => {
+    return aggregate.wrapAsync(fn)(...args);
+  };
+
+  aggregate.nest = (...args) => {
+    return aggregate.call(() => withAggregate(...args));
+  };
+
+  aggregate.nestAsync = (...args) => {
+    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);
+    parent.call(child.close);
+    return result;
+  };
+
+  aggregate.mapAsync = async (...args) => {
+    const parent = aggregate;
+    const {result, aggregate: child} = await mapAggregateAsync(...args);
+    parent.call(child.close);
+    return result;
+  };
+
+  aggregate.filter = (...args) => {
+    const parent = aggregate;
+    const {result, aggregate: child} = filterAggregate(...args);
+    parent.call(child.close);
+    return result;
+  };
+
+  aggregate.throws = aggregateThrows;
+
+  aggregate.close = () => {
+    if (errors.length) {
+      const error = Reflect.construct(errorClass, [errors, message]);
+
+      if (translucent) {
+        error[Symbol.for('hsmusic.aggregate.translucent')] = translucent;
+      }
+
+      throw error;
+    }
+  };
+
+  return aggregate;
+}
+
+openAggregate.errorClassSymbol = Symbol('error class');
+
+// Utility function for providing {errorClass} parameter to aggregate functions.
+export function aggregateThrows(errorClass) {
+  return {[openAggregate.errorClassSymbol]: errorClass};
+}
+
+// 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 {
+    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.
+//
+// Optionally, override returnOnFail to disable filtering and map errored inputs
+// to a particular output.
+//
+// Note the aggregate property is the result of openAggregate(), still unclosed;
+// 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);
+  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);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
+  const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
+  return _mapAggregate('async', promiseAll, array, fn, remainingOpts);
+}
+
+// Helper function for mapAggregate which holds code common between sync and
+// async versions.
+export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) {
+  const failureSymbol = Symbol();
+
+  const aggregate = openAggregate({
+    returnOnFail: failureSymbol,
+    ...aggregateOpts,
+  });
+
+  if (mode === 'sync') {
+    const result = array
+      .map(aggregate.wrap(fn))
+      .filter((value) => value !== failureSymbol);
+    return {result, aggregate};
+  } else {
+    return promiseAll(array.map(aggregate.wrapAsync(fn)))
+      .then((values) => {
+        const result = values.filter((value) => value !== failureSymbol);
+        return {result, aggregate};
+      });
+  }
+}
+
+// Performs an ordinary array filter with the given function, collating into a
+// results array (with errored inputs filtered out) and an error aggregate.
+//
+// Optionally, override returnOnFail to disable filtering errors and map errored
+// inputs to a particular output.
+//
+// As with mapAggregate, the returned aggregate property is not yet closed.
+export function filterAggregate(array, 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);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
+  const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
+  return _filterAggregate('async', promiseAll, array, fn, remainingOpts);
+}
+
+// Helper function for filterAggregate which holds code common between sync and
+// async versions.
+function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) {
+  const failureSymbol = Symbol();
+
+  const aggregate = openAggregate({
+    returnOnFail: failureSymbol,
+    ...aggregateOpts,
+  });
+
+  function filterFunction(value) {
+    // Filter out results which match the failureSymbol, i.e. errored
+    // inputs.
+    if (value === failureSymbol) return false;
+
+    // Always keep results which match the overridden returnOnFail
+    // value, if provided.
+    if (value === aggregateOpts.returnOnFail) return true;
+
+    // Otherwise, filter according to the returned value of the wrapped
+    // function.
+    return value.output;
+  }
+
+  function mapFunction(value) {
+    // Then turn the results back into their corresponding input, or, if
+    // provided, the overridden returnOnFail value.
+    return value === aggregateOpts.returnOnFail ? value : value.input;
+  }
+
+  if (mode === 'sync') {
+    const result = array
+      .map(aggregate.wrap((input, index, array) => {
+        const output = fn(input, index, array);
+        return {input, output};
+      }))
+      .filter(filterFunction)
+      .map(mapFunction);
+
+    return {result, aggregate};
+  } else {
+    return promiseAll(
+      array.map(aggregate.wrapAsync(async (input, index, array) => {
+        const output = await fn(input, index, array);
+        return {input, output};
+      }))
+    ).then((values) => {
+      const result = values.filter(filterFunction).map(mapFunction);
+
+      return {result, aggregate};
+    });
+  }
+}
+
+// Totally sugar function for opening an aggregate, running the provided
+// 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);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
+  return _withAggregate('sync', opts, fn);
+}
+
+export function withAggregateAsync(arg1, arg2) {
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
+  return _withAggregate('async', opts, fn);
+}
+
+export function _withAggregate(mode, aggregateOpts, fn) {
+  const aggregate = openAggregate(aggregateOpts);
+
+  if (mode === 'sync') {
+    const result = fn(aggregate);
+    aggregate.close();
+    return result;
+  } else {
+    return fn(aggregate).then((result) => {
+      aggregate.close();
+      return result;
+    });
+  }
+}
+
+export const unhelpfulTraceLines = [
+  /sugar/,
+  /sort/,
+  /aggregate/,
+  /composite/,
+  /cacheable-object/,
+  /node:/,
+  /<anonymous>/,
+];
+
+export function getUsefulTraceLine(trace, {helpful, unhelpful}) {
+  if (!trace) return '';
+
+  for (const traceLine of trace.split('\n')) {
+    if (!traceLine.trim().startsWith('at')) {
+      continue;
+    }
+
+    if (!empty(unhelpful)) {
+      if (unhelpful.some(regex => regex.test(traceLine))) {
+        continue;
+      }
+    }
+
+    if (!empty(helpful)) {
+      for (const regex of helpful) {
+        const match = traceLine.match(regex);
+
+        if (match) {
+          return match[1] ?? traceLine;
+        }
+      }
+
+      continue;
+    }
+
+    return traceLine;
+  }
+
+  return '';
+}
+
+export function showAggregate(topError, {
+  pathToFileURL = f => f,
+  showTraces = true,
+  showTranslucent = showTraces,
+  print = true,
+} = {}) {
+  const getTranslucency = error =>
+    error[Symbol.for('hsmusic.aggregate.translucent')] ?? false;
+
+  const determineCauseHelper = cause => {
+    if (!cause) {
+      return null;
+    }
+
+    const translucency = getTranslucency(cause);
+
+    if (!translucency) {
+      return cause;
+    }
+
+    if (translucency === 'single') {
+      if (cause.errors?.length === 1) {
+        return determineCauseHelper(cause.errors[0]);
+      } else {
+        return cause;
+      }
+    }
+
+    if (cause.cause) {
+      return determineCauseHelper(cause.cause);
+    }
+
+    if (cause.errors) {
+      return determineErrorsHelper(cause);
+    }
+
+    return cause;
+  };
+
+  const determineCause = error =>
+    (showTranslucent
+      ? error.cause ?? null
+      : determineCauseHelper(error.cause));
+
+  const determineErrorsHelper = error => {
+    const translucency = getTranslucency(error);
+
+    if (!translucency) {
+      return [error];
+    }
+
+    if (translucency === 'single' && error.errors?.length >= 2) {
+      return [error];
+    }
+
+    const errors = [];
+
+    if (error.cause) {
+      errors.push(...determineErrorsHelper(error.cause));
+    }
+
+    if (error.errors) {
+      errors.push(...error.errors.flatMap(determineErrorsHelper));
+    }
+
+    return errors;
+  };
+
+  const determineErrors = error =>
+    (showTranslucent
+      ? error.errors ?? null
+      : error.errors?.flatMap(determineErrorsHelper) ?? null);
+
+  const flattenErrorStructure = (error, level = 0) => {
+    const cause = determineCause(error); // may be an array!
+    const errors = determineErrors(error);
+
+    return {
+      level,
+
+      kind: error.constructor.name,
+      message: error.message,
+
+      trace:
+        (error[Symbol.for(`hsmusic.aggregate.traceFrom`)]
+          ? error[Symbol.for(`hsmusic.aggregate.traceFrom`)].stack
+          : error.stack),
+
+      cause:
+        (Array.isArray(cause)
+          ? cause.map(cause => flattenErrorStructure(cause, level + 1))
+       : cause
+          ? flattenErrorStructure(cause, level + 1)
+          : null),
+
+      errors:
+        (errors
+          ? errors.map(error => flattenErrorStructure(error, level + 1))
+          : null),
+
+      options: {
+        alwaysTrace:
+          error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)],
+
+        helpfulTraceLines:
+          error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)],
+
+        unhelpfulTraceLines:
+          error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)],
+      }
+    };
+  };
+
+  const recursive = ({
+    level,
+    kind,
+    message,
+    trace,
+    cause,
+    errors,
+    options: {
+      alwaysTrace,
+      helpfulTraceLines: ownHelpfulTraceLines,
+      unhelpfulTraceLines: ownUnhelpfulTraceLines,
+    },
+  }, index, apparentSiblings) => {
+    const causeSingle = Array.isArray(cause) ? null : cause;
+    const causeArray = Array.isArray(cause) ? cause : null;
+
+    const subApparentSiblings =
+      (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));
+
+    const messagePart =
+      message || `(no message)`;
+
+    const kindPart =
+      kind || `unnamed kind`;
+
+    let headerPart =
+      (showTraces
+        ? `[${kindPart}] ${messagePart}`
+     : errors
+        ? `[${messagePart}]`
+     : anythingHasErrorsThisLayer
+        ? ` ${messagePart}`
+        : messagePart);
+
+    if (showTraces || alwaysTrace) {
+      const traceLine =
+        getUsefulTraceLine(trace, {
+          unhelpful:
+            (ownUnhelpfulTraceLines
+              ? unhelpfulTraceLines.concat(ownUnhelpfulTraceLines)
+              : unhelpfulTraceLines),
+
+          helpful:
+            (ownHelpfulTraceLines
+              ? ownHelpfulTraceLines
+              : null),
+        });
+
+      const tracePart =
+        (traceLine
+          ? '- ' +
+            traceLine
+              .trim()
+              .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match))
+          : '(no stack trace)');
+
+      headerPart += ` ${colors.dim(tracePart)}`;
+    }
+
+    const head1 = level % 2 === 0 ? '\u21aa' : colors.dim('\u21aa');
+    const bar1 = ' ';
+
+    const causePart =
+      (causeSingle
+        ? recursive(causeSingle, 0, subApparentSiblings)
+            .split('\n')
+            .map((line, i) => i === 0 ? ` ${head1} ${line}` : ` ${bar1} ${line}`)
+            .join('\n')
+        : '');
+
+    const head2 = level % 2 === 0 ? '\u257f' : colors.dim('\u257f');
+    const bar2 = level % 2 === 0 ? '\u2502' : colors.dim('\u254e');
+
+    const errorsPart =
+      (presentedAsErrors
+        ? presentedAsErrors
+            .map((error, index) => recursive(error, index + 1, subApparentSiblings))
+            .flatMap(str => str.split('\n'))
+            .map((line, i) => i === 0 ? ` ${head2} ${line}` : ` ${bar2} ${line}`)
+            .join('\n')
+        : '');
+
+    return [headerPart, errorsPart, causePart].filter(Boolean).join('\n');
+  };
+
+  const structure = flattenErrorStructure(topError);
+  const message = recursive(structure, 0, [structure]);
+
+  if (print) {
+    console.error(message);
+  } else {
+    return message;
+  }
+}
+
+export function annotateError(error, ...callbacks) {
+  for (const callback of callbacks) {
+    error = callback(error) ?? error;
+  }
+
+  return error;
+}
+
+export function annotateErrorWithIndex(error, index) {
+  return Object.assign(error, {
+    [Symbol.for('hsmusic.annotateError.indexInSourceArray')]:
+      index,
+
+    message:
+      `(${colors.yellow(`#${index + 1}`)}) ` +
+      error.message,
+  });
+}
+
+export function annotateErrorWithFile(error, file) {
+  return Object.assign(error, {
+    [Symbol.for('hsmusic.annotateError.file')]:
+      file,
+
+    message:
+      error.message +
+      (error.message.includes('\n') ? '\n' : ' ') +
+      `(file: ${colors.bright(colors.blue(file))})`,
+  });
+}
+
+export function asyncAdaptiveDecorateError(fn, callback) {
+  if (typeof callback !== 'function') {
+    throw new Error(`Expected callback to be a function, got ${typeAppearance(callback)}`);
+  }
+
+  const syncDecorated = function (...args) {
+    try {
+      return fn(...args);
+    } catch (caughtError) {
+      throw callback(caughtError, ...args);
+    }
+  };
+
+  const asyncDecorated = async function(...args) {
+    try {
+      return await fn(...args);
+    } catch (caughtError) {
+      throw callback(caughtError, ...args);
+    }
+  };
+
+  syncDecorated.async = asyncDecorated;
+
+  return syncDecorated;
+}
+
+export function decorateError(fn, callback) {
+  return asyncAdaptiveDecorateError(fn, callback);
+}
+
+export function asyncDecorateError(fn, callback) {
+  return asyncAdaptiveDecorateError(fn, callback).async;
+}
+
+export function decorateErrorWithAnnotation(fn, ...annotationCallbacks) {
+  return asyncAdaptiveDecorateError(fn,
+    (caughtError, ...args) =>
+      annotateError(caughtError,
+        ...annotationCallbacks
+          .map(callback => error => callback(error, ...args))));
+}
+
+export function decorateErrorWithIndex(fn) {
+  return decorateErrorWithAnnotation(fn,
+    (caughtError, _value, index) =>
+      annotateErrorWithIndex(caughtError, index));
+}
+
+export function decorateErrorWithCause(fn, cause) {
+  return asyncAdaptiveDecorateError(fn,
+    (caughtError) =>
+      Object.assign(caughtError, {cause}));
+}
+
+export function asyncDecorateErrorWithAnnotation(fn, ...annotationCallbacks) {
+  return decorateErrorWithAnnotation(fn, ...annotationCallbacks).async;
+}
+
+export function asyncDecorateErrorWithIndex(fn) {
+  return decorateErrorWithIndex(fn).async;
+}
+
+export function asyncDecorateErrorWithCause(fn, cause) {
+  return decorateErrorWithCause(fn, cause).async;
+}
+
+export function conditionallySuppressError(conditionFn, callbackFn) {
+  return (...args) => {
+    try {
+      return callbackFn(...args);
+    } catch (error) {
+      if (conditionFn(error, ...args) === true) {
+        return;
+      }
+
+      throw error;
+    }
+  };
+}
diff --git a/src/cli.js b/src/cli.js
new file mode 100644
index 00000000..bd4ec685
--- /dev/null
+++ b/src/cli.js
@@ -0,0 +1,515 @@
+// Utility functions for CLI- and de8ugging-rel8ted stuff.
+
+import {sortByName} from '#sort';
+
+export const ENABLE_COLOR =
+  ((process.env.CLICOLOR_FORCE && process.env.CLICOLOR_FORCE === '1') ??
+    (process.env.CLICOLOR &&
+      process.env.CLICOLOR === '1' &&
+      process.stdout.hasColors &&
+      process.stdout.hasColors()) ??
+    (process.stdout.hasColors ? process.stdout.hasColors() : true));
+
+const C = (n) =>
+  ENABLE_COLOR ? (text) => `\x1b[${n}m${text}\x1b[0m` : (text) => text;
+
+export const colors = {
+  bright: C('1'),
+  dim: C('2'),
+  normal: C('22'),
+  black: C('30'),
+  red: C('31'),
+  green: C('32'),
+  yellow: C('33'),
+  blue: C('34'),
+  magenta: C('35'),
+  cyan: C('36'),
+  white: C('37'),
+};
+
+const logColor =
+  (color) =>
+  (literals, ...values) => {
+    const w = (s) => process.stdout.write(s);
+    const wc = (text) => {
+      if (ENABLE_COLOR) w(text);
+    };
+
+    wc(`\x1b[${color}m`);
+    for (let i = 0; i < literals.length; i++) {
+      w(literals[i]);
+      if (values[i] !== undefined) {
+        wc(`\x1b[1m`);
+        w(String(values[i]));
+        wc(`\x1b[0;${color}m`);
+      }
+    }
+    wc(`\x1b[0m`);
+    w('\n');
+  };
+
+export const logInfo = logColor(2);
+export const logWarn = logColor(33);
+export const logError = logColor(31);
+
+// Stolen as #@CK from mtui!
+export async function parseOptions(options, optionDescriptorMap) {
+  // This function is sorely lacking in comments, but the basic usage is
+  // as such:
+  //
+  // options is the array of options you want to process;
+  // optionDescriptorMap is a mapping of option names to objects that describe
+  // the expected value for their corresponding options.
+  //
+  // Returned is...
+  // - a mapping of any specified option names to their values
+  // - a process.exit(1) and error message if there were any issues
+  //
+  // Here are examples of optionDescriptorMap to cover all the things you can
+  // do with it:
+  //
+  // optionDescriptorMap: {
+  //   'telnet-server': {type: 'flag'},
+  //   't': {alias: 'telnet-server'}
+  // }
+  //
+  // options: ['t'] -> result: {'telnet-server': true}
+  //
+  // optionDescriptorMap: {
+  //   'directory': {
+  //     type: 'value',
+  //     validate(name) {
+  //       // const whitelistedDirectories = ['apple', 'banana']
+  //       if (whitelistedDirectories.includes(name)) {
+  //         return true
+  //       } else {
+  //         return 'a whitelisted directory'
+  //       }
+  //     }
+  //   },
+  //   'files': {type: 'series'}
+  // }
+  //
+  // ['--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];
+
+  const result = Object.create(null);
+  for (let i = 0; i < options.length; i++) {
+    const option = options[i];
+    if (option.startsWith('--')) {
+      // --x can be a flag or expect a value or series of values
+      let name = option.slice(2).split('=')[0]; // '--x'.split('=') = ['--x']
+      let descriptor = optionDescriptorMap[name];
+
+      if (!descriptor) {
+        if (handleUnknown) {
+          handleUnknown(option);
+        } else {
+          console.error(`Unknown option name: ${name}`);
+          process.exit(1);
+        }
+        continue;
+      }
+
+      if (descriptor.alias) {
+        name = descriptor.alias;
+        descriptor = optionDescriptorMap[name];
+      }
+
+      switch (descriptor.type) {
+        case 'flag': {
+          result[name] = true;
+          break;
+        }
+
+        case 'value': {
+          let value = option.slice(2).split('=')[1];
+          if (!value) {
+            value = options[++i];
+            if (!value || value.startsWith('-')) {
+              value = null;
+            }
+          }
+
+          if (!value) {
+            console.error(`Expected a value for --${name}`);
+            process.exit(1);
+          }
+
+          result[name] = value;
+          break;
+        }
+
+        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(';')) {
+            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(';');
+          result[name] = options.slice(i + 1, endIndex);
+          i = endIndex;
+          break;
+        }
+      }
+
+      if (descriptor.validate) {
+        const validation = await descriptor.validate(result[name]);
+        if (validation !== true) {
+          console.error(`Expected ${validation} for --${name}`);
+          process.exit(1);
+        }
+      }
+    } else if (option.startsWith('-')) {
+      // mtui doesn't use any -x=y or -x y format optionuments
+      // -x will always just be a flag
+      let name = option.slice(1);
+      let descriptor = optionDescriptorMap[name];
+      if (!descriptor) {
+        if (handleUnknown) {
+          handleUnknown(option);
+        } else {
+          console.error(`Unknown option name: ${name}`);
+          process.exit(1);
+        }
+        continue;
+      }
+
+      if (descriptor.alias) {
+        name = descriptor.alias;
+        descriptor = optionDescriptorMap[name];
+      }
+
+      if (descriptor.type === 'flag') {
+        result[name] = true;
+      } else {
+        console.error(`Use --${name} (value) to specify ${name}`);
+        process.exit(1);
+      }
+    } else if (handleDashless) {
+      handleDashless(option);
+    }
+  }
+  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();
+
+export function decorateTime(arg1, arg2) {
+  const [id, functionToBeWrapped] =
+    typeof arg1 === 'string' || typeof arg1 === 'symbol'
+      ? [arg1, arg2]
+      : [Symbol(arg1.name), arg1];
+
+  const meta = decorateTime.idMetaMap[id] ?? {
+    wrappedName: functionToBeWrapped.name,
+    timeSpent: 0,
+    timesCalled: 0,
+    displayTime() {
+      const align1 = 48;
+      const align2 = 22;
+
+      const averageTime = (meta.timeSpent / meta.timesCalled).toExponential(1);
+      const idPart = typeof id === 'symbol' ? id.description : id;
+      const timePart = `${meta.timeSpent} ms / ${meta.timesCalled} calls`;
+      const avgPart = `(avg: ${averageTime} ms)`;
+
+      const alignPart1 =
+        (idPart.length >= align1
+          ? ' '
+          : ' ' + '.'.repeat(Math.max(0, align1 - 2 - idPart.length)) + ' ');
+
+      const alignPart2 =
+        (timePart.length >= align2
+          ? ' '
+          : ' '.repeat(Math.max(0, align2 - timePart.length)));
+
+      console.log(
+        colors.bright(idPart) +
+        alignPart1 +
+        timePart +
+        alignPart2 +
+        colors.dim(avgPart));
+    },
+  };
+
+  decorateTime.idMetaMap[id] = meta;
+
+  const fn = function (...args) {
+    const start = Date.now();
+    const ret = functionToBeWrapped.apply(this, args);
+    const end = Date.now();
+    meta.timeSpent += end - start;
+    meta.timesCalled++;
+    return ret;
+  };
+
+  fn.displayTime = meta.displayTime;
+
+  return fn;
+}
+
+decorateTime.idMetaMap = Object.create(null);
+
+decorateTime.displayTime = function () {
+  const map = decorateTime.idMetaMap;
+
+  const keys = [
+    ...Object.getOwnPropertySymbols(map),
+    ...Object.getOwnPropertyNames(map),
+  ];
+
+  if (!keys.length) {
+    return;
+  }
+
+  console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m');
+
+  const metas =
+    keys
+      .map(key => map[key])
+      .filter(meta => meta.timeSpent >= 1)  // Milliseconds!
+      .sort((a, b) => a.timeSpent - b.timeSpent);
+
+  for (const meta of metas) {
+    meta.displayTime();
+  }
+};
+
+export function progressPromiseAll(msgOrMsgFn, array) {
+  if (!array.length) {
+    return Promise.resolve([]);
+  }
+
+  const msgFn =
+    typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn;
+
+  let done = 0,
+    total = array.length;
+  process.stdout.write(`\r${msgFn()} [0/${total}]`);
+  const start = Date.now();
+  return Promise.all(
+    array.map((promise) =>
+      Promise.resolve(promise).then((val) => {
+        done++;
+        // const pc = `${done}/${total}`;
+        const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd(
+          '99.9%'.length,
+          ' '
+        );
+        if (done === total) {
+          const time = Date.now() - start;
+          process.stdout.write(
+            `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`
+          );
+        } else {
+          process.stdout.write(`\r${msgFn()} [${pc}] `);
+        }
+        return val;
+      })
+    )
+  );
+}
+
+export function progressCallAll(msgOrMsgFn, array) {
+  if (!array.length) {
+    return [];
+  }
+
+  const msgFn =
+    typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn;
+
+  const updateInterval = 1000 / 60;
+
+  let done = 0,
+    total = array.length;
+  process.stdout.write(`\r${msgFn()} [0/${total}]`);
+  const start = Date.now();
+  const vals = [];
+  let lastTime = 0;
+
+  for (const fn of array) {
+    const val = fn();
+    done++;
+
+    if (done === total) {
+      const pc = '100%'.padEnd('99.9%'.length, ' ');
+      const time = Date.now() - start;
+      process.stdout.write(
+        `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`
+      );
+    } else if (Date.now() - lastTime >= updateInterval) {
+      const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd('99.9%'.length, ' ');
+      process.stdout.write(`\r${msgFn()} [${pc}] `);
+      lastTime = Date.now();
+    }
+    vals.push(val);
+  }
+
+  return vals;
+}
+
+export function fileIssue({
+  topMessage = `This shouldn't happen.`,
+} = {}) {
+  if (topMessage) {
+    console.error(colors.red(`${topMessage} Please let the HSMusic developers know:`));
+  }
+  console.error(colors.red(`- https://hsmusic.wiki/feedback/`));
+  console.error(colors.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`));
+}
+
+export async function logicalCWD() {
+  if (process.env.PWD) {
+    return process.env.PWD;
+  }
+
+  const {exec} = await import('node:child_process');
+  const {stat} = await import('node:fs/promises');
+
+  try {
+    await stat('/bin/sh');
+  } catch (error) {
+    // Not logical, so sad.
+    return process.cwd();
+  }
+
+  const proc = exec('/bin/pwd -L');
+
+  let output = '';
+  proc.stdout.on('data', buf => { output += buf; });
+
+  await new Promise(resolve => proc.on('exit', resolve));
+
+  return output.trim();
+}
+
+export async function logicalPathTo(target) {
+  const {relative} = await import('node:path');
+  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/common-util/colors.js b/src/common-util/colors.js
new file mode 100644
index 00000000..7298c46a
--- /dev/null
+++ b/src/common-util/colors.js
@@ -0,0 +1,44 @@
+// Color and theming utility functions! Handy.
+
+export function getColors(themeColor, {
+  // chroma.js external dependency (https://gka.github.io/chroma.js/)
+  chroma,
+} = {}) {
+  if (!chroma) {
+    throw new Error('chroma.js library must be passed or bound for color manipulation');
+  }
+
+  const primary = chroma(themeColor);
+
+  const dark = primary.luminance(0.02);
+  const dim = primary.desaturate(2).darken(1.5);
+  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);
+  const shadow = primary.desaturate(4).set('hsl.l', 0.05).alpha(0.8);
+
+  const hsl = primary.hsl();
+  if (isNaN(hsl[0])) hsl[0] = 0;
+
+  return {
+    primary: primary.hex(),
+
+    dark: dark.hex(),
+    dim: dim.hex(),
+    deep: deep.hex(),
+    deepGhost: deepGhost.hex(),
+    light: light.hex(),
+    lightGhost: lightGhost.hex(),
+
+    bg: bg.hex(),
+    bgBlack: bgBlack.hex(),
+    shadow: shadow.hex(),
+
+    rgb: primary.rgb(),
+    hsl,
+  };
+}
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/common-util/serialize.js b/src/common-util/serialize.js
new file mode 100644
index 00000000..eb18a759
--- /dev/null
+++ b/src/common-util/serialize.js
@@ -0,0 +1,77 @@
+// Utils used when per-wiki-object data files.
+// Retained for reference and/or later reorganization.
+//
+// Not to be confused with data/serialize.js, which provides a generic
+// interface for serializing any Thing object.
+
+/*
+export function serializeLink(thing) {
+  const ret = {};
+  ret.name = thing.name;
+  ret.directory = thing.directory;
+  if (thing.color) ret.color = thing.color;
+  return ret;
+}
+
+export function serializeContribs(contribs) {
+  return contribs.map(({artist, annotation}) => {
+    const ret = {};
+    ret.artist = serializeLink(artist);
+    if (annotation) ret.contribution = annotation;
+    return ret;
+  });
+}
+
+export function serializeImagePaths(original, {thumb}) {
+  return {
+    original,
+    medium: thumb.medium(original),
+    small: thumb.small(original),
+  };
+}
+
+export function serializeCover(thing, pathFunction, {
+  serializeImagePaths,
+  urls,
+}) {
+  const coverPath = pathFunction(thing, {
+    to: urls.from('media.root').to,
+  });
+
+  const {artTags} = thing;
+
+  const cwTags = artTags.filter((tag) => tag.isContentWarning);
+  const linkTags = artTags.filter((tag) => !tag.isContentWarning);
+
+  return {
+    paths: serializeImagePaths(coverPath),
+    tags: linkTags.map(serializeLink),
+    warnings: cwTags.map((tag) => tag.name),
+  };
+}
+
+export function serializeGroupsForAlbum(album, {serializeLink}) {
+  return album.groups
+    .map((group) => {
+      const index = group.albums.indexOf(album);
+      const next = group.albums[index + 1] || null;
+      const previous = group.albums[index - 1] || null;
+      return {group, index, next, previous};
+    })
+    .map(({group, index, next, previous}) => ({
+      link: serializeLink(group),
+      descriptionShort: group.descriptionShort,
+      albumIndex: index,
+      nextAlbum: next && serializeLink(next),
+      previousAlbum: previous && serializeLink(previous),
+      urls: group.urls,
+    }));
+}
+
+export function serializeGroupsForTrack(track, {serializeLink}) {
+  return track.album.groups.map((group) => ({
+    link: serializeLink(group),
+    urls: group.urls,
+  }));
+}
+*/
diff --git a/src/common-util/sort.js b/src/common-util/sort.js
new file mode 100644
index 00000000..d93d94c1
--- /dev/null
+++ b/src/common-util/sort.js
@@ -0,0 +1,461 @@
+// Sorting functions - all utils here are mutating, so make sure to initially
+// slice/filter/somehow generate a new array from input data if retaining the
+// 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';
+
+// General sorting utilities! These don't do any sorting on their own but are
+// handy in the sorting functions below (or if you're making your own sort).
+
+export function compareCaseLessSensitive(a, b) {
+  // Compare two strings without considering capitalization... unless they
+  // happen to be the same that way.
+
+  const al = a.toLowerCase();
+  const bl = b.toLowerCase();
+
+  return al === bl
+    ? 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
+// to have considered while sorting. The words part of this is English-only for
+// now, which is totally evil.
+export function normalizeName(s) {
+  // Turn (some) ligatures into expanded variant for cleaner sorting, e.g.
+  // "ff" into "ff", in decompose mode, so that "ü" is represented as two
+  // bytes ("u" + \u0308 combining diaeresis).
+  s = s.normalize('NFKD');
+
+  // Replace one or more whitespace of any kind in a row, as well as certain
+  // punctuation, with a single typical space, then trim the ends.
+  s = s
+    .replace(
+      /[\p{Separator}\p{Dash_Punctuation}\p{Connector_Punctuation}]+/gu,
+      ' '
+    )
+    .trim();
+
+  // Discard anything that isn't a letter, number, or space.
+  s = s.replace(/[^\p{Letter}\p{Number} ]/gu, '').trim();
+
+  // Remove common English (only, for now) prefixes.
+  s = s.replace(/^(?:an?|the) /i, '');
+
+  return s;
+}
+
+// Component sort functions - these sort by one particular property, applying
+// unique particulars where appropriate. Usually you don't want to use these
+// directly, but if you're making a custom sort they can come in handy.
+
+// Universal method for sorting things into a predictable order, as directory
+// is taken to be unique. There are two exceptions where this function (and
+// thus any of the composite functions that start with it) *can't* be taken as
+// deterministic:
+//
+//  1) Mixed data of two different Things, as directories are only taken as
+//     unique within one given class of Things. For example, this function
+//     won't be deterministic if its array contains both <album:ithaca> and
+//     <track:ithaca>.
+//
+//  2) Duplicate directories, or multiple instances of the "same" Thing.
+//     This function doesn't differentiate between two objects of the same
+//     directory, regardless of any other properties or the overall "identity"
+//     of the object.
+//
+// These exceptions are unavoidable except for not providing that kind of data
+// in the first place, but you can still ensure the overall program output is
+// deterministic by ensuring the input is arbitrarily sorted according to some
+// other criteria - ex, although sortByDirectory itself isn't determinstic when
+// given mixed track and album data, the final output (what goes on the site)
+// will always be the same if you're doing sortByDirectory([...albumData,
+// ...trackData]), because the initial sort places albums before tracks - and
+// sortByDirectory will handle the rest, given all directories are unique
+// except when album and track directories overlap with each other.
+export function sortByDirectory(data, {
+  getDirectory = object => object.directory,
+} = {}) {
+  const directories = data.map(getDirectory);
+
+  sortMultipleArrays(data, directories,
+    (a, b, directoryA, directoryB) =>
+      compareCaseLessSensitive(directoryA, directoryB));
+
+  return data;
+}
+
+export function sortByName(data, {
+  getName = object => object.name,
+} = {}) {
+  const names = data.map(getName);
+  const normalizedNames = names.map(normalizeName);
+
+  sortMultipleArrays(data, normalizedNames, names,
+    (
+      a, b,
+      normalizedA, normalizedB,
+      nonNormalizedA, nonNormalizedB,
+    ) =>
+      compareNormalizedNames(
+        normalizedA, normalizedB,
+        nonNormalizedA, nonNormalizedB,
+      ));
+
+  return data;
+}
+
+export function compareNormalizedNames(
+  normalizedA, normalizedB,
+  nonNormalizedA, nonNormalizedB,
+) {
+  const comparison = compareCaseLessSensitive(normalizedA, normalizedB);
+  return (
+    (comparison === 0
+      ? compareCaseLessSensitive(nonNormalizedA, nonNormalizedB)
+      : comparison));
+}
+
+export function sortByDate(data, {
+  getDate = object => object.date,
+  latestFirst = false,
+} = {}) {
+  const dates = data.map(getDate);
+
+  sortMultipleArrays(data, dates,
+    (a, b, dateA, dateB) =>
+      compareDates(dateA, dateB, {latestFirst}));
+
+  return data;
+}
+
+export function compareDates(a, b, {
+  latestFirst = false,
+} = {}) {
+  if (a && b) {
+    return (latestFirst ? b - a : a - b);
+  }
+
+  // It's possible for objects with and without dates to be mixed
+  // together in the same array. If that's the case, we put all items
+  // without dates at the end.
+  if (a) return -1;
+  if (b) return 1;
+
+  // If neither of the items being compared have a date, don't move
+  // them relative to each other. This is basically the same as
+  // filtering out all non-date items and then pushing them at the
+  // end after sorting the rest.
+  return 0;
+}
+
+export function getLatestDate(dates) {
+  const filtered = dates.filter(Boolean);
+  if (empty(filtered)) return null;
+
+  return filtered
+    .reduce(
+      (accumulator, date) =>
+        date > accumulator ? date : accumulator,
+      -Infinity);
+}
+
+export function getEarliestDate(dates) {
+  const filtered = dates.filter(Boolean);
+  if (empty(filtered)) return null;
+
+  return filtered
+    .reduce(
+      (accumulator, date) =>
+        date < accumulator ? date : accumulator,
+      Infinity);
+}
+
+// Funky sort which takes a data set and a corresponding list of "counts",
+// which are really arbitrary numbers representing some property of each data
+// object defined by the caller. It sorts and mutates *both* of these, so the
+// sorted data will still correspond to the same indexed count.
+export function sortByCount(data, counts, {
+  greatestFirst = false,
+} = {}) {
+  sortMultipleArrays(data, counts, (data1, data2, count1, count2) =>
+    (greatestFirst
+      ? count2 - count1
+      : count1 - count2));
+
+  return data;
+}
+
+export function sortByPositionInParent(data, {
+  getParent,
+  getChildren,
+}) {
+  return data.sort((a, b) => {
+    const parentA = getParent(a);
+    const parentB = getParent(b);
+
+    // Don't change the sort when the two items are from separate parents.
+    // This function doesn't change the order of parents or try to "merge"
+    // two separated chunks of items from the same parent together.
+    if (parentA !== parentB) {
+      return 0;
+    }
+
+    // Don't change the sort when either (or both) of the items doesn't
+    // even have a parent (e.g. it's the passed data is a mixed array of
+    // children and parents).
+    if (!parentA || !parentB) {
+      return 0;
+    }
+
+    const indexA = getChildren(parentA).indexOf(a);
+    const indexB = getChildren(parentB).indexOf(b);
+
+    // If the getParent/getChildren relationship doesn't go both ways for
+    // some reason, don't change the sort.
+    if (indexA === -1 || indexB === -1) {
+      return 0;
+    }
+
+    return indexA - indexB;
+  });
+}
+
+export function sortByPositionInAlbum(data) {
+  return sortByPositionInParent(data, {
+    getParent: track => track.album,
+    getChildren: album => album.tracks,
+  });
+}
+
+export function sortByPositionInFlashAct(data) {
+  return sortByPositionInParent(data, {
+    getParent: flash => flash.act,
+    getChildren: act => act.flashes,
+  });
+}
+
+// Sorts data so that items are grouped together according to whichever of a
+// set of arbitrary given conditions is true first. If no conditions are met
+// for a given item, it's moved over to the end!
+export function sortByConditions(data, conditions) {
+  return data.sort((a, b) => {
+    const ai = conditions.findIndex((f) => f(a));
+    const bi = conditions.findIndex((f) => f(b));
+
+    if (ai >= 0 && bi >= 0) {
+      return ai - bi;
+    } else if (ai >= 0) {
+      return -1;
+    } else if (bi >= 0) {
+      return 1;
+    } else {
+      return 0;
+    }
+  });
+}
+
+// Composite sorting functions - these consider multiple properties, generally
+// always returning the same output regardless of how the input was originally
+// sorted (or left unsorted). If you're working with arbitrarily sorted inputs
+// (typically wiki data, either in full or unsorted filter), these make sure
+// what gets put on the actual website (or wherever) is deterministic. Also
+// they're just handy sorting utilities.
+//
+// Note that because these are each comprised of multiple component sorting
+// functions, they expect more than just one property to be present for full
+// sorting (listed above each function). If you're mapping thing objects to
+// another representation, try to include all of these listed properties.
+
+// Expects thing properties:
+//  * directory (or override getDirectory)
+//  * name (or override getName)
+export function sortAlphabetically(data, {
+  getDirectory,
+  getName,
+} = {}) {
+  sortByDirectory(data, {getDirectory});
+  sortByName(data, {getName});
+  return data;
+}
+
+// Expects thing properties:
+//  * directory (or override getDirectory)
+//  * name (or override getName)
+//  * date (or override getDate)
+export function sortChronologically(data, {
+  latestFirst = false,
+  getDirectory,
+  getName,
+  getDate,
+} = {}) {
+  sortAlphabetically(data, {getDirectory, getName});
+  sortByDate(data, {latestFirst, getDate});
+  return data;
+}
+
+// This one's a little odd! Sorts an array of {entry, thing} pairs using
+// the provided sortFunction, which will operate on each item's `thing`, not
+// its entry (or the item as a whole). If multiple entries are associated
+// with the same thing, they'll end up bunched together in the output,
+// retaining their original relative positioning.
+export function sortEntryThingPairs(data, sortFunction) {
+  const things = unique(data.map(item => item.thing));
+  sortFunction(things);
+
+  const outputArrays = [];
+  const thingToOutputArray = new Map();
+
+  for (const thing of things) {
+    const array = [];
+    thingToOutputArray.set(thing, array);
+    outputArrays.push(array);
+  }
+
+  for (const item of data) {
+    thingToOutputArray.get(item.thing).push(item);
+  }
+
+  data.splice(0, data.length, ...outputArrays.flat());
+
+  return data;
+}
+
+/*
+// Alternate draft version of sortEntryThingPairs.
+// See: https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607412168
+
+// Maps the provided "preparation" function across a list of arbitrary values,
+// building up a list of sortable values; sorts these with the provided sorting
+// function; and reorders the sources to match their corresponding prepared
+// values. As usual, if multiple source items correspond to the same sorting
+// data, this retains the source relative positioning.
+export function prepareAndSort(sources, prepareForSort, sortFunction) {
+  const prepared = [];
+  const preparedToSource = new Map();
+
+  for (const original of originals) {
+    const prep = prepareForSort(source);
+    prepared.push(prep);
+    preparedToSource.set(prep, source);
+  }
+
+  sortFunction(prepared);
+
+  sources.splice(0, ...sources.length, prepared.map(prep => preparedToSource.get(prep)));
+
+  return sources;
+}
+*/
+
+// Highly contextual sort functions - these are only for very specific types
+// of Things, and have appropriately hard-coded behavior.
+
+// Sorts so that tracks from the same album are generally grouped together in
+// their original (album track list) order, while prioritizing date (by default
+// release date but can be overridden) above all else.
+//
+// This function also works for data lists which contain only tracks.
+export function sortAlbumsTracksChronologically(data, {
+  latestFirst = false,
+  getDate,
+} = {}) {
+  // Sort albums before tracks...
+  sortByConditions(data, [(t) => t.album === undefined]);
+
+  // Group tracks by album...
+  sortByDirectory(data, {
+    getDirectory: (t) => (t.album ? t.album.directory : t.directory),
+  });
+
+  // Sort tracks by position in album...
+  sortByPositionInAlbum(data);
+
+  // ...and finally sort by date. If tracks from more than one album were
+  // released on the same date, they'll still be grouped together by album,
+  // and tracks within an album will retain their relative positioning (i.e.
+  // stay in the same order as part of the album's track listing).
+  sortByDate(data, {latestFirst, getDate});
+
+  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...
+  sortAlphabetically(data, {
+    getName: flash => flash.act.name,
+    getDirectory: flash => flash.act.directory,
+  });
+
+  // Sort flashes by position in act...
+  sortByPositionInFlashAct(data);
+
+  // ...and finally sort by date. If flashes from more than one act were
+  // released on the same date, they'll still be grouped together by act,
+  // and flashes within an act will retain their relative positioning (i.e.
+  // stay in the same order as the act's flash listing).
+  sortByDate(data, {latestFirst, getDate});
+
+  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/common-util/sugar.js b/src/common-util/sugar.js
new file mode 100644
index 00000000..66e160aa
--- /dev/null
+++ b/src/common-util/sugar.js
@@ -0,0 +1,885 @@
+// Syntactic sugar! (Mostly.)
+// Generic functions - these are useful just a8out everywhere.
+//
+// Friendly(!) disclaimer: these utility functions haven't 8een tested all that
+// much. Do not assume it will do exactly what you want it to do in all cases.
+// It will likely only do exactly what I want it to, and only in the cases I
+// decided were relevant enough to 8other handling.
+
+// 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
+// actually use this. 8ut it's still awesome, 8ecause I say so.
+export function* splitArray(array, fn) {
+  let lastIndex = 0;
+  while (lastIndex < array.length) {
+    let nextIndex = array.findIndex((item, index) => index >= lastIndex && fn(item));
+    if (nextIndex === -1) {
+      nextIndex = array.length;
+    }
+    yield array.slice(lastIndex, nextIndex);
+    // Plus one because we don't want to include the dividing line in the
+    // next array we yield.
+    lastIndex = nextIndex + 1;
+  }
+}
+
+// Null-accepting function to check if an array or set is empty. Accepts null
+// (which is treated as empty) as a shorthand for "hey, check if this property
+// is an array with/without stuff in it" for objects where properties that are
+// PRESENT but don't currently have a VALUE are null (rather than undefined).
+export function empty(value) {
+  if (value === null) {
+    return true;
+  }
+
+  if (Array.isArray(value)) {
+    return value.length === 0;
+  }
+
+  if (value instanceof Set) {
+    return value.size === 0;
+  }
+
+  throw new Error(`Expected array, set, or null`);
+}
+
+// Repeats all the items of an array a number of times.
+export function repeat(times, array) {
+  if (times === 0) return [];
+  if (array === null || array === undefined) return [];
+  if (Array.isArray(array) && empty(array)) return [];
+
+  const out = [];
+
+  for (let n = 1; n <= times; n++) {
+    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,
+  valuePastEdge = null,
+} = {}) {
+  if (index === -1) {
+    return valuePastEdge;
+  }
+
+  if (offset === 0) {
+    return array[index];
+  }
+
+  if (wrap) {
+    return array[(index + offset) % array.length];
+  }
+
+  if (offset > 0 && index + offset > array.length - 1) {
+    return valuePastEdge;
+  }
+
+  if (offset < 0 && index + offset < 0) {
+    return valuePastEdge;
+  }
+
+  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
+// returns null (or values in the array are nullish), they'll just be skipped in
+// the sum.
+export function accumulateSum(array, fn = x => x) {
+  return array.reduce(
+    (accumulator, value, index, array) =>
+      accumulator +
+        fn(value, index, array) ?? 0,
+    0);
+}
+
+// Stitches together the items of separate arrays into one array of objects
+// whose keys are the corresponding items from each array at that index.
+// This is mostly useful for iterating over multiple arrays at once!
+export function stitchArrays(keyToArray) {
+  const errors = [];
+
+  for (const [key, value] of Object.entries(keyToArray)) {
+    if (value === null) continue;
+    if (Array.isArray(value)) continue;
+    errors.push(new TypeError(`(${key}) Expected array or null, got ${typeAppearance(value)}`));
+  }
+
+  if (!empty(errors)) {
+    throw new AggregateError(errors, `Expected arrays or null`);
+  }
+
+  const keys = Object.keys(keyToArray);
+  const arrays = Object.values(keyToArray).filter(val => Array.isArray(val));
+  const length = Math.max(...arrays.map(({length}) => length));
+  const results = [];
+
+  for (let i = 0; i < length; i++) {
+    const object = {};
+    for (const key of keys) {
+      object[key] =
+        (Array.isArray(keyToArray[key])
+          ? keyToArray[key][i]
+          : null);
+    }
+    results.push(object);
+  }
+
+  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:
+//
+//   [
+//     [123, 'orange', null],
+//     [456, 'apple', true],
+//     [789, 'banana', false],
+//     [1000, 'pear', undefined],
+//   ]
+//
+// Into this:
+//
+//   [
+//     [123, 456, 789, 1000],
+//     ['orange', 'apple', 'banana', 'pear'],
+//     [null, true, false, undefined],
+//   ]
+//
+// And back again, if you call it again on its results.
+export function transposeArrays(arrays) {
+  if (empty(arrays)) {
+    return [];
+  }
+
+  const length = arrays[0].length;
+  const results = new Array(length).fill(null).map(() => []);
+
+  for (const array of arrays) {
+    for (let i = 0; i < length; i++) {
+      results[i].push(array[i]);
+    }
+  }
+
+  return results;
+}
+
+export const mapInPlace = (array, fn) =>
+  array.splice(0, array.length, ...array.map(fn));
+
+export const unique = (arr) => Array.from(new Set(arr));
+
+export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
+  arr1.length === arr2.length &&
+  (checkOrder
+    ? 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) => {
+  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();
+  for (const item of set1) {
+    if (set2.has(item)) {
+      intersection.add(item);
+    }
+  }
+  return intersection;
+}
+
+export function filterProperties(object, properties, {
+  preserveOriginalOrder = false,
+} = {}) {
+  if (typeof object !== 'object' || object === null) {
+    throw new TypeError(`Expected object to be an object, got ${typeAppearance(object)}`);
+  }
+
+  if (!Array.isArray(properties)) {
+    throw new TypeError(`Expected properties to be an array, got ${typeAppearance(properties)}`);
+  }
+
+  const filteredObject = {};
+
+  if (preserveOriginalOrder) {
+    for (const property of Object.keys(object)) {
+      if (properties.includes(property)) {
+        filteredObject[property] = object[property];
+      }
+    }
+  } else {
+    for (const property of properties) {
+      if (Object.hasOwn(object, property)) {
+        filteredObject[property] = object[property];
+      }
+    }
+  }
+
+  return filteredObject;
+}
+
+export function queue(array, max = 50) {
+  if (max === 0) {
+    return array.map((fn) => fn());
+  }
+
+  const begin = [];
+  let current = 0;
+  const ret = array.map(
+    (fn) =>
+      new Promise((resolve, reject) => {
+        begin.push(() => {
+          current++;
+          Promise.resolve(fn()).then((value) => {
+            current--;
+            if (current < max && begin.length) {
+              begin.shift()();
+            }
+            resolve(value);
+          }, reject);
+        });
+      })
+  );
+
+  for (let i = 0; i < max && begin.length; i++) {
+    begin.shift()();
+  }
+
+  return ret;
+}
+
+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
+// past stage ~~1~~ 2 yet: https://github.com/tc39/proposal-regex-escaping
+export function escapeRegex(string) {
+  return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
+}
+
+export function splitKeys(key) {
+  return key.split(/(?<=(?<!\\)(?:\\\\)*)\./);
+}
+
+// Follows a key path like 'foo.bar.baz' to get an item nested deeply inside
+// 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));
+}
+
+// Gets the "look" of some arbitrary value. It's like typeof, but smarter.
+// Don't use this for actually validating types - it's only suitable for
+// inclusion in error messages.
+export function typeAppearance(value) {
+  if (value === null) return 'null';
+  if (value === undefined) return 'undefined';
+  if (Array.isArray(value)) return 'array';
+  return typeof value;
+}
+
+// Limits a string to the desired length, filling in an ellipsis at the end
+// if it cuts any text off.
+export function cut(text, length = 40) {
+  if (text.length >= length) {
+    const index = Math.max(1, length - 3);
+    return text.slice(0, index) + '...';
+  } else {
+    return text;
+  }
+}
+
+// Limits a string to the desired length, filling in an ellipsis at the start
+// if it cuts any text off.
+export function cutStart(text, length = 40) {
+  if (text.length >= length) {
+    const index = Math.min(text.length - 1, text.length - length + 3);
+    return '...' + text.slice(index);
+  } else {
+    return text;
+  }
+}
+
+// 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:
+//
+// * its line and column numbers;
+// * if `formatWhere` is true (the default), a pretty-formatted,
+//   human-readable indication of the match's placement in the string;
+// * if `getContainingLine` is true, the entire line (or multiple lines)
+//   of text containing the match.
+//
+export function* iterateMultiline(content, iterator, {
+  formatWhere = true,
+  getContainingLine = false,
+} = {}) {
+  const lineRegexp = /\n/g;
+  const isMultiline = content.includes('\n');
+
+  let lineNumber = 0;
+  let startOfLine = 0;
+  let previousIndex = 0;
+
+  const countLineBreaks = (index, length) => {
+    const range = content.slice(index, index + length);
+    const lineBreaks = Array.from(range.matchAll(lineRegexp));
+    if (!empty(lineBreaks)) {
+      lineNumber += lineBreaks.length;
+      startOfLine = index + lineBreaks.at(-1).index + 1;
+    }
+  };
+
+  for (const result of iterator) {
+    const {index, length} = result;
+
+    countLineBreaks(previousIndex, index - previousIndex);
+
+    const matchStartOfLine = startOfLine;
+
+    previousIndex = index + length;
+
+    const columnNumber = index - startOfLine;
+
+    const where =
+      (formatWhere && isMultiline
+        ? `line: ${lineNumber + 1}, col: ${columnNumber + 1}`
+     : formatWhere
+        ? `pos: ${index + 1}`
+        : null);
+
+    countLineBreaks(index, length);
+
+    let containingLine = null;
+    if (getContainingLine) {
+      const nextLineResult =
+        content
+          .slice(previousIndex)
+          .matchAll(lineRegexp)
+          .next();
+
+      const nextStartOfLine =
+        (nextLineResult.done
+          ? content.length
+          : previousIndex + nextLineResult.value.index);
+
+      containingLine =
+        content.slice(matchStartOfLine, nextStartOfLine);
+    }
+
+    yield {
+      ...result,
+      lineNumber,
+      columnNumber,
+      where,
+      containingLine,
+    };
+  }
+}
+
+// Iterates over regular expression matches within a single- or multiline
+// string, yielding each match as well as contextual details; this accepts
+// the same options (and provides the same context) as iterateMultiline.
+export function* matchMultiline(content, matchRegexp, options) {
+  const matchAllIterator =
+    content.matchAll(matchRegexp);
+
+  const cleanMatchAllIterator =
+    (function*() {
+      for (const match of matchAllIterator) {
+        yield {
+          index: match.index,
+          length: match[0].length,
+          match,
+        };
+      }
+    })();
+
+  const multilineIterator =
+    iterateMultiline(content, cleanMatchAllIterator, options);
+
+  yield* multilineIterator;
+}
+
+// Binds default values for arguments in a {key: value} type function argument
+// (typically the second argument, but may be overridden by providing a
+// [bindOpts.bindIndex] argument). Typically useful for preparing a function for
+// reuse within one or multiple other contexts, which may not be aware of
+// required or relevant values provided in the initial context.
+//
+// This function also passes the identity of `this` through (the returned value
+// is not an arrow function), though note it's not a true bound function either
+// (since Function.prototype.bind only supports positional arguments, not
+// "options" specified via key/value).
+//
+export function bindOpts(fn, bind) {
+  const bindIndex = bind[bindOpts.bindIndex] ?? 1;
+
+  const bound = function (...args) {
+    const opts = args[bindIndex] ?? {};
+    return Reflect.apply(fn, this, [
+      ...args.slice(0, bindIndex),
+      {...bind, ...opts}
+    ]);
+  };
+
+  annotateFunction(bound, {
+    name: fn,
+    trait: 'options-bound',
+  });
+
+  for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(fn))) {
+    if (key === 'length') continue;
+    if (key === 'name') continue;
+    if (key === 'arguments') continue;
+    if (key === 'caller') continue;
+    if (key === 'prototype') continue;
+    Object.defineProperty(bound, key, descriptor);
+  }
+
+  return bound;
+}
+
+bindOpts.bindIndex = Symbol();
+
+// Sorts multiple arrays by an arbitrary function (which is the last argument).
+// Paired values from each array are provided to the callback sequentially:
+//
+//   (a_fromFirstArray, b_fromFirstArray,
+//    a_fromSecondArray, b_fromSecondArray,
+//    a_fromThirdArray, b_fromThirdArray) =>
+//     relative positioning (negative, positive, or zero)
+//
+// Like native single-array sort, this is a mutating function.
+export function sortMultipleArrays(...args) {
+  const arrays = args.slice(0, -1);
+  const fn = args.at(-1);
+
+  const length = arrays[0].length;
+  const symbols = new Array(length).fill(null).map(() => Symbol());
+  const indexes = Object.fromEntries(symbols.map((symbol, index) => [symbol, index]));
+
+  symbols.sort((a, b) => {
+    const indexA = indexes[a];
+    const indexB = indexes[b];
+
+    const args = [];
+    for (let i = 0; i < arrays.length; i++) {
+      args.push(arrays[i][indexA]);
+      args.push(arrays[i][indexB]);
+    }
+
+    return fn(...args);
+  });
+
+  for (const array of arrays) {
+    // Note: We're mutating this array pulling values from itself, but only all
+    // at once after all those values have been pulled.
+    array.splice(0, array.length, ...symbols.map(symbol => array[indexes[symbol]]));
+  }
+
+  return arrays;
+}
+
+// Filters multiple arrays by an arbitrary function (which is the last argument).
+// Values from each array are provided to the callback sequentially:
+//
+//   (value_fromFirstArray,
+//    value_fromSecondArray,
+//    value_fromThirdArray,
+//    index,
+//    [firstArray, secondArray, thirdArray]) =>
+//      true or false
+//
+// Please be aware that this is a mutating function, unlike native single-array
+// filter. The mutated arrays are returned. Also attached under `.removed` are
+// corresponding arrays of items filtered out.
+export function filterMultipleArrays(...args) {
+  const arrays = args.slice(0, -1);
+  const fn = args.at(-1);
+
+  const removed = new Array(arrays.length).fill(null).map(() => []);
+
+  for (let i = arrays[0].length - 1; i >= 0; i--) {
+    const args = arrays.map(array => array[i]);
+    args.push(i, arrays);
+
+    if (!fn(...args)) {
+      for (let j = 0; j < arrays.length; j++) {
+        const item = arrays[j][i];
+        arrays[j].splice(i, 1);
+        removed[j].unshift(item);
+      }
+    }
+  }
+
+  Object.assign(arrays, {removed});
+  return arrays;
+}
+
+// Corresponding filter function for sortByCount. By default, items whose
+// corresponding count is zero will be removed.
+export function filterByCount(data, counts, {
+  min = 1,
+  max = Infinity,
+} = {}) {
+  filterMultipleArrays(data, counts, (data, count) =>
+    count >= min && count <= max);
+}
+
+// Reduces multiple arrays with an arbitrary function (which is the last
+// argument). Note that this reduces into multiple accumulators, one for
+// each input array, not just a single value. That's reflected in both the
+// callback parameters:
+//
+//   (accumulator1,
+//    accumulator2,
+//    value_fromFirstArray,
+//    value_fromSecondArray,
+//    index,
+//    [firstArray, secondArray]) =>
+//      [newAccumulator1, newAccumulator2]
+//
+// As well as the final return value of reduceMultipleArrays:
+//
+//   [finalAccumulator1, finalAccumulator2]
+//
+// This is not a mutating function.
+export function reduceMultipleArrays(...args) {
+  const [arrays, fn, initialAccumulators] =
+    (typeof args.at(-1) === 'function'
+      ? [args.slice(0, -1), args.at(-1), null]
+      : [args.slice(0, -2), args.at(-2), args.at(-1)]);
+
+  if (empty(arrays[0])) {
+    throw new TypeError(`Reduce of empty arrays with no initial value`);
+  }
+
+  let [accumulators, i] =
+    (initialAccumulators
+      ? [initialAccumulators, 0]
+      : [arrays.map(array => array[0]), 1]);
+
+  for (; i < arrays[0].length; i++) {
+    const args = [...accumulators, ...arrays.map(array => array[i])];
+    args.push(i, arrays);
+    accumulators = fn(...args);
+  }
+
+  return accumulators;
+}
+
+export function chunkByConditions(array, conditions) {
+  if (empty(array)) {
+    return [];
+  }
+
+  if (empty(conditions)) {
+    return [array];
+  }
+
+  const out = [];
+  let cur = [array[0]];
+  for (let i = 1; i < array.length; i++) {
+    const item = array[i];
+    const prev = array[i - 1];
+    let chunk = false;
+    for (const condition of conditions) {
+      if (condition(item, prev)) {
+        chunk = true;
+        break;
+      }
+    }
+    if (chunk) {
+      out.push(cur);
+      cur = [item];
+    } else {
+      cur.push(item);
+    }
+  }
+  out.push(cur);
+  return out;
+}
+
+export function chunkByProperties(array, properties) {
+  return chunkByConditions(
+    array,
+    properties.map((p) => (a, b) => {
+      if (a[p] instanceof Date && b[p] instanceof Date) return +a[p] !== +b[p];
+
+      if (a[p] !== b[p]) return true;
+
+      // Not sure if this line is still necessary with the specific check for
+      // d8tes a8ove, 8ut, uh, keeping it anyway, just in case....?
+      if (a[p] != b[p]) return true;
+
+      return false;
+    })
+  ).map((chunk) => ({
+    ...Object.fromEntries(properties.map((p) => [p, chunk[0][p]])),
+    chunk,
+  }));
+}
+
+export function chunkMultipleArrays(...args) {
+  const arrays = args.slice(0, -1);
+  const fn = args.at(-1);
+
+  if (arrays[0].length === 0) {
+    return [];
+  }
+
+  const newChunk = index => arrays.map(array => [array[index]]);
+  const results = [newChunk(0)];
+
+  for (let i = 1; i < arrays[0].length; i++) {
+    const current = results.at(-1);
+
+    const args = [];
+    for (let j = 0; j < arrays.length; j++) {
+      const item = arrays[j][i];
+      const previous = current[j].at(-1);
+      args.push(item, previous);
+    }
+
+    if (fn(...args)) {
+      results.push(newChunk(i));
+      continue;
+    }
+
+    for (let j = 0; j < arrays.length; j++) {
+      current[j].push(arrays[j][i]);
+    }
+  }
+
+  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
+//   (data *unfulfilled) generateShrekTwo
+//
+export function annotateFunction(fn, {
+  name: nameOrFunction = null,
+  description: newDescription,
+  trait: newTrait,
+}) {
+  let name;
+
+  if (typeof nameOrFunction === 'function') {
+    name = nameOrFunction.name;
+  } else if (typeof nameOrFunction === 'string') {
+    name = nameOrFunction;
+  }
+
+  name ??= fn.name ?? 'anonymous';
+
+  const match = name.match(/^ *(?<prefix>.*?) *\((?<description>.*)( #(?<trait>.*))?\) *(?<suffix>.*) *$/);
+
+  let prefix, suffix, description, trait;
+  if (match) {
+    ({prefix, suffix, description, trait} = match.groups);
+  }
+
+  prefix ??= '';
+  suffix ??= name;
+  description ??= '';
+  trait ??= '';
+
+  if (newDescription) {
+    if (description) {
+      description += '; ' + newDescription;
+    } else {
+      description = newDescription;
+    }
+  }
+
+  if (newTrait) {
+    if (trait) {
+      trait += ' #' + newTrait;
+    } else {
+      trait = '#' + newTrait;
+    }
+  }
+
+  let parenthesesPart;
+
+  if (description && trait) {
+    parenthesesPart = `${description} ${trait}`;
+  } else if (description || trait) {
+    parenthesesPart = description || trait;
+  } else {
+    parenthesesPart = '';
+  }
+
+  let finalName;
+
+  if (prefix && parenthesesPart) {
+    finalName = `${prefix} (${parenthesesPart}) ${suffix}`;
+  } else if (parenthesesPart) {
+    finalName = `(${parenthesesPart}) ${suffix}`;
+  } else {
+    finalName = suffix;
+  }
+
+  Object.defineProperty(fn, 'name', {value: finalName});
+}
diff --git a/src/common-util/wiki-data.js b/src/common-util/wiki-data.js
new file mode 100644
index 00000000..0aa18ddb
--- /dev/null
+++ b/src/common-util/wiki-data.js
@@ -0,0 +1,499 @@
+// Utility functions for interacting with wiki data.
+
+import {accumulateSum, chunkByConditions, empty, unique} from './sugar.js';
+import {sortByDate} from './sort.js';
+
+// This is a duplicate binding of filterMultipleArrays that's included purely
+// to leave wiki-data.js compatible with the release build of HSMusic.
+// Sorry! This is really ridiculous!! If the next update after 10/25/2023 has
+// released, this binding is no longer needed!
+export {filterMultipleArrays} from './sugar.js';
+
+// Generic value operations
+
+export function getKebabCase(name) {
+  return name
+
+    // Spaces to dashes
+    .split(' ')
+    .join('-')
+
+    // Punctuation as words
+    .replace(/&/g, '-and-')
+    .replace(/\+/g, '-plus-')
+    .replace(/%/g, '-percent-')
+
+    // Punctuation which only divides words, not single characters
+    .replace(/(\b[^\s-.]{2,})\./g, '$1-')
+    .replace(/\.([^\s-.]{2,})\b/g, '-$1')
+
+    // Punctuation which doesn't divide a number following a non-number
+    .replace(/(?<=[0-9])\^/g, '-')
+    .replace(/\^(?![0-9])/g, '-')
+
+    // General punctuation which always separates surrounding words
+    .replace(/[/@#$%*()_=,[\]{}|\\;:<>?`~]/g, '-')
+
+    // Accented characters
+    .replace(/[áâäàå]/gi, 'a')
+    .replace(/[çč]/gi, 'c')
+    .replace(/[éêëè]/gi, 'e')
+    .replace(/[íîïì]/gi, 'i')
+    .replace(/[óôöò]/gi, 'o')
+    .replace(/[úûüù]/gi, 'u')
+
+    // Strip other characters
+    .replace(/[^a-z0-9-]/gi, '')
+
+    // Combine consecutive dashes
+    .replace(/-{2,}/g, '-')
+
+    // Trim dashes on boundaries
+    .replace(/^-+|-+$/g, '')
+
+    // Always lowercase
+    .toLowerCase();
+}
+
+// Specific data utilities
+
+// Matches heading details from commentary data in roughly the formats:
+//
+//    <i>artistReference:</i> (annotation, date)
+//    <i>artistReference|artistDisplayText:</i> (annotation, date)
+//
+// where capturing group "annotation" can be any text at all, except that the
+// last entry (past a comma or the only content within parentheses), if parsed
+// as a date, is the capturing group "date". "Parsing as a date" means matching
+// one of these formats:
+//
+//   * "25 December 2019" - one or two number digits, followed by any text,
+//     followed by four number digits
+//   * "December 25, 2019" - one all-letters word, a space, one or two number
+//     digits, a comma, and four number digits
+//   * "12/25/2019" etc - three sets of one to four number digits, separated
+//     by slashes or dashes (only valid orders are MM/DD/YYYY and YYYY/MM/DD)
+//
+// Note that the annotation and date are always wrapped by one opening and one
+// closing parentheses. The whole heading does NOT need to match the entire
+// line it occupies (though it does always start at the first position on that
+// line), and if there is more than one closing parenthesis on the line, the
+// annotation will always cut off only at the last parenthesis, or a comma
+// preceding a date and then the last parenthesis. This is to ensure that
+// parentheses can be part of the actual annotation content.
+//
+// Capturing group "artistReference" is all the characters between <i> and </i>
+// (apart from the pipe and "artistDisplayText" text, if present), and is either
+// the name of an artist or an "artist:directory"-style reference.
+//
+// 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>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?:(?<dateKind>sometime|throughout|around) )?${dateRegex('date')}(?: ?- ?${dateRegex('secondDate')})?(?: (?<accessKind>captured|accessed) ${dateRegex('accessDate')})?)?\))?`;
+export const commentaryRegexCaseInsensitive =
+  new RegExp(commentaryRegexRaw, 'gmi');
+export const commentaryRegexCaseSensitive =
+  new RegExp(commentaryRegexRaw, 'gm');
+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));
+}
+
+export function getAlbumCover(album, {to}) {
+  // Some albums don't have art! This function returns null in that case.
+  if (album.hasCoverArt) {
+    return to('media.albumCover', album.directory, album.coverArtFileExtension);
+  } else {
+    return null;
+  }
+}
+
+export function getAlbumListTag(album) {
+  return album.hasTrackNumbers ? 'ol' : 'ul';
+}
+
+// This gets all the track o8jects defined in every al8um, and sorts them 8y
+// date released. Generally, albumData will pro8a8ly already 8e sorted 8efore
+// you pass it to this function, 8ut individual tracks can have their own
+// original release d8, distinct from the al8um's d8. I allowed that 8ecause
+// in Homestuck, the first four Vol.'s were com8ined into one al8um really
+// early in the history of the 8andcamp, and I still want to use that as the
+// al8um listing (not the original four al8um listings), 8ut if I only did
+// that, all the tracks would 8e sorted as though they were released at the
+// same time as the compilation al8um - i.e, after some other al8ums (including
+// Vol.'s 5 and 6!) were released. That would mess with chronological listings
+// including tracks from multiple al8ums, like artist pages. So, to fix that,
+// I gave tracks an Original Date field, defaulting to the release date of the
+// al8um if not specified. Pretty reasona8le, I think! Oh, and this feature can
+// 8e used for other projects too, like if you wanted to have an al8um listing
+// compiling a 8unch of songs with radically different & interspersed release
+// d8s, 8ut still keep the al8um listing in a specific order, since that isn't
+// sorted 8y date.
+export function getAllTracks(albumData) {
+  return sortByDate(albumData.flatMap((album) => album.tracks));
+}
+
+export function getArtistNumContributions(artist) {
+  return accumulateSum(
+    [
+      unique(
+        ([
+          artist.trackArtistContributions,
+          artist.trackContributorContributions,
+          artist.trackCoverArtistContributions,
+        ]).flat()
+          .map(({thing}) => thing)),
+
+      artist.albumCoverArtistContributions,
+      artist.flashContributorContributions,
+    ],
+    ({length}) => length);
+}
+
+export function getFlashCover(flash, {to}) {
+  return to('media.flashArt', flash.directory, flash.coverArtFileExtension);
+}
+
+export function getFlashLink(flash) {
+  return `https://homestuck.com/story/${flash.page}`;
+}
+
+export function getTotalDuration(tracks, {
+  mainReleasesOnly = false,
+} = {}) {
+  if (mainReleasesOnly) {
+    tracks = tracks.filter(t => !t.mainReleaseTrack);
+  }
+
+  return accumulateSum(tracks, track => track.duration);
+}
+
+export function getTrackCover(track, {to}) {
+  // Some albums don't have any track art at all, and in those, every track
+  // just inherits the album's own cover art. Note that since cover art isn't
+  // guaranteed on albums either, it's possible that this function returns
+  // null!
+  if (!track.hasUniqueCoverArt) {
+    return getAlbumCover(track.album, {to});
+  } else {
+    return to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension);
+  }
+}
+
+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}) {
+  const sortedAlbums = albumData
+    .filter((album) => album.isListedOnHomepage)
+    .sort((a, b) => {
+      if (a.dateAddedToWiki > b.dateAddedToWiki) return -1;
+      if (a.dateAddedToWiki < b.dateAddedToWiki) return 1;
+      if (a.date > b.date) return -1;
+      if (a.date < b.date) return 1;
+      return 0;
+    });
+
+  // When multiple al8ums are added to the wiki at a time, we want to show
+  // all of them 8efore pulling al8ums from the next (earlier) date. We also
+  // want to show a diverse selection of al8ums - with limited space, we'd
+  // rather not show only the latest al8ums, if those happen to all 8e
+  // closely rel8ted!
+  //
+  // Specifically, we're concerned with avoiding too much overlap amongst
+  // the primary (first/top-most) group. We do this 8y collecting every
+  // primary group present amongst the al8ums for a given d8 into one
+  // (ordered) array, initially sorted (inherently) 8y latest al8um from
+  // the group. Then we cycle over the array, adding one al8um from each
+  // group until all the al8ums from that release d8 have 8een added (or
+  // we've met the total target num8er of al8ums). Once we've added all the
+  // al8ums for a given group, it's struck from the array (so the groups
+  // with the most additions on one d8 will have their oldest releases
+  // collected more towards the end of the list).
+
+  const albums = [];
+
+  let i = 0;
+  outerLoop: while (i < sortedAlbums.length) {
+    // 8uild up a list of groups and their al8ums 8y order of decending
+    // release, iter8ting until we're on a different d8. (We use a map for
+    // indexing so we don't have to iter8te through the entire array each
+    // time we access one of its entries. This is 8asically unnecessary
+    // since this will never 8e an expensive enough task for that to
+    // matter.... 8ut it's nicer code. BBBB) )
+    const currentDate = sortedAlbums[i].dateAddedToWiki;
+    const groupMap = new Map();
+    const groupArray = [];
+    for (let album; (album = sortedAlbums[i]) && +album.dateAddedToWiki === +currentDate; i++) {
+      const primaryGroup = album.groups[0];
+      if (groupMap.has(primaryGroup)) {
+        groupMap.get(primaryGroup).push(album);
+      } else {
+        const entry = [album];
+        groupMap.set(primaryGroup, entry);
+        groupArray.push(entry);
+      }
+    }
+
+    // Then cycle over that sorted array, adding one al8um from each to
+    // the main array until we've run out or have met the target num8er
+    // of al8ums.
+    while (!empty(groupArray)) {
+      let j = 0;
+      while (j < groupArray.length) {
+        const entry = groupArray[j];
+        const album = entry.shift();
+        albums.push(album);
+
+        // This is the only time we ever add anything to the main al8um
+        // list, so it's also the only place we need to check if we've
+        // met the target length.
+        if (albums.length === numAlbums) {
+          // If we've met it, 8r8k out of the outer loop - we're done
+          // here!
+          break outerLoop;
+        }
+
+        if (empty(entry)) {
+          groupArray.splice(j, 1);
+        } else {
+          j++;
+        }
+      }
+    }
+  }
+
+  return albums;
+}
+
+export function getNewReleases(numReleases, {albumData}) {
+  return albumData
+    .filter((album) => album.isListedOnHomepage)
+    .reverse()
+    .slice(0, numReleases);
+}
+
+// Carousel layout and utilities
+
+// Layout constants:
+//
+// Carousels support fitting 4-18 items, with a few "dead" zones to watch out
+// for, namely when a multiple of 6, 5, or 4 columns would drop the last tiles.
+//
+// Carousels are limited to 1-3 rows and 4-6 columns.
+// Lower edge case: 1-3 items are treated as 4 items (with blank space).
+// Upper edge case: all items past 18 are dropped (treated as 18 items).
+//
+// This is all done through JS instead of CSS because it's just... ANNOYING...
+// to write a mapping like this in CSS lol.
+const carouselLayoutMap = [
+  // 0-3
+  null, null, null, null,
+
+  // 4-6
+  {rows: 1, columns: 4}, //  4: 1x4, drop 0
+  {rows: 1, columns: 5}, //  5: 1x5, drop 0
+  {rows: 1, columns: 6}, //  6: 1x6, drop 0
+
+  // 7-12
+  {rows: 1, columns: 6}, //  7: 1x6, drop 1
+  {rows: 2, columns: 4}, //  8: 2x4, drop 0
+  {rows: 2, columns: 4}, //  9: 2x4, drop 1
+  {rows: 2, columns: 5}, // 10: 2x5, drop 0
+  {rows: 2, columns: 5}, // 11: 2x5, drop 1
+  {rows: 2, columns: 6}, // 12: 2x6, drop 0
+
+  // 13-18
+  {rows: 2, columns: 6}, // 13: 2x6, drop 1
+  {rows: 2, columns: 6}, // 14: 2x6, drop 2
+  {rows: 3, columns: 5}, // 15: 3x5, drop 0
+  {rows: 3, columns: 5}, // 16: 3x5, drop 1
+  {rows: 3, columns: 5}, // 17: 3x5, drop 2
+  {rows: 3, columns: 6}, // 18: 3x6, drop 0
+];
+
+const minCarouselLayoutItems = carouselLayoutMap.findIndex(x => x !== null);
+const maxCarouselLayoutItems = carouselLayoutMap.length - 1;
+const shortestCarouselLayout = carouselLayoutMap[minCarouselLayoutItems];
+const longestCarouselLayout = carouselLayoutMap[maxCarouselLayoutItems];
+
+export function getCarouselLayoutForNumberOfItems(numItems) {
+  return (
+    numItems < minCarouselLayoutItems ? shortestCarouselLayout :
+    numItems > maxCarouselLayoutItems ? longestCarouselLayout :
+    carouselLayoutMap[numItems]);
+}
+
+export function filterItemsForCarousel(items) {
+  if (empty(items)) {
+    return [];
+  }
+
+  return items
+    .filter(item => item.hasCoverArt)
+    .filter(item => item.artTags.every(artTag => !artTag.isContentWarning))
+    .slice(0, maxCarouselLayoutItems + 1);
+}
+
+// Ridiculous caching support nonsense
+
+export class TupleMap {
+  static maxNestedTupleLength = 25;
+
+  #store = [undefined, null, null, null];
+
+  #lifetime(value) {
+    if (Array.isArray(value) && value.length <= TupleMap.maxNestedTupleLength) {
+      return 'tuple';
+    } else if (
+      typeof value === 'object' && value !== null ||
+      typeof value === 'function'
+    ) {
+      return 'weak';
+    } else {
+      return 'strong';
+    }
+  }
+
+  #getSubstoreShallow(value, store) {
+    const lifetime = this.#lifetime(value);
+    const mapIndex = {weak: 1, strong: 2, tuple: 3}[lifetime];
+
+    let map = store[mapIndex];
+    if (map === null) {
+      map = store[mapIndex] =
+        (lifetime === 'weak' ? new WeakMap()
+       : lifetime === 'strong' ? new Map()
+       : lifetime === 'tuple' ? new TupleMap()
+       : null);
+    }
+
+    if (map.has(value)) {
+      return map.get(value);
+    } else {
+      const substore = [undefined, null, null, null];
+      map.set(value, substore);
+      return substore;
+    }
+  }
+
+  #getSubstoreDeep(tuple, store = this.#store) {
+    if (tuple.length === 0) {
+      return store;
+    } else {
+      const [first, ...rest] = tuple;
+      return this.#getSubstoreDeep(rest, this.#getSubstoreShallow(first, store));
+    }
+  }
+
+  get(tuple) {
+    const store = this.#getSubstoreDeep(tuple);
+    return store[0];
+  }
+
+  has(tuple) {
+    const store = this.#getSubstoreDeep(tuple);
+    return store[0] !== undefined;
+  }
+
+  set(tuple, value) {
+    const store = this.#getSubstoreDeep(tuple);
+    store[0] = value;
+    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-function.js b/src/content-function.js
new file mode 100644
index 00000000..44f8b842
--- /dev/null
+++ b/src/content-function.js
@@ -0,0 +1,683 @@
+import {inspect as nodeInspect} from 'node:util';
+
+import {decorateError} from '#aggregate';
+import {colors, decorateTime, ENABLE_COLOR} from '#cli';
+import {Template} from '#html';
+import {annotateFunction, empty, setIntersection} from '#sugar';
+
+function inspect(value, opts = {}) {
+  return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
+}
+
+const DECORATE_TIME = process.env.HSMUSIC_DEBUG_CONTENT_PERF === '1';
+
+export class ContentFunctionSpecError extends Error {}
+
+export default function contentFunction({
+  contentDependencies = [],
+  extraDependencies = [],
+
+  slots,
+  sprawl,
+  query,
+  relations,
+  data,
+  generate,
+}) {
+  const expectedContentDependencyKeys = new Set(contentDependencies);
+  const expectedExtraDependencyKeys = new Set(extraDependencies);
+
+  // Initial checks. These only need to be run once per description of a
+  // content function, and don't depend on any mutable context (e.g. which
+  // dependencies have been fulfilled so far).
+
+  const overlappingContentExtraDependencyKeys =
+    setIntersection(expectedContentDependencyKeys, expectedExtraDependencyKeys);
+
+  if (!empty(overlappingContentExtraDependencyKeys)) {
+    throw new ContentFunctionSpecError(`Overlap in content and extra dependency keys: ${[...overlappingContentExtraDependencyKeys].join(', ')}`);
+  }
+
+  if (!generate) {
+    throw new ContentFunctionSpecError(`Expected generate function`);
+  }
+
+  if (sprawl && !expectedExtraDependencyKeys.has('wikiData')) {
+    throw new ContentFunctionSpecError(`Content functions which sprawl must specify wikiData in extraDependencies`);
+  }
+
+  if (slots && !expectedExtraDependencyKeys.has('html')) {
+    throw new ContentFunctionSpecError(`Content functions with slots must specify html in extraDependencies`);
+  }
+
+  if (slots) {
+    Template.validateSlotsDescription(slots);
+  }
+
+  // Pass all the details to expectDependencies, which will recursively build
+  // up a set of fulfilled dependencies and make functions like `relations`
+  // and `generate` callable only with sufficient fulfilled dependencies.
+
+  return expectDependencies({
+    slots,
+    sprawl,
+    query,
+    relations,
+    data,
+    generate,
+
+    expectedContentDependencyKeys,
+    expectedExtraDependencyKeys,
+    missingContentDependencyKeys: new Set(expectedContentDependencyKeys),
+    missingExtraDependencyKeys: new Set(expectedExtraDependencyKeys),
+    invalidatingDependencyKeys: new Set(),
+    fulfilledDependencyKeys: new Set(),
+    fulfilledDependencies: {},
+  });
+}
+
+contentFunction.identifyingSymbol = Symbol(`Is a content function?`);
+
+export function expectDependencies({
+  slots,
+  sprawl,
+  query,
+  relations,
+  data,
+  generate,
+
+  expectedContentDependencyKeys,
+  expectedExtraDependencyKeys,
+  missingContentDependencyKeys,
+  missingExtraDependencyKeys,
+  invalidatingDependencyKeys,
+  fulfilledDependencyKeys,
+  fulfilledDependencies,
+}) {
+  const hasSprawlFunction = !!sprawl;
+  const hasQueryFunction = !!query;
+  const hasRelationsFunction = !!relations;
+  const hasDataFunction = !!data;
+  const hasSlotsDescription = !!slots;
+
+  const isInvalidated = !empty(invalidatingDependencyKeys);
+  const isMissingContentDependencies = !empty(missingContentDependencyKeys);
+  const isMissingExtraDependencies = !empty(missingExtraDependencyKeys);
+
+  let wrappedGenerate;
+
+  const optionalDecorateTime = (prefix, fn) =>
+    (DECORATE_TIME
+      ? decorateTime(`${prefix}/${generate.name}`, fn)
+      : fn);
+
+  if (isInvalidated) {
+    wrappedGenerate = function() {
+      throw new Error(`Generate invalidated because unfulfilled dependencies provided: ${[...invalidatingDependencyKeys].join(', ')}`);
+    };
+
+    annotateFunction(wrappedGenerate, {name: generate, trait: 'invalidated'});
+    wrappedGenerate.fulfilled = false;
+  } else if (isMissingContentDependencies || isMissingExtraDependencies) {
+    wrappedGenerate = function() {
+      throw new Error(`Dependencies still needed: ${[...missingContentDependencyKeys, ...missingExtraDependencyKeys].join(', ')}`);
+    };
+
+    annotateFunction(wrappedGenerate, {name: generate, trait: 'unfulfilled'});
+    wrappedGenerate.fulfilled = false;
+  } else {
+    let callUnderlyingGenerate = ([arg1, arg2], ...extraArgs) => {
+      if (hasDataFunction && !arg1) {
+        throw new Error(`Expected data`);
+      }
+
+      if (hasDataFunction && hasRelationsFunction && !arg2) {
+        throw new Error(`Expected relations`);
+      }
+
+      if (hasRelationsFunction && !arg1) {
+        throw new Error(`Expected relations`);
+      }
+
+      try {
+        if (hasDataFunction && hasRelationsFunction) {
+          return generate(arg1, arg2, ...extraArgs, fulfilledDependencies);
+        } else if (hasDataFunction || hasRelationsFunction) {
+          return generate(arg1, ...extraArgs, fulfilledDependencies);
+        } else {
+          return generate(...extraArgs, fulfilledDependencies);
+        }
+      } catch (caughtError) {
+        const error = new Error(
+          `Error generating content for ${generate.name}`,
+          {cause: caughtError});
+
+        error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true;
+        error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = caughtError;
+
+        error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [
+          /content-function\.js/,
+          /util\/html\.js/,
+        ];
+
+        error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
+          /content\/dependencies\/(.*\.js:.*(?=\)))/,
+        ];
+
+        throw error;
+      }
+    };
+
+    callUnderlyingGenerate =
+      optionalDecorateTime(`generate`, callUnderlyingGenerate);
+
+    if (hasSlotsDescription) {
+      const stationery = fulfilledDependencies.html.stationery({
+        annotation: generate.name,
+
+        // These extra slots are for the data and relations (positional) args.
+        // No hacks to store them temporarily or otherwise "invisibly" alter
+        // the behavior of the template description's `content`, since that
+        // would be expressly against the purpose of templates!
+        slots: {
+          _cfArg1: {validate: v => v.isObject},
+          _cfArg2: {validate: v => v.isObject},
+          ...slots,
+        },
+
+        content(slots) {
+          const args = [slots._cfArg1, slots._cfArg2];
+          return callUnderlyingGenerate(args, slots);
+        },
+      });
+
+      wrappedGenerate = function(...args) {
+        return stationery.template().slots({
+          _cfArg1: args[0] ?? null,
+          _cfArg2: args[1] ?? null,
+        });
+      };
+    } else {
+      wrappedGenerate = function(...args) {
+        return callUnderlyingGenerate(args);
+      };
+    }
+
+    wrappedGenerate.fulfill = function() {
+      throw new Error(`All dependencies already fulfilled (${generate.name})`);
+    };
+
+    annotateFunction(wrappedGenerate, {name: generate, trait: 'fulfilled'});
+    wrappedGenerate.fulfilled = true;
+  }
+
+  wrappedGenerate[contentFunction.identifyingSymbol] = true;
+
+  if (hasSprawlFunction) {
+    wrappedGenerate.sprawl = optionalDecorateTime(`sprawl`, sprawl);
+  }
+
+  if (hasQueryFunction) {
+    wrappedGenerate.query = optionalDecorateTime(`query`, query);
+  }
+
+  if (hasRelationsFunction) {
+    wrappedGenerate.relations = optionalDecorateTime(`relations`, relations);
+  }
+
+  if (hasDataFunction) {
+    wrappedGenerate.data = optionalDecorateTime(`data`, data);
+  }
+
+  wrappedGenerate.fulfill ??= function fulfill(dependencies) {
+    // To avoid unneeded destructuring, `fullfillDependencies` is a mutating
+    // function. But `fulfill` itself isn't meant to mutate! We create a copy
+    // of these variables, so their original values are kept for additional
+    // calls to this same `fulfill`.
+    const newlyMissingContentDependencyKeys = new Set(missingContentDependencyKeys);
+    const newlyMissingExtraDependencyKeys = new Set(missingExtraDependencyKeys);
+    const newlyInvalidatingDependencyKeys = new Set(invalidatingDependencyKeys);
+    const newlyFulfilledDependencyKeys = new Set(fulfilledDependencyKeys);
+    const newlyFulfilledDependencies = {...fulfilledDependencies};
+
+    try {
+      fulfillDependencies(dependencies, {
+        missingContentDependencyKeys: newlyMissingContentDependencyKeys,
+        missingExtraDependencyKeys: newlyMissingExtraDependencyKeys,
+        invalidatingDependencyKeys: newlyInvalidatingDependencyKeys,
+        fulfilledDependencyKeys: newlyFulfilledDependencyKeys,
+        fulfilledDependencies: newlyFulfilledDependencies,
+      });
+    } catch (error) {
+      error.message += ` (${generate.name})`;
+      throw error;
+    }
+
+    return expectDependencies({
+      slots,
+      sprawl,
+      query,
+      relations,
+      data,
+      generate,
+
+      expectedContentDependencyKeys,
+      expectedExtraDependencyKeys,
+      missingContentDependencyKeys: newlyMissingContentDependencyKeys,
+      missingExtraDependencyKeys: newlyMissingExtraDependencyKeys,
+      invalidatingDependencyKeys: newlyInvalidatingDependencyKeys,
+      fulfilledDependencyKeys: newlyFulfilledDependencyKeys,
+      fulfilledDependencies: newlyFulfilledDependencies,
+    });
+
+  };
+
+  Object.assign(wrappedGenerate, {
+    contentDependencies: expectedContentDependencyKeys,
+    extraDependencies: expectedExtraDependencyKeys,
+  });
+
+  return wrappedGenerate;
+}
+
+export function fulfillDependencies(dependencies, {
+  missingContentDependencyKeys,
+  missingExtraDependencyKeys,
+  invalidatingDependencyKeys,
+  fulfilledDependencyKeys,
+  fulfilledDependencies,
+}) {
+  // This is a mutating function. Be aware: it WILL mutate the provided sets
+  // and objects EVEN IF there are errors. This function doesn't exit early,
+  // so all provided dependencies which don't have an associated error should
+  // be treated as fulfilled (this is reflected via fulfilledDependencyKeys).
+
+  const errors = [];
+
+  for (let [key, value] of Object.entries(dependencies)) {
+    if (fulfilledDependencyKeys.has(key)) {
+      errors.push(new Error(`Dependency ${key} is already fulfilled`));
+      continue;
+    }
+
+    const isContentKey = missingContentDependencyKeys.has(key);
+    const isExtraKey = missingExtraDependencyKeys.has(key);
+
+    if (!isContentKey && !isExtraKey) {
+      errors.push(new Error(`Dependency ${key} is not expected`));
+      continue;
+    }
+
+    if (value === undefined) {
+      errors.push(new Error(`Dependency ${key} was provided undefined`));
+      continue;
+    }
+
+    const isContentFunction =
+      !!value?.[contentFunction.identifyingSymbol];
+
+    const isFulfilledContentFunction =
+      isContentFunction && value.fulfilled;
+
+    if (isContentKey) {
+      if (!isContentFunction) {
+        errors.push(new Error(`Content dependency ${key} is not a content function (got ${value})`));
+        continue;
+      }
+
+      if (!isFulfilledContentFunction) {
+        invalidatingDependencyKeys.add(key);
+      }
+
+      missingContentDependencyKeys.delete(key);
+    } else if (isExtraKey) {
+      if (isContentFunction) {
+        errors.push(new Error(`Extra dependency ${key} is a content function`));
+        continue;
+      }
+
+      missingExtraDependencyKeys.delete(key);
+    }
+
+    fulfilledDependencyKeys.add(key);
+    fulfilledDependencies[key] = value;
+  }
+
+  if (!empty(errors)) {
+    throw new AggregateError(errors, `Errors fulfilling dependencies`);
+  }
+}
+
+export function getArgsForRelationsAndData(contentFunction, wikiData, ...args) {
+  const insertArgs = [];
+
+  if (contentFunction.sprawl) {
+    insertArgs.push(contentFunction.sprawl(wikiData, ...args));
+  }
+
+  if (contentFunction.query) {
+    insertArgs.unshift(contentFunction.query(...insertArgs, ...args));
+  }
+
+  // Note: Query is generally intended to "filter" the provided args/sprawl,
+  // so in most cases it shouldn't be necessary to access the original args
+  // or sprawl afterwards. These are left available for now (as the second
+  // and later arguments in relations/data), but if they don't find any use,
+  // we can refactor this step to remove them.
+
+  return [...insertArgs, ...args];
+}
+
+export function getRelationsTree(dependencies, contentFunctionName, wikiData, ...args) {
+  const relationIdentifier = Symbol('Relation');
+
+  function recursive(contentFunctionName, args, traceStack) {
+    const contentFunction = dependencies[contentFunctionName];
+    if (!contentFunction) {
+      throw new Error(`Couldn't find dependency ${contentFunctionName}`);
+    }
+
+    // TODO: It's a bit awkward to pair this list of arguments with the output of
+    // getRelationsTree, but we do need to evaluate it right away (for the upcoming
+    // call to relations), and we're going to be reusing the same results for a
+    // later call to data (outside of getRelationsTree). There might be a nicer way
+    // of handling this.
+    const argsForRelationsAndData =
+      decorateErrorWithRelationStack(getArgsForRelationsAndData, traceStack)
+        (contentFunction, wikiData, ...args);
+
+    const result = {
+      name: contentFunctionName,
+      args: argsForRelationsAndData,
+      trace: traceStack,
+    };
+
+    if (contentFunction.relations) {
+      const listedDependencies = new Set(contentFunction.contentDependencies);
+
+      // Note: "slots" here is a completely separate concept from HTML template
+      // slots, which are handled completely within the content function. Here,
+      // relation slots are just references to a position within the relations
+      // layout that are referred to by a symbol - when the relation is ready,
+      // its result will be "slotted" into the layout.
+      const relationSlots = {};
+
+      const relationSymbolMessage = (() => {
+        let num = 1;
+        return name => `#${num++} ${name}`;
+      })();
+
+      const relationFunction = (name, ...args) => {
+        if (!listedDependencies.has(name)) {
+          throw new Error(`Called relation('${name}') but ${contentFunctionName} doesn't list that dependency`);
+        }
+
+        const relationSymbol = Symbol(relationSymbolMessage(name));
+        const traceError = new Error();
+
+        relationSlots[relationSymbol] = {name, args, traceError};
+
+        return {[relationIdentifier]: relationSymbol};
+      };
+
+      const relationsLayout =
+        contentFunction.relations(relationFunction, ...argsForRelationsAndData);
+
+      const relationsTree = Object.fromEntries(
+        Object.getOwnPropertySymbols(relationSlots)
+          .map(symbol => [symbol, relationSlots[symbol]])
+          .map(([symbol, {name, args, traceError}]) => [
+            symbol,
+            recursive(name, args, [...traceStack, {name, args, traceError}]),
+          ]));
+
+      result.relations = {
+        layout: relationsLayout,
+        slots: relationSlots,
+        tree: relationsTree,
+      };
+    }
+
+    return result;
+  }
+
+  const root =
+    recursive(contentFunctionName, args,
+      [{name: contentFunctionName, args, traceError: new Error()}]);
+
+  return {root, relationIdentifier};
+}
+
+export function flattenRelationsTree({root, relationIdentifier}) {
+  const flatRelationSlots = {};
+
+  function recursive(node) {
+    const flatNode = {
+      name: node.name,
+      args: node.args,
+      trace: node.trace,
+      relations: node.relations?.layout ?? null,
+    };
+
+    if (node.relations) {
+      const {tree, slots} = node.relations;
+      for (const slot of Object.getOwnPropertySymbols(slots)) {
+        flatRelationSlots[slot] = recursive(tree[slot]);
+      }
+    }
+
+    return flatNode;
+  }
+
+  return {
+    root: recursive(root, []),
+    relationIdentifier,
+    flatRelationSlots,
+  };
+}
+
+export function fillRelationsLayoutFromSlotResults(relationIdentifier, results, layout) {
+  function recursive(object) {
+    if (typeof object !== 'object' || object === null) {
+      return object;
+    }
+
+    if (Array.isArray(object)) {
+      return object.map(recursive);
+    }
+
+    if (relationIdentifier in object) {
+      return results[object[relationIdentifier]];
+    }
+
+    if (object.constructor !== Object) {
+      throw new Error(`Expected primitive, array, relation, or normal {key: value} style Object, got constructor ${object.constructor?.name}`);
+    }
+
+    return Object.fromEntries(
+      Object.entries(object)
+        .map(([key, value]) => [key, recursive(value)]));
+  }
+
+  return recursive(layout);
+}
+
+export function getNeededContentDependencyNames(contentDependencies, name) {
+  const set = new Set();
+
+  function recursive(name) {
+    const contentFunction = contentDependencies[name];
+    for (const dependencyName of contentFunction?.contentDependencies ?? []) {
+      recursive(dependencyName);
+    }
+    set.add(name);
+  }
+
+  recursive(name);
+
+  return set;
+}
+
+export const decorateErrorWithRelationStack = (fn, traceStack) =>
+  decorateError(fn, caughtError => {
+    let cause = caughtError;
+
+    for (const {name, args, traceError} of traceStack.slice().reverse()) {
+      const nameText = colors.green(`"${name}"`);
+      const namePart = `Error in relation(${nameText})`;
+
+      const argsPart =
+        (empty(args)
+          ? ``
+          : ` called with args: ${inspect(args)}`);
+
+      const error = new Error(namePart + argsPart, {cause});
+
+      error[Symbol.for('hsmusic.aggregate.alwaysTrace')] = true;
+      error[Symbol.for('hsmusic.aggregate.traceFrom')] = traceError;
+
+      error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [
+        /content-function\.js/,
+        /util\/html\.js/,
+      ];
+
+      error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
+        /content\/dependencies\/(.*\.js:.*(?=\)))/,
+      ];
+
+      cause = error;
+    }
+
+    return cause;
+  });
+
+export function quickEvaluate({
+  contentDependencies: allContentDependencies,
+  extraDependencies: allExtraDependencies,
+
+  name,
+  args = [],
+  slots = null,
+  multiple = null,
+  postprocess = null,
+}) {
+  if (multiple !== null) {
+    return multiple.map(opts =>
+      quickEvaluate({
+        contentDependencies: allContentDependencies,
+        extraDependencies: allExtraDependencies,
+
+        ...opts,
+        name: opts.name ?? name,
+        args: opts.args ?? args,
+        slots: opts.slots ?? slots,
+        postprocess: opts.postprocess ?? postprocess,
+      }));
+  }
+
+  const treeInfo = getRelationsTree(allContentDependencies, name, allExtraDependencies.wikiData ?? {}, ...args);
+  const flatTreeInfo = flattenRelationsTree(treeInfo);
+  const {root, relationIdentifier, flatRelationSlots} = flatTreeInfo;
+
+  const neededContentDependencyNames =
+    getNeededContentDependencyNames(allContentDependencies, name);
+
+  // Content functions aren't recursive, so by following the set above
+  // sequentually, we will always provide fulfilled content functions as the
+  // dependencies for later content functions.
+  const fulfilledContentDependencies = {};
+  for (const name of neededContentDependencyNames) {
+    const unfulfilledContentFunction = allContentDependencies[name];
+    if (!unfulfilledContentFunction) continue;
+
+    const {contentDependencies, extraDependencies} = unfulfilledContentFunction;
+
+    if (empty(contentDependencies) && empty(extraDependencies)) {
+      fulfilledContentDependencies[name] = unfulfilledContentFunction;
+      continue;
+    }
+
+    const fulfillments = {};
+
+    for (const dependencyName of contentDependencies ?? []) {
+      if (dependencyName in fulfilledContentDependencies) {
+        fulfillments[dependencyName] =
+          fulfilledContentDependencies[dependencyName];
+      }
+    }
+
+    for (const dependencyName of extraDependencies ?? []) {
+      if (dependencyName in allExtraDependencies) {
+        fulfillments[dependencyName] =
+          allExtraDependencies[dependencyName];
+      }
+    }
+
+    fulfilledContentDependencies[name] =
+      unfulfilledContentFunction.fulfill(fulfillments);
+  }
+
+  // There might still be unfulfilled content functions if dependencies weren't
+  // provided as part of allContentDependencies or allExtraDependencies.
+  // Catch and report these early, together in an aggregate error.
+  const unfulfilledErrors = [];
+  const unfulfilledNames = [];
+  for (const name of neededContentDependencyNames) {
+    const contentFunction = fulfilledContentDependencies[name];
+    if (!contentFunction) continue;
+    if (!contentFunction.fulfilled) {
+      try {
+        contentFunction();
+      } catch (error) {
+        error.message = `(${name}) ${error.message}`;
+        unfulfilledErrors.push(error);
+        unfulfilledNames.push(name);
+      }
+    }
+  }
+
+  if (!empty(unfulfilledErrors)) {
+    throw new AggregateError(unfulfilledErrors, `Content functions unfulfilled (${unfulfilledNames.join(', ')})`);
+  }
+
+  const slotResults = {};
+
+  function runContentFunction({name, args, relations: layout, trace: traceStack}) {
+    const callDecorated = (fn, ...args) =>
+      decorateErrorWithRelationStack(fn, traceStack)(...args);
+
+    const contentFunction = fulfilledContentDependencies[name];
+
+    if (!contentFunction) {
+      throw new Error(`Content function ${name} unfulfilled or not listed`);
+    }
+
+    const generateArgs = [];
+
+    if (contentFunction.data) {
+      generateArgs.push(callDecorated(contentFunction.data, ...args));
+    }
+
+    if (layout) {
+      generateArgs.push(fillRelationsLayoutFromSlotResults(relationIdentifier, slotResults, layout));
+    }
+
+    return callDecorated(contentFunction, ...generateArgs);
+  }
+
+  for (const slot of Object.getOwnPropertySymbols(flatRelationSlots)) {
+    slotResults[slot] = runContentFunction(flatRelationSlots[slot]);
+  }
+
+  let topLevelResult = runContentFunction(root);
+
+  if (slots) {
+    topLevelResult.setSlots(slots);
+  }
+
+  if (postprocess) {
+    topLevelResult = postprocess(topLevelResult);
+  }
+
+  return topLevelResult;
+}
diff --git a/src/content/dependencies/generateAbsoluteDatetimestamp.js b/src/content/dependencies/generateAbsoluteDatetimestamp.js
new file mode 100644
index 00000000..930b6f13
--- /dev/null
+++ b/src/content/dependencies/generateAbsoluteDatetimestamp.js
@@ -0,0 +1,53 @@
+export default {
+  contentDependencies: [
+    'generateDatetimestampTemplate',
+    'generateTooltip',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  data: (date) =>
+    ({date}),
+
+  relations: (relation) => ({
+    template:
+      relation('generateDatetimestampTemplate'),
+
+    tooltip:
+      relation('generateTooltip'),
+  }),
+
+  slots: {
+    style: {
+      validate: v => v.is('full', 'year'),
+      default: 'full',
+    },
+
+    // Only has an effect for 'year' style.
+    tooltip: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots, {language}) =>
+    relations.template.slots({
+      mainContent:
+        (slots.style === 'full'
+          ? language.formatDate(data.date)
+       : slots.style === 'year'
+          ? data.date.getFullYear().toString()
+          : null),
+
+      tooltip:
+        slots.tooltip &&
+        slots.style === 'year' &&
+          relations.tooltip.slots({
+            content:
+              language.formatDate(data.date),
+          }),
+
+      datetime:
+        data.date.toISOString(),
+    }),
+};
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js
new file mode 100644
index 00000000..68120b23
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -0,0 +1,26 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    chunks: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    chunkItems: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('ul', {class: 'additional-files-list'},
+      {[html.onlyIfContent]: true},
+
+      stitchArrays({
+        chunk: slots.chunks,
+        items: slots.chunkItems,
+      }).map(({chunk, items}) =>
+          chunk.clone()
+            .slot('items', items))),
+};
diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js
new file mode 100644
index 00000000..507b2329
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesListChunk.js
@@ -0,0 +1,46 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    description: {
+      type: 'html',
+      mutable: false,
+    },
+
+    items: {
+      validate: v => v.looseArrayOf(v.isHTML),
+    },
+  },
+
+  generate: (slots, {html, language}) =>
+    language.encapsulate('releaseInfo.additionalFiles.entry', capsule =>
+      html.tag('li',
+        html.tag('details',
+          html.isBlank(slots.items) &&
+            {open: true},
+
+          [
+            html.tag('summary',
+              html.tag('span',
+                language.$(capsule, {
+                  title:
+                    html.tag('b', slots.title),
+                }))),
+
+            html.tag('ul', [
+              html.tag('li', {class: 'entry-description'},
+                {[html.onlyIfContent]: true},
+                slots.description),
+
+              (html.isBlank(slots.items)
+                ? html.tag('li',
+                    language.$(capsule, 'noFilesAvailable'))
+                : slots.items),
+            ]),
+          ]))),
+};
diff --git a/src/content/dependencies/generateAdditionalFilesListChunkItem.js b/src/content/dependencies/generateAdditionalFilesListChunkItem.js
new file mode 100644
index 00000000..c37d6bb2
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesListChunkItem.js
@@ -0,0 +1,30 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    fileLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    fileSize: {
+      validate: v => v.isWholeNumber,
+    },
+  },
+
+  generate(slots, {html, language}) {
+    const itemParts = ['releaseInfo.additionalFiles.file'];
+    const itemOptions = {file: slots.fileLink};
+
+    if (slots.fileSize) {
+      itemParts.push('withSize');
+      itemOptions.size = language.formatFileSize(slots.fileSize);
+    }
+
+    const li =
+      html.tag('li',
+        language.$(...itemParts, itemOptions));
+
+    return li;
+  },
+};
diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js
new file mode 100644
index 00000000..b7392dfd
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalNamesBox.js
@@ -0,0 +1,28 @@
+export default {
+  contentDependencies: ['generateAdditionalNamesBoxItem'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, additionalNames) => ({
+    items:
+      additionalNames
+        .map(entry => relation('generateAdditionalNamesBoxItem', entry)),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tag('div', {id: 'additional-names-box'},
+      {class: 'drop'},
+      {[html.onlyIfContent]: true},
+
+      [
+        html.tag('p',
+          {[html.onlyIfSiblings]: true},
+
+          language.$('misc.additionalNames.title')),
+
+        html.tag('ul',
+          {[html.onlyIfContent]: true},
+
+          relations.items
+            .map(item => html.tag('li', item))),
+      ]),
+};
diff --git a/src/content/dependencies/generateAdditionalNamesBoxItem.js b/src/content/dependencies/generateAdditionalNamesBoxItem.js
new file mode 100644
index 00000000..e3e59a34
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalNamesBoxItem.js
@@ -0,0 +1,48 @@
+export default {
+  contentDependencies: ['transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    nameContent:
+      relation('transformContent', entry.name),
+
+    annotationContent:
+      (entry.annotation
+        ? relation('transformContent', entry.annotation)
+        : null),
+  }),
+
+  generate: (relations, {html, language}) => {
+    const prefix = 'misc.additionalNames.item';
+
+    const itemParts = [prefix];
+    const itemOptions = {};
+
+    itemOptions.name =
+      html.tag('span', {class: 'additional-name'},
+        relations.nameContent.slot('mode', 'inline'));
+
+    const accentParts = [prefix, 'accent'];
+    const accentOptions = {};
+
+    if (relations.annotationContent) {
+      accentParts.push('withAnnotation');
+      accentOptions.annotation =
+        relations.annotationContent.slots({
+          mode: 'inline',
+          absorbPunctuationFollowingExternalLinks: false,
+        });
+    }
+
+    if (accentParts.length > 2) {
+      itemParts.push('withAccent');
+      itemOptions.accent =
+        html.tag('span', {class: 'accent'},
+          html.metatag('chunkwrap', {split: ','},
+            html.resolve(
+              language.$(...accentParts, accentOptions))));
+    }
+
+    return language.$(...itemParts, itemOptions);
+  },
+};
diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js
new file mode 100644
index 00000000..ad17206f
--- /dev/null
+++ b/src/content/dependencies/generateAlbumAdditionalFilesList.js
@@ -0,0 +1,96 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalFilesList',
+    'generateAdditionalFilesListChunk',
+    'generateAdditionalFilesListChunkItem',
+    'linkAlbumAdditionalFile',
+    'transformContent',
+  ],
+
+  extraDependencies: ['getSizeOfMediaFile', 'html', 'urls'],
+
+  relations: (relation, album, additionalFiles) => ({
+    list:
+      relation('generateAdditionalFilesList', additionalFiles),
+
+    chunks:
+      additionalFiles
+        .map(() => relation('generateAdditionalFilesListChunk')),
+
+    chunkDescriptions:
+      additionalFiles
+        .map(({description}) =>
+          (description
+            ? relation('transformContent', description)
+            : null)),
+
+    chunkItems:
+      additionalFiles
+        .map(({files}) =>
+          (files ?? [])
+            .map(() => relation('generateAdditionalFilesListChunkItem'))),
+
+    chunkItemFileLinks:
+      additionalFiles
+        .map(({files}) =>
+          (files ?? [])
+            .map(file => relation('linkAlbumAdditionalFile', album, file))),
+  }),
+
+  data: (album, additionalFiles) => ({
+    albumDirectory: album.directory,
+
+    chunkTitles:
+      additionalFiles
+        .map(({title}) => title),
+
+    chunkItemLocations:
+      additionalFiles
+        .map(({files}) => files ?? []),
+  }),
+
+  slots: {
+    showFileSizes: {type: 'boolean', default: true},
+  },
+
+  generate: (data, relations, slots, {getSizeOfMediaFile, urls}) =>
+    relations.list.slots({
+      chunks:
+        stitchArrays({
+          chunk: relations.chunks,
+          description: relations.chunkDescriptions,
+          title: data.chunkTitles,
+        }).map(({chunk, title, description}) =>
+            chunk.slots({
+              title,
+              description:
+                (description
+                  ? description.slot('mode', 'inline')
+                  : null),
+            })),
+
+      chunkItems:
+        stitchArrays({
+          items: relations.chunkItems,
+          fileLinks: relations.chunkItemFileLinks,
+          locations: data.chunkItemLocations,
+        }).map(({items, fileLinks, locations}) =>
+            stitchArrays({
+              item: items,
+              fileLink: fileLinks,
+              location: locations,
+            }).map(({item, fileLink, location}) =>
+                item.slots({
+                  fileLink: fileLink,
+                  fileSize:
+                    (slots.showFileSizes
+                      ? getSizeOfMediaFile(
+                          urls
+                            .from('media.root')
+                            .to('media.albumAdditionalFile', data.albumDirectory, location))
+                      : 0),
+                }))),
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumArtInfoBox.js b/src/content/dependencies/generateAlbumArtInfoBox.js
new file mode 100644
index 00000000..8c44c930
--- /dev/null
+++ b/src/content/dependencies/generateAlbumArtInfoBox.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: ['generateReleaseInfoContributionsLine'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    wallpaperArtistContributionsLine:
+      (album.wallpaperArtwork
+        ? relation('generateReleaseInfoContributionsLine',
+            album.wallpaperArtwork.artistContribs)
+        : null),
+
+    bannerArtistContributionsLine:
+      (album.bannerArtwork
+        ? relation('generateReleaseInfoContributionsLine',
+            album.bannerArtwork.artistContribs)
+        : null),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule =>
+      html.tag('div', {class: 'album-art-info'},
+        {[html.onlyIfContent]: true},
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.wallpaperArtistContributionsLine?.slots({
+              stringKey: capsule + '.wallpaperArtBy',
+              chronologyKind: 'wallpaperArt',
+            }),
+
+            relations.bannerArtistContributionsLine?.slots({
+              stringKey: capsule + '.bannerArtBy',
+              chronologyKind: 'bannerArt',
+            }),
+          ]))),
+};
diff --git a/src/content/dependencies/generateAlbumArtworkColumn.js b/src/content/dependencies/generateAlbumArtworkColumn.js
new file mode 100644
index 00000000..e6762463
--- /dev/null
+++ b/src/content/dependencies/generateAlbumArtworkColumn.js
@@ -0,0 +1,38 @@
+export default {
+  contentDependencies: ['generateAlbumArtInfoBox', 'generateCoverArtwork'],
+  extraDependencies: ['html'],
+
+  relations: (relation, album) => ({
+    firstCover:
+      (album.hasCoverArt
+        ? relation('generateCoverArtwork', album.coverArtworks[0])
+        : null),
+
+    restCovers:
+      (album.hasCoverArt
+        ? album.coverArtworks.slice(1).map(artwork =>
+            relation('generateCoverArtwork', artwork))
+        : []),
+
+    albumArtInfoBox:
+      relation('generateAlbumArtInfoBox', album),
+  }),
+
+  generate: (relations, {html}) =>
+    html.tags([
+      relations.firstCover?.slots({
+        showOriginDetails: true,
+        showArtTagDetails: true,
+        showReferenceDetails: true,
+      }),
+
+      relations.albumArtInfoBox,
+
+      relations.restCovers.map(cover =>
+        cover.slots({
+          showOriginDetails: true,
+          showArtTagDetails: true,
+          showReferenceDetails: true,
+        })),
+    ]),
+};
diff --git a/src/content/dependencies/generateAlbumBanner.js b/src/content/dependencies/generateAlbumBanner.js
new file mode 100644
index 00000000..3cc141bc
--- /dev/null
+++ b/src/content/dependencies/generateAlbumBanner.js
@@ -0,0 +1,37 @@
+export default {
+  contentDependencies: ['generateBanner'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album) {
+    if (!album.hasBannerArt) {
+      return {};
+    }
+
+    return {
+      banner: relation('generateBanner'),
+    };
+  },
+
+  data(album) {
+    if (!album.hasBannerArt) {
+      return {};
+    }
+
+    return {
+      path: ['media.albumBanner', album.directory, album.bannerFileExtension],
+      dimensions: album.bannerDimensions,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    if (!relations.banner) {
+      return html.blank();
+    }
+
+    return relations.banner.slots({
+      path: data.path,
+      dimensions: data.dimensions,
+      alt: language.$('misc.alt.albumBanner'),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
new file mode 100644
index 00000000..1e39b47d
--- /dev/null
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -0,0 +1,306 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumCommentarySidebar',
+    'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
+    'generateAlbumStyleRules',
+    'generateCommentaryEntry',
+    'generateContentHeading',
+    'generateCoverArtwork',
+    'generatePageLayout',
+    'linkAlbum',
+    'linkExternal',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album) {
+    const query = {};
+
+    query.tracksWithCommentary =
+      album.tracks
+        .filter(({commentary}) => !empty(commentary));
+
+    query.thingsWithCommentary =
+      (empty(album.commentary)
+        ? query.tracksWithCommentary
+        : [album, ...query.tracksWithCommentary]);
+
+    return query;
+  },
+
+  relations(relation, query, album) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.secondaryNav =
+      relation('generateAlbumSecondaryNav', album);
+
+    relations.sidebar =
+      relation('generateAlbumCommentarySidebar', album);
+
+    relations.albumStyleRules =
+      relation('generateAlbumStyleRules', album, null);
+
+    relations.albumLink =
+      relation('linkAlbum', album);
+
+    relations.albumNavAccent =
+      relation('generateAlbumNavAccent', album, null);
+
+    if (!empty(album.commentary)) {
+      relations.albumCommentaryHeading =
+        relation('generateContentHeading');
+
+      relations.albumCommentaryLink =
+        relation('linkAlbum', album);
+
+      relations.albumCommentaryListeningLinks =
+        album.urls.map(url => relation('linkExternal', url));
+
+      if (album.hasCoverArt) {
+        relations.albumCommentaryCover =
+          relation('generateCoverArtwork', album.coverArtworks[0]);
+      }
+
+      relations.albumCommentaryEntries =
+        album.commentary
+          .map(entry => relation('generateCommentaryEntry', entry));
+    }
+
+    relations.trackCommentaryHeadings =
+      query.tracksWithCommentary
+        .map(() => relation('generateContentHeading'));
+
+    relations.trackCommentaryLinks =
+      query.tracksWithCommentary
+        .map(track => relation('linkTrack', track));
+
+    relations.trackCommentaryListeningLinks =
+      query.tracksWithCommentary
+        .map(track =>
+          track.urls.map(url => relation('linkExternal', url)));
+
+    relations.trackCommentaryCovers =
+      query.tracksWithCommentary
+        .map(track =>
+          (track.hasUniqueCoverArt
+            ? relation('generateCoverArtwork', track.trackArtworks[0])
+            : null));
+
+    relations.trackCommentaryEntries =
+      query.tracksWithCommentary
+        .map(track =>
+          track.commentary
+            .map(entry => relation('generateCommentaryEntry', entry)));
+
+    return relations;
+  },
+
+  data(query, album) {
+    const data = {};
+
+    data.name = album.name;
+    data.color = album.color;
+    data.date = album.date;
+
+    data.entryCount =
+      query.thingsWithCommentary
+        .flatMap(({commentary}) => commentary)
+        .length;
+
+    data.wordCount =
+      query.thingsWithCommentary
+        .flatMap(({commentary}) => commentary)
+        .map(({body}) => body)
+        .join(' ')
+        .split(' ')
+        .length;
+
+    data.trackCommentaryTrackDates =
+      query.tracksWithCommentary
+        .map(track => track.dateFirstReleased);
+
+    data.trackCommentaryDirectories =
+      query.tracksWithCommentary
+        .map(track => track.directory);
+
+    data.trackCommentaryColors =
+      query.tracksWithCommentary
+        .map(track =>
+          (track.color === album.color
+            ? null
+            : track.color));
+
+    return data;
+  },
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumCommentaryPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            album: data.name,
+          }),
+
+        headingMode: 'sticky',
+
+        color: data.color,
+        styleRules: [relations.albumStyleRules],
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          html.tag('p',
+            {[html.joinChildren]: html.tag('br')},
+
+            [
+              data.date &&
+              data.entryCount >= 1 &&
+                language.$('releaseInfo.albumReleased', {
+                  date:
+                    html.tag('b',
+                      language.formatDate(data.date)),
+                }),
+
+              language.encapsulate(pageCapsule, 'infoLine', workingCapsule => {
+                const workingOptions = {};
+
+                if (data.entryCount >= 1) {
+                  workingOptions.words =
+                    html.tag('b',
+                      language.formatWordCount(data.wordCount, {unit: true}));
+
+                  workingOptions.entries =
+                    html.tag('b',
+                      language.countCommentaryEntries(data.entryCount, {unit: true}));
+                }
+
+                if (data.entryCount === 0) {
+                  workingCapsule += '.withoutCommentary';
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })
+            ]),
+
+          relations.albumCommentaryEntries &&
+            language.encapsulate(pageCapsule, 'entry', entryCapsule => [
+              language.encapsulate(entryCapsule, 'title.albumCommentary', titleCapsule =>
+                relations.albumCommentaryHeading.slots({
+                  tag: 'h3',
+                  attributes: {id: 'album-commentary'},
+                  color: data.color,
+
+                  title:
+                    language.$(titleCapsule, {
+                      album: relations.albumCommentaryLink,
+                    }),
+
+                  stickyTitle:
+                    language.$(titleCapsule, 'sticky', {
+                      album: data.name,
+                    }),
+
+                  accent:
+                    language.$(titleCapsule, 'accent', {
+                      [language.onlyIfOptions]: ['listeningLinks'],
+                      listeningLinks:
+                        language.formatUnitList(
+                          relations.albumCommentaryListeningLinks
+                            .map(link => link.slots({
+                              context: 'album',
+                              tab: 'separate',
+                            }))),
+                    }),
+                })),
+
+              relations.albumCommentaryCover
+                ?.slots({mode: 'commentary'}),
+
+              relations.albumCommentaryEntries,
+            ]),
+
+          stitchArrays({
+            heading: relations.trackCommentaryHeadings,
+            link: relations.trackCommentaryLinks,
+            listeningLinks: relations.trackCommentaryListeningLinks,
+            directory: data.trackCommentaryDirectories,
+            cover: relations.trackCommentaryCovers,
+            entries: relations.trackCommentaryEntries,
+            color: data.trackCommentaryColors,
+            trackDate: data.trackCommentaryTrackDates,
+          }).map(({
+              heading,
+              link,
+              listeningLinks,
+              directory,
+              cover,
+              entries,
+              color,
+              trackDate,
+            }) =>
+              language.encapsulate(pageCapsule, 'entry', entryCapsule => [
+                language.encapsulate(entryCapsule, 'title.trackCommentary', titleCapsule =>
+                  heading.slots({
+                    tag: 'h3',
+                    attributes: {id: directory},
+                    color,
+
+                    title:
+                      language.$(titleCapsule, {
+                        track: link,
+                      }),
+
+                    accent:
+                      language.$(titleCapsule, 'accent', {
+                        [language.onlyIfOptions]: ['listeningLinks'],
+                        listeningLinks:
+                          language.formatUnitList(
+                            listeningLinks.map(link =>
+                              link.slot('tab', 'separate'))),
+                      }),
+                  })),
+
+              cover?.slots({mode: 'commentary'}),
+
+              trackDate &&
+              trackDate !== data.date &&
+                html.tag('p', {class: 'track-info'},
+                  language.$('releaseInfo.trackReleased', {
+                    date: language.formatDate(trackDate),
+                  })),
+
+              entries.map(entry => entry.slot('color', color)),
+            ])),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {
+            html:
+              relations.albumLink
+                .slot('attributes', {class: 'current'}),
+
+            accent:
+              relations.albumNavAccent.slots({
+                showTrackNavigation: false,
+                showExtraLinks: true,
+                currentExtra: 'commentary',
+              }),
+          },
+        ],
+
+        secondaryNav:
+          relations.secondaryNav.slots({
+            alwaysVisible: true,
+          }),
+
+        leftSidebar: relations.sidebar,
+      })),
+};
diff --git a/src/content/dependencies/generateAlbumCommentarySidebar.js b/src/content/dependencies/generateAlbumCommentarySidebar.js
new file mode 100644
index 00000000..9ecec66d
--- /dev/null
+++ b/src/content/dependencies/generateAlbumCommentarySidebar.js
@@ -0,0 +1,73 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarTrackSection',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackSections:
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection',
+          album,
+          null,
+          trackSection)),
+  }),
+
+  data: (album) => ({
+    albumHasCommentary:
+      !empty(album.commentary),
+
+    anyTrackHasCommentary:
+      album.tracks.some(track => !empty(track.commentary)),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumCommentaryPage', pageCapsule =>
+      relations.sidebar.slots({
+        stickyMode: 'column',
+        boxes: [
+          relations.sidebarBox.slots({
+            attributes: {class: 'commentary-track-list-sidebar-box'},
+            content: [
+              html.tag('h1', relations.albumLink),
+
+              html.tag('p', {[html.onlyIfContent]: true},
+                language.encapsulate(pageCapsule, 'sidebar', workingCapsule => {
+                  if (data.anyTrackHasCommentary) return html.blank();
+
+                  if (data.albumHasCommentary) {
+                    workingCapsule += '.noTrackCommentary';
+                  } else {
+                    workingCapsule += '.noCommentary';
+                  }
+
+                  return language.$(workingCapsule);
+                })),
+
+              data.anyTrackHasCommentary &&
+                relations.trackSections.map(section =>
+                  section.slots({
+                    anchor: true,
+                    open: true,
+                    mode: 'commentary',
+                  })),
+            ],
+          }),
+        ]
+      })),
+}
diff --git a/src/content/dependencies/generateAlbumGalleryAlbumGrid.js b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js
new file mode 100644
index 00000000..7f152871
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js
@@ -0,0 +1,90 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCoverGrid',
+    'image',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (album) => ({
+    artworks:
+      (album.hasCoverArt
+        ? album.coverArtworks
+        : []),
+  }),
+
+  relations: (relation, query, album) => ({
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    albumLinks:
+      query.artworks.map(_artwork =>
+        relation('linkAlbum', album)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, album) => ({
+    albumName:
+      album.name,
+
+    artworkLabels:
+      query.artworks
+        .map(artwork => artwork.label),
+
+    artworkArtists:
+      query.artworks
+        .map(artwork => artwork.artistContribs
+          .map(contrib => contrib.artist.name)),
+  }),
+
+  slots: {
+    attributes: {type: 'attributes', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+
+      slots.attributes,
+
+      [
+        relations.coverArtistsLine,
+
+        relations.coverGrid.slots({
+          links:
+            relations.albumLinks,
+
+          names:
+            data.artworkLabels
+              .map(label => label ?? data.albumName),
+
+          images:
+            stitchArrays({
+              image: relations.images,
+              label: data.artworkLabels,
+            }).map(({image, label}) =>
+                image.slots({
+                  missingSourceContent:
+                    language.$('misc.albumGalleryGrid.noCoverArt', {
+                      name:
+                        label ?? data.albumName,
+                    }),
+                })),
+
+          info:
+            data.artworkArtists.map(artists =>
+              language.$('misc.coverGrid.details.coverArtists', {
+                [language.onlyIfOptions]: ['artists'],
+
+                artists:
+                  language.formatUnitList(artists),
+              })),
+        }),
+      ]),
+};
diff --git a/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js b/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js
new file mode 100644
index 00000000..7dcdf6de
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: ['linkArtistGallery'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, coverArtists) {
+    return {
+      coverArtistLinks:
+        coverArtists
+          .map(artist => relation('linkArtistGallery', artist)),
+    };
+  },
+
+  generate(relations, {html, language}) {
+    return (
+      html.tag('p', {class: 'quick-info'},
+        language.$('albumGalleryPage.coverArtistsLine', {
+          artists: language.formatConjunctionList(relations.coverArtistLinks),
+        })));
+  },
+};
diff --git a/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js b/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js
new file mode 100644
index 00000000..ad99cb87
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js
@@ -0,0 +1,7 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  generate: ({html, language}) =>
+    html.tag('p', {class: 'quick-info'},
+      language.$('albumGalleryPage.noTrackArtworksLine')),
+};
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
new file mode 100644
index 00000000..2ba3b272
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -0,0 +1,167 @@
+import {stitchArrays, unique} from '#sugar';
+import {getKebabCase} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateAlbumGalleryAlbumGrid',
+    'generateAlbumGalleryNoTrackArtworksLine',
+    'generateAlbumGalleryStatsLine',
+    'generateAlbumGalleryTrackGrid',
+    'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
+    'generateAlbumStyleRules',
+    'generateIntrapageDotSwitcher',
+    'generatePageLayout',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album) {
+    const query = {};
+
+    const trackArtworkLabels =
+      album.tracks
+        .map(track => track.trackArtworks
+          .map(artwork => artwork.label));
+
+    const recurranceThreshold = 2;
+
+    // This list may include null, if some artworks are not labelled!
+    // That's expected.
+    query.recurringTrackArtworkLabels =
+      unique(trackArtworkLabels.flat())
+        .filter(label =>
+          trackArtworkLabels
+            .filter(labels => labels.includes(label))
+            .length >=
+          (label === null
+            ? 1
+            : recurranceThreshold));
+
+    return query;
+  },
+
+  relations: (relation, query, album) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', album, null),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    albumNavAccent:
+      relation('generateAlbumNavAccent', album, null),
+
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', album),
+
+    statsLine:
+      relation('generateAlbumGalleryStatsLine', album),
+
+    noTrackArtworksLine:
+      (album.tracks.every(track => !track.hasUniqueCoverArt)
+        ? relation('generateAlbumGalleryNoTrackArtworksLine')
+        : null),
+
+    setSwitcher:
+      relation('generateIntrapageDotSwitcher'),
+
+    albumGrid:
+      relation('generateAlbumGalleryAlbumGrid', album),
+
+    trackGrids:
+      query.recurringTrackArtworkLabels.map(label =>
+        relation('generateAlbumGalleryTrackGrid', album, label)),
+  }),
+
+  data: (query, album) => ({
+    trackGridLabels:
+      query.recurringTrackArtworkLabels,
+
+    trackGridIDs:
+      query.recurringTrackArtworkLabels.map(label =>
+        'track-grid-' +
+          (label
+            ? getKebabCase(label)
+            : 'no-label')),
+
+    name:
+      album.name,
+
+    color:
+      album.color,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumGalleryPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            album: data.name,
+          }),
+
+        headingMode: 'static',
+
+        color: data.color,
+        styleRules: [relations.albumStyleRules],
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          relations.statsLine,
+
+          relations.albumGrid,
+
+          relations.noTrackArtworksLine,
+
+          data.trackGridLabels.some(value => value !== null) &&
+            html.tag('p', {class: 'gallery-set-switcher'},
+              language.encapsulate(pageCapsule, 'setSwitcher', switcherCapsule =>
+                language.$(switcherCapsule, {
+                  sets:
+                    relations.setSwitcher.slots({
+                      initialOptionIndex: 0,
+
+                      titles:
+                        data.trackGridLabels.map(label =>
+                          label ??
+                          language.$(switcherCapsule, 'unlabeledSet')),
+
+                      targetIDs:
+                        data.trackGridIDs,
+                    }),
+                }))),
+
+          stitchArrays({
+            grid: relations.trackGrids,
+            id: data.trackGridIDs,
+          }).map(({grid, id}, index) =>
+              grid.slots({
+                attributes: [
+                  {id},
+                  index >= 1 && {style: 'display: none'},
+                ],
+              })),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {
+            html:
+              relations.albumLink
+                .slot('attributes', {class: 'current'}),
+            accent:
+              relations.albumNavAccent.slots({
+                showTrackNavigation: false,
+                showExtraLinks: true,
+                currentExtra: 'gallery',
+              }),
+          },
+        ],
+
+        secondaryNav: relations.secondaryNav,
+      })),
+};
diff --git a/src/content/dependencies/generateAlbumGalleryStatsLine.js b/src/content/dependencies/generateAlbumGalleryStatsLine.js
new file mode 100644
index 00000000..75bffb36
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryStatsLine.js
@@ -0,0 +1,38 @@
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data(album) {
+    return {
+      name: album.name,
+      date: album.date,
+      duration: getTotalDuration(album.tracks),
+      numTracks: album.tracks.length,
+    };
+  },
+
+  generate(data, {html, language}) {
+    const parts = ['albumGalleryPage.statsLine'];
+    const options = {};
+
+    options.tracks =
+      html.tag('b',
+        language.countTracks(data.numTracks, {unit: true}));
+
+    options.duration =
+      html.tag('b',
+        language.formatDuration(data.duration, {unit: true}));
+
+    if (data.date) {
+      parts.push('withDate');
+      options.date =
+        html.tag('b',
+          language.formatDate(data.date));
+    }
+
+    return (
+      html.tag('p', {class: 'quick-info'},
+        language.formatString(...parts, options)));
+  },
+};
diff --git a/src/content/dependencies/generateAlbumGalleryTrackGrid.js b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
new file mode 100644
index 00000000..85e7576c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
@@ -0,0 +1,122 @@
+import {compareArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumGalleryCoverArtistsLine',
+    'generateCoverGrid',
+    'image',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album, label) {
+    const query = {};
+
+    query.artworks =
+      album.tracks.map(track =>
+        track.trackArtworks.find(artwork => artwork.label === label) ??
+        null);
+
+    const presentArtworks =
+      query.artworks.filter(Boolean);
+
+    if (presentArtworks.length > 1) {
+      const allArtistArrays =
+        presentArtworks
+          .map(artwork => artwork.artistContribs
+            .map(contrib => contrib.artist));
+
+      const allSameArtists =
+        allArtistArrays
+          .slice(1)
+          .every(artists => compareArrays(artists, allArtistArrays[0]));
+
+      if (allSameArtists) {
+        query.artistsForAllTrackArtworks =
+          allArtistArrays[0];
+      }
+    }
+
+    return query;
+  },
+
+  relations: (relation, query, album, _label) => ({
+    coverArtistsLine:
+      (query.artistsForAllTrackArtworks
+        ? relation('generateAlbumGalleryCoverArtistsLine',
+            query.artistsForAllTrackArtworks)
+        : null),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackLinks:
+      album.tracks
+        .map(track => relation('linkTrack', track)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, album, _label) => ({
+    trackNames:
+      album.tracks
+        .map(track => track.name),
+
+    trackArtworkArtists:
+      query.artworks.map(artwork =>
+        (query.artistsForAllTrackArtworks
+          ? null
+       : artwork
+          ? artwork.artistContribs
+              .map(contrib => contrib.artist.name)
+          : null)),
+  }),
+
+  slots: {
+    attributes: {type: 'attributes', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+
+      slots.attributes,
+
+      [
+        relations.coverArtistsLine,
+
+        relations.coverGrid.slots({
+          links:
+            relations.trackLinks,
+
+          names:
+            data.trackNames,
+
+          images:
+            stitchArrays({
+              image: relations.images,
+              name: data.trackNames,
+            }).map(({image, name}) =>
+                image.slots({
+                  missingSourceContent:
+                    language.$('misc.albumGalleryGrid.noCoverArt', {name}),
+                })),
+
+          info:
+            data.trackArtworkArtists.map(artists =>
+              language.$('misc.coverGrid.details.coverArtists', {
+                [language.onlyIfOptions]: ['artists'],
+
+                artists:
+                  language.formatUnitList(artists),
+              })),
+        }),
+      ]),
+};
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
new file mode 100644
index 00000000..d0788523
--- /dev/null
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -0,0 +1,238 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateAlbumAdditionalFilesList',
+    'generateAlbumArtworkColumn',
+    'generateAlbumBanner',
+    'generateAlbumNavAccent',
+    'generateAlbumReleaseInfo',
+    'generateAlbumSecondaryNav',
+    'generateAlbumSidebar',
+    'generateAlbumSocialEmbed',
+    'generateAlbumStyleRules',
+    'generateAlbumTrackList',
+    'generateCommentaryEntry',
+    'generateContentHeading',
+    'generatePageLayout',
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', album, null),
+
+    socialEmbed:
+      relation('generateAlbumSocialEmbed', album),
+
+    albumNavAccent:
+      relation('generateAlbumNavAccent', album, null),
+
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', album),
+
+    sidebar:
+      relation('generateAlbumSidebar', album, null),
+
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', album.additionalNames),
+
+    artworkColumn:
+      relation('generateAlbumArtworkColumn', album),
+
+    banner:
+      (album.hasBannerArt
+        ? relation('generateAlbumBanner', album)
+        : null),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    releaseInfo:
+      relation('generateAlbumReleaseInfo', album),
+
+    galleryLink:
+      (album.tracks.some(t => t.hasUniqueCoverArt)
+        ? relation('linkAlbumGallery', album)
+        : null),
+
+    commentaryLink:
+      ([album, ...album.tracks].some(({commentary}) => !empty(commentary))
+        ? relation('linkAlbumCommentary', album)
+        : null),
+
+    trackList:
+      relation('generateAlbumTrackList', album),
+
+    additionalFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        album,
+        album.additionalFiles),
+
+    artistCommentaryEntries:
+      album.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
+
+    creditSourceEntries:
+      album.creditSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
+  }),
+
+  data: (album) => ({
+    name:
+      album.name,
+
+    color:
+      album.color,
+
+    dateAddedToWiki:
+      album.dateAddedToWiki,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            album: data.name,
+          }),
+
+        color: data.color,
+        headingMode: 'sticky',
+        styleRules: [relations.albumStyleRules],
+
+        additionalNames: relations.additionalNamesBox,
+
+        artworkColumnContent:
+          relations.artworkColumn,
+
+        mainContent: [
+          relations.releaseInfo,
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate('releaseInfo', capsule => [
+              !html.isBlank(relations.additionalFilesList) &&
+                language.$(capsule, 'additionalFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#additional-files'},
+                    language.$(capsule, 'additionalFiles.shortcut.link')),
+                }),
+
+              (relations.galleryLink && relations.commentaryLink
+                ? language.encapsulate(capsule, 'viewGalleryOrCommentary', capsule =>
+                    language.$(capsule, {
+                      gallery:
+                        relations.galleryLink
+                          .slot('content', language.$(capsule, 'gallery')),
+
+                      commentary:
+                        relations.commentaryLink
+                          .slot('content', language.$(capsule, 'commentary')),
+                    }))
+
+             : relations.galleryLink
+                ? language.encapsulate(capsule, 'viewGallery', capsule =>
+                    language.$(capsule, {
+                      link:
+                        relations.galleryLink
+                          .slot('content', language.$(capsule, 'link')),
+                    }))
+
+             : relations.commentaryLink
+                ? language.encapsulate(capsule, 'viewCommentary', capsule =>
+                    language.$(capsule, {
+                      link:
+                        relations.commentaryLink
+                          .slot('content', language.$(capsule, 'link')),
+                    }))
+
+                : html.blank()),
+
+              !html.isBlank(relations.creditSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#credit-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
+
+          relations.trackList,
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate('releaseInfo', capsule => [
+              language.$(capsule, 'addedToWiki', {
+                [language.onlyIfOptions]: ['date'],
+                date: language.formatDate(data.dateAddedToWiki),
+              }),
+            ])),
+
+          language.encapsulate('releaseInfo.additionalFiles', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'additional-files'},
+                  title: language.$(capsule, 'heading'),
+                }),
+
+              relations.additionalFilesList,
+            ])),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'artist-commentary'},
+                title: language.$('misc.artistCommentary'),
+              }),
+
+            relations.artistCommentaryEntries,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'credit-sources'},
+                title: language.$('misc.creditSources'),
+              }),
+
+            relations.creditSourceEntries,
+          ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {
+            auto: 'current',
+            accent:
+              relations.albumNavAccent.slots({
+                showTrackNavigation: true,
+                showExtraLinks: true,
+              }),
+          },
+        ],
+
+        banner: relations.banner ?? null,
+        bannerPosition: 'top',
+
+        secondaryNav: relations.secondaryNav,
+
+        leftSidebar: relations.sidebar,
+
+        socialEmbed: relations.socialEmbed,
+      })),
+};
diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js
new file mode 100644
index 00000000..432c5f3d
--- /dev/null
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -0,0 +1,142 @@
+import {atOffset, empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
+    'linkTrack',
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album, track) {
+    const query = {};
+
+    const index =
+      (track
+        ? album.tracks.indexOf(track)
+        : null);
+
+    query.previousTrack =
+      (track
+        ? atOffset(album.tracks, index, -1)
+        : null);
+
+    query.nextTrack =
+      (track
+        ? atOffset(album.tracks, index, +1)
+        : null);
+
+    return query;
+  },
+
+  relations: (relation, query, album, _track) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+
+    previousTrackLink:
+      (query.previousTrack
+        ? relation('linkTrack', query.previousTrack)
+        : null),
+
+    nextTrackLink:
+      (query.nextTrack
+        ? relation('linkTrack', query.nextTrack)
+        : null),
+
+    albumGalleryLink:
+      relation('linkAlbumGallery', album),
+
+    albumCommentaryLink:
+      relation('linkAlbumCommentary', album),
+  }),
+
+  data: (query, album, track) => ({
+    hasMultipleTracks:
+      album.tracks.length > 1,
+
+    commentaryPageIsStub:
+      [album, ...album.tracks]
+        .every(({commentary}) => empty(commentary)),
+
+    galleryIsStub:
+      album.tracks.every(t => !t.hasUniqueCoverArt),
+
+    isTrackPage:
+      !!track,
+  }),
+
+  slots: {
+    showTrackNavigation: {type: 'boolean', default: false},
+    showExtraLinks: {type: 'boolean', default: false},
+
+    currentExtra: {
+      validate: v => v.is('gallery', 'commentary'),
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const albumNavCapsule = language.encapsulate('albumPage.nav');
+    const trackNavCapsule = language.encapsulate('trackPage.nav');
+
+    const previousLink =
+      data.isTrackPage &&
+        relations.previousLink.slot('link', relations.previousTrackLink);
+
+    const nextLink =
+      data.isTrackPage &&
+        relations.nextLink.slot('link', relations.nextTrackLink);
+
+    const galleryLink =
+      (!data.galleryIsStub || slots.currentExtra === 'gallery') &&
+        relations.albumGalleryLink.slots({
+          attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+          content: language.$(albumNavCapsule, 'gallery'),
+        });
+
+    const commentaryLink =
+      (!data.commentaryPageIsStub || slots.currentExtra === 'commentary') &&
+        relations.albumCommentaryLink.slots({
+          attributes: {class: slots.currentExtra === 'commentary' && 'current'},
+          content: language.$(albumNavCapsule, 'commentary'),
+        });
+
+    const randomLink =
+      data.hasMultipleTracks &&
+        html.tag('a',
+          {id: 'random-button'},
+          {href: '#', 'data-random': 'track-in-sidebar'},
+
+          (data.isTrackPage
+            ? language.$(trackNavCapsule, 'random')
+            : language.$(albumNavCapsule, 'randomTrack')));
+
+    return relations.switcher.slots({
+      links: [
+        slots.showTrackNavigation &&
+          previousLink,
+
+        slots.showTrackNavigation &&
+          nextLink,
+
+        slots.showExtraLinks &&
+          galleryLink,
+
+        slots.showExtraLinks &&
+          commentaryLink,
+
+        slots.showTrackNavigation &&
+          randomLink,
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumReferencedArtworksPage.js b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
new file mode 100644
index 00000000..7586393c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
@@ -0,0 +1,58 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleRules',
+    'generateBackToAlbumLink',
+    'generateReferencedArtworksPage',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    page:
+      relation('generateReferencedArtworksPage', album.coverArtworks[0]),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', album, null),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    backToAlbumLink:
+      relation('generateBackToAlbumLink', album),
+  }),
+
+  data: (album) => ({
+    name:
+      album.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.page.slots({
+      title:
+        language.$('albumPage.title', {
+          album:
+            data.name,
+        }),
+
+      styleRules: [relations.albumStyleRules],
+
+      navLinks: [
+        {auto: 'home'},
+
+        {
+          html:
+            relations.albumLink
+              .slot('attributes', {class: 'current'}),
+
+          accent:
+            html.tag('a', {href: ''},
+              {class: 'current'},
+
+              language.$('referencedArtworksPage.subtitle')),
+        },
+      ],
+
+      navBottomRowContent: relations.backToAlbumLink,
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumReferencingArtworksPage.js b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
new file mode 100644
index 00000000..d072d2f6
--- /dev/null
+++ b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
@@ -0,0 +1,58 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleRules',
+    'generateBackToAlbumLink',
+    'generateReferencingArtworksPage',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    page:
+      relation('generateReferencingArtworksPage', album.coverArtworks[0]),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', album, null),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    backToAlbumLink:
+      relation('generateBackToAlbumLink', album),
+  }),
+
+  data: (album) => ({
+    name:
+      album.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.page.slots({
+      title:
+        language.$('albumPage.title', {
+          album:
+            data.name,
+        }),
+
+      styleRules: [relations.albumStyleRules],
+
+      navLinks: [
+        {auto: 'home'},
+
+        {
+          html:
+            relations.albumLink
+              .slot('attributes', {class: 'current'}),
+
+          accent:
+            html.tag('a', {href: ''},
+              {class: 'current'},
+
+              language.$('referencingArtworksPage.subtitle')),
+        },
+      ],
+
+      navBottomRowContent: relations.backToAlbumLink,
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
new file mode 100644
index 00000000..0abb412c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -0,0 +1,107 @@
+import {accumulateSum, empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateReleaseInfoContributionsLine',
+    'linkExternal',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album) {
+    const relations = {};
+
+    relations.artistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.artistContribs);
+
+    relations.wallpaperArtistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs);
+
+    relations.bannerArtistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
+
+    relations.externalLinks =
+      album.urls.map(url =>
+        relation('linkExternal', url));
+
+    return relations;
+  },
+
+  data(album) {
+    const data = {};
+
+    if (album.date) {
+      data.date = album.date;
+    }
+
+    if (album.coverArtDate && +album.coverArtDate !== +album.date) {
+      data.coverArtDate = album.coverArtDate;
+    }
+
+    const durationTerms =
+      album.tracks
+        .map(track => track.duration)
+        .filter(value => value > 0);
+
+    if (empty(durationTerms)) {
+      data.duration = null;
+      data.durationApproximate = null;
+    } else {
+      data.duration = accumulateSum(durationTerms);
+      data.durationApproximate = album.tracks.length > 1;
+    }
+
+    data.numTracks = album.tracks.length;
+
+    return data;
+  },
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule =>
+      html.tags([
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.artistContributionsLine.slots({
+              stringKey: capsule + '.by',
+              featuringStringKey: capsule + '.by.featuring',
+              chronologyKind: 'album',
+            }),
+
+            language.$(capsule, 'released', {
+              [language.onlyIfOptions]: ['date'],
+              date: language.formatDate(data.date),
+            }),
+
+            language.$(capsule, 'duration', {
+              [language.onlyIfOptions]: ['duration'],
+              duration:
+                language.formatDuration(data.duration, {
+                  approximate: data.durationApproximate,
+                }),
+            }),
+          ]),
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+
+          language.$(capsule, 'listenOn', {
+            [language.onlyIfOptions]: ['links'],
+
+            links:
+              language.formatDisjunctionList(
+                relations.externalLinks
+                  .map(link =>
+                    link.slot('context', [
+                      'album',
+                      (data.numTracks === 0
+                        ? 'albumNoTracks'
+                     : data.numTracks === 1
+                        ? 'albumOneTrack'
+                        : 'albumMultipleTracks'),
+                    ]))),
+          })),
+      ])),
+};
diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js
new file mode 100644
index 00000000..bfa48f03
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSecondaryNav.js
@@ -0,0 +1,127 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumSecondaryNavGroupPart',
+    'generateAlbumSecondaryNavSeriesPart',
+    'generateDotSwitcherTemplate',
+    'generateSecondaryNav',
+  ],
+
+  extraDependencies: ['html', 'wikiData'],
+
+  sprawl: ({groupData}) => ({
+    // TODO: Series aren't their own things, so we access them weirdly.
+    seriesData:
+      groupData.flatMap(group => group.serieses),
+  }),
+
+  query(sprawl, album) {
+    const query = {};
+
+    query.groups =
+      album.groups;
+
+    query.groupSerieses =
+      query.groups
+        .map(group =>
+          group.serieses
+            .filter(series => series.albums.includes(album)));
+
+    query.disconnectedSerieses =
+      sprawl.seriesData
+        .filter(series =>
+          series.albums.includes(album) &&
+          !query.groups.includes(series.group));
+
+    return query;
+  },
+
+  relations: (relation, query, _sprawl, album) => ({
+    secondaryNav:
+      relation('generateSecondaryNav'),
+
+    // Just use a generic dot switcher here. We want the common behavior,
+    // but the "options" may each contain multiple links (group + series),
+    // so this is a different use than typical interpage dot switchers.
+    switcher:
+      relation('generateDotSwitcherTemplate'),
+
+    groupParts:
+      query.groups
+        .map(group =>
+          relation('generateAlbumSecondaryNavGroupPart',
+            group,
+            album)),
+
+    seriesParts:
+      query.groupSerieses
+        .map(serieses => serieses
+          .map(series =>
+            relation('generateAlbumSecondaryNavSeriesPart',
+              series,
+              album))),
+
+    disconnectedSeriesParts:
+      query.disconnectedSerieses
+        .map(series =>
+          relation('generateAlbumSecondaryNavSeriesPart',
+            series,
+            album)),
+  }),
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'album',
+    },
+
+    alwaysVisible: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate(relations, slots, {html}) {
+    const groupConnectedParts =
+      stitchArrays({
+        groupPart: relations.groupParts,
+        seriesParts: relations.seriesParts,
+      }).map(({groupPart, seriesParts}) => {
+          for (const part of [groupPart, ...seriesParts]) {
+            part.setSlot('mode', slots.mode);
+          }
+
+          if (html.isBlank(seriesParts)) {
+            return groupPart;
+          } else {
+            return (
+              html.tag('span', {class: 'group-with-series'},
+                {[html.joinChildren]: ''},
+
+                [groupPart, ...seriesParts]));
+          }
+        });
+
+    const allParts = [
+      ...relations.disconnectedSeriesParts,
+      ...groupConnectedParts,
+    ];
+
+    return relations.secondaryNav.slots({
+      alwaysVisible: slots.alwaysVisible,
+
+      attributes: [
+        {class: 'album-secondary-nav'},
+
+        slots.mode === 'album' &&
+          {class: 'with-previous-next'},
+      ],
+
+      content:
+        (slots.mode === 'album'
+          ? allParts
+          : relations.switcher.slot('options', allParts)),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js
new file mode 100644
index 00000000..22dfa51c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js
@@ -0,0 +1,94 @@
+import {sortChronologically} from '#sort';
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateSecondaryNavParentSiblingsPart',
+    'linkAlbumDynamically',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html'],
+
+  query(group, album) {
+    const query = {};
+
+    if (album.date) {
+      // Sort by latest first. This matches the sorting order used on group
+      // gallery pages, ensuring that previous/next matches moving up/down
+      // the gallery. Note that this makes the index offsets "backwards"
+      // compared to how latest-last chronological lists are accessed.
+      const albums =
+        sortChronologically(
+          group.albums.filter(album => album.date),
+          {latestFirst: true});
+
+      const currentIndex =
+        albums.indexOf(album);
+
+      query.previousAlbum =
+        atOffset(albums, currentIndex, +1);
+
+      query.nextAlbum =
+        atOffset(albums, currentIndex, -1);
+    }
+
+    return query;
+  },
+
+  relations: (relation, query, group, _album) => ({
+    parentSiblingsPart:
+      relation('generateSecondaryNavParentSiblingsPart'),
+
+    groupLink:
+      relation('linkGroup', group),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', group.color),
+
+    previousAlbumLink:
+      (query.previousAlbum
+        ? relation('linkAlbumDynamically', query.previousAlbum)
+        : null),
+
+    nextAlbumLink:
+      (query.nextAlbum
+        ? relation('linkAlbumDynamically', query.nextAlbum)
+        : null),
+  }),
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'album',
+    },
+  },
+
+  generate: (relations, slots) =>
+    relations.parentSiblingsPart.slots({
+      attributes: {class: 'group-nav-links'},
+
+      showPreviousNext: slots.mode === 'album',
+
+      colorStyle: relations.colorStyle,
+      mainLink: relations.groupLink,
+
+      previousLink:
+        (relations.previousAlbumLink
+          ? relations.previousAlbumLink.slots({
+              linkCommentaryPages: true,
+            })
+          : null),
+
+      nextLink:
+        (relations.nextAlbumLink
+          ? relations.nextAlbumLink.slots({
+              linkCommentaryPages: true,
+            })
+          : null),
+
+      stringsKey: 'albumSecondaryNav.group',
+      mainLinkOption: 'group',
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js
new file mode 100644
index 00000000..16f205e3
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js
@@ -0,0 +1,94 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateSecondaryNavParentSiblingsPart',
+    'linkAlbumDynamically',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(series, album) {
+    const query = {};
+
+    const albums =
+      series.albums;
+
+    const currentIndex =
+      albums.indexOf(album);
+
+    query.previousAlbum =
+      atOffset(albums, currentIndex, -1);
+
+    query.nextAlbum =
+      atOffset(albums, currentIndex, +1);
+
+    return query;
+  },
+
+  relations: (relation, query, series, _album) => ({
+    parentSiblingsPart:
+      relation('generateSecondaryNavParentSiblingsPart'),
+
+    groupLink:
+      relation('linkGroup', series.group),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', series.group.color),
+
+    previousAlbumLink:
+      (query.previousAlbum
+        ? relation('linkAlbumDynamically', query.previousAlbum)
+        : null),
+
+    nextAlbumLink:
+      (query.nextAlbum
+        ? relation('linkAlbumDynamically', query.nextAlbum)
+        : null),
+  }),
+
+  data: (_query, series) => ({
+    name: series.name,
+  }),
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'album',
+    },
+  },
+
+  generate: (data, relations, slots, {language}) =>
+    relations.parentSiblingsPart.slots({
+      attributes: {class: 'series-nav-links'},
+
+      showPreviousNext: slots.mode === 'album',
+
+      colorStyle: relations.colorStyle,
+
+      mainLink:
+        relations.groupLink.slots({
+          attributes: {class: 'series'},
+          content: language.sanitize(data.name),
+        }),
+
+      previousLink:
+        (relations.previousAlbumLink
+          ? relations.previousAlbumLink.slots({
+              linkCommentaryPages: true,
+            })
+          : null),
+
+      nextLink:
+        (relations.nextAlbumLink
+          ? relations.nextAlbumLink.slots({
+              linkCommentaryPages: true,
+            })
+          : null),
+
+      stringsKey: 'albumSecondaryNav.series',
+      mainLinkOption: 'series',
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
new file mode 100644
index 00000000..7cf689cc
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -0,0 +1,171 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import {stitchArrays, transposeArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarGroupBox',
+    'generateAlbumSidebarSeriesBox',
+    'generateAlbumSidebarTrackListBox',
+    'generatePageSidebar',
+    'generatePageSidebarConjoinedBox',
+    'generateTrackReleaseBox',
+  ],
+
+  extraDependencies: ['html', 'wikiData'],
+
+  sprawl: ({groupData}) => ({
+    // TODO: Series aren't their own things, so we access them weirdly.
+    seriesData:
+      groupData.flatMap(group => group.serieses),
+  }),
+
+  query(sprawl, album, track) {
+    const query = {};
+
+    query.groups =
+      album.groups;
+
+    query.groupSerieses =
+      query.groups
+        .map(group =>
+          group.serieses
+            .filter(series => series.albums.includes(album)));
+
+    query.disconnectedSerieses =
+      sprawl.seriesData
+        .filter(series =>
+          series.albums.includes(album) &&
+          !query.groups.includes(series.group));
+
+    if (track) {
+      const albumTrackMap =
+        new Map(transposeArrays([
+          track.allReleases.map(t => t.album),
+          track.allReleases,
+        ]));
+
+      const allReleaseAlbums =
+        sortAlbumsTracksChronologically(
+          Array.from(albumTrackMap.keys()));
+
+      const currentReleaseIndex =
+        allReleaseAlbums.indexOf(track.album);
+
+      const earlierReleaseAlbums =
+        allReleaseAlbums.slice(0, currentReleaseIndex);
+
+      const laterReleaseAlbums =
+        allReleaseAlbums.slice(currentReleaseIndex + 1);
+
+      query.earlierReleaseTracks =
+        earlierReleaseAlbums.map(album => albumTrackMap.get(album));
+
+      query.laterReleaseTracks =
+        laterReleaseAlbums.map(album => albumTrackMap.get(album));
+    }
+
+    return query;
+  },
+
+  relations: (relation, query, _sprawl, album, track) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    conjoinedBox:
+      relation('generatePageSidebarConjoinedBox'),
+
+    trackListBox:
+      relation('generateAlbumSidebarTrackListBox', album, track),
+
+    groupBoxes:
+      query.groups
+        .map(group =>
+          relation('generateAlbumSidebarGroupBox', album, group)),
+
+    seriesBoxes:
+      query.groupSerieses
+        .map(serieses => serieses
+          .map(series =>
+            relation('generateAlbumSidebarSeriesBox', album, series))),
+
+    disconnectedSeriesBoxes:
+      query.disconnectedSerieses
+        .map(series =>
+          relation('generateAlbumSidebarSeriesBox', album, series)),
+
+    earlierTrackReleaseBoxes:
+      (track
+        ? query.earlierReleaseTracks
+            .map(track =>
+              relation('generateTrackReleaseBox', track))
+        : null),
+
+    laterTrackReleaseBoxes:
+      (track
+        ? query.laterReleaseTracks
+            .map(track =>
+              relation('generateTrackReleaseBox', track))
+        : null),
+  }),
+
+  data: (_query, _sprawl, _album, track) => ({
+    isAlbumPage: !track,
+    isTrackPage: !!track,
+  }),
+
+  generate(data, relations, {html}) {
+    for (const box of [
+      ...relations.groupBoxes,
+      ...relations.seriesBoxes.flat(),
+      ...relations.disconnectedSeriesBoxes,
+    ]) {
+      box.setSlot('mode',
+        data.isAlbumPage ? 'album' : 'track');
+    }
+
+    return relations.sidebar.slots({
+      boxes: [
+        data.isAlbumPage && [
+          relations.disconnectedSeriesBoxes,
+
+          stitchArrays({
+            groupBox: relations.groupBoxes,
+            seriesBoxes: relations.seriesBoxes,
+          }).map(({groupBox, seriesBoxes}) => [
+              groupBox,
+              seriesBoxes.map(seriesBox => [
+                html.tag('div',
+                  {class: 'sidebar-box-joiner'},
+                  {class: 'collapsible'}),
+                seriesBox,
+              ]),
+            ]),
+        ],
+
+        data.isTrackPage &&
+          relations.earlierTrackReleaseBoxes,
+
+        relations.trackListBox,
+
+        data.isTrackPage &&
+          relations.laterTrackReleaseBoxes,
+
+        data.isTrackPage &&
+          relations.conjoinedBox.slots({
+            attributes: {class: 'conjoined-group-sidebar-box'},
+            boxes:
+              ([relations.disconnectedSeriesBoxes,
+                stitchArrays({
+                  groupBox: relations.groupBoxes,
+                  seriesBoxes: relations.seriesBoxes,
+                }).flatMap(({groupBox, seriesBoxes}) => [
+                    groupBox,
+                    ...seriesBoxes,
+                  ]),
+              ]).flat()
+                .map(box => box.content), /* TODO: Kludge. */
+          }),
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
new file mode 100644
index 00000000..f3be74f7
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -0,0 +1,126 @@
+import {sortChronologically} from '#sort';
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkAlbum',
+    'linkExternal',
+    'linkGroup',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album, group) {
+    const query = {};
+
+    if (album.date) {
+      const albums =
+        group.albums.filter(album => album.date);
+
+      // Sort by latest first. This matches the sorting order used on group
+      // gallery pages, ensuring that previous/next matches moving up/down
+      // the gallery. Note that this makes the index offsets "backwards"
+      // compared to how latest-last chronological lists are accessed.
+      sortChronologically(albums, {latestFirst: true});
+
+      const index =
+        albums.indexOf(album);
+
+      query.previousAlbum =
+        atOffset(albums, index, +1);
+
+      query.nextAlbum =
+        atOffset(albums, index, -1);
+    }
+
+    return query;
+  },
+
+  relations(relation, query, album, group) {
+    const relations = {};
+
+    relations.box =
+      relation('generatePageSidebarBox');
+
+    relations.groupLink =
+      relation('linkGroup', group);
+
+    relations.externalLinks =
+      group.urls.map(url =>
+        relation('linkExternal', url));
+
+    if (group.descriptionShort) {
+      relations.description =
+        relation('transformContent', group.descriptionShort);
+    }
+
+    if (query.previousAlbum) {
+      relations.previousAlbumLink =
+        relation('linkAlbum', query.previousAlbum);
+    }
+
+    if (query.nextAlbum) {
+      relations.nextAlbumLink =
+        relation('linkAlbum', query.nextAlbum);
+    }
+
+    return relations;
+  },
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'track',
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    language.encapsulate('albumSidebar.groupBox', boxCapsule =>
+      relations.box.slots({
+        attributes: {class: 'individual-group-sidebar-box'},
+        content: [
+          html.tag('h1',
+            language.$(boxCapsule, 'title', {
+              group: relations.groupLink,
+            })),
+
+          slots.mode === 'album' &&
+            relations.description
+              ?.slot('mode', 'multiline'),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'group'))),
+            })),
+
+          slots.mode === 'album' &&
+            html.tag('p', {class: 'group-chronology-link'},
+              {[html.onlyIfContent]: true},
+
+              language.$(boxCapsule, 'next', {
+                [language.onlyIfOptions]: ['album'],
+
+                album: relations.nextAlbumLink,
+              })),
+
+          slots.mode === 'album' &&
+            html.tag('p', {class: 'group-chronology-link'},
+              {[html.onlyIfContent]: true},
+
+              language.$(boxCapsule, 'previous', {
+                [language.onlyIfOptions]: ['album'],
+
+                album: relations.previousAlbumLink,
+              })),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateAlbumSidebarSeriesBox.js b/src/content/dependencies/generateAlbumSidebarSeriesBox.js
new file mode 100644
index 00000000..37616cb2
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarSeriesBox.js
@@ -0,0 +1,102 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkAlbum',
+    'linkGroup',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album, series) {
+    const query = {};
+
+    const albums =
+      series.albums;
+
+    const index =
+      albums.indexOf(album);
+
+    query.previousAlbum =
+      atOffset(albums, index, -1);
+
+    query.nextAlbum =
+      atOffset(albums, index, +1);
+
+    return query;
+  },
+
+  relations: (relation, query, _album, series) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    groupLink:
+      relation('linkGroup', series.group),
+
+    description:
+      relation('transformContent', series.description),
+
+    previousAlbumLink:
+      (query.previousAlbum
+        ? relation('linkAlbum', query.previousAlbum)
+        : null),
+
+    nextAlbumLink:
+      (query.nextAlbum
+        ? relation('linkAlbum', query.nextAlbum)
+        : null),
+  }),
+
+  data: (_query, _album, series) => ({
+    name: series.name,
+  }),
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'track',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('albumSidebar.groupBox', boxCapsule =>
+      relations.box.slots({
+        attributes: {class: 'individual-series-sidebar-box'},
+        content: [
+          html.tag('h1',
+            language.$(boxCapsule, 'title', {
+              group:
+                relations.groupLink.slots({
+                  attributes: {class: 'series'},
+                  content: language.sanitize(data.name),
+                }),
+            })),
+
+          slots.mode === 'album' &&
+            relations.description
+              ?.slot('mode', 'multiline'),
+
+          slots.mode === 'album' &&
+            html.tag('p', {class: 'series-chronology-link'},
+              {[html.onlyIfContent]: true},
+
+              language.$(boxCapsule, 'next', {
+                [language.onlyIfOptions]: ['album'],
+
+                album: relations.nextAlbumLink,
+              })),
+
+          slots.mode === 'album' &&
+            html.tag('p', {class: 'series-chronology-link'},
+              {[html.onlyIfContent]: true},
+
+              language.$(boxCapsule, 'previous', {
+                [language.onlyIfOptions]: ['album'],
+
+                album: relations.previousAlbumLink,
+              })),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateAlbumSidebarTrackListBox.js b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
new file mode 100644
index 00000000..3a244e3a
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
@@ -0,0 +1,31 @@
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarTrackSection',
+    'generatePageSidebarBox',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album, track) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackSections:
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection', album, track, trackSection)),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.box.slots({
+      attributes: {class: 'track-list-sidebar-box'},
+
+      content: [
+        html.tag('h1', relations.albumLink),
+        relations.trackSections,
+      ],
+    })
+};
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
new file mode 100644
index 00000000..dae5fa03
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -0,0 +1,167 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['getColors', 'html', 'language'],
+
+  relations(relation, album, track, trackSection) {
+    const relations = {};
+
+    relations.trackLinks =
+      trackSection.tracks.map(track =>
+        relation('linkTrack', track));
+
+    return relations;
+  },
+
+  data(album, track, trackSection) {
+    const data = {};
+
+    data.hasTrackNumbers =
+      album.hasTrackNumbers &&
+      !empty(trackSection.tracks);
+
+    data.isTrackPage = !!track;
+
+    data.name = trackSection.name;
+    data.color = trackSection.color;
+    data.isDefaultTrackSection = trackSection.isDefaultTrackSection;
+
+    data.firstTrackNumber =
+      (data.hasTrackNumbers
+        ? trackSection.tracks.at(0).trackNumber
+        : null);
+
+    data.lastTrackNumber =
+      (data.hasTrackNumbers
+        ? trackSection.tracks.at(-1).trackNumber
+        : null);
+
+    data.trackDirectories =
+      trackSection.tracks
+        .map(track => track.directory);
+
+    data.tracksAreMissingCommentary =
+      trackSection.tracks
+        .map(track => empty(track.commentary));
+
+    data.tracksAreCurrentTrack =
+      trackSection.tracks
+        .map(traaaaaaaack => traaaaaaaack === track);
+
+    data.includesCurrentTrack =
+      data.tracksAreCurrentTrack.includes(true);
+
+    return data;
+  },
+
+  slots: {
+    anchor: {type: 'boolean'},
+    open: {type: 'boolean'},
+
+    mode: {
+      validate: v => v.is('info', 'commentary'),
+      default: 'info',
+    },
+  },
+
+  generate(data, relations, slots, {getColors, html, language}) {
+    const capsule = language.encapsulate('albumSidebar.trackList');
+
+    const sectionName =
+      html.tag('b',
+        (data.isDefaultTrackSection
+          ? language.$(capsule, 'fallbackSectionName')
+          : data.name));
+
+    let colorStyle;
+    if (data.color) {
+      const {primary} = getColors(data.color);
+      colorStyle = {style: `--primary-color: ${primary}`};
+    }
+
+    const trackListItems =
+      stitchArrays({
+        trackLink: relations.trackLinks,
+        directory: data.trackDirectories,
+        isCurrentTrack: data.tracksAreCurrentTrack,
+        missingCommentary: data.tracksAreMissingCommentary,
+      }).map(({
+          trackLink,
+          directory,
+          isCurrentTrack,
+          missingCommentary,
+        }) =>
+          html.tag('li',
+            data.includesCurrentTrack &&
+            isCurrentTrack &&
+              {class: 'current'},
+
+            slots.mode === 'commentary' &&
+            missingCommentary &&
+              {class: 'no-commentary'},
+
+            language.$(capsule, 'item', {
+              track:
+                (slots.mode === 'commentary' && missingCommentary
+                  ? trackLink.slots({
+                      linkless: true,
+                    })
+               : slots.anchor
+                  ? trackLink.slots({
+                      anchor: true,
+                      hash: directory,
+                    })
+                  : trackLink),
+            })));
+
+    return html.tag('details',
+      data.includesCurrentTrack &&
+        {class: 'current'},
+
+      // Allow forcing open via a template slot.
+      // This isn't exactly janky, but the rest of this function
+      // kind of is when you contextualize it in a template...
+      slots.open &&
+        {open: true},
+
+      // Leave sidebar track sections collapsed on album info page,
+      // since there's already a view of the full track listing
+      // in the main content area.
+      data.isTrackPage &&
+
+      // Only expand the track section which includes the track
+      // currently being viewed by default.
+      data.includesCurrentTrack &&
+        {open: true},
+
+      [
+        html.tag('summary',
+          colorStyle,
+
+          html.tag('span',
+            language.encapsulate(capsule, 'group', groupCapsule =>
+              language.encapsulate(groupCapsule, workingCapsule => {
+                const workingOptions = {group: sectionName};
+
+                if (data.hasTrackNumbers) {
+                  workingCapsule += '.withRange';
+                  workingOptions.rangePart =
+                    html.tag('span', {class: 'track-section-range'},
+                      language.$(groupCapsule, 'withRange.rangePart', {
+                        range:
+                          `${data.firstTrackNumber}–${data.lastTrackNumber}`,
+                      }));
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })))),
+
+        (data.hasTrackNumbers
+          ? html.tag('ol',
+              {start: data.firstTrackNumber},
+              trackListItems)
+          : html.tag('ul', trackListItems)),
+      ]);
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js
new file mode 100644
index 00000000..e28a3fd0
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSocialEmbed.js
@@ -0,0 +1,70 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateSocialEmbed',
+    'generateAlbumSocialEmbedDescription',
+  ],
+
+  extraDependencies: ['absoluteTo', 'language'],
+
+  relations(relation, album) {
+    return {
+      socialEmbed:
+        relation('generateSocialEmbed'),
+
+      description:
+        relation('generateAlbumSocialEmbedDescription', album),
+    };
+  },
+
+  data(album) {
+    const data = {};
+
+    data.hasHeading = !empty(album.groups);
+
+    if (data.hasHeading) {
+      const firstGroup = album.groups[0];
+      data.headingGroupName = firstGroup.name;
+      data.headingGroupDirectory = firstGroup.directory;
+    }
+
+    data.hasImage = album.hasCoverArt;
+
+    if (data.hasImage) {
+      data.imagePath = album.coverArtworks[0].path;
+    }
+
+    data.albumName = album.name;
+
+    return data;
+  },
+
+  generate: (data, relations, {absoluteTo, language}) =>
+    language.encapsulate('albumPage.socialEmbed', embedCapsule =>
+      relations.socialEmbed.slots({
+        title:
+          language.$(embedCapsule, 'title', {
+            album: data.albumName,
+          }),
+
+        description: relations.description,
+
+        headingContent:
+          (data.hasHeading
+            ? language.$(embedCapsule, 'heading', {
+                group: data.headingGroupName,
+              })
+            : null),
+
+        headingLink:
+          (data.hasHeading
+            ? absoluteTo('localized.groupGallery', data.headingGroupDirectory)
+            : null),
+
+        imagePath:
+          (data.hasImage
+            ? data.imagePath
+            : null),
+      })),
+};
diff --git a/src/content/dependencies/generateAlbumSocialEmbedDescription.js b/src/content/dependencies/generateAlbumSocialEmbedDescription.js
new file mode 100644
index 00000000..69c39c3a
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js
@@ -0,0 +1,41 @@
+import {accumulateSum} from '#sugar';
+
+export default {
+  extraDependencies: ['language'],
+
+  data: (album) => ({
+    duration:
+      accumulateSum(album.tracks, track => track.duration),
+
+    tracks:
+      album.tracks.length,
+
+    date:
+      album.date,
+  }),
+
+  generate: (data, {language}) =>
+    language.encapsulate('albumPage.socialEmbed.body', workingCapsule => {
+      const workingOptions = {};
+
+      if (data.duration > 0) {
+        workingCapsule += '.withDuration';
+        workingOptions.duration =
+          language.formatDuration(data.duration);
+      }
+
+      if (data.tracks > 0) {
+        workingCapsule += '.withTracks';
+        workingOptions.tracks =
+          language.countTracks(data.tracks, {unit: true});
+      }
+
+      if (data.date) {
+        workingCapsule += '.withReleaseDate';
+        workingOptions.date =
+          language.formatDate(data.date);
+      }
+
+      return language.$(workingCapsule, workingOptions);
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js
new file mode 100644
index 00000000..6bfcc62e
--- /dev/null
+++ b/src/content/dependencies/generateAlbumStyleRules.js
@@ -0,0 +1,107 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  extraDependencies: ['to'],
+
+  data(album, track) {
+    const data = {};
+
+    data.hasWallpaper = !empty(album.wallpaperArtistContribs);
+    data.hasBanner = !empty(album.bannerArtistContribs);
+
+    if (data.hasWallpaper) {
+      if (!empty(album.wallpaperParts)) {
+        data.wallpaperMode = 'parts';
+
+        data.wallpaperPaths =
+          album.wallpaperParts.map(part =>
+            (part.asset
+              ? ['media.albumWallpaperPart', album.directory, part.asset]
+              : null));
+
+        data.wallpaperStyles =
+          album.wallpaperParts.map(part => part.style);
+      } else {
+        data.wallpaperMode = 'one';
+        data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension];
+        data.wallpaperStyle = album.wallpaperStyle;
+      }
+    }
+
+    if (data.hasBanner) {
+      data.hasBannerStyle = !!album.bannerStyle;
+      data.bannerStyle = album.bannerStyle;
+    }
+
+    data.albumDirectory = album.directory;
+
+    if (track) {
+      data.trackDirectory = track.directory;
+    }
+
+    return data;
+  },
+
+  generate(data, {to}) {
+    const indent = parts =>
+      (parts ?? [])
+        .filter(Boolean)
+        .join('\n')
+        .split('\n')
+        .map(line => ' '.repeat(4) + line)
+        .join('\n');
+
+    const rule = (selector, parts) =>
+      (!empty(parts.filter(Boolean))
+        ? [`${selector} {`, indent(parts), `}`]
+        : []);
+
+    const oneWallpaperRule =
+      data.wallpaperMode === 'one' &&
+        rule(`body::before`, [
+          `background-image: url("${to(...data.wallpaperPath)}");`,
+          data.wallpaperStyle,
+        ]);
+
+    const wallpaperPartRules =
+      data.wallpaperMode === 'parts' &&
+        stitchArrays({
+          path: data.wallpaperPaths,
+          style: data.wallpaperStyles,
+        }).map(({path, style}, index) =>
+            rule(`.wallpaper-part:nth-child(${index + 1})`, [
+              path && `background-image: url("${to(...path)}");`,
+              style,
+            ]));
+
+    const nukeBasicWallpaperRule =
+      data.wallpaperMode === 'parts' &&
+        rule(`body::before`, ['display: none']);
+
+    const wallpaperRules = [
+      oneWallpaperRule,
+      ...wallpaperPartRules || [],
+      nukeBasicWallpaperRule,
+    ];
+
+    const bannerRule =
+      data.hasBanner &&
+        rule(`#banner img`, [
+          data.bannerStyle,
+        ]);
+
+    const dataRule =
+      rule(`:root`, [
+        data.albumDirectory &&
+          `--album-directory: ${data.albumDirectory};`,
+        data.trackDirectory &&
+          `--track-directory: ${data.trackDirectory};`,
+      ]);
+
+    return (
+      [...wallpaperRules, bannerRule, dataRule]
+        .filter(Boolean)
+        .flat()
+        .join('\n'));
+  },
+};
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
new file mode 100644
index 00000000..0a949ded
--- /dev/null
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -0,0 +1,206 @@
+import {accumulateSum, empty, stitchArrays} from '#sugar';
+
+function displayTrackSections(album) {
+  if (empty(album.trackSections)) {
+    return false;
+  }
+
+  if (album.trackSections.length > 1) {
+    return true;
+  }
+
+  if (!album.trackSections[0].isDefaultTrackSection) {
+    return true;
+  }
+
+  return false;
+}
+
+function displayTracks(album) {
+  if (empty(album.tracks)) {
+    return false;
+  }
+
+  return true;
+}
+
+function getDisplayMode(album) {
+  if (displayTrackSections(album)) {
+    return 'trackSections';
+  } else if (displayTracks(album)) {
+    return 'tracks';
+  } else {
+    return 'none';
+  }
+}
+
+export default {
+  contentDependencies: [
+    'generateAlbumTrackListItem',
+    'generateContentHeading',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album) {
+    return {
+      displayMode: getDisplayMode(album),
+    };
+  },
+
+  relations(relation, query, album) {
+    const relations = {};
+
+    switch (query.displayMode) {
+      case 'trackSections':
+        relations.trackSectionHeadings =
+          album.trackSections.map(() =>
+            relation('generateContentHeading'));
+
+        relations.trackSectionDescriptions =
+          album.trackSections.map(section =>
+            relation('transformContent', section.description));
+
+        relations.trackSectionItems =
+          album.trackSections.map(section =>
+            section.tracks.map(track =>
+              relation('generateAlbumTrackListItem', track, album)));
+
+        break;
+
+      case 'tracks':
+        relations.items =
+          album.tracks.map(track =>
+            relation('generateAlbumTrackListItem', track, album));
+
+        break;
+    }
+
+    return relations;
+  },
+
+  data(query, album) {
+    const data = {};
+
+    data.displayMode = query.displayMode;
+    data.hasTrackNumbers = album.hasTrackNumbers;
+
+    switch (query.displayMode) {
+      case 'trackSections':
+        data.trackSectionNames =
+          album.trackSections
+            .map(section => section.name);
+
+        data.trackSectionDurations =
+          album.trackSections
+            .map(section =>
+              accumulateSum(section.tracks, track => track.duration));
+
+        data.trackSectionDurationsApproximate =
+          album.trackSections
+            .map(section => section.tracks.length > 1);
+
+        if (album.hasTrackNumbers) {
+          data.trackSectionsStartCountingFrom =
+            album.trackSections
+              .map(section => section.startCountingFrom);
+        } else {
+          data.trackSectionsStartCountingFrom =
+            album.trackSections
+              .map(() => null);
+        }
+
+        break;
+    }
+
+    return data;
+  },
+
+  slots: {
+    collapseDurationScope: {
+      validate: v =>
+        v.is('never', 'track', 'section', 'album'),
+
+      default: 'album',
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const listTag = (data.hasTrackNumbers ? 'ol' : 'ul');
+
+    const slotItems = items =>
+      items.map(item =>
+        item.slots({
+          collapseDurationScope:
+            slots.collapseDurationScope,
+        }));
+
+    switch (data.displayMode) {
+      case 'trackSections':
+        return html.tag('dl', {class: 'album-group-list'},
+          stitchArrays({
+            heading: relations.trackSectionHeadings,
+            description: relations.trackSectionDescriptions,
+            items: relations.trackSectionItems,
+
+            name: data.trackSectionNames,
+            duration: data.trackSectionDurations,
+            durationApproximate: data.trackSectionDurationsApproximate,
+            startCountingFrom: data.trackSectionsStartCountingFrom,
+          }).map(({
+              heading,
+              description,
+              items,
+
+              name,
+              duration,
+              durationApproximate,
+              startCountingFrom,
+            }) => [
+              language.encapsulate('trackList.section', capsule =>
+                heading.slots({
+                  tag: 'dt',
+
+                  title:
+                    language.encapsulate(capsule, capsule => {
+                      const options = {section: name};
+
+                      if (duration !== 0) {
+                        capsule += '.withDuration';
+                        options.duration =
+                          language.formatDuration(duration, {
+                            approximate: durationApproximate,
+                          });
+                      }
+
+                      return language.$(capsule, options);
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky', {
+                      section: name,
+                    }),
+                })),
+
+              html.tag('dd', [
+                html.tag('blockquote',
+                  {[html.onlyIfContent]: true},
+                  description),
+
+                html.tag(listTag,
+                  data.hasTrackNumbers &&
+                    {start: startCountingFrom},
+
+                  slotItems(items)),
+              ]),
+            ]));
+
+      case 'tracks':
+        return html.tag(listTag, slotItems(relations.items));
+
+      default:
+        return html.blank();
+    }
+  }
+};
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
new file mode 100644
index 00000000..44297c15
--- /dev/null
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -0,0 +1,62 @@
+export default {
+  contentDependencies: ['generateTrackListItem'],
+  extraDependencies: ['html'],
+
+  query: (track, album) => ({
+    trackHasDuration:
+      !!track.duration,
+
+    sectionHasDuration:
+      !album.trackSections
+        .some(section =>
+          section.tracks.every(track => !track.duration) &&
+          section.tracks.includes(track)),
+
+    albumHasDuration:
+      album.tracks.some(track => track.duration),
+  }),
+
+  relations: (relation, query, track) => ({
+    item:
+      relation('generateTrackListItem',
+        track,
+        track.album.artistContribs),
+  }),
+
+  data: (query, track, album) => ({
+    trackHasDuration: query.trackHasDuration,
+    sectionHasDuration: query.sectionHasDuration,
+    albumHasDuration: query.albumHasDuration,
+
+    colorize:
+      track.color !== album.color,
+  }),
+
+  slots: {
+    collapseDurationScope: {
+      validate: v =>
+        v.is('never', 'track', 'section', 'album'),
+
+      default: 'album',
+    },
+  },
+
+  generate: (data, relations, slots) =>
+    relations.item.slots({
+      showArtists: true,
+
+      showDuration:
+        (slots.collapseDurationScope === 'track'
+          ? data.trackHasDuration
+       : slots.collapseDurationScope === 'section'
+          ? data.sectionHasDuration
+       : slots.collapseDurationScope === 'album'
+          ? data.albumHasDuration
+          : true),
+
+      colorMode:
+        (data.colorize
+          ? 'line'
+          : 'none'),
+    }),
+};
diff --git a/src/content/dependencies/generateArtTagAncestorDescendantMapList.js b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
new file mode 100644
index 00000000..80d19b5a
--- /dev/null
+++ b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
@@ -0,0 +1,153 @@
+import {
+  filterMultipleArrays,
+  sortMultipleArrays,
+  stitchArrays,
+  unique,
+} from '#sugar';
+
+export default {
+  contentDependencies: ['linkArtTagDynamically'],
+  extraDependencies: ['html', 'language'],
+
+  // Recursion ain't too pretty!
+
+  query(ancestorArtTag, targetArtTag) {
+    const recursive = artTag => {
+      const artTags =
+        artTag.directDescendantArtTags.slice();
+
+      const displayBriefly =
+        !artTags.includes(targetArtTag) &&
+        artTags.length > 3;
+
+      const artTagsIncludeTargetArtTag =
+        artTags.map(artTag => artTag.allDescendantArtTags.includes(targetArtTag));
+
+      const numExemptArtTags =
+        (displayBriefly
+          ? artTagsIncludeTargetArtTag
+              .filter(includesTargetArtTag => !includesTargetArtTag)
+              .length
+          : null);
+
+      const artTagsTimesFeaturedTotal =
+        artTags.map(artTag =>
+          unique([
+            ...artTag.directlyFeaturedInArtworks,
+            ...artTag.indirectlyFeaturedInArtworks,
+          ]).length);
+
+      const sublists =
+        stitchArrays({
+          artTag: artTags,
+          includesTargetArtTag: artTagsIncludeTargetArtTag,
+        }).map(({artTag, includesTargetArtTag}) =>
+            (includesTargetArtTag
+              ? recursive(artTag)
+              : null));
+
+      if (displayBriefly) {
+        filterMultipleArrays(artTags, sublists, artTagsTimesFeaturedTotal,
+          (artTag, sublist) =>
+            artTag === targetArtTag ||
+            sublist !== null);
+      } else {
+        sortMultipleArrays(artTags, sublists, artTagsTimesFeaturedTotal,
+          (artTagA, artTagB, sublistA, sublistB) =>
+            (sublistA && sublistB
+              ? 0
+           : !sublistA && !sublistB
+              ? 0
+           : sublistA
+              ? 1
+              : -1));
+      }
+
+      return {
+        displayBriefly,
+        numExemptArtTags,
+        artTags,
+        artTagsTimesFeaturedTotal,
+        sublists,
+      };
+    };
+
+    return {root: recursive(ancestorArtTag)};
+  },
+
+  relations(relation, query, _ancestorArtTag, _targetArtTag) {
+    const recursive = ({artTags, sublists}) => ({
+      artTagLinks:
+        artTags
+          .map(artTag => relation('linkArtTagDynamically', artTag)),
+
+      sublists:
+        sublists
+          .map(sublist => (sublist ? recursive(sublist) : null)),
+    });
+
+    return {root: recursive(query.root)};
+  },
+
+  data(query, _ancestorArtTag, targetArtTag) {
+    const recursive = ({
+      displayBriefly,
+      numExemptArtTags,
+      artTags,
+      artTagsTimesFeaturedTotal,
+      sublists,
+    }) => ({
+      displayBriefly,
+      numExemptArtTags,
+      artTagsTimesFeaturedTotal,
+
+      artTagsAreTargetTag:
+        artTags
+          .map(artTag => artTag === targetArtTag),
+
+      sublists:
+        sublists
+          .map(sublist => (sublist ? recursive(sublist) : null)),
+    });
+
+    return {root: recursive(query.root)};
+  },
+
+  generate(data, relations, {html, language}) {
+    const recursive = (dataNode, relationsNode) =>
+      html.tag('dl', {class: dataNode === data.root && 'tree-list'}, [
+        dataNode.displayBriefly &&
+          html.tag('dt',
+            language.$('artTagPage.sidebar.otherTagsExempt', {
+              tags:
+                language.countArtTags(dataNode.numExemptArtTags, {unit: true}),
+            })),
+
+        stitchArrays({
+          isTargetTag: dataNode.artTagsAreTargetTag,
+          timesFeaturedTotal: dataNode.artTagsTimesFeaturedTotal,
+          dataSublist: dataNode.sublists,
+
+          artTagLink: relationsNode.artTagLinks,
+          relationsSublist: relationsNode.sublists,
+        }).map(({
+            isTargetTag, timesFeaturedTotal, dataSublist,
+            artTagLink, relationsSublist,
+          }) => [
+            html.tag('dt',
+              {class: (dataSublist || isTargetTag) && 'current'},
+              [
+                artTagLink,
+                html.tag('span', {class: 'times-used'},
+                  language.countTimesFeatured(timesFeaturedTotal)),
+              ]),
+
+            dataSublist &&
+              html.tag('dd',
+                recursive(dataSublist, relationsSublist)),
+          ]),
+      ]);
+
+    return recursive(data.root, relations.root);
+  },
+};
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
new file mode 100644
index 00000000..344e7bda
--- /dev/null
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -0,0 +1,222 @@
+import {sortArtworksChronologically} from '#sort';
+import {empty, unique} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateArtTagGalleryPageFeaturedLine',
+    'generateArtTagGalleryPageShowingLine',
+    'generateArtTagNavLinks',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'generateQuickDescription',
+    'image',
+    'linkAnythingMan',
+    'linkArtTagGallery',
+    'linkExternal',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      enableListings: wikiInfo.enableListings,
+    };
+  },
+
+  query(sprawl, artTag) {
+    const directArtworks = artTag.directlyFeaturedInArtworks;
+    const indirectArtworks = artTag.indirectlyFeaturedInArtworks;
+    const allArtworks = unique([...directArtworks, ...indirectArtworks]);
+
+    sortArtworksChronologically(allArtworks, {latestFirst: true});
+
+    return {directArtworks, indirectArtworks, allArtworks};
+  },
+
+  relations(relation, query, sprawl, artTag) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.navLinks =
+      relation('generateArtTagNavLinks', artTag);
+
+    relations.additionalNamesBox =
+      relation('generateAdditionalNamesBox', artTag.additionalNames);
+
+    relations.quickDescription =
+      relation('generateQuickDescription', artTag);
+
+    relations.featuredLine =
+      relation('generateArtTagGalleryPageFeaturedLine');
+
+    relations.showingLine =
+      relation('generateArtTagGalleryPageShowingLine');
+
+    if (!empty(artTag.extraReadingURLs)) {
+      relations.extraReadingLinks =
+        artTag.extraReadingURLs
+          .map(url => relation('linkExternal', url));
+    }
+
+    if (!empty(artTag.directAncestorArtTags)) {
+      relations.ancestorLinks =
+        artTag.directAncestorArtTags
+          .map(artTag => relation('linkArtTagGallery', artTag));
+    }
+
+    if (!empty(artTag.directDescendantArtTags)) {
+      relations.descendantLinks =
+        artTag.directDescendantArtTags
+          .map(artTag => relation('linkArtTagGallery', artTag));
+    }
+
+    relations.coverGrid =
+      relation('generateCoverGrid');
+
+    relations.links =
+      query.allArtworks
+        .map(artwork => relation('linkAnythingMan', artwork.thing));
+
+    relations.images =
+      query.allArtworks
+        .map(artwork => relation('image', artwork));
+
+    return relations;
+  },
+
+  data(query, sprawl, artTag) {
+    const data = {};
+
+    data.enableListings = sprawl.enableListings;
+
+    data.name = artTag.name;
+    data.color = artTag.color;
+
+    data.numArtworksIndirectly = query.indirectArtworks.length;
+    data.numArtworksDirectly = query.directArtworks.length;
+    data.numArtworksTotal = query.allArtworks.length;
+
+    data.names =
+      query.allArtworks
+        .map(artwork => artwork.thing.name);
+
+    data.coverArtists =
+      query.allArtworks
+        .map(artwork => artwork.artistContribs
+          .map(contrib => contrib.artist.name));
+
+    data.onlyFeaturedIndirectly =
+      query.allArtworks.map(artwork =>
+        !query.directArtworks.includes(artwork));
+
+    data.hasMixedDirectIndirect =
+      data.onlyFeaturedIndirectly.includes(true) &&
+      data.onlyFeaturedIndirectly.includes(false);
+
+    return data;
+  },
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artTagGalleryPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            tag: data.name,
+          }),
+
+        headingMode: 'static',
+        color: data.color,
+
+        additionalNames: relations.additionalNamesBox,
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          relations.quickDescription.slots({
+            extraReadingLinks: relations.extraReadingLinks ?? null,
+          }),
+
+          data.numArtworksTotal === 0 &&
+            html.tag('p', {class: 'quick-info'},
+              language.encapsulate(pageCapsule, 'featuredLine.notFeatured', capsule => [
+                language.$(capsule),
+                html.tag('br'),
+                language.$(capsule, 'callToAction'),
+              ])),
+
+          data.numArtworksTotal >= 1 &&
+            relations.featuredLine.clone()
+              .slots({
+                showing: 'all',
+                count: data.numArtworksTotal,
+              }),
+
+          data.hasMixedDirectIndirect && [
+            relations.featuredLine.clone()
+              .slots({
+                showing: 'direct',
+                count: data.numArtworksDirectly,
+              }),
+
+            relations.featuredLine.clone()
+              .slots({
+                showing: 'indirect',
+                count: data.numArtworksIndirectly,
+              }),
+          ],
+
+          relations.ancestorLinks &&
+            html.tag('p', {id: 'descends-from-line'},
+              {class: 'quick-info'},
+              language.$(pageCapsule, 'descendsFrom', {
+                tags: language.formatUnitList(relations.ancestorLinks),
+              })),
+
+          relations.descendantLinks &&
+            html.tag('p', {id: 'descendants-line'},
+              {class: 'quick-info'},
+              language.$(pageCapsule, 'descendants', {
+                tags: language.formatUnitList(relations.descendantLinks),
+              })),
+
+          data.hasMixedDirectIndirect && [
+            relations.showingLine.clone()
+              .slot('showing', 'all'),
+
+            relations.showingLine.clone()
+              .slot('showing', 'direct'),
+
+            relations.showingLine.clone()
+              .slot('showing', 'indirect'),
+          ],
+
+          relations.coverGrid
+            .slots({
+              links: relations.links,
+              images: relations.images,
+              names: data.names,
+              lazy: 12,
+
+              classes:
+                data.onlyFeaturedIndirectly.map(onlyFeaturedIndirectly =>
+                  (onlyFeaturedIndirectly ? 'featured-indirectly' : '')),
+
+              info:
+                data.coverArtists.map(names =>
+                  (names === null
+                    ? null
+                    : language.$('misc.coverGrid.details.coverArtists', {
+                        artists: language.formatUnitList(names),
+                      }))),
+            }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks:
+          html.resolve(
+            relations.navLinks
+              .slot('currentExtra', 'gallery')),
+      })),
+};
diff --git a/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js
new file mode 100644
index 00000000..b4620fa4
--- /dev/null
+++ b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js
@@ -0,0 +1,23 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    showing: {
+      validate: v => v.is('all', 'direct', 'indirect'),
+    },
+
+    count: {type: 'number'},
+  },
+
+  generate: (slots, {html, language}) =>
+    language.encapsulate('artTagGalleryPage', pageCapsule =>
+      html.tag('p', {class: 'quick-info'},
+        {id: `featured-${slots.showing}-line`},
+
+        language.$(pageCapsule, 'featuredLine', slots.showing, {
+          coverArts:
+            language.countArtworks(slots.count, {
+              unit: true,
+            }),
+        }))),
+};
diff --git a/src/content/dependencies/generateArtTagGalleryPageShowingLine.js b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js
new file mode 100644
index 00000000..6df4d0e5
--- /dev/null
+++ b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js
@@ -0,0 +1,22 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    showing: {
+      validate: v => v.is('all', 'direct', 'indirect'),
+    },
+
+    count: {type: 'number'},
+  },
+
+  generate: (slots, {html, language}) =>
+    language.encapsulate('artTagGalleryPage', pageCapsule =>
+      html.tag('p', {class: 'quick-info'},
+        {id: `showing-${slots.showing}-line`},
+
+        language.$(pageCapsule, 'showingLine', {
+          showing:
+            html.tag('a', {href: '#'},
+              language.$(pageCapsule, 'showingLine', slots.showing)),
+        }))),
+};
diff --git a/src/content/dependencies/generateArtTagInfoPage.js b/src/content/dependencies/generateArtTagInfoPage.js
new file mode 100644
index 00000000..9df51b77
--- /dev/null
+++ b/src/content/dependencies/generateArtTagInfoPage.js
@@ -0,0 +1,281 @@
+import {empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateArtTagNavLinks',
+    'generateArtTagSidebar',
+    'generateContentHeading',
+    'generatePageLayout',
+    'linkArtTagGallery',
+    'linkArtTagInfo',
+    'linkExternal',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    enableListings: wikiInfo.enableListings,
+  }),
+
+  query(sprawl, artTag) {
+    const query = {};
+
+    query.directThings =
+      artTag.directlyFeaturedInArtworks;
+
+    query.indirectThings =
+      artTag.indirectlyFeaturedInArtworks;
+
+    query.allThings =
+      unique([...query.directThings, ...query.indirectThings]);
+
+    query.allDescendantsHaveMoreDescendants =
+      artTag.directDescendantArtTags
+        .every(descendant => !empty(descendant.directDescendantArtTags));
+
+    return query;
+  },
+
+  relations: (relation, query, sprawl, artTag) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    navLinks:
+      relation('generateArtTagNavLinks', artTag),
+
+    sidebar:
+      relation('generateArtTagSidebar', artTag),
+
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', artTag.additionalNames),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    description:
+      relation('transformContent', artTag.description),
+
+    galleryLink:
+      (empty(query.allThings)
+        ? null
+        : relation('linkArtTagGallery', artTag)),
+
+    extraReadingLinks:
+      artTag.extraReadingURLs
+        .map(url => relation('linkExternal', url)),
+
+    relatedArtTagLinks:
+      artTag.relatedArtTags
+        .map(({artTag}) => relation('linkArtTagInfo', artTag)),
+
+    directAncestorLinks:
+      artTag.directAncestorArtTags
+        .map(artTag => relation('linkArtTagInfo', artTag)),
+
+    directDescendantInfoLinks:
+      artTag.directDescendantArtTags
+        .map(artTag => relation('linkArtTagInfo', artTag)),
+
+    directDescendantGalleryLinks:
+      artTag.directDescendantArtTags.map(artTag =>
+        (query.allDescendantsHaveMoreDescendants
+          ? null
+          : relation('linkArtTagGallery', artTag))),
+  }),
+
+  data: (query, sprawl, artTag) => ({
+    enableListings:
+      sprawl.enableListings,
+
+    name:
+      artTag.name,
+
+    color:
+      artTag.color,
+
+    numArtworksIndirectly:
+      query.indirectThings.length,
+
+    numArtworksDirectly:
+      query.directThings.length,
+
+    numArtworksTotal:
+      query.allThings.length,
+
+    relatedArtTagAnnotations:
+      artTag.relatedArtTags
+        .map(({annotation}) => annotation),
+
+    directDescendantTimesFeaturedTotal:
+      artTag.directDescendantArtTags.map(artTag =>
+        unique([
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
+        ]).length),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artTagInfoPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            tag: language.sanitize(data.name),
+          }),
+
+        headingMode: 'sticky',
+        color: data.color,
+
+        additionalNames: relations.additionalNamesBox,
+
+        mainContent: [
+          html.tag('p',
+            language.encapsulate(pageCapsule, 'featuredIn', capsule =>
+              (data.numArtworksTotal === 0
+                ? language.$(capsule, 'notFeatured')
+
+             : data.numArtworksDirectly === 0
+                ? language.$(capsule, 'indirectlyOnly', {
+                    artworks:
+                      language.countArtworks(data.numArtworksIndirectly, {unit: true}),
+                  })
+
+             : data.numArtworksIndirectly === 0
+                ? language.$(capsule, 'directlyOnly', {
+                    artworks:
+                      language.countArtworks(data.numArtworksDirectly, {unit: true}),
+                  })
+
+                : language.$(capsule, 'directlyAndIndirectly', {
+                    artworksDirectly:
+                      language.countArtworks(data.numArtworksDirectly, {unit: true}),
+
+                    artworksIndirectly:
+                      language.countArtworks(data.numArtworksIndirectly, {unit: false}),
+
+                    artworksTotal:
+                      language.countArtworks(data.numArtworksTotal, {unit: false}),
+                  })))),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$(pageCapsule, 'viewArtGallery', {
+              [language.onlyIfOptions]: ['link'],
+
+              link:
+                relations.galleryLink
+                  ?.slot('content', language.$(pageCapsule, 'viewArtGallery.link')),
+            })),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.encapsulate(pageCapsule, 'seeAlso', capsule =>
+              language.$(capsule, {
+                [language.onlyIfOptions]: ['tags'],
+
+                tags:
+                  language.formatUnitList(
+                    stitchArrays({
+                      artTagLink: relations.relatedArtTagLinks,
+                      annotation: data.relatedArtTagAnnotations,
+                    }).map(({artTagLink, annotation}) =>
+                        (html.isBlank(annotation)
+                          ? artTagLink
+                          : language.$(capsule, 'tagWithAnnotation', {
+                              tag: artTagLink,
+                              annotation,
+                            })))),
+              }))),
+
+          html.tag('blockquote',
+            {[html.onlyIfContent]: true},
+
+            relations.description
+              .slot('mode', 'multiline')),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$(pageCapsule, 'readMoreOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              tag: language.sanitize(data.name),
+              links: language.formatDisjunctionList(relations.extraReadingLinks),
+            })),
+
+          language.encapsulate(pageCapsule, 'descendsFromTags', listCapsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  title:
+                    language.$(listCapsule, {
+                      tag: language.sanitize(data.name),
+                    }),
+                }),
+
+              html.tag('ul',
+                {[html.onlyIfContent]: true},
+
+                relations.directAncestorLinks
+                  .map(link =>
+                    html.tag('li',
+                      language.$(listCapsule, 'item', {
+                        tag: link,
+                      })))),
+            ])),
+
+          language.encapsulate(pageCapsule, 'descendantTags', listCapsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  title:
+                    language.$(listCapsule, {
+                      tag: language.sanitize(data.name),
+                    }),
+                }),
+
+              html.tag('ul',
+                {[html.onlyIfContent]: true},
+
+                stitchArrays({
+                  infoLink: relations.directDescendantInfoLinks,
+                  galleryLink: relations.directDescendantGalleryLinks,
+                  timesFeaturedTotal: data.directDescendantTimesFeaturedTotal,
+                }).map(({infoLink, galleryLink, timesFeaturedTotal}) =>
+                    html.tag('li',
+                      language.encapsulate(listCapsule, 'item', itemCapsule =>
+                        language.encapsulate(itemCapsule, workingCapsule => {
+                          const workingOptions = {};
+
+                          workingOptions.tag = infoLink;
+
+                          if (!html.isBlank(galleryLink ?? html.blank())) {
+                            workingCapsule += '.withGallery';
+                            workingOptions.gallery =
+                              galleryLink.slot('content',
+                                language.$(itemCapsule, 'withGallery.gallery'));
+                          }
+
+                          if (timesFeaturedTotal >= 1) {
+                            workingCapsule += `.withTimesUsed`;
+                            workingOptions.timesUsed =
+                              language.countTimesFeatured(timesFeaturedTotal, {
+                                unit: true,
+                              });
+                          }
+
+                          return language.$(workingCapsule, workingOptions);
+                        }))))),
+            ])),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: relations.navLinks.content,
+
+        leftSidebar:
+          relations.sidebar,
+      })),
+};
diff --git a/src/content/dependencies/generateArtTagNavLinks.js b/src/content/dependencies/generateArtTagNavLinks.js
new file mode 100644
index 00000000..9061a09f
--- /dev/null
+++ b/src/content/dependencies/generateArtTagNavLinks.js
@@ -0,0 +1,81 @@
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'linkArtTagInfo',
+    'linkArtTagGallery',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) =>
+    ({enableListings: wikiInfo.enableListings}),
+
+  relations: (relation, sprawl, tag) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    mainLink:
+      relation('linkArtTagInfo', tag),
+
+    infoLink:
+      relation('linkArtTagInfo', tag),
+
+    galleryLink:
+      relation('linkArtTagGallery', tag),
+  }),
+
+  data: (sprawl) =>
+    ({enableListings: sprawl.enableListings}),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(data, relations, slots, {language}) {
+    if (!data.enableListings) {
+      return [
+        {auto: 'home'},
+        {auto: 'current'},
+      ];
+    }
+
+    const infoLink =
+      relations.infoLink.slots({
+        attributes: {class: slots.currentExtra === null && 'current'},
+        content: language.$('misc.nav.info'),
+      });
+
+    const galleryLink =
+      relations.galleryLink.slots({
+        attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+        content: language.$('misc.nav.gallery'),
+      });
+
+    return [
+      {auto: 'home'},
+
+      data.enableListings &&
+        {
+          path: ['localized.listingIndex'],
+          title: language.$('listingIndex.title'),
+        },
+
+      {
+        html:
+          language.$('artTagPage.nav.tag', {
+            tag: relations.mainLink,
+          }),
+
+        accent:
+          relations.switcher.slots({
+            links: [
+              infoLink,
+              galleryLink,
+            ],
+          }),
+      },
+    ].filter(Boolean);
+  },
+};
diff --git a/src/content/dependencies/generateArtTagSidebar.js b/src/content/dependencies/generateArtTagSidebar.js
new file mode 100644
index 00000000..9e2f813c
--- /dev/null
+++ b/src/content/dependencies/generateArtTagSidebar.js
@@ -0,0 +1,124 @@
+import {collectTreeLeaves, empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'generateArtTagAncestorDescendantMapList',
+    'linkArtTagDynamically',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({artTagData}) =>
+    ({artTagData}),
+
+  query(sprawl, artTag) {
+    const baobab = artTag.ancestorArtTagBaobabTree;
+    const uniqueLeaves = new Set(collectTreeLeaves(baobab));
+
+    // Just match the order in tag data.
+    const furthestAncestorArtTags =
+      sprawl.artTagData
+        .filter(artTag => uniqueLeaves.has(artTag));
+
+    return {furthestAncestorArtTags};
+  },
+
+  relations: (relation, query, sprawl, artTag) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    artTagLink:
+      relation('linkArtTagDynamically', artTag),
+
+    directDescendantArtTagLinks:
+      artTag.directDescendantArtTags
+        .map(descendantArtTag =>
+          relation('linkArtTagDynamically', descendantArtTag)),
+
+    furthestAncestorArtTagMapLists:
+      query.furthestAncestorArtTags
+        .map(ancestorArtTag =>
+          relation('generateArtTagAncestorDescendantMapList',
+            ancestorArtTag,
+            artTag)),
+  }),
+
+  data: (query, sprawl, artTag) => ({
+    name: artTag.name,
+
+    directDescendantTimesFeaturedTotal:
+      artTag.directDescendantArtTags.map(artTag =>
+        unique([
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
+        ]).length),
+
+    furthestAncestorArtTagNames:
+      query.furthestAncestorArtTags
+        .map(ancestorArtTag => ancestorArtTag.name),
+  }),
+
+  generate(data, relations, {html, language}) {
+    if (
+      empty(relations.directDescendantArtTagLinks) &&
+      empty(relations.furthestAncestorArtTagMapLists)
+    ) {
+      return relations.sidebar;
+    }
+
+    return relations.sidebar.slots({
+      boxes: [
+        relations.sidebarBox.slots({
+          content: [
+            html.tag('h1',
+              relations.artTagLink),
+
+            !empty(relations.directDescendantArtTagLinks) &&
+              html.tag('details', {class: 'current', open: true}, [
+                html.tag('summary',
+                  html.tag('span',
+                    html.tag('b',
+                      language.sanitize(data.name)))),
+
+                html.tag('ul',
+                  stitchArrays({
+                    link: relations.directDescendantArtTagLinks,
+                    timesFeaturedTotal: data.directDescendantTimesFeaturedTotal,
+                  }).map(({link, timesFeaturedTotal}) =>
+                      html.tag('li', [
+                        link,
+                        html.tag('span', {class: 'times-used'},
+                          language.countTimesFeatured(timesFeaturedTotal)),
+                      ]))),
+              ]),
+
+            stitchArrays({
+              name: data.furthestAncestorArtTagNames,
+              list: relations.furthestAncestorArtTagMapLists,
+            }).map(({name, list}) =>
+                html.tag('details',
+                  {
+                    class: 'has-tree-list',
+                    open:
+                      empty(relations.directDescendantArtTagLinks) &&
+                      relations.furthestAncestorArtTagMapLists.length === 1,
+                  },
+                  [
+                    html.tag('summary',
+                      html.tag('span',
+                        html.tag('b',
+                          language.sanitize(name)))),
+
+                      list,
+                    ])),
+          ],
+        }),
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateArtistArtworkColumn.js b/src/content/dependencies/generateArtistArtworkColumn.js
new file mode 100644
index 00000000..a4135489
--- /dev/null
+++ b/src/content/dependencies/generateArtistArtworkColumn.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations: (relation, artist) => ({
+    coverArtwork:
+      (artist.hasAvatar
+        ? relation('generateCoverArtwork', artist.avatarArtwork)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.coverArtwork,
+};
diff --git a/src/content/dependencies/generateArtistCredit.js b/src/content/dependencies/generateArtistCredit.js
new file mode 100644
index 00000000..6bdbeb23
--- /dev/null
+++ b/src/content/dependencies/generateArtistCredit.js
@@ -0,0 +1,180 @@
+import {compareArrays, empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistCreditWikiEditsPart',
+    'linkContribution',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (creditContributions, contextContributions) => {
+    const query = {};
+
+    const featuringFilter = contribution =>
+      contribution.annotation === 'featuring';
+
+    const wikiEditFilter = contribution =>
+      contribution.annotation?.startsWith('edits for wiki');
+
+    const normalFilter = contribution =>
+      !featuringFilter(contribution) &&
+      !wikiEditFilter(contribution);
+
+    query.normalContributions =
+      creditContributions.filter(normalFilter);
+
+    query.featuringContributions =
+      creditContributions.filter(featuringFilter);
+
+    query.wikiEditContributions =
+      creditContributions.filter(wikiEditFilter);
+
+    const contextNormalContributions =
+      contextContributions.filter(normalFilter);
+
+    // Note that the normal contributions will implicitly *always*
+    // "differ from context" if no context contributions are given,
+    // as in release info lines.
+    query.normalContributionsDifferFromContext =
+      !compareArrays(
+        query.normalContributions.map(({artist}) => artist),
+        contextNormalContributions.map(({artist}) => artist),
+        {checkOrder: false});
+
+    return query;
+  },
+
+  relations: (relation, query, _creditContributions, _contextContributions) => ({
+    normalContributionLinks:
+      query.normalContributions
+        .map(contrib => relation('linkContribution', contrib)),
+
+    featuringContributionLinks:
+      query.featuringContributions
+        .map(contrib => relation('linkContribution', contrib)),
+
+    wikiEditsPart:
+      relation('generateArtistCreditWikiEditsPart',
+        query.wikiEditContributions),
+  }),
+
+  data: (query, _creditContributions, _contextContributions) => ({
+    normalContributionsDifferFromContext:
+      query.normalContributionsDifferFromContext,
+
+    hasWikiEdits:
+      !empty(query.wikiEditContributions),
+  }),
+
+  slots: {
+    // This string is mandatory.
+    normalStringKey: {type: 'string'},
+
+    // This string is optional.
+    // Without it, there's no special behavior for "featuring" credits.
+    normalFeaturingStringKey: {type: 'string'},
+
+    // This string is optional.
+    // Without it, "featuring" credits will always be alongside main credits.
+    // It won't be used if contextContributions isn't provided.
+    featuringStringKey: {type: 'string'},
+
+    additionalStringOptions: {validate: v => v.isObject},
+
+    showAnnotation: {type: 'boolean', default: false},
+    showExternalLinks: {type: 'boolean', default: false},
+    showChronology: {type: 'boolean', default: false},
+    showWikiEdits: {type: 'boolean', default: false},
+
+    trimAnnotation: {type: 'boolean', default: false},
+
+    chronologyKind: {type: 'string'},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    if (!slots.normalStringKey) return html.blank();
+
+    for (const link of [
+      ...relations.normalContributionLinks,
+      ...relations.featuringContributionLinks,
+    ]) {
+      link.setSlots({
+        showExternalLinks: slots.showExternalLinks,
+        showChronology: slots.showChronology,
+        trimAnnotation: slots.trimAnnotation,
+        chronologyKind: slots.chronologyKind,
+      });
+    }
+
+    for (const link of relations.normalContributionLinks) {
+      link.setSlots({
+        showAnnotation: slots.showAnnotation,
+      });
+    }
+
+    for (const link of relations.featuringContributionLinks) {
+      link.setSlots({
+        showAnnotation:
+          (slots.featuringStringKey || slots.normalFeaturingStringKey
+            ? false
+            : slots.showAnnotation),
+      });
+    }
+
+    if (empty(relations.normalContributionLinks)) {
+      return html.blank();
+    }
+
+    const artistsList =
+      (data.hasWikiEdits && slots.showWikiEdits
+        ? language.$('misc.artistLink.withEditsForWiki', {
+            artists:
+              language.formatConjunctionList(relations.normalContributionLinks),
+
+            edits:
+              relations.wikiEditsPart.slots({
+                showAnnotation: slots.showAnnotation,
+              }),
+          })
+        : language.formatConjunctionList(relations.normalContributionLinks));
+
+    const featuringList =
+      language.formatConjunctionList(relations.featuringContributionLinks);
+
+    const everyoneList =
+      language.formatConjunctionList([
+        ...relations.normalContributionLinks,
+        ...relations.featuringContributionLinks,
+      ]);
+
+    if (empty(relations.featuringContributionLinks)) {
+      if (data.normalContributionsDifferFromContext) {
+        return language.$(slots.normalStringKey, {
+          ...slots.additionalStringOptions,
+          artists: artistsList,
+        });
+      } else {
+        return html.blank();
+      }
+    }
+
+    if (data.normalContributionsDifferFromContext && slots.normalFeaturingStringKey) {
+      return language.$(slots.normalFeaturingStringKey, {
+        ...slots.additionalStringOptions,
+        artists: artistsList,
+        featuring: featuringList,
+      });
+    } else if (slots.featuringStringKey) {
+      return language.$(slots.featuringStringKey, {
+        ...slots.additionalStringOptions,
+        artists: featuringList,
+      });
+    } else {
+      return language.$(slots.normalStringKey, {
+        ...slots.additionalStringOptions,
+        artists: everyoneList,
+      });
+    }
+  },
+};
diff --git a/src/content/dependencies/generateArtistCreditWikiEditsPart.js b/src/content/dependencies/generateArtistCreditWikiEditsPart.js
new file mode 100644
index 00000000..70296e39
--- /dev/null
+++ b/src/content/dependencies/generateArtistCreditWikiEditsPart.js
@@ -0,0 +1,55 @@
+export default {
+  contentDependencies: [
+    'generateTextWithTooltip',
+    'generateTooltip',
+    'linkContribution',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, contributions) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateTooltip'),
+
+    contributionLinks:
+      contributions
+        .map(contrib => relation('linkContribution', contrib)),
+  }),
+
+  slots: {
+    showAnnotation: {type: 'boolean', default: true},
+  },
+
+  generate: (relations, slots, {language}) =>
+    language.encapsulate('misc.artistLink.withEditsForWiki', capsule =>
+      relations.textWithTooltip.slots({
+        attributes:
+          {class: 'wiki-edits'},
+
+        text:
+          language.$(capsule, 'edits'),
+
+        tooltip:
+          relations.tooltip.slots({
+            attributes:
+              {class: 'wiki-edits-tooltip'},
+
+            content:
+              language.$(capsule, 'editsLine', {
+                [language.onlyIfOptions]: ['artists'],
+
+                artists:
+                  language.formatConjunctionList(
+                    relations.contributionLinks.map(link =>
+                      link.slots({
+                        showAnnotation: slots.showAnnotation,
+                        trimAnnotation: true,
+                        preventTooltip: true,
+                      }))),
+                }),
+          }),
+      })),
+};
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
new file mode 100644
index 00000000..6a24275e
--- /dev/null
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -0,0 +1,108 @@
+import {sortArtworksChronologically} from '#sort';
+
+export default {
+  contentDependencies: [
+    'generateArtistNavLinks',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkAnythingMan',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (artist) => ({
+    artworks:
+      sortArtworksChronologically(
+        ([
+          artist.albumCoverArtistContributions,
+          artist.trackCoverArtistContributions,
+        ]).flat()
+          .filter(contrib => !contrib.annotation?.startsWith(`edits for wiki`))
+          .map(contrib => contrib.thing),
+        {latestFirst: true}),
+  }),
+
+  relations: (relation, query, artist) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    artistNavLinks:
+      relation('generateArtistNavLinks', artist),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      query.artworks
+        .map(artwork => relation('linkAnythingMan', artwork.thing)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, artist) => ({
+    name:
+      artist.name,
+
+    numArtworks:
+      query.artworks.length,
+
+    names:
+      query.artworks
+        .map(artwork => artwork.thing.name),
+
+    otherCoverArtists:
+      query.artworks
+        .map(artwork => artwork.artistContribs
+          .filter(contrib => contrib.artist !== artist)
+          .map(contrib => contrib.artist.name)),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistGalleryPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            artist: data.name,
+          }),
+
+        headingMode: 'static',
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          html.tag('p', {class: 'quick-info'},
+            language.$(pageCapsule, 'infoLine', {
+              coverArts:
+                language.countArtworks(data.numArtworks, {
+                  unit: true,
+                }),
+            })),
+
+          relations.coverGrid
+            .slots({
+              links: relations.links,
+              images: relations.images,
+              names: data.names,
+
+              info:
+                data.otherCoverArtists.map(names =>
+                  language.$('misc.coverGrid.details.otherCoverArtists', {
+                    [language.onlyIfOptions]: ['artists'],
+
+                    artists: language.formatUnitList(names),
+                  })),
+            }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks:
+          relations.artistNavLinks
+            .slots({
+              showExtraLinks: true,
+              currentExtra: 'gallery',
+            })
+            .content,
+      })),
+}
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
new file mode 100644
index 00000000..3e0cd1d2
--- /dev/null
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -0,0 +1,234 @@
+import {empty, filterProperties, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: ['linkGroup'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({groupCategoryData}) {
+    return {
+      groupOrder: groupCategoryData.flatMap(category => category.groups),
+    }
+  },
+
+  query(sprawl, tracksAndAlbums) {
+    const filteredAlbums = tracksAndAlbums.filter(thing => !thing.album);
+    const filteredTracks = tracksAndAlbums.filter(thing => thing.album);
+
+    const allAlbums = unique([
+      ...filteredAlbums,
+      ...filteredTracks.map(track => track.album),
+    ]);
+
+    const allGroupsUnordered = new Set(Array.from(allAlbums).flatMap(album => album.groups));
+    const allGroupsOrdered = sprawl.groupOrder.filter(group => allGroupsUnordered.has(group));
+
+    const mapTemplate = allGroupsOrdered.map(group => [group, 0]);
+    const groupToCountMap = new Map(mapTemplate);
+    const groupToDurationMap = new Map(mapTemplate);
+    const groupToDurationCountMap = new Map(mapTemplate);
+
+    for (const album of filteredAlbums) {
+      for (const group of album.groups) {
+        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
+      }
+    }
+
+    for (const track of filteredTracks) {
+      for (const group of track.album.groups) {
+        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
+        if (track.duration && track.mainReleaseTrack === null) {
+          groupToDurationMap.set(group, groupToDurationMap.get(group) + track.duration);
+          groupToDurationCountMap.set(group, groupToDurationCountMap.get(group) + 1);
+        }
+      }
+    }
+
+    const groupsSortedByCount =
+      allGroupsOrdered
+        .slice()
+        .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.get(a));
+
+    // The filter here ensures all displayed groups have at least some duration
+    // when sorting by duration.
+    const groupsSortedByDuration =
+      allGroupsOrdered
+        .filter(group => groupToDurationMap.get(group) > 0)
+        .sort((a, b) => groupToDurationMap.get(b) - groupToDurationMap.get(a));
+
+    const groupCountsSortedByCount =
+      groupsSortedByCount
+        .map(group => groupToCountMap.get(group));
+
+    const groupDurationsSortedByCount =
+      groupsSortedByCount
+        .map(group => groupToDurationMap.get(group));
+
+    const groupDurationsApproximateSortedByCount =
+      groupsSortedByCount
+        .map(group => groupToDurationCountMap.get(group) > 1);
+
+    const groupCountsSortedByDuration =
+      groupsSortedByDuration
+        .map(group => groupToCountMap.get(group));
+
+    const groupDurationsSortedByDuration =
+      groupsSortedByDuration
+        .map(group => groupToDurationMap.get(group));
+
+    const groupDurationsApproximateSortedByDuration =
+      groupsSortedByDuration
+        .map(group => groupToDurationCountMap.get(group) > 1);
+
+    return {
+      groupsSortedByCount,
+      groupsSortedByDuration,
+
+      groupCountsSortedByCount,
+      groupDurationsSortedByCount,
+      groupDurationsApproximateSortedByCount,
+
+      groupCountsSortedByDuration,
+      groupDurationsSortedByDuration,
+      groupDurationsApproximateSortedByDuration,
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      groupLinksSortedByCount:
+        query.groupsSortedByCount
+          .map(group => relation('linkGroup', group)),
+
+      groupLinksSortedByDuration:
+        query.groupsSortedByDuration
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return filterProperties(query, [
+      'groupCountsSortedByCount',
+      'groupDurationsSortedByCount',
+      'groupDurationsApproximateSortedByCount',
+
+      'groupCountsSortedByDuration',
+      'groupDurationsSortedByDuration',
+      'groupDurationsApproximateSortedByDuration',
+    ]);
+  },
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    showBothColumns: {type: 'boolean'},
+    showSortButton: {type: 'boolean'},
+    visible: {type: 'boolean', default: true},
+
+    sort: {validate: v => v.is('count', 'duration')},
+    countUnit: {validate: v => v.is('tracks', 'artworks')},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('artistPage.groupContributions', capsule => {
+      if (slots.sort === 'count' && empty(relations.groupLinksSortedByCount)) {
+        return html.blank();
+      } else if (slots.sort === 'duration' && empty(relations.groupLinksSortedByDuration)) {
+        return html.blank();
+      }
+
+      const getCounts = counts =>
+        counts.map(count => {
+          switch (slots.countUnit) {
+            case 'tracks': return language.countTracks(count, {unit: true});
+            case 'artworks': return language.countArtworks(count, {unit: true});
+          }
+        });
+
+      // We aren't displaying the "~" approximate symbol here for now.
+      // The general notion that these sums aren't going to be 100% accurate
+      // is made clear by the "XYZ has contributed ~1:23:45 hours of music..."
+      // line that's always displayed above this table.
+      const getDurations = (durations, approximate) =>
+        stitchArrays({
+          duration: durations,
+          approximate: approximate,
+        }).map(({duration}) => language.formatDuration(duration));
+
+      const topLevelClasses = [
+        'group-contributions-sorted-by-' + slots.sort,
+        slots.visible && 'visible',
+      ];
+
+      // TODO: It feels pretty awkward that this component is the only one that
+      // has enough knowledge to decide if the sort button is even applicable...
+      const switchingSortPossible =
+        !empty(relations.groupLinksSortedByCount) &&
+        !empty(relations.groupLinksSortedByDuration);
+
+      return html.tags([
+        html.tag('dt', {class: topLevelClasses},
+          language.encapsulate(capsule, 'title', capsule =>
+            (switchingSortPossible && slots.showSortButton
+              ? language.$(capsule, 'withSortButton', {
+                  title: slots.title,
+                  sort:
+                    html.tag('a', {class: 'group-contributions-sort-button'},
+                      {href: '#'},
+
+                      (slots.sort === 'count'
+                        ? language.$(capsule, 'sorting.count')
+                        : language.$(capsule, 'sorting.duration'))),
+                })
+              : slots.title))),
+
+        html.tag('dd', {class: topLevelClasses},
+          html.tag('ul', {class: 'group-contributions-table'},
+            {role: 'list'},
+
+            (slots.sort === 'count'
+              ? stitchArrays({
+                  group: relations.groupLinksSortedByCount,
+                  count: getCounts(data.groupCountsSortedByCount),
+                  duration:
+                    getDurations(
+                      data.groupDurationsSortedByCount,
+                      data.groupDurationsApproximateSortedByCount),
+                }).map(({group, count, duration}) =>
+                    language.encapsulate(capsule, 'item', capsule =>
+                      html.tag('li',
+                        html.tag('div', {class: 'group-contributions-row'}, [
+                          group,
+                          html.tag('span', {class: 'group-contributions-metrics'},
+                            // When sorting by count, duration details aren't necessarily
+                            // available for all items.
+                            (slots.showBothColumns && duration
+                              ? language.$(capsule, 'countDurationAccent', {count, duration})
+                              : language.$(capsule, 'countAccent', {count}))),
+                        ]))))
+
+              : stitchArrays({
+                  group: relations.groupLinksSortedByDuration,
+                  count: getCounts(data.groupCountsSortedByDuration),
+                  duration:
+                    getDurations(
+                      data.groupDurationsSortedByDuration,
+                      data.groupDurationsApproximateSortedByDuration),
+                }).map(({group, count, duration}) =>
+                    language.encapsulate(capsule, 'item', capsule =>
+                      html.tag('li',
+                        html.tag('div', {class: 'group-contributions-row'}, [
+                          group,
+                          html.tag('span', {class: 'group-contributions-metrics'},
+                            // Count details are always available, since they're just the
+                            // number of contributions directly. And duration details are
+                            // guaranteed for every item when sorting by duration.
+                            (slots.showBothColumns
+                              ? language.$(capsule, 'durationCountAccent', {duration, count})
+                              : language.$(capsule, 'durationAccent', {duration}))),
+                        ]))))))),
+      ]);
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
new file mode 100644
index 00000000..3a3cf8b7
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -0,0 +1,401 @@
+import {empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistArtworkColumn',
+    'generateArtistGroupContributionsInfo',
+    'generateArtistInfoPageArtworksChunkedList',
+    'generateArtistInfoPageCommentaryChunkedList',
+    'generateArtistInfoPageFlashesChunkedList',
+    'generateArtistInfoPageTracksChunkedList',
+    'generateArtistNavLinks',
+    'generateContentHeading',
+    'generatePageLayout',
+    'linkArtistGallery',
+    'linkExternal',
+    'linkGroup',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (artist) => ({
+    // Even if an artist has served as both "artist" (compositional) and
+    // "contributor" (instruments, production, etc) on the same track, that
+    // track only counts as one unique contribution in the list.
+    allTracks:
+      unique(
+        ([
+          artist.trackArtistContributions,
+          artist.trackContributorContributions,
+        ]).flat()
+          .map(({thing}) => thing)),
+
+    // Artworks are different, though. We intentionally duplicate album data
+    // objects when the artist has contributed some combination of cover art,
+    // wallpaper, and banner - these each count as a unique contribution.
+    allArtworkThings:
+      ([
+        artist.albumCoverArtistContributions,
+        artist.albumWallpaperArtistContributions,
+        artist.albumBannerArtistContributions,
+        artist.trackCoverArtistContributions,
+      ]).flat()
+        .filter(({annotation}) => !annotation?.startsWith('edits for wiki'))
+        .map(({thing}) => thing.thing),
+
+    // Banners and wallpapers don't show up in the artist gallery page, only
+    // cover art.
+    hasGallery:
+      !empty(artist.albumCoverArtistContributions) ||
+      !empty(artist.trackCoverArtistContributions),
+
+    aliasLinkedGroups:
+      artist.closelyLinkedGroups
+        .filter(({annotation}) =>
+          annotation === 'alias'),
+
+    generalLinkedGroups:
+      artist.closelyLinkedGroups
+        .filter(({annotation}) =>
+          annotation !== 'alias'),
+  }),
+
+  relations: (relation, query, artist) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    artistNavLinks:
+      relation('generateArtistNavLinks', artist),
+
+    artworkColumn:
+      relation('generateArtistArtworkColumn', artist),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    contextNotes:
+      relation('transformContent', artist.contextNotes),
+
+    closeGroupLinks:
+      query.generalLinkedGroups
+        .map(({group}) => relation('linkGroup', group)),
+
+    aliasGroupLinks:
+      query.aliasLinkedGroups
+        .map(({group}) => relation('linkGroup', group)),
+
+    visitLinks:
+      artist.urls
+        .map(url => relation('linkExternal', url)),
+
+    tracksChunkedList:
+      relation('generateArtistInfoPageTracksChunkedList', artist),
+
+    tracksGroupInfo:
+      relation('generateArtistGroupContributionsInfo', query.allTracks),
+
+    artworksChunkedList:
+      relation('generateArtistInfoPageArtworksChunkedList', artist, false),
+
+    editsForWikiArtworksChunkedList:
+      relation('generateArtistInfoPageArtworksChunkedList', artist, true),
+
+    artworksGroupInfo:
+      relation('generateArtistGroupContributionsInfo', query.allArtworkThings),
+
+    artistGalleryLink:
+      (query.hasGallery
+        ? relation('linkArtistGallery', artist)
+        : null),
+
+    flashesChunkedList:
+      relation('generateArtistInfoPageFlashesChunkedList', artist),
+
+    commentaryChunkedList:
+      relation('generateArtistInfoPageCommentaryChunkedList', artist, false),
+
+    wikiEditorCommentaryChunkedList:
+      relation('generateArtistInfoPageCommentaryChunkedList', artist, true),
+  }),
+
+  data: (query, artist) => ({
+    name:
+      artist.name,
+
+    closeGroupAnnotations:
+      query.generalLinkedGroups
+        .map(({annotation}) => annotation),
+
+    totalTrackCount:
+      query.allTracks.length,
+
+    totalDuration:
+      artist.totalDuration,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistPage', pageCapsule =>
+      relations.layout.slots({
+        title: data.name,
+        headingMode: 'sticky',
+
+        artworkColumnContent:
+          relations.artworkColumn,
+
+        mainContent: [
+          html.tags([
+            html.tag('p',
+              {[html.onlyIfSiblings]: true},
+              language.$('releaseInfo.note')),
+
+            html.tag('blockquote',
+              {[html.onlyIfContent]: true},
+              relations.contextNotes),
+          ]),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate(pageCapsule, 'closelyLinkedGroups', capsule => [
+              language.encapsulate(capsule, capsule => {
+                const [workingCapsule, option] =
+                  (relations.closeGroupLinks.length === 0
+                    ? [null, null]
+                 : relations.closeGroupLinks.length === 1
+                    ? [language.encapsulate(capsule, 'one'), 'group']
+                    : [language.encapsulate(capsule, 'multiple'), 'groups']);
+
+                if (!workingCapsule) return html.blank();
+
+                return language.$(workingCapsule, {
+                  [option]:
+                    language.formatUnitList(
+                      stitchArrays({
+                        link: relations.closeGroupLinks,
+                        annotation: data.closeGroupAnnotations,
+                      }).map(({link, annotation}) =>
+                          language.encapsulate(capsule, 'group', workingCapsule => {
+                            const workingOptions = {group: link};
+
+                            if (annotation) {
+                              workingCapsule += '.withAnnotation';
+                              workingOptions.annotation = annotation;
+                            }
+
+                            return language.$(workingCapsule, workingOptions);
+                          }))),
+                });
+              }),
+
+              language.$(capsule, 'alias', {
+                [language.onlyIfOptions]: ['groups'],
+
+                groups:
+                  language.formatConjunctionList(relations.aliasGroupLinks),
+              }),
+            ])),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatDisjunctionList(
+                  relations.visitLinks
+                    .map(link => link.slot('context', 'artist'))),
+            })),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.encapsulate(pageCapsule, 'viewArtGallery', capsule =>
+              language.$(capsule, {
+                [language.onlyIfOptions]: ['link'],
+
+                link:
+                  relations.artistGalleryLink?.slots({
+                    content:
+                      language.$(capsule, 'link'),
+                  }),
+              }))),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$('misc.jumpTo.withLinks', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatUnitList([
+                  !html.isBlank(relations.tracksChunkedList) &&
+                    html.tag('a',
+                      {href: '#tracks'},
+                      language.$(pageCapsule, 'trackList.title')),
+
+                  (!html.isBlank(relations.artworksChunkedList) ||
+                   !html.isBlank(relations.editsForWikiArtworksChunkedList)) &&
+                      html.tag('a',
+                        {href: '#art'},
+                        language.$(pageCapsule, 'artList.title')),
+
+                  !html.isBlank(relations.flashesChunkedList) &&
+                    html.tag('a',
+                      {href: '#flashes'},
+                      language.$(pageCapsule, 'flashList.title')),
+
+                  (!html.isBlank(relations.commentaryChunkedList) ||
+                   !html.isBlank(relations.wikiEditorCommentaryChunkedList)) &&
+                    html.tag('a',
+                      {href: '#commentary'},
+                      language.$(pageCapsule, 'commentaryList.title')),
+                ].filter(Boolean)),
+            })),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
+                attributes: {id: 'tracks'},
+                title: language.$(pageCapsule, 'trackList.title'),
+              }),
+
+            data.totalDuration > 0 &&
+              html.tag('p',
+                {[html.onlyIfSiblings]: true},
+
+                language.$(pageCapsule, 'contributedDurationLine', {
+                  artist: data.name,
+                  duration:
+                    language.formatDuration(data.totalDuration, {
+                      approximate: data.totalTrackCount > 1,
+                      unit: true,
+                    }),
+                })),
+
+            relations.tracksChunkedList.slots({
+              groupInfo:
+                language.encapsulate(pageCapsule, 'groupContributions', capsule => [
+                  relations.tracksGroupInfo.clone()
+                    .slots({
+                      title: language.$(capsule, 'title.music'),
+                      showSortButton: true,
+                      sort: 'count',
+                      countUnit: 'tracks',
+                      visible: true,
+                    }),
+
+                  relations.tracksGroupInfo.clone()
+                    .slots({
+                      title: language.$(capsule, 'title.music'),
+                      showSortButton: true,
+                      sort: 'duration',
+                      countUnit: 'tracks',
+                      visible: false,
+                    }),
+                ]),
+            }),
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
+                attributes: {id: 'art'},
+                title: language.$(pageCapsule, 'artList.title'),
+              }),
+
+            html.tag('p',
+              {[html.onlyIfContent]: true},
+
+              language.encapsulate(pageCapsule, 'viewArtGallery', capsule =>
+                language.$(capsule, 'orBrowseList', {
+                  [language.onlyIfOptions]: ['link'],
+
+                  link:
+                    relations.artistGalleryLink?.slots({
+                      content: language.$(capsule, 'link'),
+                    }),
+                }))),
+
+            relations.artworksChunkedList
+              .slots({
+                groupInfo:
+                  language.encapsulate(pageCapsule, 'groupContributions', capsule =>
+                    relations.artworksGroupInfo
+                      .slots({
+                        title: language.$(capsule, 'title.artworks'),
+                        showBothColumns: false,
+                        sort: 'count',
+                        countUnit: 'artworks',
+                      })),
+              }),
+
+            html.tags([
+              language.encapsulate(pageCapsule, 'wikiEditArtworks', capsule =>
+                relations.contentHeading.clone()
+                  .slots({
+                    tag: 'p',
+
+                    title:
+                      language.$(capsule, {artist: data.name}),
+
+                    stickyTitle:
+                      language.$(capsule, 'sticky'),
+                  })),
+
+              relations.editsForWikiArtworksChunkedList,
+            ]),
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
+                attributes: {id: 'flashes'},
+                title: language.$(pageCapsule, 'flashList.title'),
+              }),
+
+            relations.flashesChunkedList,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
+                attributes: {id: 'commentary'},
+                title: language.$(pageCapsule, 'commentaryList.title'),
+              }),
+
+            relations.commentaryChunkedList,
+
+            html.tags([
+              language.encapsulate(pageCapsule, 'wikiEditorCommentary', capsule =>
+                relations.contentHeading.clone()
+                  .slots({
+                    tag: 'p',
+
+                    title:
+                      language.$(capsule, {artist: data.name}),
+
+                    stickyTitle:
+                      language.$(capsule, 'sticky'),
+                  })),
+
+              relations.wikiEditorCommentaryChunkedList,
+            ]),
+          ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks:
+          relations.artistNavLinks
+            .slots({
+              showExtraLinks: true,
+            })
+            .content,
+      })),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunk.js b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js
new file mode 100644
index 00000000..66e4204a
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js
@@ -0,0 +1,50 @@
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageArtworksChunkItem',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album, contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunk'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    items:
+      contribs
+        .map(contrib =>
+          relation('generateArtistInfoPageArtworksChunkItem', contrib)),
+  }),
+
+  data: (_album, contribs) => ({
+    dates:
+      contribs
+        .map(contrib => contrib.date),
+  }),
+
+  slots: {
+    filterEditsForWiki: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots) =>
+    relations.template.slots({
+      mode: 'album',
+      albumLink: relations.albumLink,
+
+      dates:
+        (slots.filterEditsForWiki
+          ? Array.from({length: data.dates}, () => null)
+          : data.dates),
+
+      items:
+        relations.items.map(item =>
+          item.slot('filterEditsForWiki', slots.filterEditsForWiki)),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
new file mode 100644
index 00000000..2f2fe0c5
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
@@ -0,0 +1,72 @@
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageOtherArtistLinks',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (contrib) => ({
+    kind:
+      (contrib.isBannerArtistContribution
+        ? 'banner'
+     : contrib.isWallpaperArtistContribution
+        ? 'wallpaper'
+     : contrib.isForAlbum
+        ? 'album-cover'
+        : 'track-cover'),
+  }),
+
+  relations: (relation, query, contrib) => ({
+    template:
+      relation('generateArtistInfoPageChunkItem'),
+
+    trackLink:
+      (query.kind === 'track-cover'
+        ? relation('linkTrack', contrib.thing.thing)
+        : null),
+
+    otherArtistLinks:
+      relation('generateArtistInfoPageOtherArtistLinks', [contrib]),
+  }),
+
+  data: (query, contrib) => ({
+    kind:
+      query.kind,
+
+    annotation:
+      contrib.annotation,
+  }),
+
+  slots: {
+    filterEditsForWiki: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.template.slots({
+      otherArtistLinks: relations.otherArtistLinks,
+
+      annotation:
+        (slots.filterEditsForWiki
+          ? data.annotation?.replace(/^edits for wiki(: )?/, '')
+          : data.annotation),
+
+      content:
+        language.encapsulate('artistPage.creditList.entry', capsule =>
+          (data.kind === 'track-cover'
+            ? language.$(capsule, 'track', {
+                track: relations.trackLink,
+              })
+            : html.tag('i',
+                language.encapsulate(capsule, 'album', capsule =>
+                  (data.kind === 'wallpaper'
+                    ? language.$(capsule, 'wallpaperArt')
+                 : data.kind === 'banner'
+                    ? language.$(capsule, 'bannerArt')
+                    : language.$(capsule, 'coverArt')))))),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
new file mode 100644
index 00000000..75a4aa5a
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -0,0 +1,72 @@
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {chunkByConditions, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkedList',
+    'generateArtistInfoPageArtworksChunk',
+  ],
+
+  query(artist, filterEditsForWiki) {
+    const query = {};
+
+    const allContributions = [
+      ...artist.albumCoverArtistContributions,
+      ...artist.albumWallpaperArtistContributions,
+      ...artist.albumBannerArtistContributions,
+      ...artist.trackCoverArtistContributions,
+    ];
+
+    const filteredContributions =
+      allContributions
+        .filter(({annotation}) =>
+          (filterEditsForWiki
+            ? annotation?.startsWith(`edits for wiki`)
+            : !annotation?.startsWith(`edits for wiki`)));
+
+    sortContributionsChronologically(
+      filteredContributions,
+      sortAlbumsTracksChronologically,
+      {getThing: contrib => contrib.thing.thing});
+
+    query.contribs =
+      chunkByConditions(filteredContributions, [
+        ({date: date1}, {date: date2}) =>
+          +date1 !== +date2,
+        ({thing: {thing: thing1}}, {thing: {thing: thing2}}) =>
+          (thing1.album ?? thing1) !==
+          (thing2.album ?? thing2),
+      ]);
+
+    query.albums =
+      query.contribs
+        .map(contribs => contribs[0].thing.thing)
+        .map(thing => thing.album ?? thing);
+
+    return query;
+  },
+
+  relations: (relation, query, _artist, _filterEditsForWiki) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
+
+    chunks:
+      stitchArrays({
+        album: query.albums,
+        contribs: query.contribs,
+      }).map(({album, contribs}) =>
+          relation('generateArtistInfoPageArtworksChunk', album, contribs)),
+  }),
+
+  data: (_query, _artist, filterEditsForWiki) => ({
+    filterEditsForWiki,
+  }),
+
+  generate: (data, relations) =>
+    relations.chunkedList.slots({
+      chunks:
+        relations.chunks.map(chunk =>
+          chunk.slot('filterEditsForWiki', data.filterEditsForWiki)),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js
new file mode 100644
index 00000000..fce68a7d
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageChunk.js
@@ -0,0 +1,114 @@
+import {empty} from '#sugar';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    mode: {
+      validate: v => v.is('flash', 'album'),
+    },
+
+    id: {type: 'string'},
+
+    albumLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    flashActLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    items: {
+      type: 'html',
+      mutable: false,
+    },
+
+    dates: {
+      validate: v => v.sparseArrayOf(v.isDate),
+    },
+
+    duration: {validate: v => v.isDuration},
+    durationApproximate: {type: 'boolean'},
+  },
+
+  generate(slots, {html, language}) {
+    let earliestDate = null;
+    let latestDate = null;
+    let onlyDate = null;
+
+    if (!empty(slots.dates)) {
+      earliestDate =
+        slots.dates
+          .reduce((a, b) => a <= b ? a : b);
+
+      latestDate =
+        slots.dates
+          .reduce((a, b) => a <= b ? b : a);
+
+      if (+earliestDate === +latestDate) {
+        onlyDate = earliestDate;
+      }
+    }
+
+    let accentedLink;
+
+    accent: {
+      switch (slots.mode) {
+        case 'album': {
+          accentedLink = slots.albumLink;
+
+          const options = {album: accentedLink};
+          const parts = ['artistPage.creditList.album'];
+
+          if (onlyDate) {
+            parts.push('withDate');
+            options.date = language.formatDate(onlyDate);
+          }
+
+          if (slots.duration) {
+            parts.push('withDuration');
+            options.duration =
+              language.formatDuration(slots.duration, {
+                approximate: slots.durationApproximate,
+              });
+          }
+
+          accentedLink = language.formatString(...parts, options);
+          break;
+        }
+
+        case 'flash': {
+          accentedLink = slots.flashActLink;
+
+          const options = {act: accentedLink};
+          const parts = ['artistPage.creditList.flashAct'];
+
+          if (onlyDate) {
+            parts.push('withDate');
+            options.date = language.formatDate(onlyDate);
+          } else if (earliestDate && latestDate) {
+            parts.push('withDateRange');
+            options.dateRange =
+              language.formatDateRange(earliestDate, latestDate);
+          }
+
+          accentedLink = language.formatString(...parts, options);
+          break;
+        }
+      }
+    }
+
+    return html.tags([
+      html.tag('dt',
+        slots.id && {id: slots.id},
+        accentedLink),
+
+      html.tag('dd',
+        html.tag('ul',
+          {class: 'offset-tooltips'},
+          slots.items)),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js
new file mode 100644
index 00000000..7987b642
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js
@@ -0,0 +1,91 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['generateTextWithTooltip'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+  }),
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    annotation: {
+      type: 'html',
+      mutable: false,
+    },
+
+    otherArtistLinks: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    rereleaseTooltip: {
+      type: 'html',
+      mutable: false,
+    },
+
+    firstReleaseTooltip: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry', entryCapsule =>
+      html.tag('li',
+        slots.rerelease && {class: 'rerelease'},
+
+        language.encapsulate(entryCapsule, workingCapsule => {
+          const workingOptions = {entry: slots.content};
+
+          if (!html.isBlank(slots.rereleaseTooltip)) {
+            workingCapsule += '.rerelease';
+            workingOptions.rerelease =
+              relations.textWithTooltip.slots({
+                attributes: {class: 'rerelease'},
+                text: language.$(entryCapsule, 'rerelease.term'),
+                tooltip: slots.rereleaseTooltip,
+              });
+
+            return language.$(workingCapsule, workingOptions);
+          }
+
+          if (!html.isBlank(slots.firstReleaseTooltip)) {
+            workingCapsule += '.firstRelease';
+            workingOptions.firstRelease =
+              relations.textWithTooltip.slots({
+                attributes: {class: 'first-release'},
+                text: language.$(entryCapsule, 'firstRelease.term'),
+                tooltip: slots.firstReleaseTooltip,
+              });
+
+            return language.$(workingCapsule, workingOptions);
+          }
+
+          let anyAccent = false;
+
+          if (!empty(slots.otherArtistLinks)) {
+            anyAccent = true;
+            workingCapsule += '.withArtists';
+            workingOptions.artists =
+              language.formatConjunctionList(slots.otherArtistLinks);
+          }
+
+          if (!html.isBlank(slots.annotation)) {
+            anyAccent = true;
+            workingCapsule += '.withAnnotation';
+            workingOptions.annotation = slots.annotation;
+          }
+
+          if (anyAccent) {
+            return language.$(workingCapsule, workingOptions);
+          } else {
+            return slots.content;
+          }
+        }))),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageChunkedList.js b/src/content/dependencies/generateArtistInfoPageChunkedList.js
new file mode 100644
index 00000000..e7915ab7
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageChunkedList.js
@@ -0,0 +1,20 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    groupInfo: {
+      type: 'html',
+      mutable: false,
+    },
+
+    chunks: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('dl',
+      {[html.onlyIfContent]: true},
+      [slots.groupInfo, slots.chunks]),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
new file mode 100644
index 00000000..d0c5e14e
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
@@ -0,0 +1,280 @@
+import {chunkByProperties, stitchArrays} from '#sugar';
+
+import {
+  sortAlbumsTracksChronologically,
+  sortByDate,
+  sortEntryThingPairs,
+} from '#sort';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageChunkItem',
+    'linkAlbum',
+    'linkFlash',
+    'linkFlashAct',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(artist, filterWikiEditorCommentary) {
+    const processEntry = ({
+      thing,
+      entry,
+
+      chunkType,
+      itemType,
+
+      album = null,
+      track = null,
+      flashAct = null,
+      flash = null,
+    }) => ({
+      thing: thing,
+      entry: {
+        chunkType,
+        itemType,
+
+        album,
+        track,
+        flashAct,
+        flash,
+
+        annotation: entry.annotation,
+      },
+    });
+
+    const processAlbumEntry = ({thing: album, entry}) =>
+      processEntry({
+        thing: album,
+        entry: entry,
+
+        chunkType: 'album',
+        itemType: 'album',
+
+        album: album,
+        track: null,
+      });
+
+    const processTrackEntry = ({thing: track, entry}) =>
+      processEntry({
+        thing: track,
+        entry: entry,
+
+        chunkType: 'album',
+        itemType: 'track',
+
+        album: track.album,
+        track: track,
+      });
+
+    const processFlashEntry = ({thing: flash, entry}) =>
+      processEntry({
+        thing: flash,
+        entry: entry,
+
+        chunkType: 'flash-act',
+        itemType: 'flash',
+
+        flashAct: flash.act,
+        flash: flash,
+      });
+
+    const processEntries = ({things, processEntry}) =>
+      things
+        .flatMap(thing =>
+          thing.commentary
+            .filter(entry => entry.artists.includes(artist))
+
+            .filter(({annotation}) =>
+              (filterWikiEditorCommentary
+                ? annotation?.match(/^wiki editor/i)
+                : !annotation?.match(/^wiki editor/i)))
+
+            .map(entry => processEntry({thing, entry})));
+
+    const processAlbumEntries = ({albums}) =>
+      processEntries({
+        things: albums,
+        processEntry: processAlbumEntry,
+      });
+
+    const processTrackEntries = ({tracks}) =>
+      processEntries({
+        things: tracks,
+        processEntry: processTrackEntry,
+      });
+
+    const processFlashEntries = ({flashes}) =>
+      processEntries({
+        things: flashes,
+        processEntry: processFlashEntry,
+      });
+
+    const {
+      albumsAsCommentator,
+      tracksAsCommentator,
+      flashesAsCommentator,
+    } = artist;
+
+    const albumEntries =
+      processAlbumEntries({
+        albums: albumsAsCommentator,
+      });
+
+    const trackEntries =
+      processTrackEntries({
+        tracks: tracksAsCommentator,
+      });
+
+    const flashEntries =
+      processFlashEntries({
+        flashes: flashesAsCommentator,
+      })
+
+    const albumTrackEntries =
+      sortEntryThingPairs(
+        [...albumEntries, ...trackEntries],
+        sortAlbumsTracksChronologically);
+
+    const allEntries =
+      sortEntryThingPairs(
+        [...albumTrackEntries, ...flashEntries],
+        sortByDate);
+
+    const chunks =
+      chunkByProperties(
+        allEntries.map(({entry}) => entry),
+        ['chunkType', 'album', 'flashAct']);
+
+    return {chunks};
+  },
+
+  relations: (relation, query, _artist, filterWikiEditorCommentary) => ({
+    chunks:
+      query.chunks
+        .map(() => relation('generateArtistInfoPageChunk')),
+
+    chunkLinks:
+      query.chunks
+        .map(({chunkType, album, flashAct}) =>
+          (chunkType === 'album'
+            ? relation('linkAlbum', album)
+         : chunkType === 'flash-act'
+            ? relation('linkFlashAct', flashAct)
+            : null)),
+
+    items:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(() => relation('generateArtistInfoPageChunkItem'))),
+
+    itemLinks:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(({track, flash}) =>
+            (track
+              ? relation('linkTrack', track)
+           : flash
+              ? relation('linkFlash', flash)
+              : null))),
+
+    itemAnnotations:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(({annotation}) =>
+            relation('transformContent',
+              (filterWikiEditorCommentary
+                ? annotation?.replace(/^wiki editor(, )?/i, '')
+                : annotation)))),
+  }),
+
+  data: (query, _artist, _filterWikiEditorCommentary) => ({
+    chunkTypes:
+      query.chunks
+        .map(({chunkType}) => chunkType),
+
+    itemTypes:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(({itemType}) => itemType)),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    html.tag('dl',
+      {[html.onlyIfContent]: true},
+
+      stitchArrays({
+        chunk: relations.chunks,
+        chunkLink: relations.chunkLinks,
+        chunkType: data.chunkTypes,
+
+        items: relations.items,
+        itemLinks: relations.itemLinks,
+        itemAnnotations: relations.itemAnnotations,
+        itemTypes: data.itemTypes,
+      }).map(({
+          chunk,
+          chunkLink,
+          chunkType,
+
+          items,
+          itemLinks,
+          itemAnnotations,
+          itemTypes,
+        }) =>
+          language.encapsulate('artistPage.creditList.entry', capsule =>
+            (chunkType === 'album'
+              ? chunk.slots({
+                  mode: 'album',
+                  albumLink: chunkLink,
+                  items:
+                    stitchArrays({
+                      item: items,
+                      link: itemLinks,
+                      annotation: itemAnnotations,
+                      type: itemTypes,
+                    }).map(({item, link, annotation, type}) =>
+                      item.slots({
+                        annotation:
+                          annotation.slots({
+                            mode: 'inline',
+                            absorbPunctuationFollowingExternalLinks: false,
+                          }),
+
+                        content:
+                          (type === 'album'
+                            ? html.tag('i',
+                                language.$(capsule, 'album.commentary'))
+                            : language.$(capsule, 'track', {track: link})),
+                      })),
+                })
+           : chunkType === 'flash-act'
+              ? chunk.slots({
+                  mode: 'flash',
+                  flashActLink: chunkLink,
+                  items:
+                    stitchArrays({
+                      item: items,
+                      link: itemLinks,
+                      annotation: itemAnnotations,
+                    }).map(({item, link, annotation}) =>
+                      item.slots({
+                        annotation:
+                          (annotation
+                            ? annotation.slots({
+                                mode: 'inline',
+                                absorbPunctuationFollowingExternalLinks: false,
+                              })
+                            : null),
+
+                        content:
+                          language.$(capsule, 'flash', {
+                            flash: link,
+                          }),
+                      })),
+                })
+              : null)))),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
new file mode 100644
index 00000000..f86dead7
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
@@ -0,0 +1,75 @@
+import {sortChronologically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateTooltip',
+    'linkOtherReleaseOnArtistInfoPage',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    rereleases:
+      sortChronologically(track.allReleases).slice(1),
+  }),
+
+  relations: (relation, query, track, artist) => ({
+    tooltip:
+      relation('generateTooltip'),
+
+    firstReleaseColorStyle:
+      relation('generateColorStyleAttribute', track.color),
+
+    rereleaseLinks:
+      query.rereleases
+        .map(rerelease =>
+          relation('linkOtherReleaseOnArtistInfoPage', rerelease, artist)),
+  }),
+
+  data: (query, track) => ({
+    firstReleaseDate:
+      track.dateFirstReleased ??
+      track.album.date,
+
+    rereleaseDates:
+      query.rereleases
+        .map(rerelease =>
+          rerelease.dateFirstReleased ??
+          rerelease.album.date),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry.firstRelease', capsule =>
+      relations.tooltip.slots({
+        attributes: [
+          {class: 'first-release-tooltip'},
+          relations.firstReleaseColorStyle,
+        ],
+
+        contentAttributes: [
+          {[html.joinChildren]: html.tag('hr', {class: 'cute'})},
+        ],
+
+        content:
+          stitchArrays({
+            rereleaseLink: relations.rereleaseLinks,
+            rereleaseDate: data.rereleaseDates,
+          }).map(({rereleaseLink, rereleaseDate}) =>
+              html.tags([
+                language.$(capsule, 'rerelease', {
+                  album:
+                    html.metatag('blockwrap', rereleaseLink),
+                }),
+
+                html.tag('br'),
+
+                language.formatRelativeDate(rereleaseDate, data.firstReleaseDate, {
+                  considerRoundingDays: true,
+                  approximate: true,
+                  absolute: true,
+                }),
+              ])),
+      })),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunk.js b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js
new file mode 100644
index 00000000..8aa7223a
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js
@@ -0,0 +1,34 @@
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageFlashesChunkItem',
+    'linkFlashAct',
+  ],
+
+  relations: (relation, flashAct, contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunk'),
+
+    flashActLink:
+      relation('linkFlashAct', flashAct),
+
+    items:
+      contribs
+        .map(contrib =>
+          relation('generateArtistInfoPageFlashesChunkItem', contrib)),
+  }),
+
+  data: (_flashAct, contribs) => ({
+    dates:
+      contribs
+        .map(contrib => contrib.date),
+  }),
+
+  generate: (data, relations) =>
+    relations.template.slots({
+      mode: 'flash',
+      flashActLink: relations.flashActLink,
+      dates: data.dates,
+      items: relations.items,
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js
new file mode 100644
index 00000000..e4908bf9
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js
@@ -0,0 +1,34 @@
+export default {
+  contentDependencies: ['generateArtistInfoPageChunkItem', 'linkFlash'],
+
+  extraDependencies: ['language'],
+
+  relations: (relation, contrib) => ({
+    // Flashes and games can list multiple contributors as collaborative
+    // credits, but we don't display these on the artist page, since they
+    // usually involve many artists crediting a larger team where collaboration
+    // isn't as relevant (without more particular details that aren't tracked
+    // on the wiki).
+
+    template:
+      relation('generateArtistInfoPageChunkItem'),
+
+    flashLink:
+      relation('linkFlash', contrib.thing),
+  }),
+
+  data: (contrib) => ({
+    annotation:
+      contrib.annotation,
+  }),
+
+  generate: (data, relations, {language}) =>
+    relations.template.slots({
+      annotation: data.annotation,
+
+      content:
+        language.$('artistPage.creditList.entry.flash', {
+          flash: relations.flashLink,
+        }),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
new file mode 100644
index 00000000..b347faf5
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
@@ -0,0 +1,62 @@
+import {sortContributionsChronologically, sortFlashesChronologically}
+  from '#sort';
+import {chunkByConditions, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkedList',
+    'generateArtistInfoPageFlashesChunk',
+  ],
+
+  extraDependencies: ['wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    enableFlashesAndGames:
+      wikiInfo.enableFlashesAndGames,
+  }),
+
+  query(sprawl, artist) {
+    const query = {};
+
+    const allContributions =
+      (sprawl.enableFlashesAndGames
+        ? [
+            ...artist.flashContributorContributions,
+          ]
+      : []);
+
+    sortContributionsChronologically(
+      allContributions,
+      sortFlashesChronologically);
+
+    query.contribs =
+      chunkByConditions(allContributions, [
+        ({thing: flash1}, {thing: flash2}) =>
+          flash1.act !== flash2.act,
+      ]);
+
+    query.flashActs =
+      query.contribs
+        .map(contribs => contribs[0].thing)
+        .map(thing => thing.act);
+
+    return query;
+  },
+
+  relations: (relation, query, _sprawl, _artist) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
+
+    chunks:
+      stitchArrays({
+        flashAct: query.flashActs,
+        contribs: query.contribs,
+      }).map(({flashAct, contribs}) =>
+          relation('generateArtistInfoPageFlashesChunk', flashAct, contribs)),
+  }),
+
+  generate: (relations) =>
+    relations.chunkedList.slots({
+      chunks: relations.chunks,
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
new file mode 100644
index 00000000..dcee9c00
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
@@ -0,0 +1,30 @@
+import {unique} from '#sugar';
+
+export default {
+  contentDependencies: ['linkArtist'],
+
+  query(contribs) {
+    const associatedContributionsByOtherArtists =
+      contribs
+        .flatMap(ownContrib =>
+          ownContrib.associatedContributions
+            .filter(associatedContrib =>
+              associatedContrib.artist !== ownContrib.artist));
+
+    const otherArtists =
+      unique(
+        associatedContributionsByOtherArtists
+          .map(contrib => contrib.artist));
+
+    return {otherArtists};
+  },
+
+  relations: (relation, query) => ({
+    artistLinks:
+      query.otherArtists
+        .map(artist => relation('linkArtist', artist)),
+  }),
+
+  generate: (relations) =>
+    relations.artistLinks,
+};
diff --git a/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
new file mode 100644
index 00000000..1d849919
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
@@ -0,0 +1,61 @@
+import {sortChronologically} from '#sort';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateTooltip',
+    'linkOtherReleaseOnArtistInfoPage'
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    firstRelease:
+      sortChronologically(track.allReleases)[0],
+  }),
+
+  relations: (relation, query, track, artist) => ({
+    tooltip:
+      relation('generateTooltip'),
+
+    rereleaseColorStyle:
+      relation('generateColorStyleAttribute', track.color),
+
+    firstReleaseLink:
+      relation('linkOtherReleaseOnArtistInfoPage', query.firstRelease, artist),
+  }),
+
+  data: (query, track) => ({
+    rereleaseDate:
+      track.dateFirstReleased ??
+      track.album.date,
+
+    firstReleaseDate:
+      query.firstRelease.dateFirstReleased ??
+      query.firstRelease.album.date,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('artistPage.creditList.entry.rerelease', capsule =>
+      relations.tooltip.slots({
+        attributes: [
+          {class: 'rerelease-tooltip'},
+          relations.rereleaseColorStyle,
+        ],
+
+        content: [
+          language.$(capsule, 'firstRelease', {
+            album:
+              html.metatag('blockwrap', relations.firstReleaseLink),
+          }),
+
+          html.tag('br'),
+
+          language.formatRelativeDate(data.firstReleaseDate, data.rereleaseDate, {
+            considerRoundingDays: true,
+            approximate: true,
+            absolute: true,
+          }),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunk.js b/src/content/dependencies/generateArtistInfoPageTracksChunk.js
new file mode 100644
index 00000000..f6d70901
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunk.js
@@ -0,0 +1,67 @@
+import {unique} from '#sugar';
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageTracksChunkItem',
+    'linkAlbum',
+  ],
+
+  relations: (relation, artist, album, trackContribLists) => ({
+    template:
+      relation('generateArtistInfoPageChunk'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    // Intentional mapping here: each item may be associated with
+    // more than one contribution.
+    items:
+      trackContribLists.map(trackContribs =>
+        relation('generateArtistInfoPageTracksChunkItem',
+          artist,
+          trackContribs)),
+  }),
+
+  data(_artist, album, trackContribLists) {
+    const data = {};
+
+    const contribs =
+      trackContribLists.flat();
+
+    data.dates =
+      contribs
+        .map(contrib => contrib.date);
+
+    // TODO: Duration stuff should *maybe* be in proper data logic? Maaaybe?
+    const durationTerms =
+      unique(
+        contribs
+          .filter(contrib => contrib.countInDurationTotals)
+          .map(contrib => contrib.thing)
+          .filter(track => track.isMainRelease)
+          .filter(track => track.duration > 0));
+
+    data.duration =
+      getTotalDuration(durationTerms);
+
+    data.durationApproximate =
+      durationTerms.length > 1;
+
+    return data;
+  },
+
+  generate: (data, relations) =>
+    relations.template.slots({
+      mode: 'album',
+
+      albumLink: relations.albumLink,
+
+      dates: data.dates,
+      duration: data.duration,
+      durationApproximate: data.durationApproximate,
+
+      items: relations.items,
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
new file mode 100644
index 00000000..a42d6fee
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
@@ -0,0 +1,146 @@
+import {sortChronologically} from '#sort';
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageFirstReleaseTooltip',
+    'generateArtistInfoPageOtherArtistLinks',
+    'generateArtistInfoPageRereleaseTooltip',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query (_artist, contribs) {
+    const query = {};
+
+    // TODO: Very mysterious what to do if the set of contributions is,
+    // in total, associated with more than one thing. No design yet.
+    query.track =
+      contribs[0].thing;
+
+    const creditedAsArtist =
+      contribs
+        .some(contrib => contrib.isArtistContribution);
+
+    const creditedAsContributor =
+      contribs
+        .some(contrib => contrib.isContributorContribution);
+
+    const annotatedContribs =
+      contribs
+        .filter(contrib => contrib.annotation);
+
+    const annotatedArtistContribs =
+      annotatedContribs
+        .filter(contrib => contrib.isArtistContribution);
+
+    const annotatedContributorContribs =
+      annotatedContribs
+        .filter(contrib => contrib.isContributorContribution);
+
+    // Don't display annotations associated with crediting in the
+    // Contributors field if the artist is also credited as an Artist
+    // *and* the Artist-field contribution is non-annotated. This is
+    // so that we don't misrepresent the artist - the contributor
+    // annotation tends to be for "secondary" and performance roles.
+    // For example, this avoids crediting Marcy Nabors on Renewed
+    // Return seemingly only for "bass clarinet" when they're also
+    // the one who composed and arranged Renewed Return!
+    if (
+      creditedAsArtist &&
+      creditedAsContributor &&
+      empty(annotatedArtistContribs)
+    ) {
+      query.displayedContributions = null;
+    } else if (
+      !empty(annotatedArtistContribs) ||
+      !empty(annotatedContributorContribs)
+    ) {
+      query.displayedContributions = [
+        ...annotatedArtistContribs,
+        ...annotatedContributorContribs,
+      ];
+    }
+
+    // It's kinda awkward to perform this chronological sort here,
+    // per track, rather than just reusing the one that's done to
+    // sort all the items on the page altogether... but then, the
+    // sort for the page is actually *a different* sort, on purpsoe.
+    // That sort is according to the dates of the contributions;
+    // this is according to the dates of the tracks. Those can be
+    // different - and it's the latter that determines whether the
+    // track is a rerelease!
+    const allReleasesChronologically =
+      sortChronologically(query.track.allReleases);
+
+    query.isFirstRelease =
+      allReleasesChronologically[0] === query.track;
+
+    query.isRerelease =
+      allReleasesChronologically[0] !== query.track;
+
+    query.hasOtherReleases =
+      !empty(query.track.otherReleases);
+
+    return query;
+  },
+
+  relations: (relation, query, artist, contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunkItem'),
+
+    trackLink:
+      relation('linkTrack', query.track),
+
+    otherArtistLinks:
+      relation('generateArtistInfoPageOtherArtistLinks', contribs),
+
+    rereleaseTooltip:
+      (query.isRerelease
+        ? relation('generateArtistInfoPageRereleaseTooltip', query.track, artist)
+        : null),
+
+    firstReleaseTooltip:
+      (query.isFirstRelease && query.hasOtherReleases
+        ? relation('generateArtistInfoPageFirstReleaseTooltip', query.track, artist)
+        : null),
+  }),
+
+  data: (query) => ({
+    duration:
+      query.track.duration,
+
+    contribAnnotations:
+      (query.displayedContributions
+        ? query.displayedContributions
+            .map(contrib => contrib.annotation)
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.template.slots({
+      otherArtistLinks: relations.otherArtistLinks,
+      rereleaseTooltip: relations.rereleaseTooltip,
+      firstReleaseTooltip: relations.firstReleaseTooltip,
+
+      annotation:
+        (data.contribAnnotations
+          ? language.formatUnitList(data.contribAnnotations)
+          : html.blank()),
+
+      content:
+        language.encapsulate('artistPage.creditList.entry.track', workingCapsule => {
+          const workingOptions = {track: relations.trackLink};
+
+          if (data.duration) {
+            workingCapsule += '.withDuration';
+            workingOptions.duration =
+              language.formatDuration(data.duration);
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
new file mode 100644
index 00000000..84eb29ac
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -0,0 +1,81 @@
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {stitchArrays} from '#sugar';
+import {chunkArtistTrackContributions} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunkedList',
+    'generateArtistInfoPageTracksChunk',
+  ],
+
+  query(artist) {
+    const query = {};
+
+    const allContributions = [
+      ...artist.trackArtistContributions,
+      ...artist.trackContributorContributions,
+    ];
+
+    sortContributionsChronologically(
+      allContributions,
+      sortAlbumsTracksChronologically);
+
+    query.contribs =
+      chunkArtistTrackContributions(allContributions);
+
+    query.albums =
+      query.contribs
+        .map(contribs =>
+          contribs[0][0].thing.album);
+
+    return query;
+  },
+
+  relations: (relation, query, artist) => ({
+    chunkedList:
+      relation('generateArtistInfoPageChunkedList'),
+
+    chunks:
+      stitchArrays({
+        album: query.albums,
+        contribs: query.contribs,
+      }).map(({album, contribs}) =>
+          relation('generateArtistInfoPageTracksChunk',
+            artist,
+            album,
+            contribs)),
+  }),
+
+  data: (query, _artist) => ({
+    albumDirectories:
+      query.albums
+        .map(album => album.directory),
+
+    albumChunkIndices:
+      query.albums
+        .reduce(([indices, map], album) => {
+          if (map.has(album)) {
+            const n = map.get(album);
+            indices.push(n);
+            map.set(album, n + 1);
+          } else {
+            indices.push(0);
+            map.set(album, 1);
+          }
+          return [indices, map];
+        }, [[], new Map()])
+        [0],
+  }),
+
+  generate: (data, relations) =>
+    relations.chunkedList.slots({
+      chunks:
+        stitchArrays({
+          chunk: relations.chunks,
+          albumDirectory: data.albumDirectories,
+          albumChunkIndex: data.albumChunkIndices,
+        }).map(({chunk, albumDirectory, albumChunkIndex}) =>
+            chunk.slot('id', `tracks-${albumDirectory}-${albumChunkIndex}`)),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js
new file mode 100644
index 00000000..1b4b6eca
--- /dev/null
+++ b/src/content/dependencies/generateArtistNavLinks.js
@@ -0,0 +1,94 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'linkArtist',
+    'linkArtistGallery',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    enableListings:
+      wikiInfo.enableListings,
+  }),
+
+  query: (_sprawl, artist) => ({
+    hasGallery:
+      !empty(artist.albumCoverArtistContributions) ||
+      !empty(artist.trackCoverArtistContributions),
+  }),
+
+  relations: (relation, query, _sprawl, artist) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    artistMainLink:
+      relation('linkArtist', artist),
+
+    artistInfoLink:
+      relation('linkArtist', artist),
+
+    artistGalleryLink:
+      (query.hasGallery
+        ? relation('linkArtistGallery', artist)
+        : null),
+  }),
+
+  data: (_query, sprawl) => ({
+    enableListings:
+      sprawl.enableListings,
+  }),
+
+  slots: {
+    showExtraLinks: {type: 'boolean', default: false},
+
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) => [
+    {auto: 'home'},
+
+    data.enableListings &&
+      {
+        path: ['localized.listingIndex'],
+        title: language.$('listingIndex.title'),
+      },
+
+    {
+      html:
+        language.$('artistPage.nav.artist', {
+          artist: relations.artistMainLink,
+        }),
+
+      accent:
+        relations.switcher.slots({
+          links: [
+            relations.artistInfoLink.slots({
+              attributes: [
+                slots.currentExtra === null &&
+                  {class: 'current'},
+
+                {[html.onlyIfSiblings]: true},
+              ],
+
+              content: language.$('misc.nav.info'),
+            }),
+
+            slots.showExtraLinks &&
+              relations.artistGalleryLink?.slots({
+                attributes: [
+                  slots.currentExtra === 'gallery' &&
+                    {class: 'current'},
+                ],
+
+                content: language.$('misc.nav.gallery'),
+              }),
+          ],
+        }),
+    },
+  ],
+};
diff --git a/src/content/dependencies/generateBackToAlbumLink.js b/src/content/dependencies/generateBackToAlbumLink.js
new file mode 100644
index 00000000..6648b463
--- /dev/null
+++ b/src/content/dependencies/generateBackToAlbumLink.js
@@ -0,0 +1,15 @@
+export default {
+  contentDependencies: ['linkAlbum'],
+  extraDependencies: ['language'],
+
+  relations: (relation, track) => ({
+    trackLink:
+      relation('linkAlbum', track),
+  }),
+
+  generate: (relations, {language}) =>
+    relations.trackLink.slots({
+      content: language.$('albumPage.nav.backToAlbum'),
+      color: false,
+    }),
+};
diff --git a/src/content/dependencies/generateBackToTrackLink.js b/src/content/dependencies/generateBackToTrackLink.js
new file mode 100644
index 00000000..8677d811
--- /dev/null
+++ b/src/content/dependencies/generateBackToTrackLink.js
@@ -0,0 +1,15 @@
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['language'],
+
+  relations: (relation, track) => ({
+    trackLink:
+      relation('linkTrack', track),
+  }),
+
+  generate: (relations, {language}) =>
+    relations.trackLink.slots({
+      content: language.$('trackPage.nav.backToTrack'),
+      color: false,
+    }),
+};
diff --git a/src/content/dependencies/generateBanner.js b/src/content/dependencies/generateBanner.js
new file mode 100644
index 00000000..15eb08eb
--- /dev/null
+++ b/src/content/dependencies/generateBanner.js
@@ -0,0 +1,33 @@
+export default {
+  extraDependencies: ['html', 'to'],
+
+  slots: {
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
+    },
+
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
+
+    alt: {
+      type: 'string',
+    },
+  },
+
+  generate: (slots, {html, to}) =>
+    html.tag('div', {id: 'banner'},
+      html.tag('img',
+        {src: to(...slots.path)},
+
+        (slots.dimensions
+          ? {width: slots.dimensions[0]}
+          : {width: 1100}),
+
+        (slots.dimensions
+          ? {height: slots.dimensions[1]}
+          : {height: 200}),
+
+        slots.alt &&
+          {alt: slots.alt})),
+};
diff --git a/src/content/dependencies/generateColorStyleAttribute.js b/src/content/dependencies/generateColorStyleAttribute.js
new file mode 100644
index 00000000..03d95ac5
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleAttribute.js
@@ -0,0 +1,37 @@
+export default {
+  contentDependencies: ['generateColorStyleVariables'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    colorVariables:
+      relation('generateColorStyleVariables'),
+  }),
+
+  data: (color) => ({
+    color:
+      color ?? null,
+  }),
+
+  slots: {
+    color: {
+      validate: v => v.isColor,
+    },
+
+    context: {
+      validate: v => v.is(
+        'any-content',
+        'image-box',
+        'primary-only'),
+
+      default: 'any-content',
+    },
+  },
+
+  generate: (data, relations, slots) => ({
+    style:
+      relations.colorVariables.slots({
+        color: slots.color ?? data.color,
+        context: slots.context,
+      }).content,
+  }),
+};
diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js
new file mode 100644
index 00000000..c412b8f2
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleRules.js
@@ -0,0 +1,42 @@
+export default {
+  contentDependencies: ['generateColorStyleVariables'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    variables:
+      relation('generateColorStyleVariables'),
+  }),
+
+  data: (color) => ({
+    color:
+      color ?? null,
+  }),
+
+  slots: {
+    color: {
+      validate: v => v.isColor,
+    },
+  },
+
+  generate(data, relations, slots) {
+    const color = data.color ?? slots.color;
+
+    if (!color) {
+      return '';
+    }
+
+    return [
+      `:root {`,
+      ...(
+        relations.variables
+          .slots({
+            color,
+            context: 'page-root',
+            mode: 'property-list',
+          })
+          .content
+          .map(line => line + ';')),
+      `}`,
+    ].join('\n');
+  },
+};
diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js
new file mode 100644
index 00000000..5270dbe4
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -0,0 +1,91 @@
+export default {
+  extraDependencies: ['html', 'getColors'],
+
+  slots: {
+    color: {
+      validate: v => v.isColor,
+    },
+
+    context: {
+      validate: v => v.is(
+        'any-content',
+        'image-box',
+        'page-root',
+        'image-box',
+        'primary-only'),
+
+      default: 'any-content',
+    },
+
+    mode: {
+      validate: v => v.is('style', 'property-list'),
+      default: 'style',
+    },
+  },
+
+  generate(slots, {getColors}) {
+    if (!slots.color) return [];
+
+    const {
+      primary,
+      dark,
+      dim,
+      deep,
+      deepGhost,
+      lightGhost,
+      bg,
+      bgBlack,
+      shadow,
+    } = getColors(slots.color);
+
+    let anyContent = [
+      `--primary-color: ${primary}`,
+      `--dark-color: ${dark}`,
+      `--dim-color: ${dim}`,
+      `--deep-color: ${deep}`,
+      `--deep-ghost-color: ${deepGhost}`,
+      `--light-ghost-color: ${lightGhost}`,
+      `--bg-color: ${bg}`,
+      `--bg-black-color: ${bgBlack}`,
+      `--shadow-color: ${shadow}`,
+    ];
+
+    let selectedProperties;
+
+    switch (slots.context) {
+      case 'any-content':
+        selectedProperties = anyContent;
+        break;
+
+      case 'image-box':
+        selectedProperties = [
+          `--primary-color: ${primary}`,
+          `--dim-color: ${dim}`,
+          `--deep-color: ${deep}`,
+          `--bg-black-color: ${bgBlack}`,
+        ];
+        break;
+
+      case 'page-root':
+        selectedProperties = [
+          ...anyContent,
+          `--page-primary-color: ${primary}`,
+        ];
+        break;
+
+      case 'primary-only':
+        selectedProperties = [
+          `--primary-color: ${primary}`,
+        ];
+        break;
+    }
+
+    switch (slots.mode) {
+      case 'style':
+        return selectedProperties.join('; ');
+
+      case 'property-list':
+        return selectedProperties;
+    }
+  },
+};
diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
new file mode 100644
index 00000000..c93020f3
--- /dev/null
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -0,0 +1,112 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCommentaryEntryDate',
+    'generateColorStyleAttribute',
+    'linkArtist',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    artistLinks:
+      (!empty(entry.artists) && !entry.artistDisplayText
+        ? entry.artists
+            .map(artist => relation('linkArtist', artist))
+        : null),
+
+    artistsContent:
+      (entry.artistDisplayText
+        ? relation('transformContent', entry.artistDisplayText)
+        : null),
+
+    annotationContent:
+      (entry.annotation
+        ? relation('transformContent', entry.annotation)
+        : null),
+
+    bodyContent:
+      (entry.body
+        ? relation('transformContent', entry.body)
+        : null),
+
+    colorStyle:
+      relation('generateColorStyleAttribute'),
+
+    date:
+      relation('generateCommentaryEntryDate', entry),
+  }),
+
+  slots: {
+    color: {validate: v => v.isColor},
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    language.encapsulate('misc.artistCommentary.entry', entryCapsule =>
+      html.tags([
+        html.tag('p', {class: 'commentary-entry-heading'},
+          slots.color &&
+            relations.colorStyle.clone()
+              .slot('color', slots.color),
+
+          !html.isBlank(relations.date) &&
+            {class: 'dated'},
+
+          language.encapsulate(entryCapsule, 'title', titleCapsule => [
+            html.tag('span', {class: 'commentary-entry-heading-text'},
+              language.encapsulate(titleCapsule, workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.artists =
+                  html.tag('span', {class: 'commentary-entry-artists'},
+                    (relations.artistsContent
+                      ? relations.artistsContent.slot('mode', 'inline')
+                   : relations.artistLinks
+                      ? language.formatConjunctionList(relations.artistLinks)
+                      : language.$(titleCapsule, 'noArtists')));
+
+                const accent =
+                  html.tag('span', {class: 'commentary-entry-accent'},
+                    {[html.onlyIfContent]: true},
+
+                    language.encapsulate(titleCapsule, 'accent', accentCapsule =>
+                      language.encapsulate(accentCapsule, workingCapsule => {
+                        const workingOptions = {};
+
+                        if (relations.annotationContent) {
+                          workingCapsule += '.withAnnotation';
+                          workingOptions.annotation =
+                            relations.annotationContent.slots({
+                              mode: 'inline',
+                              absorbPunctuationFollowingExternalLinks: false,
+                            });
+                        }
+
+                        if (workingCapsule === accentCapsule) {
+                          return html.blank();
+                        } else {
+                          return language.$(workingCapsule, workingOptions);
+                        }
+                      })));
+
+                if (!html.isBlank(accent)) {
+                  workingCapsule += '.withAccent';
+                  workingOptions.accent = accent;
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })),
+
+            relations.date,
+          ])),
+
+        html.tag('blockquote', {class: 'commentary-entry-body'},
+          slots.color &&
+            relations.colorStyle.clone()
+              .slot('color', slots.color),
+
+          relations.bodyContent.slot('mode', 'multiline')),
+      ])),
+};
diff --git a/src/content/dependencies/generateCommentaryEntryDate.js b/src/content/dependencies/generateCommentaryEntryDate.js
new file mode 100644
index 00000000..f1cf5cb3
--- /dev/null
+++ b/src/content/dependencies/generateCommentaryEntryDate.js
@@ -0,0 +1,93 @@
+export default {
+  contentDependencies: ['generateTextWithTooltip', 'generateTooltip'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _entry) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateTooltip'),
+  }),
+
+  data: (entry) => ({
+    date: entry.date,
+    secondDate: entry.secondDate,
+    dateKind: entry.dateKind,
+
+    accessDate: entry.accessDate,
+    accessKind: entry.accessKind,
+  }),
+
+  generate(data, relations, {html, language}) {
+    const titleCapsule = language.encapsulate('misc.artistCommentary.entry.title');
+
+    const willDisplayTooltip =
+      !!(data.accessKind && data.accessDate);
+
+    const topAttributes =
+      {class: 'commentary-date'};
+
+    const time =
+      html.tag('time',
+        {[html.onlyIfContent]: true},
+
+        (willDisplayTooltip
+          ? {class: 'text-with-tooltip-interaction-cue'}
+          : topAttributes),
+
+        language.encapsulate(titleCapsule, 'date', workingCapsule => {
+          const workingOptions = {};
+
+          if (!data.date) {
+            return html.blank();
+          }
+
+          const rangeNeeded =
+            data.dateKind === 'sometime' ||
+            data.dateKind === 'throughout';
+
+          if (rangeNeeded && !data.secondDate) {
+            workingOptions.date = language.formatDate(data.date);
+            return language.$(workingCapsule, workingOptions);
+          }
+
+          if (data.dateKind) {
+            workingCapsule += '.' + data.dateKind;
+          }
+
+          if (data.secondDate) {
+            workingCapsule += '.range';
+            workingOptions.dateRange =
+              language.formatDateRange(data.date, data.secondDate);
+          } else {
+            workingOptions.date =
+              language.formatDate(data.date);
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }));
+
+    if (willDisplayTooltip) {
+      return relations.textWithTooltip.slots({
+        customInteractionCue: true,
+
+        attributes: topAttributes,
+        text: time,
+
+        tooltip:
+          relations.tooltip.slots({
+            attributes: {class: 'commentary-date-tooltip'},
+
+            content:
+              language.$(titleCapsule, 'date', data.accessKind, {
+                date:
+                  language.formatDate(data.accessDate),
+              }),
+          }),
+      });
+    } else {
+      return time;
+    }
+  },
+}
diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js
new file mode 100644
index 00000000..d68ba42e
--- /dev/null
+++ b/src/content/dependencies/generateCommentaryIndexPage.js
@@ -0,0 +1,104 @@
+import {sortChronologically} from '#sort';
+import {accumulateSum, filterMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generatePageLayout', 'linkAlbumCommentary'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query(sprawl) {
+    const query = {};
+
+    query.albums =
+      sortChronologically(sprawl.albumData.slice());
+
+    const entries =
+      query.albums.map(album =>
+        [album, ...album.tracks]
+          .filter(({commentary}) => commentary)
+          .flatMap(({commentary}) => commentary));
+
+    query.wordCounts =
+      entries.map(entries =>
+        accumulateSum(
+          entries,
+          entry => entry.body.split(' ').length));
+
+    query.entryCounts =
+      entries.map(entries => entries.length);
+
+    filterMultipleArrays(query.albums, query.wordCounts, query.entryCounts,
+      (album, wordCount, entryCount) => entryCount >= 1);
+
+    return query;
+  },
+
+  relations(relation, query) {
+    return {
+      layout:
+        relation('generatePageLayout'),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbumCommentary', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      wordCounts: query.wordCounts,
+      entryCounts: query.entryCounts,
+
+      totalWordCount: accumulateSum(query.wordCounts),
+      totalEntryCount: accumulateSum(query.entryCounts),
+    };
+  },
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('commentaryIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+
+        headingMode: 'static',
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          html.tag('p', language.$(pageCapsule, 'infoLine', {
+            words:
+              html.tag('b',
+                language.formatWordCount(data.totalWordCount, {unit: true})),
+
+            entries:
+              html.tag('b',
+                  language.countCommentaryEntries(data.totalEntryCount, {unit: true})),
+          })),
+
+          language.encapsulate(pageCapsule, 'albumList', listCapsule => [
+            html.tag('p',
+              language.$(listCapsule, 'title')),
+
+            html.tag('ul',
+              stitchArrays({
+                albumLink: relations.albumLinks,
+                wordCount: data.wordCounts,
+                entryCount: data.entryCounts,
+              }).map(({albumLink, wordCount, entryCount}) =>
+                html.tag('li',
+                  language.$(listCapsule, 'item', {
+                    album: albumLink,
+                    words: language.formatWordCount(wordCount, {unit: true}),
+                    entries: language.countCommentaryEntries(entryCount, {unit: true}),
+                  })))),
+          ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
new file mode 100644
index 00000000..f52bc043
--- /dev/null
+++ b/src/content/dependencies/generateContentHeading.js
@@ -0,0 +1,61 @@
+export default {
+  extraDependencies: ['html'],
+  contentDependencies: ['generateColorStyleAttribute'],
+
+  relations: (relation) => ({
+    colorStyle: relation('generateColorStyleAttribute'),
+  }),
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    stickyTitle: {
+      type: 'html',
+      mutable: false,
+    },
+
+    accent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    color: {validate: v => v.isColor},
+
+    tag: {
+      type: 'string',
+      default: 'p',
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag(slots.tag, {class: 'content-heading'},
+      {tabindex: '0'},
+      {[html.onlyIfSiblings]: true},
+
+      slots.attributes,
+
+      slots.color &&
+        relations.colorStyle.slot('color', slots.color),
+
+      [
+        html.tag('span', {class: 'content-heading-main-title'},
+          {[html.onlyIfContent]: true},
+          slots.title),
+
+        html.tag('template', {class: 'content-heading-sticky-title'},
+          {[html.onlyIfContent]: true},
+          slots.stickyTitle),
+
+        html.tag('span', {class: 'content-heading-accent'},
+          {[html.onlyIfContent]: true},
+          slots.accent),
+      ]),
+}
diff --git a/src/content/dependencies/generateContributionList.js b/src/content/dependencies/generateContributionList.js
new file mode 100644
index 00000000..d1c3de0f
--- /dev/null
+++ b/src/content/dependencies/generateContributionList.js
@@ -0,0 +1,29 @@
+export default {
+  contentDependencies: ['linkContribution'],
+  extraDependencies: ['html'],
+
+  relations: (relation, contributions) => ({
+    contributionLinks:
+      contributions
+        .map(contrib => relation('linkContribution', contrib)),
+  }),
+
+  slots: {
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('ul',
+      {[html.onlyIfContent]: true},
+
+      relations.contributionLinks
+        .map(contributionLink =>
+          html.tag('li',
+            contributionLink.slots({
+              showAnnotation: true,
+              showExternalLinks: true,
+              showChronology: true,
+              preventWrapping: false,
+              chronologyKind: slots.chronologyKind,
+            })))),
+};
diff --git a/src/content/dependencies/generateContributionTooltip.js b/src/content/dependencies/generateContributionTooltip.js
new file mode 100644
index 00000000..3a31014d
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltip.js
@@ -0,0 +1,48 @@
+export default {
+  contentDependencies: [
+    'generateContributionTooltipChronologySection',
+    'generateContributionTooltipExternalLinkSection',
+    'generateTooltip',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, contribution) => ({
+    tooltip:
+      relation('generateTooltip'),
+
+    externalLinkSection:
+      relation('generateContributionTooltipExternalLinkSection', contribution),
+
+    chronologySection:
+      relation('generateContributionTooltipChronologySection', contribution),
+  }),
+
+  slots: {
+    showExternalLinks: {type: 'boolean'},
+    showChronology: {type: 'boolean'},
+
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    relations.tooltip.slots({
+      attributes:
+        {class: 'contribution-tooltip'},
+
+      contentAttributes: {
+        [html.joinChildren]:
+          html.tag('span', {class: 'tooltip-divider'}),
+      },
+
+      content: [
+        slots.showExternalLinks &&
+          relations.externalLinkSection,
+
+        slots.showChronology &&
+          relations.chronologySection.slots({
+            kind: slots.chronologyKind,
+          }),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateContributionTooltipChronologySection.js b/src/content/dependencies/generateContributionTooltipChronologySection.js
new file mode 100644
index 00000000..378c0e1c
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltipChronologySection.js
@@ -0,0 +1,129 @@
+import Thing from '#thing';
+
+function getName(thing) {
+  if (!thing) {
+    return null;
+  }
+
+  const referenceType = thing.constructor[Thing.referenceType];
+
+  if (referenceType === 'artwork') {
+    return thing.thing.name;
+  }
+
+  return thing.name;
+}
+
+export default {
+  contentDependencies: ['linkAnythingMan'],
+  extraDependencies: ['html', 'language'],
+
+  query(contribution) {
+    let previous = contribution;
+    while (previous && previous.thing === contribution.thing) {
+      previous = previous.previousBySameArtist;
+    }
+
+    let next = contribution;
+    while (next && next.thing === contribution.thing) {
+      next = next.nextBySameArtist;
+    }
+
+    return {previous, next};
+  },
+
+  relations: (relation, query, _contribution) => ({
+    previousLink:
+      (query.previous
+        ? relation('linkAnythingMan', query.previous.thing)
+        : null),
+
+    nextLink:
+      (query.next
+        ? relation('linkAnythingMan', query.next.thing)
+        : null),
+  }),
+
+  data: (query, _contribution) => ({
+    previousName:
+      getName(query.previous?.thing),
+
+    nextName:
+      getName(query.next?.thing),
+  }),
+
+  slots: {
+    kind: {
+      validate: v =>
+        v.is(
+          'album',
+          'bannerArt',
+          'coverArt',
+          'flash',
+          'track',
+          'trackArt',
+          'trackContribution',
+          'wallpaperArt'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('misc.artistLink.chronology', capsule =>
+      html.tags([
+        html.tags([
+          relations.previousLink?.slots({
+            attributes: {class: 'chronology-link'},
+            content: [
+              html.tag('span', {class: 'chronology-symbol'},
+                language.$(capsule, 'previous.symbol')),
+
+              html.tag('span', {class: 'chronology-text'},
+                language.sanitize(data.previousName)),
+            ],
+          }),
+
+          html.tag('span', {class: 'chronology-info'},
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(capsule, 'previous.info', workingCapsule => {
+              const workingOptions = {};
+
+              if (slots.kind) {
+                workingCapsule += '.withKind';
+                workingOptions.kind =
+                  language.$(capsule, 'kind', slots.kind);
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            })),
+        ]),
+
+        html.tags([
+          relations.nextLink?.slots({
+            attributes: {class: 'chronology-link'},
+            content: [
+              html.tag('span', {class: 'chronology-symbol'},
+                language.$(capsule, 'next.symbol')),
+
+              html.tag('span', {class: 'chronology-text'},
+                language.sanitize(data.nextName)),
+            ],
+          }),
+
+          html.tag('span', {class: 'chronology-info'},
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(capsule, 'next.info', workingCapsule => {
+              const workingOptions = {};
+
+              if (slots.kind) {
+                workingCapsule += '.withKind';
+                workingOptions.kind =
+                  language.$(capsule, 'kind', slots.kind);
+              }
+
+              return language.$(workingCapsule, workingOptions);
+            }))
+        ]),
+      ])),
+};
diff --git a/src/content/dependencies/generateContributionTooltipExternalLinkSection.js b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js
new file mode 100644
index 00000000..4f9a23ed
--- /dev/null
+++ b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js
@@ -0,0 +1,70 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateExternalHandle',
+    'generateExternalIcon',
+    'generateExternalPlatform',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, contribution) => ({
+    icons:
+      contribution.artist.urls
+        .map(url => relation('generateExternalIcon', url)),
+
+    handles:
+      contribution.artist.urls
+        .map(url => relation('generateExternalHandle', url)),
+
+    platforms:
+      contribution.artist.urls
+        .map(url => relation('generateExternalPlatform', url)),
+  }),
+
+  data: (contribution) => ({
+    urls: contribution.artist.urls,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('misc.artistLink', capsule =>
+      html.tags(
+        stitchArrays({
+          icon: relations.icons,
+          handle: relations.handles,
+          platform: relations.platforms,
+          url: data.urls,
+        }).map(({icon, handle, platform, url}) => {
+            for (const template of [icon, handle, platform]) {
+              template.setSlot('context', 'artist');
+            }
+
+            return [
+              html.tag('a', {class: 'external-link'},
+                {href: url},
+
+                [
+                  icon,
+
+                  html.tag('span', {class: 'external-handle'},
+                    (html.isBlank(handle)
+                      ? platform
+                      : handle)),
+                ]),
+
+              html.tag('span', {class: 'external-platform'},
+                // This is a pretty ridiculous hack, but we currently
+                // don't have a way of telling formatExternalLink to *not*
+                // use the fallback string, which just formats the URL as
+                // its host/domain... so is technically detectable.
+                (((new URL(url))
+                    .host
+                    .endsWith(
+                      html.resolve(platform, {normalize: 'string'})))
+
+                  ? language.$(capsule, 'noExternalLinkPlatformName')
+                  : platform)),
+            ];
+          }))),
+};
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
new file mode 100644
index 00000000..3a10ab20
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -0,0 +1,121 @@
+export default {
+  contentDependencies: [
+    'generateCoverArtworkArtTagDetails',
+    'generateCoverArtworkArtistDetails',
+    'generateCoverArtworkOriginDetails',
+    'generateCoverArtworkReferenceDetails',
+    'image',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, artwork) => ({
+    image:
+      relation('image', artwork),
+
+    originDetails:
+      relation('generateCoverArtworkOriginDetails', artwork),
+
+    artTagDetails:
+      relation('generateCoverArtworkArtTagDetails', artwork),
+
+    artistDetails:
+      relation('generateCoverArtworkArtistDetails', artwork),
+
+    referenceDetails:
+      relation('generateCoverArtworkReferenceDetails', artwork),
+  }),
+
+  data: (artwork) => ({
+    color:
+      artwork.thing.color ?? null,
+
+    dimensions:
+      artwork.dimensions,
+  }),
+
+  slots: {
+    alt: {type: 'string'},
+
+    color: {
+      validate: v => v.isColor,
+    },
+
+    mode: {
+      validate: v => v.is('primary', 'thumbnail', 'commentary'),
+      default: 'primary',
+    },
+
+    showOriginDetails: {type: 'boolean', default: false},
+    showArtTagDetails: {type: 'boolean', default: false},
+    showArtistDetails: {type: 'boolean', default: false},
+    showReferenceDetails: {type: 'boolean', default: false},
+
+    details: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate(data, relations, slots, {html}) {
+    const {image} = relations;
+
+    image.setSlots({
+      color: slots.color ?? data.color,
+      alt: slots.alt,
+    });
+
+    const square =
+      (data.dimensions
+        ? data.dimensions[0] === data.dimensions[1]
+        : true);
+
+    if (square) {
+      image.setSlot('square', true);
+    } else {
+      image.setSlot('dimensions', data.dimensions);
+    }
+
+    return (
+      html.tag('div', {class: 'cover-artwork'},
+        slots.mode === 'commentary' &&
+          {class: 'commentary-art'},
+
+        (slots.mode === 'primary'
+          ? [
+              relations.image.slots({
+                thumb: 'medium',
+                reveal: true,
+                link: true,
+              }),
+
+              slots.showOriginDetails &&
+                relations.originDetails,
+
+              slots.showArtTagDetails &&
+                relations.artTagDetails,
+
+              slots.showArtistDetails &&
+                relations.artistDetails,
+
+              slots.showReferenceDetails &&
+                relations.referenceDetails,
+
+              slots.details,
+            ]
+       : slots.mode === 'thumbnail'
+          ? relations.image.slots({
+              thumb: 'small',
+              reveal: false,
+              link: false,
+            })
+       : slots.mode === 'commentary'
+          ? relations.image.slots({
+              thumb: 'medium',
+              reveal: true,
+              link: true,
+              lazy: true,
+            })
+          : html.blank())));
+  },
+};
diff --git a/src/content/dependencies/generateCoverArtworkArtTagDetails.js b/src/content/dependencies/generateCoverArtworkArtTagDetails.js
new file mode 100644
index 00000000..b20f599b
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkArtTagDetails.js
@@ -0,0 +1,50 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkArtTagGallery'],
+  extraDependencies: ['html'],
+
+  query: (artwork) => ({
+    linkableArtTags:
+      artwork.artTags
+        .filter(tag => !tag.isContentWarning),
+  }),
+
+  relations: (relation, query, _artwork) => ({
+    artTagLinks:
+      query.linkableArtTags
+        .map(tag => relation('linkArtTagGallery', tag)),
+  }),
+
+  data: (query, _artwork) => {
+    const seenShortNames = new Set();
+    const duplicateShortNames = new Set();
+
+    for (const {nameShort: shortName} of query.linkableArtTags) {
+      if (seenShortNames.has(shortName)) {
+        duplicateShortNames.add(shortName);
+      } else {
+        seenShortNames.add(shortName);
+      }
+    }
+
+    const preferShortName =
+      query.linkableArtTags
+        .map(artTag => !duplicateShortNames.has(artTag.nameShort));
+
+    return {preferShortName};
+  },
+
+  generate: (data, relations, {html}) =>
+    html.tag('ul', {class: 'image-details'},
+      {[html.onlyIfContent]: true},
+
+      {class: 'art-tag-details'},
+
+      stitchArrays({
+        artTagLink: relations.artTagLinks,
+        preferShortName: data.preferShortName,
+      }).map(({artTagLink, preferShortName}) =>
+          html.tag('li',
+            artTagLink.slot('preferShortName', preferShortName)))),
+};
diff --git a/src/content/dependencies/generateCoverArtworkArtistDetails.js b/src/content/dependencies/generateCoverArtworkArtistDetails.js
new file mode 100644
index 00000000..3ead80ab
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkArtistDetails.js
@@ -0,0 +1,25 @@
+export default {
+  contentDependencies: ['linkArtistGallery'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, artwork) => ({
+    artistLinks:
+      artwork.artistContribs
+        .map(contrib => contrib.artist)
+        .map(artist =>
+          relation('linkArtistGallery', artist)),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tag('p', {class: 'image-details'},
+      {[html.onlyIfContent]: true},
+
+      {class: 'illustrator-details'},
+
+      language.$('misc.coverGrid.details.coverArtists', {
+        [language.onlyIfOptions]: ['artists'],
+
+        artists:
+          language.formatConjunctionList(relations.artistLinks),
+      })),
+};
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
new file mode 100644
index 00000000..08a01cfe
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -0,0 +1,98 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'generateArtistCredit',
+    'generateAbsoluteDatetimestamp',
+    'linkAlbum',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'pagePath'],
+
+  query: (artwork) => ({
+    artworkThingType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    credit:
+      relation('generateArtistCredit', artwork.artistContribs, []),
+
+    source:
+      relation('transformContent', artwork.source),
+
+    albumLink:
+      (query.artworkThingType === 'album'
+        ? relation('linkAlbum', artwork.thing)
+        : null),
+
+    datetimestamp:
+      (artwork.date && artwork.date !== artwork.thing.date
+        ? relation('generateAbsoluteDatetimestamp', artwork.date)
+        : null),
+  }),
+
+
+  data: (query, artwork) => ({
+    label:
+      artwork.label,
+
+    artworkThingType:
+      query.artworkThingType,
+  }),
+
+  generate: (data, relations, {html, language, pagePath}) =>
+    language.encapsulate('misc.coverArtwork', capsule =>
+      html.tag('p', {class: 'image-details'},
+        {[html.onlyIfContent]: true},
+        {[html.joinChildren]: html.tag('br')},
+
+        {class: 'origin-details'},
+
+        [
+          language.encapsulate(capsule, 'artworkBy', workingCapsule => {
+            const workingOptions = {};
+
+            if (data.label) {
+              workingCapsule += '.customLabel';
+              workingOptions.label = data.label;
+            }
+
+            if (relations.datetimestamp) {
+              workingCapsule += '.withYear';
+              workingOptions.year =
+                relations.datetimestamp.slots({
+                  style: 'year',
+                  tooltip: true,
+                });
+            }
+
+            return relations.credit.slots({
+              showAnnotation: true,
+              showExternalLinks: true,
+              showChronology: true,
+              showWikiEdits: true,
+
+              trimAnnotation: false,
+
+              chronologyKind: 'coverArt',
+
+              normalStringKey: workingCapsule,
+              additionalStringOptions: workingOptions,
+            });
+          }),
+
+          pagePath[0] === 'track' &&
+          data.artworkThingType === 'album' &&
+            language.$(capsule, 'trackArtFromAlbum', {
+              album:
+                relations.albumLink.slot('color', false),
+            }),
+
+          language.$(capsule, 'source', {
+            [language.onlyIfOptions]: ['source'],
+            source: relations.source.slot('mode', 'inline'),
+          }),
+        ])),
+};
diff --git a/src/content/dependencies/generateCoverArtworkReferenceDetails.js b/src/content/dependencies/generateCoverArtworkReferenceDetails.js
new file mode 100644
index 00000000..035ab586
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkReferenceDetails.js
@@ -0,0 +1,60 @@
+export default {
+  contentDependencies: ['linkReferencedArtworks', 'linkReferencingArtworks'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, artwork) => ({
+    referencedArtworksLink:
+      relation('linkReferencedArtworks', artwork),
+
+    referencingArtworksLink:
+      relation('linkReferencingArtworks', artwork),
+  }),
+
+  data: (artwork) => ({
+    referenced:
+      artwork.referencedArtworks.length,
+
+    referencedBy:
+      artwork.referencedByArtworks.length,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule => {
+      const referencedText =
+        language.$(capsule, 'referencesArtworks', {
+          [language.onlyIfOptions]: ['artworks'],
+
+          artworks:
+            language.countArtworks(data.referenced, {
+              blankIfZero: true,
+              unit: true,
+            }),
+        });
+
+      const referencingText =
+        language.$(capsule, 'referencedByArtworks', {
+          [language.onlyIfOptions]: ['artworks'],
+
+          artworks:
+            language.countArtworks(data.referencedBy, {
+              blankIfZero: true,
+              unit: true,
+            }),
+        });
+
+      return (
+        html.tag('p', {class: 'image-details'},
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          {class: 'reference-details'},
+
+          [
+            !html.isBlank(referencedText) &&
+              relations.referencedArtworksLink.slot('content', referencedText),
+
+            !html.isBlank(referencingText) &&
+              relations.referencingArtworksLink.slot('content', referencingText),
+          ]));
+    }),
+}
diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js
new file mode 100644
index 00000000..430f651e
--- /dev/null
+++ b/src/content/dependencies/generateCoverCarousel.js
@@ -0,0 +1,55 @@
+import {empty, repeat, stitchArrays} from '#sugar';
+import {getCarouselLayoutForNumberOfItems} from '#wiki-data';
+
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    images: {validate: v => v.strictArrayOf(v.isHTML)},
+    links: {validate: v => v.strictArrayOf(v.isHTML)},
+
+    lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)},
+  },
+
+  generate(slots, {html}) {
+    const stitched =
+      stitchArrays({
+        image: slots.images,
+        link: slots.links,
+      });
+
+    if (empty(stitched)) {
+      return;
+    }
+
+    const layout = getCarouselLayoutForNumberOfItems(stitched.length);
+
+    return html.tags([
+      html.tag('div', {class: 'carousel-container'},
+        {'data-carousel-rows': layout.rows},
+        {'data-carousel-columns': layout.columns},
+
+        repeat(3, [
+          html.tag('div', {class: 'carousel-grid'},
+            {'aria-hidden': 'true'},
+
+            stitched.map(({image, link}, index) =>
+              html.tag('div', {class: 'carousel-item'},
+                link.slots({
+                  attributes: {tabindex: '-1'},
+                  content:
+                    image.slots({
+                      thumb: 'small',
+                      square: true,
+                      lazy:
+                        (typeof slots.lazy === 'number'
+                          ? index >= slots.lazy
+                       : typeof slots.lazy === 'boolean'
+                          ? slots.lazy
+                          : false),
+                    }),
+                })))),
+        ])),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
new file mode 100644
index 00000000..29ac08b7
--- /dev/null
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -0,0 +1,90 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateGridActionLinks'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation) {
+    return {
+      actionLinks: relation('generateGridActionLinks'),
+    };
+  },
+
+  slots: {
+    images: {validate: v => v.strictArrayOf(v.isHTML)},
+    links: {validate: v => v.strictArrayOf(v.isHTML)},
+    names: {validate: v => v.strictArrayOf(v.isHTML)},
+    info: {validate: v => v.strictArrayOf(v.isHTML)},
+
+    // Differentiating from sparseArrayOf here - this list of classes should
+    // have the same length as the items above, i.e. nulls aren't going to be
+    // filtered out of it, but it is okay to *include* null (standing in for
+    // no classes for this grid item).
+    classes: {
+      validate: v =>
+        v.strictArrayOf(
+          v.optional(
+            v.anyOf(
+              v.isArray,
+              v.isString))),
+    },
+
+    lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)},
+    actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    html.tag('div', {class: 'grid-listing'},
+      {[html.onlyIfContent]: true},
+
+      [
+        stitchArrays({
+          classes: slots.classes,
+          image: slots.images,
+          link: slots.links,
+          name: slots.names,
+          info: slots.info,
+        }).map(({classes, image, link, name, info}, index) =>
+            link.slots({
+              attributes: [
+                {class: ['grid-item', 'box']},
+
+                (classes
+                  ? {class: classes}
+                  : null),
+              ],
+
+              colorContext: 'image-box',
+
+              content: [
+                image.slots({
+                  thumb: 'medium',
+                  square: true,
+                  lazy:
+                    (typeof slots.lazy === 'number'
+                      ? index >= slots.lazy
+                   : typeof slots.lazy === 'boolean'
+                      ? slots.lazy
+                      : false),
+                }),
+
+                html.tag('span',
+                  {[html.onlyIfContent]: true},
+
+                  language.sanitize(name)),
+
+                html.tag('span',
+                  {[html.onlyIfContent]: true},
+
+                  language.$('misc.coverGrid.details.accent', {
+                    [language.onlyIfOptions]: ['details'],
+
+                    details: info,
+                  })),
+              ],
+            })),
+
+        relations.actionLinks
+          .slot('actionLinks', slots.actionLinks),
+      ]),
+};
diff --git a/src/content/dependencies/generateDatetimestampTemplate.js b/src/content/dependencies/generateDatetimestampTemplate.js
new file mode 100644
index 00000000..a92d15fc
--- /dev/null
+++ b/src/content/dependencies/generateDatetimestampTemplate.js
@@ -0,0 +1,40 @@
+export default {
+  contentDependencies: ['generateTextWithTooltip'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+  }),
+
+  slots: {
+    mainContent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    tooltip: {
+      type: 'html',
+      mutable: true,
+    },
+
+    datetime: {type: 'string'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    relations.textWithTooltip.slots({
+      attributes: {class: 'datetimestamp'},
+
+      text:
+        html.tag('time',
+          {datetime: slots.datetime},
+          slots.mainContent),
+
+      tooltip:
+        (html.isBlank(slots.tooltip)
+          ? null
+          : slots.tooltip.slots({
+              attributes: [{class: 'datetimestamp-tooltip'}],
+            })),
+    }),
+};
diff --git a/src/content/dependencies/generateDotSwitcherTemplate.js b/src/content/dependencies/generateDotSwitcherTemplate.js
new file mode 100644
index 00000000..22205922
--- /dev/null
+++ b/src/content/dependencies/generateDotSwitcherTemplate.js
@@ -0,0 +1,41 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    options: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    initialOptionIndex: {type: 'number'},
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('span', {class: 'dot-switcher'},
+      {[html.onlyIfContent]: true},
+      {[html.noEdgeWhitespace]: true},
+      {[html.joinChildren]: ''},
+
+      slots.attributes,
+
+      slots.options
+        .map((option, index) =>
+          html.tag('span',
+            {[html.onlyIfContent]: true},
+
+            html.resolve(option, {normalize: 'tag'})
+              .onlyIfSiblings &&
+                {[html.onlyIfSiblings]: true},
+
+            index === slots.initialOptionIndex &&
+              {class: 'current'},
+
+            [
+              html.metatag('imaginary-sibling'),
+              option,
+            ]))),
+};
diff --git a/src/content/dependencies/generateExternalHandle.js b/src/content/dependencies/generateExternalHandle.js
new file mode 100644
index 00000000..8c0368a4
--- /dev/null
+++ b/src/content/dependencies/generateExternalHandle.js
@@ -0,0 +1,20 @@
+import {isExternalLinkContext} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, slots, {language}) =>
+    language.formatExternalLink(data.url, {
+      style: 'handle',
+      context: slots.context,
+    }),
+};
diff --git a/src/content/dependencies/generateExternalIcon.js b/src/content/dependencies/generateExternalIcon.js
new file mode 100644
index 00000000..637af658
--- /dev/null
+++ b/src/content/dependencies/generateExternalIcon.js
@@ -0,0 +1,26 @@
+import {isExternalLinkContext} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language', 'to'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, slots, {html, language, to}) =>
+    html.tag('span', {class: 'external-icon'},
+      html.tag('svg',
+        html.tag('use', {
+          href:
+            to('staticMisc.icon',
+              language.formatExternalLink(data.url, {
+                style: 'icon-id',
+                context: slots.context,
+              })),
+        }))),
+};
diff --git a/src/content/dependencies/generateExternalPlatform.js b/src/content/dependencies/generateExternalPlatform.js
new file mode 100644
index 00000000..c4f63ecf
--- /dev/null
+++ b/src/content/dependencies/generateExternalPlatform.js
@@ -0,0 +1,20 @@
+import {isExternalLinkContext} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, slots, {language}) =>
+    language.formatExternalLink(data.url, {
+      style: 'platform',
+      context: slots.context,
+    }),
+};
diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js
new file mode 100644
index 00000000..84ab549d
--- /dev/null
+++ b/src/content/dependencies/generateFlashActGalleryPage.js
@@ -0,0 +1,85 @@
+import striptags from 'striptags';
+
+export default {
+  contentDependencies: [
+    'generateCoverGrid',
+    'generateFlashActNavAccent',
+    'generateFlashActSidebar',
+    'generatePageLayout',
+    'image',
+    'linkFlash',
+    'linkFlashAct',
+    'linkFlashIndex',
+  ],
+
+  extraDependencies: ['language'],
+
+  relations: (relation, act) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    flashIndexLink:
+      relation('linkFlashIndex'),
+
+    flashActNavLink:
+      relation('linkFlashAct', act),
+
+    flashActNavAccent:
+      relation('generateFlashActNavAccent', act),
+
+    sidebar:
+      relation('generateFlashActSidebar', act, null),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    coverGridImages:
+      act.flashes
+        .map(flash => relation('image', flash.coverArtwork)),
+
+    flashLinks:
+      act.flashes
+        .map(flash => relation('linkFlash', flash)),
+  }),
+
+  data: (act) => ({
+    name: act.name,
+    color: act.color,
+
+    flashNames:
+      act.flashes.map(flash => flash.name),
+  }),
+
+  generate: (data, relations, {language}) =>
+    language.encapsulate('flashPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            flash: striptags(data.name),
+          }),
+
+        color: data.color,
+        headingMode: 'static',
+
+        mainClasses: ['flash-index'],
+        mainContent: [
+          relations.coverGrid.slots({
+            links: relations.flashLinks,
+            images: relations.coverGridImages,
+            names: data.flashNames,
+            lazy: 6,
+          }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.flashIndexLink},
+          {html: relations.flashActNavLink},
+        ],
+
+        navBottomRowContent: relations.flashActNavAccent,
+
+        leftSidebar: relations.sidebar,
+      })),
+};
diff --git a/src/content/dependencies/generateFlashActNavAccent.js b/src/content/dependencies/generateFlashActNavAccent.js
new file mode 100644
index 00000000..c4ec77b8
--- /dev/null
+++ b/src/content/dependencies/generateFlashActNavAccent.js
@@ -0,0 +1,64 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
+    'linkFlashAct',
+  ],
+
+  extraDependencies: ['wikiData'],
+
+  sprawl: ({flashActData}) =>
+    ({flashActData}),
+
+  query(sprawl, flashAct) {
+    // Like with generateFlashNavAccent, don't sort chronologically here.
+    const flashActs =
+      sprawl.flashActData;
+
+    const index =
+      flashActs.indexOf(flashAct);
+
+    const previousFlashAct =
+      atOffset(flashActs, index, -1);
+
+    const nextFlashAct =
+      atOffset(flashActs, index, +1);
+
+    return {previousFlashAct, nextFlashAct};
+  },
+
+  relations: (relation, query) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+
+    previousFlashActLink:
+      (query.previousFlashAct
+        ? relation('linkFlashAct', query.previousFlashAct)
+        : null),
+
+    nextFlashActLink:
+      (query.nextFlashAct
+        ? relation('linkFlashAct', query.nextFlashAct)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.switcher.slots({
+      links: [
+        relations.previousLink
+          .slot('link', relations.previousFlashActLink),
+
+        relations.nextLink
+          .slot('link', relations.nextFlashActLink),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js
new file mode 100644
index 00000000..1421dde9
--- /dev/null
+++ b/src/content/dependencies/generateFlashActSidebar.js
@@ -0,0 +1,30 @@
+export default {
+  contentDependencies: [
+    'generateFlashActSidebarCurrentActBox',
+    'generateFlashActSidebarSideMapBox',
+    'generatePageSidebar',
+  ],
+
+  relations: (relation, act, flash) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    currentActBox:
+      relation('generateFlashActSidebarCurrentActBox', act, flash),
+
+    sideMapBox:
+      relation('generateFlashActSidebarSideMapBox', act, flash),
+  }),
+
+  data: (_act, flash) => ({
+    isFlashActPage: !flash,
+  }),
+
+  generate: (data, relations) =>
+    relations.sidebar.slots({
+      boxes:
+        (data.isFlashActPage
+          ? [relations.sideMapBox, relations.currentActBox]
+          : [relations.currentActBox, relations.sideMapBox]),
+    }),
+};
diff --git a/src/content/dependencies/generateFlashActSidebarCurrentActBox.js b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js
new file mode 100644
index 00000000..6d152c7c
--- /dev/null
+++ b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js
@@ -0,0 +1,64 @@
+export default {
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkFlash',
+    'linkFlashAct',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, act, _flash) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    actLink:
+      relation('linkFlashAct', act),
+
+    flashLinks:
+      act.flashes
+        .map(flash => relation('linkFlash', flash)),
+  }),
+
+  data: (act, flash) => ({
+    isFlashActPage:
+      !flash,
+
+    currentFlashIndex:
+      act.flashes.indexOf(flash),
+
+    customListTerminology:
+      act.listTerminology,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.box.slots({
+      attributes: {class: 'flash-act-map-sidebar-box'},
+
+      content: [
+        html.tag('h1', relations.actLink),
+
+        html.tag('details',
+          (data.isFlashActPage
+            ? {}
+            : {class: 'current', open: true}),
+
+          [
+            html.tag('summary',
+              html.tag('span',
+                html.tag('b',
+                  (data.customListTerminology
+                    ? language.sanitize(data.customListTerminology)
+                    : language.$('flashSidebar.flashList.entriesInThisSection'))))),
+
+            html.tag('ul',
+              relations.flashLinks
+                .map((flashLink, index) =>
+                  html.tag('li',
+                    index === data.currentFlashIndex &&
+                      {class: 'current'},
+
+                    flashLink))),
+          ]),
+        ],
+    }),
+};
diff --git a/src/content/dependencies/generateFlashActSidebarSideMapBox.js b/src/content/dependencies/generateFlashActSidebarSideMapBox.js
new file mode 100644
index 00000000..7b26ef31
--- /dev/null
+++ b/src/content/dependencies/generateFlashActSidebarSideMapBox.js
@@ -0,0 +1,85 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generatePageSidebarBox',
+    'linkFlashAct',
+    'linkFlashIndex',
+  ],
+
+  extraDependencies: ['html', 'wikiData'],
+
+  sprawl: ({flashSideData}) => ({flashSideData}),
+
+  relations: (relation, sprawl, _act, _flash) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    flashIndexLink:
+      relation('linkFlashIndex'),
+
+    sideColorStyles:
+      sprawl.flashSideData
+        .map(side => relation('generateColorStyleAttribute', side.color)),
+
+    sideActLinks:
+      sprawl.flashSideData
+        .map(side => side.acts
+          .map(act => relation('linkFlashAct', act))),
+  }),
+
+  data: (sprawl, act, flash) => ({
+    isFlashActPage:
+      !flash,
+
+    sideNames:
+      sprawl.flashSideData
+        .map(side => side.name),
+
+    currentSideIndex:
+      sprawl.flashSideData.indexOf(act.side),
+
+    currentActIndex:
+      act.side.acts.indexOf(act),
+  }),
+
+  generate: (data, relations, {html}) =>
+    relations.box.slots({
+      attributes: {class: 'flash-act-map-sidebar-box'},
+
+      content: [
+        html.tag('h1', relations.flashIndexLink),
+
+        stitchArrays({
+          sideName: data.sideNames,
+          sideColorStyle: relations.sideColorStyles,
+          actLinks: relations.sideActLinks,
+        }).map(({sideName, sideColorStyle, actLinks}, sideIndex) =>
+            html.tag('details',
+              sideIndex === data.currentSideIndex &&
+                {class: 'current'},
+
+              data.isFlashActPage &&
+              sideIndex === data.currentSideIndex &&
+                {open: true},
+
+              sideColorStyle.slot('context', 'primary-only'),
+
+              [
+                html.tag('summary',
+                  html.tag('span',
+                    html.tag('b', sideName))),
+
+                html.tag('ul',
+                  actLinks.map((actLink, actIndex) =>
+                    html.tag('li',
+                      sideIndex === data.currentSideIndex &&
+                      actIndex === data.currentActIndex &&
+                        {class: 'current'},
+
+                      actLink))),
+              ])),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateFlashArtworkColumn.js b/src/content/dependencies/generateFlashArtworkColumn.js
new file mode 100644
index 00000000..5987df9e
--- /dev/null
+++ b/src/content/dependencies/generateFlashArtworkColumn.js
@@ -0,0 +1,11 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations: (relation, flash) => ({
+    coverArtwork:
+      relation('generateCoverArtwork', flash.coverArtwork),
+  }),
+
+  generate: (relations) =>
+    relations.coverArtwork,
+};
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
new file mode 100644
index 00000000..2788406c
--- /dev/null
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -0,0 +1,144 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkFlash',
+    'linkFlashAct',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({flashActData}) => ({flashActData}),
+
+  query(sprawl) {
+    const flashActs =
+      sprawl.flashActData.slice();
+
+    const jumpActs =
+      flashActs
+        .filter(act => act.side.acts.indexOf(act) === 0);
+
+    return {flashActs, jumpActs};
+  },
+
+  relations: (relation, query) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    jumpLinkColorStyles:
+      query.jumpActs
+        .map(act => relation('generateColorStyleAttribute', act.side.color)),
+
+    actColorStyles:
+      query.flashActs
+        .map(act => relation('generateColorStyleAttribute', act.color)),
+
+    actLinks:
+      query.flashActs
+        .map(act => relation('linkFlashAct', act)),
+
+    actCoverGrids:
+      query.flashActs
+        .map(() => relation('generateCoverGrid')),
+
+    actCoverGridLinks:
+      query.flashActs
+        .map(act => act.flashes
+          .map(flash => relation('linkFlash', flash))),
+
+    actCoverGridImages:
+      query.flashActs
+        .map(act => act.flashes
+          .map(flash => relation('image', flash.coverArtwork))),
+  }),
+
+  data: (query) => ({
+    jumpLinkAnchors:
+      query.jumpActs
+        .map(act => act.directory),
+
+    jumpLinkLabels:
+      query.jumpActs
+        .map(act => act.side.name),
+
+    actAnchors:
+      query.flashActs
+        .map(act => act.directory),
+
+    actCoverGridNames:
+      query.flashActs
+        .map(act => act.flashes
+          .map(flash => flash.name)),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('flashIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+        headingMode: 'static',
+
+        mainClasses: ['flash-index'],
+        mainContent: [
+          html.tags([
+            html.tag('p', {class: 'quick-info'},
+              {[html.onlyIfSiblings]: true},
+              language.$('misc.jumpTo')),
+
+            html.tag('ul', {class: 'quick-info'},
+              {[html.onlyIfContent]: true},
+              stitchArrays({
+                colorStyle: relations.jumpLinkColorStyles,
+                anchor: data.jumpLinkAnchors,
+                label: data.jumpLinkLabels,
+              }).map(({colorStyle, anchor, label}) =>
+                  html.tag('li',
+                    html.tag('a',
+                      {href: '#' + anchor},
+                      colorStyle,
+                      label)))),
+          ]),
+
+          stitchArrays({
+            colorStyle: relations.actColorStyles,
+            actLink: relations.actLinks,
+            anchor: data.actAnchors,
+
+            coverGrid: relations.actCoverGrids,
+            coverGridImages: relations.actCoverGridImages,
+            coverGridLinks: relations.actCoverGridLinks,
+            coverGridNames: data.actCoverGridNames,
+          }).map(({
+              colorStyle,
+              actLink,
+              anchor,
+
+              coverGrid,
+              coverGridImages,
+              coverGridLinks,
+              coverGridNames,
+            }, index) => [
+              html.tag('h2',
+                {id: anchor},
+                colorStyle,
+                actLink),
+
+              coverGrid.slots({
+                links: coverGridLinks,
+                images: coverGridImages,
+                names: coverGridNames,
+                lazy: index === 0 ? 4 : true,
+              }),
+            ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
new file mode 100644
index 00000000..095e43c4
--- /dev/null
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -0,0 +1,202 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateCommentaryEntry',
+    'generateContentHeading',
+    'generateContributionList',
+    'generateFlashActSidebar',
+    'generateFlashArtworkColumn',
+    'generateFlashNavAccent',
+    'generatePageLayout',
+    'generateTrackList',
+    'linkExternal',
+    'linkFlashAct',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(flash) {
+    const query = {};
+
+    query.urls = [];
+
+    if (flash.page) {
+      query.urls.push(`https://homestuck.com/story/${flash.page}`);
+    }
+
+    if (!empty(flash.urls)) {
+      query.urls.push(...flash.urls);
+    }
+
+    return query;
+  },
+
+  relations: (relation, query, flash) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    sidebar:
+      relation('generateFlashActSidebar', flash.act, flash),
+
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', flash.additionalNames),
+
+    externalLinks:
+      query.urls
+        .map(url => relation('linkExternal', url)),
+
+    artworkColumn:
+      relation('generateFlashArtworkColumn', flash),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    flashActLink:
+      relation('linkFlashAct', flash.act),
+
+    flashNavAccent:
+      relation('generateFlashNavAccent', flash),
+
+    featuredTracksList:
+      relation('generateTrackList', flash.featuredTracks),
+
+    contributorContributionList:
+      relation('generateContributionList', flash.contributorContribs),
+
+    artistCommentaryEntries:
+      flash.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
+
+    creditSourceEntries:
+      flash.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
+  }),
+
+  data: (_query, flash) => ({
+    name:
+      flash.name,
+
+    color:
+      flash.color,
+
+    date:
+      flash.date,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('flashPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            flash: data.name,
+          }),
+
+        color: data.color,
+        headingMode: 'sticky',
+
+        additionalNames: relations.additionalNamesBox,
+
+        artworkColumnContent: relations.artworkColumn,
+
+        mainContent: [
+          html.tag('p',
+            language.$('releaseInfo.released', {
+              date: language.formatDate(data.date),
+            })),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$('releaseInfo.playOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'flash'))),
+            })),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate('releaseInfo', capsule => [
+              !html.isBlank(relations.artistCommentaryEntries) &&
+                language.encapsulate(capsule, 'readCommentary', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#artist-commentary'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.creditSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#credit-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'features'},
+                title:
+                  language.$('releaseInfo.tracksFeatured', {
+                    flash: html.tag('i', data.name),
+                  }),
+              }),
+
+            relations.featuredTracksList,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'contributors'},
+                title: language.$('releaseInfo.contributors'),
+              }),
+
+            relations.contributorContributionList.slots({
+              chronologyKind: 'flash',
+            }),
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'artist-commentary'},
+                title: language.$('misc.artistCommentary'),
+              }),
+
+            relations.artistCommentaryEntries,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'credit-sources'},
+                title: language.$('misc.creditSources'),
+              }),
+
+            relations.creditSourceEntries,
+          ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.flashActLink.slot('color', false)},
+          {auto: 'current'},
+        ],
+
+        navBottomRowContent: relations.flashNavAccent,
+
+        leftSidebar: relations.sidebar,
+      })),
+};
diff --git a/src/content/dependencies/generateFlashNavAccent.js b/src/content/dependencies/generateFlashNavAccent.js
new file mode 100644
index 00000000..0f5d2d6b
--- /dev/null
+++ b/src/content/dependencies/generateFlashNavAccent.js
@@ -0,0 +1,66 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
+    'linkFlash',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({flashActData}) =>
+    ({flashActData}),
+
+  query(sprawl, flash) {
+    // Don't sort chronologically here. The previous/next buttons should match
+    // the order in the sidebar, by act rather than date.
+    const flashes =
+      sprawl.flashActData
+        .flatMap(act => act.flashes);
+
+    const index =
+      flashes.indexOf(flash);
+
+    const previousFlash =
+      atOffset(flashes, index, -1);
+
+    const nextFlash =
+      atOffset(flashes, index, +1);
+
+    return {previousFlash, nextFlash};
+  },
+
+  relations: (relation, query) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+
+    previousFlashLink:
+      (query.previousFlash
+        ? relation('linkFlash', query.previousFlash)
+        : null),
+
+    nextFlashLink:
+      (query.nextFlash
+        ? relation('linkFlash', query.nextFlash)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.switcher.slots({
+      links: [
+        relations.previousLink
+          .slot('link', relations.previousFlashLink),
+
+        relations.nextLink
+          .slot('link', relations.nextFlashLink),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js
new file mode 100644
index 00000000..dfd83aef
--- /dev/null
+++ b/src/content/dependencies/generateFooterLocalizationLinks.js
@@ -0,0 +1,59 @@
+import {sortByName} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  extraDependencies: [
+    'defaultLanguage',
+    'html',
+    'language',
+    'languages',
+    'pagePath',
+    'to',
+  ],
+
+  generate({
+    defaultLanguage,
+    html,
+    language,
+    languages,
+    pagePath,
+    to,
+  }) {
+    const switchableLanguages =
+      Object.entries(languages)
+        .filter(([code, language]) => code !== 'default' && !language.hidden)
+        .map(([code, language]) => language);
+
+    if (switchableLanguages.length <= 1) {
+      return html.blank();
+    }
+
+    sortByName(switchableLanguages);
+
+    const [pagePathSubkey, ...pagePathArgs] = pagePath;
+
+    const linkPaths =
+      switchableLanguages.map(language =>
+        (language === defaultLanguage
+          ? (['localizedDefaultLanguage.' + pagePathSubkey,
+              ...pagePathArgs])
+          : (['localizedWithBaseDirectory.' + pagePathSubkey,
+              language.code,
+              ...pagePathArgs])));
+
+    const links =
+      stitchArrays({
+        language: switchableLanguages,
+        linkPath: linkPaths,
+      }).map(({language, linkPath}) =>
+          html.tag('span',
+            html.tag('a',
+              {href: to(...linkPath)},
+              language.name)));
+
+    return html.tag('div', {class: 'footer-localization-links'},
+      language.$('misc.uiLanguage', {
+        languages: language.formatListWithoutSeparator(links),
+      }));
+  },
+};
diff --git a/src/content/dependencies/generateGridActionLinks.js b/src/content/dependencies/generateGridActionLinks.js
new file mode 100644
index 00000000..585a02b9
--- /dev/null
+++ b/src/content/dependencies/generateGridActionLinks.js
@@ -0,0 +1,16 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('div', {class: 'grid-actions'},
+      {[html.onlyIfContent]: true},
+
+      (slots.actionLinks ?? [])
+        .filter(link => link && !html.isBlank(link))
+        .map(link => link
+          .slot('attributes', {class: ['grid-item', 'box']}))),
+};
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
new file mode 100644
index 00000000..d51366ca
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -0,0 +1,182 @@
+import {sortChronologically} from '#sort';
+import {empty, stitchArrays} from '#sugar';
+import {filterItemsForCarousel, getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateCoverCarousel',
+    'generateCoverGrid',
+    'generateGroupNavLinks',
+    'generateGroupSecondaryNav',
+    'generateGroupSidebar',
+    'generatePageLayout',
+    'generateQuickDescription',
+    'image',
+    'linkAlbum',
+    'linkListing',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) =>
+    ({enableGroupUI: wikiInfo.enableGroupUI}),
+
+  relations(relation, sprawl, group) {
+    const relations = {};
+
+    const albums =
+      sortChronologically(group.albums.slice(), {latestFirst: true});
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.navLinks =
+      relation('generateGroupNavLinks', group);
+
+    if (sprawl.enableGroupUI) {
+      relations.secondaryNav =
+        relation('generateGroupSecondaryNav', group);
+
+      relations.sidebar =
+        relation('generateGroupSidebar', group);
+    }
+
+    const carouselAlbums = filterItemsForCarousel(group.featuredAlbums);
+
+    if (!empty(carouselAlbums)) {
+      relations.coverCarousel =
+        relation('generateCoverCarousel');
+
+      relations.carouselLinks =
+        carouselAlbums
+          .map(album => relation('linkAlbum', album));
+
+      relations.carouselImages =
+        carouselAlbums
+          .map(album => relation('image', album.coverArtworks[0]));
+    }
+
+    relations.quickDescription =
+      relation('generateQuickDescription', group);
+
+    relations.coverGrid =
+      relation('generateCoverGrid');
+
+    relations.gridLinks =
+      albums
+        .map(album => relation('linkAlbum', album));
+
+    relations.gridImages =
+      albums.map(album =>
+        (album.hasCoverArt
+          ? relation('image', album.coverArtworks[0])
+          : relation('image')));
+
+    return relations;
+  },
+
+  data(sprawl, group) {
+    const data = {};
+
+    data.name = group.name;
+    data.color = group.color;
+
+    const albums = sortChronologically(group.albums.slice(), {latestFirst: true});
+    const tracks = albums.flatMap((album) => album.tracks);
+
+    data.numAlbums = albums.length;
+    data.numTracks = tracks.length;
+    data.totalDuration = getTotalDuration(tracks, {mainReleasesOnly: true});
+
+    data.gridNames = albums.map(album => album.name);
+    data.gridDurations = albums.map(album => getTotalDuration(album.tracks));
+    data.gridNumTracks = albums.map(album => album.tracks.length);
+
+    return data;
+  },
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupGalleryPage', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title', {group: data.name}),
+        headingMode: 'static',
+
+        color: data.color,
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          relations.coverCarousel
+            ?.slots({
+              links: relations.carouselLinks,
+              images: relations.carouselImages,
+            }),
+
+          relations.quickDescription,
+
+          html.tag('p', {class: 'quick-info'},
+            language.$(pageCapsule, 'infoLine', {
+              tracks:
+                html.tag('b',
+                  language.countTracks(data.numTracks, {
+                    unit: true,
+                  })),
+
+              albums:
+                html.tag('b',
+                  language.countAlbums(data.numAlbums, {
+                    unit: true,
+                  })),
+
+              time:
+                html.tag('b',
+                  language.formatDuration(data.totalDuration, {
+                    unit: true,
+                  })),
+            })),
+
+          relations.coverGrid
+            .slots({
+              links: relations.gridLinks,
+              names: data.gridNames,
+
+              images:
+                stitchArrays({
+                  image: relations.gridImages,
+                  name: data.gridNames,
+                }).map(({image, name}) =>
+                    image.slots({
+                      missingSourceContent:
+                        language.$('misc.coverGrid.noCoverArt', {
+                          album: name,
+                        }),
+                    })),
+
+              info:
+                stitchArrays({
+                  numTracks: data.gridNumTracks,
+                  duration: data.gridDurations,
+                }).map(({numTracks, duration}) =>
+                    language.$('misc.coverGrid.details.albumLength', {
+                      tracks: language.countTracks(numTracks, {unit: true}),
+                      time: language.formatDuration(duration),
+                    })),
+            }),
+        ],
+
+        leftSidebar:
+          (relations.sidebar
+            ? relations.sidebar
+                .slot('currentExtra', 'gallery')
+                .content /* TODO: Kludge. */
+            : null),
+
+        navLinkStyle: 'hierarchical',
+        navLinks:
+          relations.navLinks
+            .slot('currentExtra', 'gallery')
+            .content,
+
+        secondaryNav:
+          relations.secondaryNav ?? null,
+      })),
+};
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
new file mode 100644
index 00000000..7b9c2afa
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -0,0 +1,179 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateGroupInfoPageAlbumsSection',
+    'generateGroupNavLinks',
+    'generateGroupSecondaryNav',
+    'generateGroupSidebar',
+    'generatePageLayout',
+    'linkArtist',
+    'linkExternal',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    enableGroupUI:
+      wikiInfo.enableGroupUI,
+
+    wikiColor:
+      wikiInfo.color,
+  }),
+
+  query: (_sprawl, group) => ({
+    aliasLinkedArtists:
+      group.closelyLinkedArtists
+        .filter(({annotation}) =>
+          annotation === 'alias'),
+
+    generalLinkedArtists:
+      group.closelyLinkedArtists
+        .filter(({annotation}) =>
+          annotation !== 'alias'),
+  }),
+
+  relations: (relation, query, sprawl, group) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    navLinks:
+      relation('generateGroupNavLinks', group),
+
+    secondaryNav:
+      (sprawl.enableGroupUI
+        ? relation('generateGroupSecondaryNav', group)
+        : null),
+
+    sidebar:
+      (sprawl.enableGroupUI
+        ? relation('generateGroupSidebar', group)
+        : null),
+
+    wikiColorAttribute:
+      relation('generateColorStyleAttribute', sprawl.wikiColor),
+
+    closeArtistLinks:
+      query.generalLinkedArtists
+        .map(({artist}) => relation('linkArtist', artist)),
+
+    aliasArtistLinks:
+      query.aliasLinkedArtists
+        .map(({artist}) => relation('linkArtist', artist)),
+
+    visitLinks:
+      group.urls
+        .map(url => relation('linkExternal', url)),
+
+    description:
+      relation('transformContent', group.description),
+
+    albumSection:
+      relation('generateGroupInfoPageAlbumsSection', group),
+  }),
+
+  data: (query, _sprawl, group) => ({
+    name:
+      group.name,
+
+    color:
+      group.color,
+
+    closeArtistAnnotations:
+      query.generalLinkedArtists
+        .map(({annotation}) => annotation),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupInfoPage', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title', {group: data.name}),
+        headingMode: 'sticky',
+        color: data.color,
+
+        mainContent: [
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate(pageCapsule, 'closelyLinkedArtists', capsule => [
+              language.encapsulate(capsule, capsule => {
+                const [workingCapsule, option] =
+                  (relations.closeArtistLinks.length === 0
+                    ? [null, null]
+                 : relations.closeArtistLinks.length === 1
+                    ? [language.encapsulate(capsule, 'one'), 'artist']
+                    : [language.encapsulate(capsule, 'multiple'), 'artists']);
+
+                if (!workingCapsule) return html.blank();
+
+                return language.$(workingCapsule, {
+                  [option]:
+                    language.formatUnitList(
+                      stitchArrays({
+                        link: relations.closeArtistLinks,
+                        annotation: data.closeArtistAnnotations,
+                      }).map(({link, annotation}) =>
+                          language.encapsulate(capsule, 'artist', workingCapsule => {
+                            const workingOptions = {};
+
+                            workingOptions.artist =
+                              link.slots({
+                                attributes: [relations.wikiColorAttribute],
+                              });
+
+                            if (annotation) {
+                              workingCapsule += '.withAnnotation';
+                              workingOptions.annotation = annotation;
+                            }
+
+                            return language.$(workingCapsule, workingOptions);
+                          }))),
+                });
+              }),
+
+              language.$(capsule, 'aliases', {
+                [language.onlyIfOptions]: ['aliases'],
+
+                aliases:
+                  language.formatConjunctionList(
+                    relations.aliasArtistLinks.map(link =>
+                      link.slots({
+                        attributes: [relations.wikiColorAttribute],
+                      }))),
+              }),
+            ])),
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
+
+              links:
+                language.formatDisjunctionList(
+                  relations.visitLinks
+                    .map(link => link.slot('context', 'group'))),
+            })),
+
+          html.tag('blockquote',
+            {[html.onlyIfContent]: true},
+            relations.description.slot('mode', 'multiline')),
+
+          relations.albumSection,
+        ],
+
+        leftSidebar:
+          (relations.sidebar
+            ? relations.sidebar
+                .content /* TODO: Kludge. */
+            : null),
+
+        navLinkStyle: 'hierarchical',
+        navLinks: relations.navLinks.content,
+
+        secondaryNav: relations.secondaryNav ?? null,
+      })),
+};
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js b/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js
new file mode 100644
index 00000000..df42598d
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js
@@ -0,0 +1,47 @@
+import {sortChronologically} from '#sort';
+
+export default {
+  contentDependencies: ['generateGroupInfoPageAlbumsListItem'],
+
+  extraDependencies: ['html'],
+
+  query: (group) => ({
+    // Typically, a latestFirst: false (default) chronological sort would be
+    // appropriate here, but navigation between adjacent albums in a group is a
+    // rather "essential" movement or relationship in the wiki, and we consider
+    // the sorting order of a group's gallery page (latestFirst: true) to be
+    // "canonical" in this regard. We exactly match its sort here, but reverse
+    // it, to still present earlier albums preceding later ones.
+    albums:
+      sortChronologically(group.albums.slice(), {latestFirst: true})
+        .reverse(),
+  }),
+
+  relations: (relation, query, group) => ({
+    items:
+      query.albums
+        .map(album =>
+          relation('generateGroupInfoPageAlbumsListItem',
+            album,
+            group)),
+  }),
+
+  slots: {
+    hidden: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('ul',
+      {id: 'group-album-list-by-date'},
+
+      slots.hidden && {style: 'display: none'},
+
+      {[html.onlyIfContent]: true},
+
+      relations.items
+        .map(item =>
+          item.slot('accentMode', 'groups'))),
+};
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js b/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js
new file mode 100644
index 00000000..bcd5d288
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js
@@ -0,0 +1,87 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateGroupInfoPageAlbumsListItem',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (group) => ({
+    closelyLinkedArtists:
+      group.closelyLinkedArtists
+        .map(({artist}) => artist),
+  }),
+
+  relations: (relation, _query, group) => ({
+    seriesHeadings:
+      group.serieses
+        .map(() => relation('generateContentHeading')),
+
+    seriesItems:
+      group.serieses
+        .map(series => series.albums
+          .map(album =>
+            relation('generateGroupInfoPageAlbumsListItem',
+              album,
+              group))),
+  }),
+
+  data: (query, group) => ({
+    seriesNames:
+      group.serieses
+        .map(series => series.name),
+
+    seriesItemsShowArtists:
+      group.serieses.map(series =>
+        (series.showAlbumArtists === 'all'
+          ? new Array(series.albums.length).fill(true)
+       : series.showAlbumArtists === 'differing'
+          ? series.albums.map(album =>
+              album.artistContribs
+                .map(contrib => contrib.artist)
+                .some(artist => !query.closelyLinkedArtists.includes(artist)))
+          : new Array(series.albums.length).fill(false))),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('groupInfoPage.albumList', listCapsule =>
+      html.tag('dl',
+        {id: 'group-album-list-by-series'},
+        {class: 'group-series-list'},
+
+        {[html.onlyIfContent]: true},
+
+        stitchArrays({
+          name: data.seriesNames,
+          itemsShowArtists: data.seriesItemsShowArtists,
+          heading: relations.seriesHeadings,
+          items: relations.seriesItems,
+        }).map(({
+            name,
+            itemsShowArtists,
+            heading,
+            items,
+          }) =>
+            html.tags([
+              heading.slots({
+                tag: 'dt',
+                title:
+                  language.$(listCapsule, 'series', {
+                    series: name,
+                  }),
+              }),
+
+              html.tag('dd',
+                html.tag('ul',
+                  stitchArrays({
+                    item: items,
+                    showArtists: itemsShowArtists,
+                  }).map(({item, showArtists}) =>
+                      item.slots({
+                        accentMode:
+                          (showArtists ? 'artists' : null),
+                      })))),
+            ])))),
+};
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
new file mode 100644
index 00000000..99e7e8ff
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
@@ -0,0 +1,136 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateArtistCredit',
+    'generateColorStyleAttribute',
+    'linkAlbum',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (album, group) => {
+    const otherCategory =
+      album.groups
+        .map(group => group.category)
+        .find(category => category !== group.category);
+
+    const otherGroups =
+      album.groups
+        .filter(group => group.category === otherCategory);
+
+    return {otherGroups};
+  },
+
+  relations: (relation, query, album, _group) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute', album.color),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    datetimestamp:
+      (album.date
+        ? relation('generateAbsoluteDatetimestamp', album.date)
+        : null),
+
+    artistCredit:
+      relation('generateArtistCredit', album.artistContribs, []),
+
+    otherGroupLinks:
+      query.otherGroups
+        .map(group => relation('linkGroup', group)),
+  }),
+
+  data: (_query, album, group) => ({
+    groupName:
+      group.name,
+
+    notFromThisGroup:
+      !group.albums.includes(album),
+  }),
+
+  slots: {
+    accentMode: {
+      validate: v => v.is('groups', 'artists'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('li',
+      relations.colorStyle,
+
+      language.encapsulate('groupInfoPage.albumList.item', itemCapsule =>
+        language.encapsulate(itemCapsule, workingCapsule => {
+          const workingOptions = {};
+
+          workingOptions.album =
+            relations.albumLink.slot('color', false);
+
+          const yearCapsule = language.encapsulate(itemCapsule, 'withYear');
+
+          if (relations.datetimestamp) {
+            workingCapsule += '.withYear';
+            workingOptions.yearAccent =
+              language.$(yearCapsule, 'accent', {
+                year:
+                  relations.datetimestamp.slots({style: 'year', tooltip: true}),
+              });
+          }
+
+          const otherGroupCapsule = language.encapsulate(itemCapsule, 'withOtherGroup');
+
+          if (
+            (slots.accentMode === 'groups' ||
+             slots.accentMode === null) &&
+            data.notFromThisGroup
+          ) {
+            workingCapsule += '.withOtherGroup';
+            workingOptions.otherGroupAccent =
+              html.tag('span', {class: 'other-group-accent'},
+                language.$(otherGroupCapsule, 'notFromThisGroup', {
+                  group:
+                    data.groupName,
+                }));
+          } else if (
+            slots.accentMode === 'groups' &&
+            !empty(relations.otherGroupLinks)
+          ) {
+            workingCapsule += '.withOtherGroup';
+            workingOptions.otherGroupAccent =
+              html.tag('span', {class: 'other-group-accent'},
+                language.$(otherGroupCapsule, 'accent', {
+                  groups:
+                    language.formatConjunctionList(
+                      relations.otherGroupLinks.map(groupLink =>
+                        groupLink.slot('color', false))),
+                }));
+          }
+
+          const artistCapsule = language.encapsulate(itemCapsule, 'withArtists');
+          const {artistCredit} = relations;
+
+          artistCredit.setSlots({
+            normalStringKey:
+              artistCapsule + '.by',
+
+            featuringStringKey:
+              artistCapsule + '.featuring',
+
+            normalFeaturingStringKey:
+              artistCapsule + '.by.featuring',
+          });
+
+          if (slots.accentMode === 'artists' && !html.isBlank(artistCredit)) {
+            workingCapsule += '.withArtists';
+            workingOptions.by =
+              html.tag('span', {class: 'by'},
+                html.metatag('chunkwrap', {split: ','},
+                  html.resolve(artistCredit)));
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }))),
+};
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsSection.js b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js
new file mode 100644
index 00000000..0b678e9d
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js
@@ -0,0 +1,93 @@
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateGroupInfoPageAlbumsListByDate',
+    'generateGroupInfoPageAlbumsListBySeries',
+    'generateIntrapageDotSwitcher',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, group) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+
+    galleryLink:
+      relation('linkGroupGallery', group),
+
+    albumsListByDate:
+      relation('generateGroupInfoPageAlbumsListByDate', group),
+
+    albumsListBySeries:
+      relation('generateGroupInfoPageAlbumsListBySeries', group),
+
+    viewSwitcher:
+      relation('generateIntrapageDotSwitcher'),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('groupInfoPage', pageCapsule =>
+      language.encapsulate(pageCapsule, 'albumList', listCapsule =>
+        html.tags([
+          relations.contentHeading
+            .slots({
+              tag: 'h2',
+              title: language.$(listCapsule, 'title'),
+            }),
+
+          html.tag('p',
+            {[html.onlyIfSiblings]: true},
+
+            language.encapsulate(pageCapsule, 'viewAlbumGallery', viewAlbumGalleryCapsule =>
+              language.encapsulate(viewAlbumGalleryCapsule, workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.link =
+                  relations.galleryLink
+                    .slot('content',
+                      language.$(viewAlbumGalleryCapsule, 'link'));
+
+                if (
+                  !html.isBlank(relations.albumsListByDate) &&
+                  !html.isBlank(relations.albumsListBySeries)
+                ) {
+                  workingCapsule += '.withViewSwitcher';
+                  workingOptions.viewSwitcher =
+                    html.tag('span', {class: 'group-view-switcher'},
+                      language.encapsulate(pageCapsule, 'viewSwitcher', switcherCapsule =>
+                        language.$(switcherCapsule, {
+                          options:
+                            relations.viewSwitcher.slots({
+                              initialOptionIndex: 0,
+
+                              titles: [
+                                language.$(switcherCapsule, 'bySeries'),
+                                language.$(switcherCapsule, 'byDate'),
+                              ],
+
+                              targetIDs: [
+                                'group-album-list-by-series',
+                                'group-album-list-by-date',
+                              ],
+                            }),
+                        })));
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              }))),
+
+          ((!html.isBlank(relations.albumsListByDate) &&
+            !html.isBlank(relations.albumsListBySeries))
+
+            ? [
+                relations.albumsListBySeries,
+                relations.albumsListByDate.slot('hidden', true),
+              ]
+
+            : [
+                relations.albumsListBySeries,
+                relations.albumsListByDate,
+              ]),
+        ]))),
+};
diff --git a/src/content/dependencies/generateGroupNavAccent.js b/src/content/dependencies/generateGroupNavAccent.js
new file mode 100644
index 00000000..0e4ebe8a
--- /dev/null
+++ b/src/content/dependencies/generateGroupNavAccent.js
@@ -0,0 +1,53 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'linkGroup',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, group) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    infoLink:
+      relation('linkGroup', group),
+
+    galleryLink:
+      (empty(group.albums)
+        ? null
+        : relation('linkGroupGallery', group)),
+  }),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate: (relations, slots, {language}) =>
+    relations.switcher.slots({
+      links: [
+        relations.infoLink.slots({
+          attributes: [
+            slots.currentExtra === null &&
+              {class: 'current'},
+          ],
+
+          content: language.$('misc.nav.info'),
+        }),
+
+        relations.galleryLink?.slots({
+          attributes: [
+            slots.currentExtra === 'gallery' &&
+              {class: 'current'},
+          ],
+
+          content: language.$('misc.nav.gallery'),
+        }),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateGroupNavLinks.js b/src/content/dependencies/generateGroupNavLinks.js
new file mode 100644
index 00000000..bdc3ee4c
--- /dev/null
+++ b/src/content/dependencies/generateGroupNavLinks.js
@@ -0,0 +1,59 @@
+export default {
+  contentDependencies: ['generateGroupNavAccent', 'linkGroup'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({groupCategoryData, wikiInfo}) => ({
+    groupCategoryData,
+    enableGroupUI: wikiInfo.enableGroupUI,
+    enableListings: wikiInfo.enableListings,
+  }),
+
+  relations: (relation, _sprawl, group) => ({
+    mainLink:
+      relation('linkGroup', group),
+
+    accent:
+      relation('generateGroupNavAccent', group),
+  }),
+
+  data: (sprawl, _group) => ({
+    enableGroupUI: sprawl.enableGroupUI,
+    enableListings: sprawl.enableListings,
+  }),
+
+  slots: {
+    showExtraLinks: {type: 'boolean', default: false},
+
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate: (data, relations, slots, {language}) =>
+    (data.enableGroupUI
+      ? [
+          {auto: 'home'},
+
+          data.enableListings &&
+            {
+              path: ['localized.listingIndex'],
+              title: language.$('listingIndex.title'),
+            },
+
+          {
+            html:
+              language.$('groupPage.nav.group', {
+                group: relations.mainLink,
+              }),
+
+            accent:
+              relations.accent
+                .slot('currentExtra', slots.currentExtra),
+          },
+        ].filter(Boolean)
+
+      : [
+          {auto: 'home'},
+          {auto: 'current'},
+        ]),
+};
diff --git a/src/content/dependencies/generateGroupSecondaryNav.js b/src/content/dependencies/generateGroupSecondaryNav.js
new file mode 100644
index 00000000..c48f3142
--- /dev/null
+++ b/src/content/dependencies/generateGroupSecondaryNav.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: [
+    'generateSecondaryNav',
+    'generateGroupSecondaryNavCategoryPart',
+  ],
+
+  relations: (relation, group) => ({
+    secondaryNav:
+      relation('generateSecondaryNav'),
+
+    categoryPart:
+      relation('generateGroupSecondaryNavCategoryPart', group.category, group),
+  }),
+
+  generate: (relations) =>
+    relations.secondaryNav.slots({
+      attributes: {class: 'nav-links-groups'},
+      content: relations.categoryPart,
+    }),
+};
diff --git a/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js b/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js
new file mode 100644
index 00000000..b2adb9f8
--- /dev/null
+++ b/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js
@@ -0,0 +1,79 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateSecondaryNavParentSiblingsPart',
+    'linkGroupDynamically',
+    'linkListing',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({listingSpec, wikiInfo}) => ({
+    groupsByCategoryListing:
+      (wikiInfo.enableListings
+        ? listingSpec
+            .find(l => l.directory === 'groups/by-category')
+        : null),
+  }),
+
+  query(sprawl, category, group) {
+    const groups = category.groups;
+    const index = groups.indexOf(group);
+
+    return {
+      previousGroup:
+        atOffset(groups, index, -1),
+
+      nextGroup:
+        atOffset(groups, index, +1),
+    };
+  },
+
+  relations: (relation, query, sprawl, category, group) => ({
+    parentSiblingsPart:
+      relation('generateSecondaryNavParentSiblingsPart'),
+
+    categoryLink:
+      (sprawl.groupsByCategoryListing
+        ? relation('linkListing', sprawl.groupsByCategoryListing)
+        : null),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', group.category.color),
+
+    previousGroupLink:
+      (query.previousGroup
+        ? relation('linkGroupDynamically', query.previousGroup)
+        : null),
+
+    nextGroupLink:
+      (query.nextGroup
+        ? relation('linkGroupDynamically', query.nextGroup)
+        : null),
+  }),
+
+  data: (_query, _sprawl, category, _group) => ({
+    name: category.name,
+  }),
+
+  generate: (data, relations, {language}) =>
+    relations.parentSiblingsPart.slots({
+      colorStyle: relations.colorStyle,
+      id: true,
+
+      mainLink:
+        (relations.categoryLink
+          ? relations.categoryLink.slots({
+              content: language.sanitize(data.name),
+            })
+          : null),
+
+      previousLink: relations.previousGroupLink,
+      nextLink: relations.nextGroupLink,
+
+      stringsKey: 'groupPage.secondaryNav.category',
+      mainLinkOption: 'category',
+    }),
+};
diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js
new file mode 100644
index 00000000..0888cbbe
--- /dev/null
+++ b/src/content/dependencies/generateGroupSidebar.js
@@ -0,0 +1,46 @@
+export default {
+  contentDependencies: [
+    'generateGroupSidebarCategoryDetails',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({groupCategoryData}) => ({groupCategoryData}),
+
+  relations: (relation, sprawl, group) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    categoryDetails:
+      sprawl.groupCategoryData.map(category =>
+        relation('generateGroupSidebarCategoryDetails', category, group)),
+  }),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    relations.sidebar.slots({
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'category-map-sidebar-box'},
+          content: [
+            html.tag('h1',
+              language.$('groupSidebar.title')),
+
+            relations.categoryDetails
+              .map(details =>
+                details.slot('currentExtra', slots.currentExtra)),
+          ],
+        }),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateGroupSidebarCategoryDetails.js b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
new file mode 100644
index 00000000..208ccd07
--- /dev/null
+++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
@@ -0,0 +1,81 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'linkGroup',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, category) {
+    return {
+      colorStyle:
+        relation('generateColorStyleAttribute', category.color),
+
+      groupInfoLinks:
+        category.groups.map(group =>
+          relation('linkGroup', group)),
+
+      groupGalleryLinks:
+        category.groups.map(group =>
+          (empty(group.albums)
+            ? null
+            : relation('linkGroupGallery', group))),
+    };
+  },
+
+  data(category, group) {
+    const data = {};
+
+    data.name = category.name;
+
+    data.isCurrentCategory = category === group.category;
+
+    if (data.isCurrentCategory) {
+      data.currentGroupIndex = category.groups.indexOf(group);
+    }
+
+    return data;
+  },
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('groupSidebar.groupList', capsule =>
+      html.tag('details',
+        data.isCurrentCategory &&
+          {class: 'current', open: true},
+
+        [
+          html.tag('summary',
+            relations.colorStyle,
+
+            html.tag('span',
+              language.$(capsule, 'category', {
+                category:
+                  html.tag('b', data.name),
+              }))),
+
+          html.tag('ul',
+            stitchArrays(({
+              infoLink: relations.groupInfoLinks,
+              galleryLink: relations.groupGalleryLinks,
+            })).map(({infoLink, galleryLink}, index) =>
+                  html.tag('li',
+                    index === data.currentGroupIndex &&
+                      {class: 'current'},
+
+                    language.$(capsule, 'item', {
+                      group:
+                        (slots.currentExtra === 'gallery'
+                          ? galleryLink ?? infoLink
+                          : infoLink),
+                    })))),
+        ])),
+};
diff --git a/src/content/dependencies/generateImageOverlay.js b/src/content/dependencies/generateImageOverlay.js
new file mode 100644
index 00000000..cfb78a1b
--- /dev/null
+++ b/src/content/dependencies/generateImageOverlay.js
@@ -0,0 +1,50 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  generate: ({html, language}) =>
+    html.tag('div', {id: 'image-overlay-container'},
+      html.tag('div', {id: 'image-overlay-content-container'}, [
+        html.tag('span', {id: 'image-overlay-image-area'},
+          html.tag('span', {id: 'image-overlay-image-layout'}, [
+            html.tag('img', {id: 'image-overlay-image'}),
+            html.tag('img', {id: 'image-overlay-image-thumb'}),
+          ])),
+
+        html.tag('div', {id: 'image-overlay-action-container'},
+          language.encapsulate('releaseInfo.viewOriginalFile', capsule => [
+            html.tag('div', {id: 'image-overlay-action-content-without-size'},
+              language.$(capsule, {
+                link: html.tag('a', {class: 'image-overlay-view-original'},
+                  language.$(capsule, 'link')),
+              })),
+
+            html.tag('div', {id: 'image-overlay-action-content-with-size'}, [
+              language.$(capsule, 'withSize', {
+                link:
+                  html.tag('a', {class: 'image-overlay-view-original'},
+                    language.$(capsule, 'link')),
+
+                size:
+                  html.tag('span',
+                    {[html.joinChildren]: ''},
+                    [
+                      html.tag('span', {id: 'image-overlay-file-size-kilobytes'},
+                        language.$('count.fileSize.kilobytes', {
+                          kilobytes:
+                            html.tag('span', {class: 'image-overlay-file-size-count'}),
+                        })),
+
+                      html.tag('span', {id: 'image-overlay-file-size-megabytes'},
+                        language.$('count.fileSize.megabytes', {
+                          megabytes:
+                            html.tag('span', {class: 'image-overlay-file-size-count'}),
+                        })),
+                    ]),
+              }),
+
+              html.tag('span', {id: 'image-overlay-file-size-warning'},
+                language.$(capsule, 'sizeWarning')),
+            ]),
+          ])),
+      ])),
+};
diff --git a/src/content/dependencies/generateInterpageDotSwitcher.js b/src/content/dependencies/generateInterpageDotSwitcher.js
new file mode 100644
index 00000000..5a33444e
--- /dev/null
+++ b/src/content/dependencies/generateInterpageDotSwitcher.js
@@ -0,0 +1,31 @@
+export default {
+  contentDependencies: ['generateDotSwitcherTemplate'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    template:
+      relation('generateDotSwitcherTemplate'),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    links: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+  },
+
+  generate: (relations, slots) =>
+    relations.template.slots({
+      attributes: [
+        {class: 'interpage'},
+        slots.attributes,
+      ],
+
+      // TODO: Do something to set a class on a link to the current page??
+      options: slots.links,
+    }),
+};
diff --git a/src/content/dependencies/generateIntrapageDotSwitcher.js b/src/content/dependencies/generateIntrapageDotSwitcher.js
new file mode 100644
index 00000000..1d58367d
--- /dev/null
+++ b/src/content/dependencies/generateIntrapageDotSwitcher.js
@@ -0,0 +1,49 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateDotSwitcherTemplate'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    template:
+      relation('generateDotSwitcherTemplate'),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    initialOptionIndex: {type: 'number'},
+
+    titles: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    targetIDs: {
+      validate: v => v.strictArrayOf(v.isString),
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    relations.template.slots({
+      attributes: [
+        {class: 'intrapage'},
+        slots.attributes,
+      ],
+
+      initialOptionIndex: slots.initialOptionIndex,
+
+      options:
+        stitchArrays({
+          title: slots.titles,
+          targetID: slots.targetIDs,
+        }).map(({title, targetID}) =>
+            html.tag('a', {href: '#'},
+              {'data-target-id': targetID},
+              {[html.onlyIfContent]: true},
+
+              language.sanitize(title))),
+    }),
+};
diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
new file mode 100644
index 00000000..deb8c4ea
--- /dev/null
+++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
@@ -0,0 +1,90 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    additionalFileTitles: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    additionalFileLinks: {
+      validate: v => v.strictArrayOf(v.strictArrayOf(v.isHTML)),
+    },
+
+    additionalFileFiles: {
+      validate: v => v.strictArrayOf(v.strictArrayOf(v.isString)),
+    },
+
+    stringsKey: {type: 'string'},
+  },
+
+  generate(slots, {html, language}) {
+    if (empty(slots.additionalFileLinks)) {
+      return html.blank();
+    }
+
+    return html.tags([
+      html.tag('dt', slots.title),
+      html.tag('dd',
+        html.tag('ul',
+          stitchArrays({
+            additionalFileTitle: slots.additionalFileTitles,
+            additionalFileLinks: slots.additionalFileLinks,
+            additionalFileFiles: slots.additionalFileFiles,
+          }).map(({
+              additionalFileTitle,
+              additionalFileLinks,
+              additionalFileFiles,
+            }) =>
+              language.encapsulate('listingPage', slots.stringsKey, 'file', capsule =>
+                (additionalFileLinks.length === 1
+                  ? html.tag('li',
+                      additionalFileLinks[0].slots({
+                        content:
+                          language.$(capsule, {
+                            title: additionalFileTitle,
+                          }),
+                      }))
+
+               : additionalFileLinks.length === 0
+                  ? html.tag('li',
+                      language.$(capsule, 'withNoFiles', {
+                        title: additionalFileTitle,
+                      }))
+
+                  : html.tag('li', {class: 'has-details'},
+                      html.tag('details', [
+                        html.tag('summary',
+                          html.tag('span',
+                            language.$(capsule, 'withMultipleFiles', {
+                              title:
+                                html.tag('b', additionalFileTitle),
+
+                              files:
+                                language.countAdditionalFiles(
+                                  additionalFileLinks.length,
+                                  {unit: true}),
+                            }))),
+
+                        html.tag('ul',
+                          stitchArrays({
+                            additionalFileLink: additionalFileLinks,
+                            additionalFileFile: additionalFileFiles,
+                          }).map(({additionalFileLink, additionalFileFile}) =>
+                              html.tag('li',
+                                additionalFileLink.slots({
+                                  content:
+                                    language.$(capsule, {
+                                      title: additionalFileFile,
+                                    }),
+                                })))),
+                      ]))))))),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateListRandomPageLinksAlbumLink.js b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js
new file mode 100644
index 00000000..b3560aca
--- /dev/null
+++ b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js
@@ -0,0 +1,18 @@
+export default {
+  contentDependencies: ['linkAlbum'],
+
+  data: (album) =>
+    ({directory: album.directory}),
+
+  relations: (relation, album) =>
+    ({albumLink: relation('linkAlbum', album)}),
+
+  generate: (data, relations) =>
+    relations.albumLink.slots({
+      anchor: true,
+      attributes: {
+        'data-random': 'track-in-album',
+        'style': `--album-directory: ${data.directory}`,
+      },
+    }),
+};
diff --git a/src/content/dependencies/generateListingIndexList.js b/src/content/dependencies/generateListingIndexList.js
new file mode 100644
index 00000000..78622e6e
--- /dev/null
+++ b/src/content/dependencies/generateListingIndexList.js
@@ -0,0 +1,131 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkListing'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({listingTargetSpec, wikiInfo}) {
+    return {listingTargetSpec, wikiInfo};
+  },
+
+  query(sprawl) {
+    const query = {};
+
+    const targetListings =
+      sprawl.listingTargetSpec
+        .map(({listings}) =>
+          listings
+            .filter(listing =>
+              !listing.featureFlag ||
+              sprawl.wikiInfo[listing.featureFlag]));
+
+    query.targets =
+      sprawl.listingTargetSpec
+        .filter((target, index) => !empty(targetListings[index]));
+
+    query.targetListings =
+      targetListings
+        .filter(listings => !empty(listings))
+
+    return query;
+  },
+
+  relations(relation, query) {
+    return {
+      listingLinks:
+        query.targetListings
+          .map(listings =>
+            listings.map(listing => relation('linkListing', listing))),
+    };
+  },
+
+  data(query, sprawl, currentListing) {
+    const data = {};
+
+    data.targetStringsKeys =
+      query.targets
+        .map(({stringsKey}) => stringsKey);
+
+    data.listingStringsKeys =
+      query.targetListings
+        .map(listings =>
+          listings.map(({stringsKey}) => stringsKey));
+
+    if (currentListing) {
+      data.currentTargetIndex =
+        query.targets
+          .indexOf(currentListing.target);
+
+      data.currentListingIndex =
+        query.targetListings
+          .find(listings => listings.includes(currentListing))
+          .indexOf(currentListing);
+    }
+
+    return data;
+  },
+
+  slots: {
+    mode: {validate: v => v.is('content', 'sidebar')},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const listingLinkLists =
+      stitchArrays({
+        listingLinks: relations.listingLinks,
+        listingStringsKeys: data.listingStringsKeys,
+      }).map(({listingLinks, listingStringsKeys}, targetIndex) =>
+          html.tag('ul',
+            stitchArrays({
+              listingLink: listingLinks,
+              listingStringsKey: listingStringsKeys,
+            }).map(({listingLink, listingStringsKey}, listingIndex) =>
+                html.tag('li',
+                  targetIndex === data.currentTargetIndex &&
+                  listingIndex === data.currentListingIndex &&
+                    {class: 'current'},
+
+                  listingLink.slots({
+                    content:
+                      language.$('listingPage', listingStringsKey, 'title.short'),
+                  })))));
+
+    const targetTitles =
+      data.targetStringsKeys
+        .map(stringsKey => language.$('listingPage.target', stringsKey));
+
+    switch (slots.mode) {
+      case 'sidebar':
+        return html.tags(
+          stitchArrays({
+            targetTitle: targetTitles,
+            listingLinkList: listingLinkLists,
+          }).map(({targetTitle, listingLinkList}, targetIndex) =>
+              html.tag('details',
+                targetIndex === data.currentTargetIndex &&
+                  {class: 'current', open: true},
+
+                [
+                  html.tag('summary',
+                    html.tag('span',
+                      html.tag('b', targetTitle))),
+
+                  listingLinkList,
+                ])));
+
+      case 'content':
+        return (
+          html.tag('dl',
+            stitchArrays({
+              targetTitle: targetTitles,
+              listingLinkList: listingLinkLists,
+            }).map(({targetTitle, listingLinkList}) => [
+                html.tag('dt', {class: 'content-heading'},
+                  targetTitle),
+
+                html.tag('dd',
+                  listingLinkList),
+              ])));
+    }
+  },
+};
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
new file mode 100644
index 00000000..5f9a99a9
--- /dev/null
+++ b/src/content/dependencies/generateListingPage.js
@@ -0,0 +1,288 @@
+import {bindOpts, empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateListingSidebar',
+    'generatePageLayout',
+    'linkListing',
+    'linkListingIndex',
+    'linkTemplate',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  relations(relation, listing) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.sidebar =
+      relation('generateListingSidebar', listing);
+
+    relations.listingsIndexLink =
+      relation('linkListingIndex');
+
+    relations.chunkHeading =
+      relation('generateContentHeading');
+
+    relations.showSkipToSectionLinkTemplate =
+      relation('linkTemplate');
+
+    if (listing.target.listings.length > 1) {
+      relations.sameTargetListingLinks =
+        listing.target.listings
+          .map(listing => relation('linkListing', listing));
+    } else {
+      relations.sameTargetListingLinks = [];
+    }
+
+    relations.seeAlsoLinks =
+      (!empty(listing.seeAlso)
+        ? listing.seeAlso
+            .map(listing => relation('linkListing', listing))
+        : []);
+
+    return relations;
+  },
+
+  data(listing) {
+    return {
+      stringsKey: listing.stringsKey,
+
+      targetStringsKey: listing.target.stringsKey,
+
+      sameTargetListingStringsKeys:
+        listing.target.listings
+          .map(listing => listing.stringsKey),
+
+      sameTargetListingsCurrentIndex:
+        listing.target.listings
+          .indexOf(listing),
+    };
+  },
+
+  slots: {
+    type: {
+      validate: v => v.is('rows', 'chunks', 'custom'),
+    },
+
+    rows: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
+
+    rowAttributes: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject))
+    },
+
+    chunkTitles: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
+
+    chunkTitleAccents: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject)),
+    },
+
+    chunkRows: {
+      validate: v => v.strictArrayOf(v.isObject),
+    },
+
+    chunkRowAttributes: {
+      validate: v => v.strictArrayOf(v.optional(v.isObject)),
+    },
+
+    showSkipToSection: {
+      type: 'boolean',
+      default: false,
+    },
+
+    chunkIDs: {
+      validate: v => v.strictArrayOf(v.optional(v.isString)),
+    },
+
+    listStyle: {
+      validate: v => v.is('ordered', 'unordered'),
+      default: 'unordered',
+    },
+
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    function formatListingString({
+      context,
+      provided = {},
+    }) {
+      const parts = ['listingPage', data.stringsKey];
+
+      if (Array.isArray(context)) {
+        parts.push(...context);
+      } else {
+        parts.push(context);
+      }
+
+      if (provided.stringsKey) {
+        parts.push(provided.stringsKey);
+      }
+
+      const options = {...provided};
+      delete options.stringsKey;
+
+      return language.formatString(...parts, options);
+    }
+
+    const formatRow = ({context, row, attributes}) =>
+      (attributes?.href
+        ? html.tag('li',
+            html.tag('a',
+              attributes,
+              formatListingString({
+                context,
+                provided: row,
+              })))
+        : html.tag('li',
+            attributes,
+            formatListingString({
+              context,
+              provided: row,
+            })));
+
+    const formatRowList = ({context, rows, rowAttributes}) =>
+      html.tag(
+        (slots.listStyle === 'ordered' ? 'ol' : 'ul'),
+        stitchArrays({
+          row: rows,
+          attributes: rowAttributes ?? rows.map(() => null),
+        }).map(
+          bindOpts(formatRow, {
+            [bindOpts.bindIndex]: 0,
+            context,
+          })));
+
+    return relations.layout.slots({
+      title: formatListingString({context: 'title'}),
+
+      headingMode: 'sticky',
+
+      mainContent: [
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          language.$('listingPage.listingsFor', {
+            [language.onlyIfOptions]: ['listings'],
+
+            target:
+              language.$('listingPage.target', data.targetStringsKey),
+
+            listings:
+              language.formatUnitList(
+                stitchArrays({
+                  link: relations.sameTargetListingLinks,
+                  stringsKey: data.sameTargetListingStringsKeys,
+                }).map(({link, stringsKey}, index) =>
+                    html.tag('span',
+                      index === data.sameTargetListingsCurrentIndex &&
+                        {class: 'current'},
+
+                      link.slots({
+                        attributes: {class: 'nowrap'},
+                        content: language.$('listingPage', stringsKey, 'title.short'),
+                      })))),
+          })),
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          language.$('listingPage.seeAlso', {
+            [language.onlyIfOptions]: ['listings'],
+            listings:
+              language.formatUnitList(relations.seeAlsoLinks),
+          })),
+
+        slots.content,
+
+        slots.type === 'rows' &&
+          formatRowList({
+            context: 'item',
+            rows: slots.rows,
+            rowAttributes: slots.rowAttributes,
+          }),
+
+        slots.type === 'chunks' &&
+          html.tag('dl', [
+            slots.showSkipToSection && [
+              html.tag('dt',
+                language.$('listingPage.skipToSection')),
+
+              html.tag('dd',
+                html.tag('ul',
+                  stitchArrays({
+                    title: slots.chunkTitles,
+                    id: slots.chunkIDs,
+                  }).filter(({id}) => id)
+                    .map(({title, id}) =>
+                      html.tag('li',
+                        relations.showSkipToSectionLinkTemplate
+                          .clone()
+                          .slots({
+                            hash: id,
+                            content:
+                              html.normalize(
+                                formatListingString({
+                                  context: 'chunk.title',
+                                  provided: title,
+                                }).toString()
+                                  .replace(/:$/, '')),
+                          }))))),
+            ],
+
+            stitchArrays({
+              title: slots.chunkTitles,
+              titleAccent: slots.chunkTitleAccents,
+              id: slots.chunkIDs,
+              rows: slots.chunkRows,
+              rowAttributes: slots.chunkRowAttributes,
+            }).map(({title, titleAccent, id, rows, rowAttributes}) => [
+                relations.chunkHeading
+                  .clone()
+                  .slots({
+                    tag: 'dt',
+                    attributes: [id && {id}],
+
+                    title:
+                      formatListingString({
+                        context: 'chunk.title',
+                        provided: title,
+                      }),
+
+                    accent:
+                      titleAccent &&
+                        formatListingString({
+                          context: ['chunk.title', title.stringsKey, 'accent'],
+                          provided: titleAccent,
+                        }),
+                  }),
+
+                html.tag('dd',
+                  formatRowList({
+                    context: 'chunk.item',
+                    rows,
+                    rowAttributes,
+                  })),
+              ]),
+          ]),
+      ],
+
+      navLinkStyle: 'hierarchical',
+      navLinks: [
+        {auto: 'home'},
+        {html: relations.listingsIndexLink},
+        {auto: 'current'},
+      ],
+
+      leftSidebar: relations.sidebar,
+    });
+  },
+};
diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js
new file mode 100644
index 00000000..aeac05cf
--- /dev/null
+++ b/src/content/dependencies/generateListingSidebar.js
@@ -0,0 +1,37 @@
+export default {
+  contentDependencies: [
+    'generateListingIndexList',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'linkListingIndex',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, currentListing) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    listingIndexLink:
+      relation('linkListingIndex'),
+
+    listingIndexList:
+      relation('generateListingIndexList', currentListing),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.sidebar.slots({
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'listing-map-sidebar-box'},
+          content: [
+            html.tag('h1', relations.listingIndexLink),
+            relations.listingIndexList.slot('mode', 'sidebar'),
+          ],
+        }),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateListingsIndexPage.js b/src/content/dependencies/generateListingsIndexPage.js
new file mode 100644
index 00000000..b57ebe15
--- /dev/null
+++ b/src/content/dependencies/generateListingsIndexPage.js
@@ -0,0 +1,89 @@
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateListingIndexList',
+    'generateListingSidebar',
+    'generatePageLayout',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({albumData, trackData, wikiInfo}) {
+    return {
+      wikiName: wikiInfo.name,
+      numTracks: trackData.length,
+      numAlbums: albumData.length,
+      totalDuration: getTotalDuration(trackData),
+    };
+  },
+
+  relations(relation) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.sidebar =
+      relation('generateListingSidebar', null);
+
+    relations.list =
+      relation('generateListingIndexList', null);
+
+    return relations;
+  },
+
+  data(sprawl) {
+    return {
+      wikiName: sprawl.wikiName,
+      numTracks: sprawl.numTracks,
+      numAlbums: sprawl.numAlbums,
+      totalDuration: sprawl.totalDuration,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.layout.slots({
+      title: language.$('listingIndex.title'),
+
+      headingMode: 'static',
+
+      mainContent: [
+        html.tag('p',
+          language.$('listingIndex.infoLine', {
+            wiki: data.wikiName,
+
+            tracks:
+              html.tag('b',
+                language.countTracks(data.numTracks, {unit: true})),
+
+            albums:
+              html.tag('b',
+                language.countAlbums(data.numAlbums, {unit: true})),
+
+            duration:
+              html.tag('b',
+                language.formatDuration(data.totalDuration, {
+                  approximate: true,
+                  unit: true,
+                })),
+          })),
+
+        html.tag('hr'),
+
+        html.tag('p',
+          language.$('listingIndex.exploreList')),
+
+        relations.list.slot('mode', 'content'),
+      ],
+
+      navLinkStyle: 'hierarchical',
+      navLinks: [
+        {auto: 'home'},
+        {auto: 'current'},
+      ],
+
+      leftSidebar: relations.sidebar,
+    });
+  },
+};
diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js
new file mode 100644
index 00000000..4f9c22f1
--- /dev/null
+++ b/src/content/dependencies/generateLyricsEntry.js
@@ -0,0 +1,25 @@
+export default {
+  contentDependencies: [
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    content:
+      relation('transformContent', entry.body),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('div', {class: 'lyrics-entry'},
+      slots.attributes,
+
+      relations.content.slot('mode', 'lyrics')),
+};
diff --git a/src/content/dependencies/generateLyricsSection.js b/src/content/dependencies/generateLyricsSection.js
new file mode 100644
index 00000000..f6b719a9
--- /dev/null
+++ b/src/content/dependencies/generateLyricsSection.js
@@ -0,0 +1,81 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateIntrapageDotSwitcher',
+    'generateLyricsEntry',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entries) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    switcher:
+      relation('generateIntrapageDotSwitcher'),
+
+    entries:
+      entries
+        .map(entry => relation('generateLyricsEntry', entry)),
+
+    annotations:
+      entries
+        .map(entry => entry.annotation)
+        .map(annotation => relation('transformContent', annotation)),
+  }),
+
+  data: (entries) => ({
+    ids:
+      Array.from(
+        {length: entries.length},
+        (_, index) => 'lyrics-entry-' + index),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo.lyrics', capsule =>
+      html.tags([
+        relations.heading
+          .slots({
+            attributes: {id: 'lyrics'},
+            title: language.$(capsule),
+          }),
+
+        html.tag('p', {class: 'lyrics-switcher'},
+          {[html.onlyIfContent]: true},
+
+          language.$(capsule, 'switcher', {
+            [language.onlyIfOptions]: ['entries'],
+
+            entries:
+              relations.switcher.slots({
+                initialOptionIndex: 0,
+
+                titles:
+                  relations.annotations.map(annotation =>
+                    annotation.slots({
+                      mode: 'inline',
+                      textOnly: true,
+                    })),
+
+                targetIDs:
+                  data.ids,
+              }),
+          })),
+
+        stitchArrays({
+          entry: relations.entries,
+          id: data.ids,
+        }).map(({entry, id}, index) =>
+            entry.slots({
+              attributes: [
+                {id},
+
+                index >= 1 &&
+                  {style: 'display: none'},
+              ],
+            })),
+      ])),
+};
diff --git a/src/content/dependencies/generateNewsEntryNavAccent.js b/src/content/dependencies/generateNewsEntryNavAccent.js
new file mode 100644
index 00000000..5d168e41
--- /dev/null
+++ b/src/content/dependencies/generateNewsEntryNavAccent.js
@@ -0,0 +1,40 @@
+export default {
+  contentDependencies: [
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
+    'linkNewsEntry',
+  ],
+
+  relations: (relation, previousEntry, nextEntry) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+
+    previousEntryLink:
+      (previousEntry
+        ? relation('linkNewsEntry', previousEntry)
+        : null),
+
+    nextEntryLink:
+      (nextEntry
+        ? relation('linkNewsEntry', nextEntry)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.switcher.slots({
+      links: [
+        relations.previousLink
+          .slot('link', relations.previousEntryLink),
+
+        relations.nextLink
+          .slot('link', relations.nextEntryLink),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateNewsEntryPage.js b/src/content/dependencies/generateNewsEntryPage.js
new file mode 100644
index 00000000..4abd87d1
--- /dev/null
+++ b/src/content/dependencies/generateNewsEntryPage.js
@@ -0,0 +1,105 @@
+import {sortChronologically} from '#sort';
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateNewsEntryNavAccent',
+    'generateNewsEntryReadAnotherLinks',
+    'generatePageLayout',
+    'linkNewsIndex',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({newsData}) {
+    return {newsData};
+  },
+
+  query({newsData}, newsEntry) {
+    const entries = sortChronologically(newsData.slice());
+
+    const index = entries.indexOf(newsEntry);
+
+    const previousEntry =
+      atOffset(entries, index, -1);
+
+    const nextEntry =
+      atOffset(entries, index, +1);
+
+    return {previousEntry, nextEntry};
+  },
+
+  relations: (relation, query, sprawl, newsEntry) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    content:
+      relation('transformContent', newsEntry.content),
+
+    newsIndexLink:
+      relation('linkNewsIndex'),
+
+    readAnotherLinks:
+      relation('generateNewsEntryReadAnotherLinks',
+        newsEntry,
+        query.previousEntry,
+        query.nextEntry),
+
+    navAccent:
+      relation('generateNewsEntryNavAccent',
+        query.previousEntry,
+        query.nextEntry),
+  }),
+
+  data: (query, sprawl, newsEntry) => ({
+    name: newsEntry.name,
+    date: newsEntry.date,
+
+    daysSincePreviousEntry:
+      query.previousEntry &&
+        Math.round((newsEntry.date - query.previousEntry.date) / 86400000),
+
+    daysUntilNextEntry:
+      query.nextEntry &&
+        Math.round((query.nextEntry.date - newsEntry.date) / 86400000),
+
+    previousEntryDate:
+      query.previousEntry?.date,
+
+    nextEntryDate:
+      query.nextEntry?.date,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('newsEntryPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            entry: data.name,
+          }),
+
+        headingMode: 'sticky',
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          html.tag('p',
+            language.$(pageCapsule, 'published', {
+              date: language.formatDate(data.date),
+            })),
+
+          relations.content,
+          relations.readAnotherLinks,
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.newsIndexLink},
+          {
+            auto: 'current',
+            accent: relations.navAccent,
+          },
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateNewsEntryReadAnotherLinks.js b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js
new file mode 100644
index 00000000..d978b0e4
--- /dev/null
+++ b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js
@@ -0,0 +1,97 @@
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateRelativeDatetimestamp',
+    'linkNewsEntry',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, currentEntry, previousEntry, nextEntry) {
+    const relations = {};
+
+    if (previousEntry) {
+      relations.previousEntryLink =
+        relation('linkNewsEntry', previousEntry);
+
+      if (previousEntry.date) {
+        relations.previousEntryDatetimestamp =
+          (currentEntry.date
+            ? relation('generateRelativeDatetimestamp',
+                previousEntry.date,
+                currentEntry.date)
+            : relation('generateAbsoluteDatetimestamp',
+                previousEntry.date));
+      }
+    }
+
+    if (nextEntry) {
+      relations.nextEntryLink =
+        relation('linkNewsEntry', nextEntry);
+
+      if (nextEntry.date) {
+        relations.nextEntryDatetimestamp =
+          (currentEntry.date
+            ? relation('generateRelativeDatetimestamp',
+                nextEntry.date,
+                currentEntry.date)
+            : relation('generateAbsoluteDatetimestamp',
+                nextEntry.date));
+      }
+    }
+
+    return relations;
+  },
+
+  generate(relations, {html, language}) {
+    const prefix = `newsEntryPage.readAnother`;
+
+    const entryLines = [];
+
+    if (relations.previousEntryLink) {
+      const parts = [prefix, `previous`];
+      const options = {};
+
+      options.entry = relations.previousEntryLink;
+
+      if (relations.previousEntryDatetimestamp) {
+        parts.push('withDate');
+        options.date =
+          relations.previousEntryDatetimestamp.slots({
+            style: 'full',
+            tooltip: true,
+          });
+      }
+
+      entryLines.push(language.$(...parts, options));
+    }
+
+    if (relations.nextEntryLink) {
+      const parts = [prefix, `next`];
+      const options = {};
+
+      options.entry = relations.nextEntryLink;
+
+      if (relations.nextEntryDatetimestamp) {
+        parts.push('withDate');
+        options.date =
+          relations.nextEntryDatetimestamp.slots({
+            style: 'full',
+            tooltip: true,
+          });
+      }
+
+      entryLines.push(language.$(...parts, options));
+    }
+
+    return (
+      html.tag('p', {class: 'read-another-links'},
+        {[html.onlyIfContent]: true},
+        {[html.joinChildren]: html.tag('br')},
+
+        entryLines.length > 1 &&
+          {class: 'offset-tooltips'},
+
+        entryLines));
+  },
+};
diff --git a/src/content/dependencies/generateNewsIndexPage.js b/src/content/dependencies/generateNewsIndexPage.js
new file mode 100644
index 00000000..02964ce8
--- /dev/null
+++ b/src/content/dependencies/generateNewsIndexPage.js
@@ -0,0 +1,94 @@
+import {sortChronologically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageLayout',
+    'linkNewsEntry',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({newsData}) {
+    return {newsData};
+  },
+
+  query({newsData}) {
+    return {
+      entries:
+        sortChronologically(
+          newsData.slice(),
+          {latestFirst: true}),
+    };
+  },
+
+  relations(relation, query) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.entryLinks =
+      query.entries
+        .map(entry => relation('linkNewsEntry', entry));
+
+    relations.viewRestLinks =
+      query.entries
+        .map(entry =>
+          (entry.content === entry.contentShort
+            ? null
+            : relation('linkNewsEntry', entry)));
+
+    relations.entryContents =
+      query.entries
+        .map(entry => relation('transformContent', entry.contentShort));
+
+    return relations;
+  },
+
+  data(query) {
+    return {
+      entryDates:
+        query.entries.map(entry => entry.date),
+
+      entryDirectories:
+        query.entries.map(entry => entry.directory),
+    };
+  },
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('newsIndex', pageCapsule =>
+      relations.layout.slots({
+        title: language.$(pageCapsule, 'title'),
+        headingMode: 'sticky',
+
+        mainClasses: ['long-content', 'news-index'],
+        mainContent:
+          stitchArrays({
+            entryLink: relations.entryLinks,
+            viewRestLink: relations.viewRestLinks,
+            content: relations.entryContents,
+            date: data.entryDates,
+            directory: data.entryDirectories,
+          }).map(({entryLink, viewRestLink, content, date, directory}) =>
+              language.encapsulate(pageCapsule, 'entry', entryCapsule =>
+                html.tag('article', {id: directory}, [
+                  html.tag('h2', [
+                    html.tag('time', language.formatDate(date)),
+                    entryLink,
+                  ]),
+
+                  content,
+
+                  viewRestLink
+                    ?.slot('content', language.$(entryCapsule, 'viewRest')),
+                ]))),
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateNextLink.js b/src/content/dependencies/generateNextLink.js
new file mode 100644
index 00000000..2e48cd2b
--- /dev/null
+++ b/src/content/dependencies/generateNextLink.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['generatePreviousNextLink'],
+
+  relations: (relation) => ({
+    link:
+      relation('generatePreviousNextLink'),
+  }),
+
+  generate: (relations) =>
+    relations.link.slots({
+      direction: 'next',
+    }),
+};
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
new file mode 100644
index 00000000..070c7c82
--- /dev/null
+++ b/src/content/dependencies/generatePageLayout.js
@@ -0,0 +1,790 @@
+import {openAggregate} from '#aggregate';
+import {atOffset, empty, repeat} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleRules',
+    'generateFooterLocalizationLinks',
+    'generateImageOverlay',
+    'generatePageSidebar',
+    'generateSearchSidebarBox',
+    'generateStickyHeadingContainer',
+    'transformContent',
+  ],
+
+  extraDependencies: [
+    'getColors',
+    'html',
+    'language',
+    'pagePath',
+    'pagePathStringFromRoot',
+    'to',
+    'wikiData',
+  ],
+
+  sprawl: ({wikiInfo}) => ({
+    enableSearch: wikiInfo.enableSearch,
+    footerContent: wikiInfo.footerContent,
+    wikiColor: wikiInfo.color,
+    wikiName: wikiInfo.nameShort,
+    canonicalBase: wikiInfo.canonicalBase,
+  }),
+
+  data: (sprawl) => ({
+    wikiColor: sprawl.wikiColor,
+    wikiName: sprawl.wikiName,
+    canonicalBase: sprawl.canonicalBase,
+  }),
+
+  relations(relation, sprawl) {
+    const relations = {};
+
+    relations.footerLocalizationLinks =
+      relation('generateFooterLocalizationLinks');
+
+    relations.stickyHeadingContainer =
+      relation('generateStickyHeadingContainer');
+
+    relations.sidebar =
+      relation('generatePageSidebar');
+
+    if (sprawl.enableSearch) {
+      relations.searchBox =
+        relation('generateSearchSidebarBox');
+    }
+
+    if (sprawl.footerContent) {
+      relations.defaultFooterContent =
+        relation('transformContent', sprawl.footerContent);
+    }
+
+    relations.colorStyleRules =
+      relation('generateColorStyleRules');
+
+    relations.imageOverlay =
+      relation('generateImageOverlay');
+
+    return relations;
+  },
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    showWikiNameInTitle: {
+      validate: v => v.is(true, false, 'auto'),
+      default: 'auto',
+    },
+
+    subtitle: {
+      type: 'html',
+      mutable: false,
+    },
+
+    showSearch: {
+      type: 'boolean',
+      default: true,
+    },
+
+    additionalNames: {
+      type: 'html',
+      mutable: false,
+    },
+
+    artworkColumnContent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    // Strictly speaking we clone this each time we use it, so it doesn't
+    // need to be marked as mutable here.
+    socialEmbed: {
+      type: 'html',
+      mutable: true,
+    },
+
+    color: {validate: v => v.isColor},
+
+    styleRules: {
+      validate: v => v.sparseArrayOf(v.isHTML),
+      default: [],
+    },
+
+    mainClasses: {
+      validate: v => v.sparseArrayOf(v.isString),
+      default: [],
+    },
+
+    // Main
+
+    mainContent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    headingMode: {
+      validate: v => v.is('sticky', 'static'),
+      default: 'static',
+    },
+
+    // Sidebars
+
+    leftSidebar: {
+      type: 'html',
+      mutable: true,
+    },
+
+    rightSidebar: {
+      type: 'html',
+      mutable: true,
+    },
+
+    // Banner
+
+    banner: {
+      type: 'html',
+      mutable: false,
+    },
+
+    bannerPosition: {
+      validate: v => v.is('top', 'bottom'),
+      default: 'top',
+    },
+
+    // Nav & Footer
+
+    navContent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    navBottomRowContent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    navLinkStyle: {
+      validate: v => v.is('hierarchical', 'index'),
+      default: 'index',
+    },
+
+    navLinks: {
+      validate: v =>
+        v.sparseArrayOf(object => {
+          v.isObject(object);
+
+          const aggregate = openAggregate({message: `Errors validating navigation link`});
+
+          aggregate.call(v.validateProperties({
+            auto: () => true,
+            html: () => true,
+
+            path: () => true,
+            title: () => true,
+            accent: () => true,
+
+            current: () => true,
+          }), object);
+
+          if (object.current !== undefined) {
+            aggregate.call(v.isBoolean, object.current);
+          }
+
+          if (object.auto || object.html) {
+            if (object.auto && object.html) {
+              aggregate.push(new TypeError(`Don't specify both auto and html`));
+            } else if (object.auto) {
+              aggregate.call(v.is('home', 'current'), object.auto);
+            } else {
+              aggregate.call(v.isHTML, object.html);
+            }
+
+            if (object.path || object.title) {
+              aggregate.push(new TypeError(`Don't specify path or title along with auto or html`));
+            }
+          } else {
+            aggregate.call(v.validateProperties({
+              path: v.strictArrayOf(v.isString),
+              title: v.isHTML,
+            }), {
+              path: object.path,
+              title: object.title,
+            });
+          }
+
+          aggregate.close();
+
+          return true;
+        })
+    },
+
+    secondaryNav: {
+      type: 'html',
+      mutable: false,
+    },
+
+    footerContent: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate(data, relations, slots, {
+    getColors,
+    html,
+    language,
+    pagePath,
+    pagePathStringFromRoot,
+    to,
+  }) {
+    const colors = getColors(slots.color ?? data.wikiColor);
+    const hasSocialEmbed = !html.isBlank(slots.socialEmbed);
+
+    // Hilariously jank. Sorry! We're going to need this content later ANYWAY,
+    // so it's "fine" to stringify it here, but this DOES mean that we're
+    // stringifying (and resolving) the content without the context that it's
+    // e.g. going to end up in a page HTML hierarchy. Might have implications
+    // later, mainly for: https://github.com/hsmusic/hsmusic-wiki/issues/434
+    const mainContentHTML = html.tags([slots.mainContent]).toString();
+    const hasID = id => mainContentHTML.includes(`id="${id}"`);
+
+    const oEmbedJSONHref =
+      (hasSocialEmbed && data.canonicalBase
+        ? data.canonicalBase +
+          pagePathStringFromRoot +
+          'oembed.json'
+        : null);
+
+    const canonicalHref =
+      (data.canonicalBase
+        ? data.canonicalBase + pagePathStringFromRoot
+        : null);
+
+    const firstItemInArtworkColumn =
+      html.smooth(slots.artworkColumnContent)
+        .content[0];
+
+    const primaryCover =
+      (firstItemInArtworkColumn &&
+       html.resolve(firstItemInArtworkColumn, {normalize: 'tag'})
+         .attributes.has('class', 'cover-artwork')
+        ? firstItemInArtworkColumn
+        : null);
+
+    const titleContentsHTML =
+      (html.isBlank(slots.title)
+        ? null
+     : html.isBlank(slots.additionalNames)
+        ? language.sanitize(slots.title)
+        : html.tag('a', {
+            href: '#additional-names-box',
+            title: language.$('misc.additionalNames.tooltip').toString(),
+          }, language.sanitize(slots.title)));
+
+    const titleHTML =
+      (html.isBlank(slots.title)
+        ? null
+     : slots.headingMode === 'sticky'
+        ? [
+            relations.stickyHeadingContainer.slots({
+              title: titleContentsHTML,
+              cover: primaryCover,
+            }),
+
+            relations.stickyHeadingContainer.clone().slots({
+              rootAttributes: {inert: true},
+            }),
+          ]
+        : html.tag('h1', titleContentsHTML));
+
+    // TODO: There could be neat interactions with the sticky heading here,
+    // but for now subtitle is totally separate.
+    const subtitleHTML =
+      (html.isBlank(slots.subtitle)
+        ? null
+        : html.tag('h2', {class: 'page-subtitle'},
+            language.sanitize(slots.subtitle)));
+
+    let footerContent = slots.footerContent;
+
+    if (html.isBlank(footerContent) && relations.defaultFooterContent) {
+      footerContent =
+        relations.defaultFooterContent.slots({
+          mode: 'multiline',
+          indicateExternalLinks: false,
+        });
+    }
+
+    const mainHTML =
+      html.tag('main', {id: 'content'},
+        {class: slots.mainClasses},
+
+        !html.isBlank(subtitleHTML) &&
+          {class: 'has-subtitle'},
+
+        [
+          titleHTML,
+
+          html.tag('div', {id: 'artwork-column'},
+            {[html.onlyIfContent]: true},
+            {class: 'isolate-tooltip-z-indexing'},
+
+            slots.artworkColumnContent),
+
+          subtitleHTML,
+
+          slots.additionalNames,
+
+          html.tag('div', {class: 'main-content-container'},
+            {[html.onlyIfContent]: true},
+            mainContentHTML),
+        ]);
+
+    const footerHTML =
+      html.tag('footer', {id: 'footer'},
+        {[html.onlyIfContent]: true},
+
+        [
+          html.tag('div', {class: 'footer-content'},
+            {[html.onlyIfContent]: true},
+            footerContent),
+
+          relations.footerLocalizationLinks,
+        ]);
+
+    const navHTML =
+      html.tag('nav', {id: 'header'},
+        {[html.onlyIfContent]: true},
+
+        !empty(slots.navLinks) &&
+          {class: 'nav-has-main-links'},
+
+        !html.isBlank(slots.navContent) &&
+          {class: 'nav-has-content'},
+
+        !html.isBlank(slots.navBottomRowContent) &&
+          {class: 'nav-has-bottom-row'},
+
+        [
+          html.tag('div', {class: 'nav-main-links'},
+            {[html.onlyIfContent]: true},
+            {class: 'nav-links-' + slots.navLinkStyle},
+
+            slots.navLinks
+              ?.filter(Boolean)
+              ?.map((cur, i, entries) => {
+                let content;
+
+                if (cur.html) {
+                  content = cur.html;
+                } else {
+                  const attributes = html.attributes();
+                  let title;
+
+                  switch (cur.auto) {
+                    case 'home':
+                      title = data.wikiName;
+                      attributes.set('href', to('localized.home'));
+                      break;
+                    case 'current':
+                      title = slots.title;
+                      attributes.set('href', '');
+                      break;
+                    case null:
+                    case undefined:
+                      title = cur.title;
+                      attributes.set('href', to(...cur.path));
+                      break;
+                  }
+
+                  content = html.tag('a', attributes, title);
+                }
+
+                const showAsCurrent =
+                  cur.current ||
+                  cur.auto === 'current' ||
+                  (slots.navLinkStyle === 'hierarchical' &&
+                    i === slots.navLinks.length - 1);
+
+                const navLink =
+                  html.tag('span', {class: 'nav-link'},
+                    showAsCurrent &&
+                      {class: 'current'},
+
+                    [
+                      html.tag('span', {class: 'nav-link-content'},
+                        content),
+
+                      html.tag('span', {class: 'nav-link-accent'},
+                        {[html.noEdgeWhitespace]: true},
+                        {[html.onlyIfContent]: true},
+
+                        language.$('misc.navAccent', {
+                          [language.onlyIfOptions]: ['links'],
+                          links: cur.accent,
+                        })),
+                    ]);
+
+                if (slots.navLinkStyle === 'index') {
+                  return navLink;
+                }
+
+                const prev =
+                  atOffset(entries, i, -1);
+
+                if (
+                  prev &&
+                  prev.releaseRestToWrapTogether !== true &&
+                  (prev.releaseRestToWrapTogether === false ||
+                   prev.auto === 'home')
+                ) {
+                  return navLink;
+                } else {
+                  return html.metatag('blockwrap', navLink);
+                }
+              })),
+
+          html.tag('div', {class: 'nav-bottom-row'},
+            {[html.onlyIfContent]: true},
+
+            language.$('misc.navAccent', {
+              [language.onlyIfOptions]: ['links'],
+              links: slots.navBottomRowContent,
+            })),
+
+          html.tag('div', {class: 'nav-content'},
+            {[html.onlyIfContent]: true},
+            slots.navContent),
+        ]);
+
+    const getSidebar = (side, id, needed) => {
+      const sidebar =
+        (html.isBlank(slots[side])
+          ? (needed
+              ? relations.sidebar.clone()
+              : html.blank())
+          : slots[side]);
+
+      if (html.isBlank(sidebar) && !needed) {
+        return sidebar;
+      }
+
+      return sidebar.slots({
+        attributes:
+          sidebar
+            .getSlotValue('attributes')
+            .with({id}),
+      });
+    }
+
+    const willShowSearch =
+      slots.showSearch && relations.searchBox;
+
+    let showingSidebarLeft;
+    let showingSidebarRight;
+    let sidebarsInContentColumn = false;
+
+    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left', willShowSearch);
+    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right', false);
+
+    if (willShowSearch) {
+      if (html.isBlank(leftSidebar)) {
+        sidebarsInContentColumn = true;
+        showingSidebarLeft = true;
+      }
+
+      leftSidebar.setSlot(
+        'boxes',
+        html.tags([
+          relations.searchBox,
+          leftSidebar.getSlotValue('boxes'),
+        ]));
+    }
+
+    const hasSidebarLeft = !html.isBlank(html.resolve(leftSidebar));
+    const hasSidebarRight = !html.isBlank(html.resolve(rightSidebar));
+
+    showingSidebarLeft ??= hasSidebarLeft;
+    showingSidebarRight ??= hasSidebarRight;
+
+    const processSkippers = skipperList =>
+      skipperList
+        .filter(({condition, id}) =>
+          (condition === undefined
+            ? hasID(id)
+            : condition))
+
+        .map(({id, string}) =>
+          html.tag('span', {class: 'skipper'},
+            {'data-for': id},
+
+            html.tag('a',
+              {href: `#${id}`},
+              language.$('misc.skippers', string))));
+
+    const skippersHTML =
+      mainHTML &&
+        html.tag('div', {id: 'skippers'}, [
+          html.tag('span', language.$('misc.skippers.skipTo')),
+          html.tag('div', {class: 'skipper-list'},
+            processSkippers([
+              {condition: true, id: 'content', string: 'content'},
+              {
+                condition: hasSidebarLeft,
+                id: 'sidebar-left',
+                string:
+                  (hasSidebarRight
+                    ? 'sidebar.left'
+                    : 'sidebar'),
+              },
+              {
+                condition: hasSidebarRight,
+                id: 'sidebar-right',
+                string:
+                  (hasSidebarLeft
+                    ? 'sidebar.right'
+                    : 'sidebar'),
+              },
+              {condition: navHTML, id: 'header', string: 'header'},
+              {condition: footerHTML, id: 'footer', string: 'footer'},
+            ])),
+
+          html.tag('div', {class: 'skipper-list'},
+            {[html.onlyIfContent]: true},
+            processSkippers([
+              {id: 'tracks', string: 'tracks'},
+              {id: 'art', string: 'artworks'},
+              {id: 'flashes', string: 'flashes'},
+              {id: 'contributors', string: 'contributors'},
+              {id: 'references', string: 'references'},
+              {id: 'referenced-by', string: 'referencedBy'},
+              {id: 'samples', string: 'samples'},
+              {id: 'sampled-by', string: 'sampledBy'},
+              {id: 'features', string: 'features'},
+              {id: 'featured-in', string: 'featuredIn'},
+              {id: 'sheet-music-files', string: 'sheetMusicFiles'},
+              {id: 'midi-project-files', string: 'midiProjectFiles'},
+              {id: 'additional-files', string: 'additionalFiles'},
+              {id: 'commentary', string: 'commentary'},
+              {id: 'artist-commentary', string: 'artistCommentary'},
+              {id: 'credit-sources', string: 'creditSources'},
+            ])),
+        ]);
+
+    const styleRulesCSS =
+      html.resolve(slots.styleRules, {normalize: 'string'});
+
+    const fallbackBackgroundStyleRule =
+      (styleRulesCSS.match(/body::before[^}]*background-image:/)
+        ? ''
+        : `body::before {\n` +
+          `    background-image: url("${to('media.path', 'bg.jpg')}");\n` +
+          `}`);
+
+    const numWallpaperParts =
+      html.resolve(slots.styleRules, {normalize: 'string'})
+        .match(/\.wallpaper-part:nth-child/g)
+        ?.length ?? 0;
+
+    const wallpaperPartsHTML =
+      html.tag('div', {class: 'wallpaper-parts'},
+        {[html.onlyIfContent]: true},
+
+        repeat(numWallpaperParts, () =>
+          html.tag('div', {class: 'wallpaper-part'})));
+
+    const layoutHTML = [
+      navHTML,
+
+      slots.bannerPosition === 'top' &&
+        slots.banner,
+
+      slots.secondaryNav,
+
+      html.tag('div', {class: 'layout-columns'}, [
+        leftSidebar,
+        mainHTML,
+        rightSidebar,
+      ]),
+
+      slots.bannerPosition === 'bottom' &&
+        slots.banner,
+
+      footerHTML,
+    ];
+
+    const pageHTML = html.tags([
+      `<!DOCTYPE html>`,
+      html.tag('html',
+        {lang: language.intlCode},
+        {'data-language-code': language.code},
+
+        {'data-url-key': 'localized.' + pagePath[0]},
+        Object.fromEntries(
+          pagePath
+            .slice(1)
+            .map((v, i) => [['data-url-value' + i], v])),
+
+        {'data-rebase-localized': to('localized.root')},
+        {'data-rebase-shared': to('shared.root')},
+        {'data-rebase-media': to('media.root')},
+        {'data-rebase-thumb': to('thumb.root')},
+        {'data-rebase-lib': to('staticLib.root')},
+        {'data-rebase-data': to('data.root')},
+
+        [
+          // developersComment,
+
+          html.tag('head', [
+            html.tag('title',
+              language.encapsulate('misc.pageTitle', workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.title = slots.title;
+
+                if (!html.isBlank(slots.subtitle)) {
+                  workingCapsule += '.withSubtitle';
+                  workingOptions.subtitle = slots.subtitle;
+                }
+
+                const showWikiName =
+                  (slots.showWikiNameInTitle === true
+                    ? true
+                 : slots.showWikiNameInTitle === 'auto'
+                    ? html.isBlank(slots.subtitle)
+                    : false);
+
+                if (showWikiName) {
+                  workingCapsule += '.withWikiName';
+                  workingOptions.wikiName = data.wikiName;
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })),
+
+            html.tag('meta', {charset: 'utf-8'}),
+            html.tag('meta', {
+              name: 'viewport',
+              content: 'width=device-width, initial-scale=1',
+            }),
+
+            slots.color && [
+              html.tag('meta', {
+                name: 'theme-color',
+                content: colors.dark,
+                media: '(prefers-color-scheme: dark)',
+              }),
+
+              html.tag('meta', {
+                name: 'theme-color',
+                content: colors.light,
+                media: '(prefers-color-scheme: light)',
+              }),
+
+              html.tag('meta', {
+                name: 'theme-color',
+                content: colors.primary,
+              }),
+            ],
+
+            /*
+            ...(
+              Object.entries(meta)
+                .filter(([key, value]) => value)
+                .map(([key, value]) => html.tag('meta', {[key]: value}))),
+            */
+
+            canonicalHref &&
+              html.tag('link', {
+                rel: 'canonical',
+                href: canonicalHref,
+              }),
+
+            /*
+            ...(
+              localizedCanonical
+                .map(({lang, href}) => html.tag('link', {
+                  rel: 'alternate',
+                  hreflang: lang,
+                  href,
+                }))),
+            */
+
+            hasSocialEmbed &&
+              slots.socialEmbed
+                .clone()
+                .slot('mode', 'html'),
+
+            oEmbedJSONHref &&
+              html.tag('link', {
+                type: 'application/json+oembed',
+                href: oEmbedJSONHref,
+              }),
+
+            html.tag('link', {
+              rel: 'stylesheet',
+              href: to('staticCSS.path', 'site.css'),
+            }),
+
+            html.tag('style', [
+              relations.colorStyleRules
+                .slot('color', slots.color ?? data.wikiColor),
+
+              fallbackBackgroundStyleRule,
+              slots.styleRules,
+            ]),
+
+            html.tag('script', {
+              src: to('staticLib.path', 'chroma-js/chroma.min.js'),
+            }),
+
+            html.tag('script', {
+              blocking: 'render',
+              src: to('staticJS.path', 'lazy-loading.js'),
+            }),
+
+            html.tag('script', {
+              blocking: 'render',
+              type: 'module',
+              src: to('staticJS.path', 'client/index.js'),
+            }),
+          ]),
+
+          html.tag('body',
+            [
+              wallpaperPartsHTML,
+
+              html.tag('div', {id: 'page-container'},
+                showingSidebarLeft &&
+                  {class: 'showing-sidebar-left'},
+
+                showingSidebarRight &&
+                  {class: 'showing-sidebar-right'},
+
+                sidebarsInContentColumn &&
+                  {class: 'sidebars-in-content-column'},
+
+                [
+                  skippersHTML,
+                  layoutHTML,
+                ]),
+
+              // infoCardHTML,
+              relations.imageOverlay,
+            ]),
+        ])
+    ]).toString();
+
+    const oEmbedJSON =
+      (hasSocialEmbed
+        ? slots.socialEmbed
+            .clone()
+            .slot('mode', 'json')
+            .content
+        : null);
+
+    return {pageHTML, oEmbedJSON};
+  },
+};
diff --git a/src/content/dependencies/generatePageSidebar.js b/src/content/dependencies/generatePageSidebar.js
new file mode 100644
index 00000000..d3b55580
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebar.js
@@ -0,0 +1,90 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    // Attributes to apply to the whole sidebar. This be added to the
+    // containing sidebar-column, arr - specify attributes on each section if
+    // that's more suitable.
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    // Content boxes to line up vertically in the sidebar.
+    boxes: {
+      type: 'html',
+      mutable: false,
+    },
+
+    // Sticky mode controls which sidebar sections, if any, follow the
+    // scroll position, "sticking" to the top of the browser viewport.
+    //
+    // 'column' - entire column, incl. multiple boxes from top, is sticky
+    // 'static' - sidebar not sticky at all, stays at top of page
+    //
+    // Note: This doesn't affect the content of any sidebar section, only
+    // the whole section's containing box (or the sidebar column as a whole).
+    stickyMode: {
+      validate: v => v.is('column', 'static'),
+      default: 'static',
+    },
+
+    // Wide sidebars generally take up more horizontal space in the normal
+    // page layout, and should be used if the content of the sidebar has
+    // a greater than typical focus compared to main content.
+    wide: {
+      type: 'boolean',
+      default: false,
+    },
+
+    // Provide to include all the HTML for the sidebar in place as usual,
+    // but start it out totally invisible. This is mainly so client-side
+    // JavaScript can show the sidebar if it needs to (and has a target
+    // to slot its own content into). If there are no boxes and this
+    // option *isn't* provided, then the sidebar will just be blank.
+    initiallyHidden: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate(slots, {html}) {
+    const attributes =
+      html.attributes({class: [
+        'sidebar-column',
+        'sidebar-multiple',
+      ]});
+
+    attributes.add(slots.attributes);
+
+    if (slots.wide) {
+      attributes.add('class', 'wide');
+    }
+
+    if (slots.stickyMode !== 'static') {
+      attributes.add('class', `sticky-${slots.stickyMode}`);
+    }
+
+    const {content: boxes} = html.smooth(slots.boxes);
+
+    const allBoxesCollapsible =
+      boxes.every(box =>
+        html.resolve(box)
+          .attributes
+          .has('class', 'collapsible'));
+
+    if (allBoxesCollapsible) {
+      attributes.add('class', 'all-boxes-collapsible');
+    }
+
+    if (slots.initiallyHidden) {
+      attributes.add('class', 'initially-hidden');
+    }
+
+    if (html.isBlank(slots.boxes) && !slots.initiallyHidden) {
+      return html.blank();
+    } else {
+      return html.tag('div', attributes, slots.boxes);
+    }
+  },
+};
diff --git a/src/content/dependencies/generatePageSidebarBox.js b/src/content/dependencies/generatePageSidebarBox.js
new file mode 100644
index 00000000..26b30494
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebarBox.js
@@ -0,0 +1,30 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    collapsible: {
+      type: 'boolean',
+      default: true,
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('div', {class: 'sidebar'},
+      {[html.onlyIfContent]: true},
+
+      slots.collapsible &&
+        {class: 'collapsible'},
+
+      slots.attributes,
+      slots.content),
+};
diff --git a/src/content/dependencies/generatePageSidebarConjoinedBox.js b/src/content/dependencies/generatePageSidebarConjoinedBox.js
new file mode 100644
index 00000000..7974c707
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebarConjoinedBox.js
@@ -0,0 +1,38 @@
+// This component is kind of unfortunately magical. It reads the content of
+// various boxes and joins them together, discarding the boxes' attributes.
+// Since it requires access to the actual box *templates* (rather than those
+// templates' resolved content), take care when slotting into this.
+
+export default {
+  contentDependencies: ['generatePageSidebarBox'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    box:
+      relation('generatePageSidebarBox'),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    boxes: {
+      validate: v => v.looseArrayOf(v.isTemplate),
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    relations.box.slots({
+      attributes: slots.attributes,
+      content:
+        slots.boxes.slice()
+          .map(box => box.getSlotValue('content'))
+          .map((content, index, {length}) => [
+            content,
+            index < length - 1 &&
+              html.tag('hr', {class: 'cute'}),
+          ]),
+    }),
+};
diff --git a/src/content/dependencies/generatePreviousLink.js b/src/content/dependencies/generatePreviousLink.js
new file mode 100644
index 00000000..775367f9
--- /dev/null
+++ b/src/content/dependencies/generatePreviousLink.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['generatePreviousNextLink'],
+
+  relations: (relation) => ({
+    link:
+      relation('generatePreviousNextLink'),
+  }),
+
+  generate: (relations) =>
+    relations.link.slots({
+      direction: 'previous',
+    }),
+};
diff --git a/src/content/dependencies/generatePreviousNextLink.js b/src/content/dependencies/generatePreviousNextLink.js
new file mode 100644
index 00000000..afae1228
--- /dev/null
+++ b/src/content/dependencies/generatePreviousNextLink.js
@@ -0,0 +1,58 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    link: {
+      type: 'html',
+      mutable: true,
+    },
+
+    direction: {
+      validate: v => v.is('previous', 'next'),
+    },
+
+    id: {
+      type: 'boolean',
+      default: true,
+    },
+
+    showWithoutLink: {
+      type: 'boolean',
+      default: true,
+    },
+  },
+
+  generate(slots, {html, language}) {
+    if (!slots.direction) {
+      return html.blank();
+    }
+
+    const attributes = html.attributes();
+
+    if (slots.id) {
+      attributes.set('id', `${slots.direction}-button`);
+    }
+
+    if (html.isBlank(slots.link)) {
+      if (slots.showWithoutLink) {
+        return (
+          html.tag('a', {class: 'inert-previous-next-link'},
+            attributes,
+            language.$('misc.nav', slots.direction)));
+      } else {
+        return html.blank();
+      }
+    }
+
+    return html.resolve(slots.link, {
+      slots: {
+        tooltipStyle: 'browser',
+        color: false,
+        attributes,
+
+        content:
+          language.$('misc.nav', slots.direction),
+      }
+    });
+  },
+};
diff --git a/src/content/dependencies/generateQuickDescription.js b/src/content/dependencies/generateQuickDescription.js
new file mode 100644
index 00000000..e144503e
--- /dev/null
+++ b/src/content/dependencies/generateQuickDescription.js
@@ -0,0 +1,134 @@
+export default {
+  contentDependencies: ['transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  query: (thing) => ({
+    hasDescription:
+      !!thing.description,
+
+    hasLongerDescription:
+      thing.description &&
+      thing.descriptionShort &&
+      thing.descriptionShort !== thing.description,
+  }),
+
+  relations: (relation, query, thing) => ({
+    description:
+      (query.hasLongerDescription || !thing.description
+        ? null
+        : relation('transformContent', thing.description)),
+
+    descriptionShort:
+      (query.hasLongerDescription
+        ? relation('transformContent', thing.descriptionShort)
+        : null),
+
+    descriptionLong:
+      (query.hasLongerDescription
+        ? relation('transformContent', thing.description)
+        : null),
+  }),
+
+  data: (query) => ({
+    hasDescription: query.hasDescription,
+    hasLongerDescription: query.hasLongerDescription,
+  }),
+
+  slots: {
+    extraReadingLinks: {
+      validate: v => v.sparseArrayOf(v.isHTML),
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const prefix = 'misc.quickDescription';
+
+    const actionsWithoutLongerDescription =
+      (data.hasLongerDescription
+        ? null
+     : slots.extraReadingLinks
+        ? language.$(prefix, 'readMore', {
+            links:
+              language.formatDisjunctionList(slots.extraReadingLinks),
+          })
+        : null);
+
+    const wrapExpandCollapseLink = (expandCollapse, content) =>
+      html.tag('a', {class: `${expandCollapse}-link`},
+        {href: '#'},
+        content);
+
+    const actionsWhenCollapsed =
+      (data.hasLongerDescription && slots.extraReadingLinks
+        ? language.$(prefix, 'expandDescription.orReadMore', {
+            links:
+              language.formatDisjunctionList(slots.extraReadingLinks),
+            expand:
+              wrapExpandCollapseLink('expand',
+                language.$(prefix, 'expandDescription.orReadMore.expand')),
+          })
+     : data.hasLongerDescription
+        ? language.$(prefix, 'expandDescription', {
+            expand:
+              wrapExpandCollapseLink('expand',
+                language.$(prefix, 'expandDescription.expand')),
+          })
+        : null);
+
+    const actionsWhenExpanded =
+      (data.hasLongerDescription && slots.extraReadingLinks
+        ? language.$(prefix, 'collapseDescription.orReadMore', {
+            links:
+              language.formatDisjunctionList(slots.extraReadingLinks),
+            collapse:
+              wrapExpandCollapseLink('collapse',
+                language.$(prefix, 'collapseDescription.orReadMore.collapse')),
+          })
+     : data.hasLongerDescription
+        ? language.$(prefix, 'collapseDescription', {
+            collapse:
+              wrapExpandCollapseLink('collapse',
+                language.$(prefix, 'collapseDescription.collapse')),
+          })
+        : null);
+
+    const wrapActions = (attributes, children) =>
+      html.tag('p', {class: 'quick-description-actions'},
+        {[html.onlyIfContent]: true},
+        attributes,
+
+        children);
+
+    const wrapContent = (attributes, content) =>
+      html.tag('blockquote', {class: 'description-content'},
+        {[html.onlyIfContent]: true},
+        attributes,
+
+        content?.slot('mode', 'multiline'));
+
+    return (
+      html.tag('div', {class: 'quick-description'},
+        {[html.onlyIfContent]: true},
+
+        data.hasLongerDescription &&
+          {class: 'collapsed'},
+
+        !data.hasLongerDescription &&
+        !slots.extraReadingLinks &&
+          {class: 'has-content-only'},
+
+        !data.hasDescription &&
+        slots.extraReadingLinks &&
+          {class: 'has-external-links-only'},
+
+        [
+          wrapContent(null, relations.description),
+          wrapContent({class: 'short'}, relations.descriptionShort),
+          wrapContent({class: 'long'}, relations.descriptionLong),
+
+          wrapActions(null, actionsWithoutLongerDescription),
+          wrapActions({class: 'when-collapsed'}, actionsWhenCollapsed),
+          wrapActions({class: 'when-expanded'}, actionsWhenExpanded),
+        ]));
+  },
+};
diff --git a/src/content/dependencies/generateReferencedArtworksPage.js b/src/content/dependencies/generateReferencedArtworksPage.js
new file mode 100644
index 00000000..154b4762
--- /dev/null
+++ b/src/content/dependencies/generateReferencedArtworksPage.js
@@ -0,0 +1,100 @@
+export default {
+  contentDependencies: [
+    'generateCoverArtwork',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkAnythingMan',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, artwork) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    cover:
+      relation('generateCoverArtwork', artwork),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      artwork.referencedArtworks.map(({artwork}) =>
+        relation('linkAnythingMan', artwork.thing)),
+
+    images:
+      artwork.referencedArtworks.map(({artwork}) =>
+        relation('image', artwork)),
+  }),
+
+  data: (artwork) => ({
+    color:
+      artwork.thing.color,
+
+    count:
+      artwork.referencedArtworks.length,
+
+    names:
+      artwork.referencedArtworks
+        .map(({artwork}) => artwork.thing.name),
+
+    coverArtistNames:
+      artwork.referencedArtworks
+        .map(({artwork}) =>
+          artwork.artistContribs
+            .map(contrib => contrib.artist.name)),
+  }),
+
+  slots: {
+    styleRules: {type: 'html', mutable: false},
+
+    title: {type: 'html', mutable: false},
+
+    navLinks: {validate: v => v.isArray},
+    navBottomRowContent: {type: 'html', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('referencedArtworksPage', pageCapsule =>
+      relations.layout.slots({
+        title: slots.title,
+        subtitle: language.$(pageCapsule, 'subtitle'),
+
+        color: data.color,
+        styleRules: slots.styleRules,
+
+        artworkColumnContent:
+          relations.cover.slots({
+            showArtistDetails: true,
+          }),
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          html.tag('p', {class: 'quick-info'},
+            language.$(pageCapsule, 'statsLine', {
+              artworks:
+                language.countArtworks(data.count, {
+                  unit: true,
+                }),
+            })),
+
+          relations.coverGrid.slots({
+            links: relations.links,
+            images: relations.images,
+            names: data.names,
+
+            info:
+              data.coverArtistNames.map(names =>
+                language.$('misc.coverGrid.details.coverArtists', {
+                  artists:
+                    language.formatUnitList(names),
+                })),
+          }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: slots.navLinks,
+        navBottomRowContent: slots.navBottomRowContent,
+      })),
+};
diff --git a/src/content/dependencies/generateReferencingArtworksPage.js b/src/content/dependencies/generateReferencingArtworksPage.js
new file mode 100644
index 00000000..55977b37
--- /dev/null
+++ b/src/content/dependencies/generateReferencingArtworksPage.js
@@ -0,0 +1,100 @@
+export default {
+  contentDependencies: [
+    'generateCoverArtwork',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkAnythingMan',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, artwork) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    cover:
+      relation('generateCoverArtwork', artwork),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      artwork.referencedByArtworks.map(({artwork}) =>
+        relation('linkAnythingMan', artwork.thing)),
+
+    images:
+      artwork.referencedByArtworks.map(({artwork}) =>
+        relation('image', artwork)),
+  }),
+
+  data: (artwork) => ({
+    color:
+      artwork.thing.color,
+
+    count:
+      artwork.referencedByArtworks.length,
+
+    names:
+      artwork.referencedByArtworks
+        .map(({artwork}) => artwork.thing.name),
+
+    coverArtistNames:
+      artwork.referencedByArtworks
+        .map(({artwork}) =>
+          artwork.artistContribs
+            .map(contrib => contrib.artist.name)),
+  }),
+
+  slots: {
+    styleRules: {type: 'html', mutable: false},
+
+    title: {type: 'html', mutable: false},
+
+    navLinks: {validate: v => v.isArray},
+    navBottomRowContent: {type: 'html', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('referencingArtworksPage', pageCapsule =>
+      relations.layout.slots({
+        title: slots.title,
+        subtitle: language.$(pageCapsule, 'subtitle'),
+
+        color: data.color,
+        styleRules: slots.styleRules,
+
+        artworkColumnContent:
+          relations.cover.slots({
+            showArtistDetails: true,
+          }),
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          html.tag('p', {class: 'quick-info'},
+            language.$(pageCapsule, 'statsLine', {
+              artworks:
+                language.countArtworks(data.count, {
+                  unit: true,
+                }),
+            })),
+
+          relations.coverGrid.slots({
+            links: relations.links,
+            images: relations.images,
+            names: data.names,
+
+            info:
+              data.coverArtistNames.map(names =>
+                language.$('misc.coverGrid.details.coverArtists', {
+                  artists:
+                    language.formatUnitList(names),
+                })),
+          }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: slots.navLinks,
+        navBottomRowContent: slots.navBottomRowContent,
+      })),
+};
diff --git a/src/content/dependencies/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js
new file mode 100644
index 00000000..a997de0e
--- /dev/null
+++ b/src/content/dependencies/generateRelativeDatetimestamp.js
@@ -0,0 +1,69 @@
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateDatetimestampTemplate',
+    'generateTooltip',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  data: (currentDate, referenceDate) =>
+    (currentDate.getTime() === referenceDate.getTime()
+      ? {equal: true, date: currentDate}
+      : {equal: false, currentDate, referenceDate}),
+
+  relations: (relation, currentDate) => ({
+    template:
+      relation('generateDatetimestampTemplate'),
+
+    fallback:
+      relation('generateAbsoluteDatetimestamp', currentDate),
+
+    tooltip:
+      relation('generateTooltip'),
+  }),
+
+  slots: {
+    style: {
+      validate: v => v.is('full', 'year'),
+      default: 'full',
+    },
+
+    tooltip: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate(data, relations, slots, {language}) {
+    if (data.equal) {
+      return relations.fallback.slots({
+        style: slots.style,
+        tooltip: slots.tooltip,
+      });
+    }
+
+    return relations.template.slots({
+      mainContent:
+        (slots.style === 'full'
+          ? language.formatDate(data.currentDate)
+       : slots.style === 'year'
+          ? data.currentDate.getFullYear().toString()
+          : null),
+
+      tooltip:
+        slots.tooltip &&
+          relations.tooltip.slots({
+            content:
+              language.formatRelativeDate(data.currentDate, data.referenceDate, {
+                considerRoundingDays: true,
+                approximate: true,
+                absolute: slots.style === 'year',
+              }),
+          }),
+
+      datetime:
+        data.currentDate.toISOString(),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js
new file mode 100644
index 00000000..016e0a2c
--- /dev/null
+++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js
@@ -0,0 +1,31 @@
+export default {
+  contentDependencies: ['generateArtistCredit'],
+  extraDependencies: ['html'],
+
+  relations: (relation, contributions) => ({
+    credit:
+      relation('generateArtistCredit', contributions, []),
+  }),
+
+  slots: {
+    stringKey: {type: 'string'},
+    featuringStringKey: {type: 'string'},
+
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (relations, slots) =>
+    relations.credit.slots({
+      showAnnotation: true,
+      showExternalLinks: true,
+      showChronology: true,
+      showWikiEdits: true,
+
+      trimAnnotation: false,
+
+      chronologyKind: slots.chronologyKind,
+
+      normalStringKey: slots.stringKey,
+      normalFeaturingStringKey: slots.featuringStringKey,
+    }),
+};
diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js
new file mode 100644
index 00000000..188a678f
--- /dev/null
+++ b/src/content/dependencies/generateSearchSidebarBox.js
@@ -0,0 +1,62 @@
+export default {
+  contentDependencies: ['generatePageSidebarBox'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('misc.search', capsule =>
+      relations.sidebarBox.slots({
+        attributes: {class: 'wiki-search-sidebar-box'},
+        collapsible: false,
+
+        content: [
+          html.tag('label', {class: 'wiki-search-label'},
+            html.tag('input', {class: 'wiki-search-input'},
+              {type: 'search'},
+
+              {
+                placeholder:
+                  language.$(capsule, 'placeholder').toString(),
+              })),
+
+          html.tag('template', {class: 'wiki-search-preparing-string'},
+            language.$(capsule, 'preparing')),
+
+          html.tag('template', {class: 'wiki-search-loading-data-string'},
+            language.$(capsule, 'loadingData')),
+
+          html.tag('template', {class: 'wiki-search-searching-string'},
+            language.$(capsule, 'searching')),
+
+          html.tag('template', {class: 'wiki-search-failed-string'},
+            language.$(capsule, 'failed')),
+
+          html.tag('template', {class: 'wiki-search-no-results-string'},
+            language.$(capsule, 'noResults')),
+
+          html.tag('template', {class: 'wiki-search-current-result-string'},
+            language.$(capsule, 'currentResult')),
+
+          html.tag('template', {class: 'wiki-search-end-search-string'},
+            language.$(capsule, 'endSearch')),
+
+          language.encapsulate(capsule, 'resultKind', capsule => [
+            html.tag('template', {class: 'wiki-search-album-result-kind-string'},
+              language.$(capsule, 'album')),
+
+            html.tag('template', {class: 'wiki-search-artist-result-kind-string'},
+              language.$(capsule, 'artist')),
+
+            html.tag('template', {class: 'wiki-search-group-result-kind-string'},
+              language.$(capsule, 'group')),
+
+            html.tag('template', {class: 'wiki-search-tag-result-kind-string'},
+              language.$(capsule, 'artTag')),
+          ]),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateSecondaryNav.js b/src/content/dependencies/generateSecondaryNav.js
new file mode 100644
index 00000000..9ce7ce9b
--- /dev/null
+++ b/src/content/dependencies/generateSecondaryNav.js
@@ -0,0 +1,30 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    alwaysVisible: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('nav', {id: 'secondary-nav'},
+      {[html.onlyIfContent]: true},
+      slots.attributes,
+
+      slots.alwaysVisible &&
+        {class: 'always-visible'},
+
+      slots.content),
+};
diff --git a/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js b/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js
new file mode 100644
index 00000000..f204f1fb
--- /dev/null
+++ b/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js
@@ -0,0 +1,115 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateInterpageDotSwitcher',
+    'generateNextLink',
+    'generatePreviousLink',
+    'linkAlbumDynamically',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    switcher:
+      relation('generateInterpageDotSwitcher'),
+
+    previousLink:
+      relation('generatePreviousLink'),
+
+    nextLink:
+      relation('generateNextLink'),
+  }),
+
+  slots: {
+    showPreviousNext: {
+      type: 'boolean',
+      default: true,
+    },
+
+    id: {
+      type: 'boolean',
+      default: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    colorStyle: {
+      type: 'html',
+      mutable: true,
+    },
+
+    mainLink: {
+      type: 'html',
+      mutable: true,
+    },
+
+    previousLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    nextLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    stringsKey: {
+      type: 'string',
+    },
+
+    mainLinkOption: {
+      type: 'string',
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    html.tag('span',
+      {[html.onlyIfContent]: true},
+      {[html.noEdgeWhitespace]: true},
+
+      slots.attributes,
+
+      !html.isBlank(slots.colorStyle) &&
+        slots.colorStyle
+          .slot('context', 'primary-only'),
+
+      language.encapsulate(slots.stringsKey, workingCapsule => {
+        const workingOptions = {
+          [language.onlyIfOptions]: [slots.mainLinkOption],
+        };
+
+        workingOptions[slots.mainLinkOption] =
+          (html.isBlank(slots.mainLink)
+            ? null
+            : slots.mainLink
+                .slot('color', false));
+
+        if (slots.showPreviousNext) addPreviousNext: {
+          if (html.isBlank(slots.previousLink) && html.isBlank(slots.nextLink)) {
+            break addPreviousNext;
+          }
+
+          workingCapsule += '.withPreviousNext';
+          workingOptions.previousNext =
+            relations.switcher.slots({
+              links: [
+                relations.previousLink.slots({
+                  id: slots.id,
+                  link: slots.previousLink,
+                }),
+
+                relations.nextLink.slots({
+                  id: slots.id,
+                  link: slots.nextLink,
+                }),
+              ],
+            });
+        }
+
+        return language.$(workingCapsule, workingOptions);
+      })),
+};
diff --git a/src/content/dependencies/generateSocialEmbed.js b/src/content/dependencies/generateSocialEmbed.js
new file mode 100644
index 00000000..513ea518
--- /dev/null
+++ b/src/content/dependencies/generateSocialEmbed.js
@@ -0,0 +1,70 @@
+export default {
+  extraDependencies: ['absoluteTo', 'html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      canonicalBase: wikiInfo.canonicalBase,
+      shortWikiName: wikiInfo.nameShort,
+    };
+  },
+
+  data(sprawl) {
+    return {
+      canonicalBase: sprawl.canonicalBase,
+      shortWikiName: sprawl.shortWikiName,
+    };
+  },
+
+  slots: {
+    mode: {validate: v => v.is('html', 'json')},
+
+    title: {type: 'string'},
+    description: {type: 'string'},
+
+    headingContent: {type: 'string'},
+    headingLink: {type: 'string'},
+    imagePath: {validate: v => v.strictArrayOf(v.isString)},
+  },
+
+  generate(data, slots, {absoluteTo, html, language}) {
+    switch (slots.mode) {
+      case 'html':
+        return html.tags([
+          slots.title &&
+            html.tag('meta', {property: 'og:title', content: slots.title}),
+
+          slots.description &&
+            html.tag('meta', {
+              property: 'og:description',
+              content: slots.description,
+            }),
+
+          slots.imagePath &&
+            html.tag('meta', {
+              property: 'og:image',
+              content: absoluteTo(...slots.imagePath),
+            }),
+        ]);
+
+      case 'json':
+        return JSON.stringify({
+          author_name:
+            (slots.headingContent
+              ? html.resolve(
+                  language.$('misc.socialEmbed.heading', {
+                    wikiName: data.shortWikiName,
+                    heading: slots.headingContent,
+                  }),
+                  {normalize: 'string'})
+              : undefined),
+
+          author_url:
+            (slots.headingLink && data.canonicalBase
+              ? data.canonicalBase.replace(/\/$/, '') +
+                '/' +
+                slots.headingLink.replace(/^\//, '')
+              : undefined),
+        });
+    }
+  },
+};
diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js
new file mode 100644
index 00000000..226152c7
--- /dev/null
+++ b/src/content/dependencies/generateStaticPage.js
@@ -0,0 +1,46 @@
+export default {
+  contentDependencies: ['generatePageLayout', 'transformContent'],
+  extraDependencies: ['html'],
+
+  relations(relation, staticPage) {
+    return {
+      layout: relation('generatePageLayout'),
+      content: relation('transformContent', staticPage.content),
+    };
+  },
+
+  data(staticPage) {
+    return {
+      name: staticPage.name,
+      stylesheet: staticPage.stylesheet,
+      script: staticPage.script,
+    };
+  },
+
+  generate(data, relations, {html}) {
+    return relations.layout
+      .slots({
+        title: data.name,
+        headingMode: 'sticky',
+
+        styleRules:
+          (data.stylesheet
+            ? [data.stylesheet]
+            : []),
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          relations.content,
+
+          data.script &&
+            html.tag('script', data.script),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {auto: 'current'},
+        ],
+      });
+  },
+};
diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js
new file mode 100644
index 00000000..ec3062a3
--- /dev/null
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -0,0 +1,59 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    rootAttributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    cover: {
+      type: 'html',
+      mutable: true,
+    },
+  },
+
+  generate: (slots, {html}) => html.tags([
+    html.tag('div', {class: 'content-sticky-heading-root'},
+      slots.rootAttributes,
+
+      !html.isBlank(slots.cover) &&
+        {class: 'has-cover'},
+
+      html.tag('div', {class: 'content-sticky-heading-anchor'},
+        html.tag('div', {class: 'content-sticky-heading-container'},
+          !html.isBlank(slots.cover) &&
+            {class: 'has-cover'},
+
+          [
+            html.tag('div', {class: 'content-sticky-heading-row'}, [
+              html.tag('h1', [
+                html.tag('span', {class: 'reference-collapsed-heading'},
+                  {inert: true},
+
+                  slots.title.clone()),
+
+                slots.title,
+              ]),
+
+              html.tag('div', {class: 'content-sticky-heading-cover-container'},
+                {[html.onlyIfContent]: true},
+
+                html.tag('div', {class: 'content-sticky-heading-cover'},
+                  {[html.onlyIfContent]: true},
+
+                  (html.isBlank(slots.cover)
+                    ? html.blank()
+                    : slots.cover.slot('mode', 'thumbnail')))),
+            ]),
+
+            html.tag('div', {class: 'content-sticky-subheading-row'},
+              html.tag('h2', {class: 'content-sticky-subheading'})),
+          ]))),
+  ]),
+};
diff --git a/src/content/dependencies/generateTextWithTooltip.js b/src/content/dependencies/generateTextWithTooltip.js
new file mode 100644
index 00000000..49ce1f61
--- /dev/null
+++ b/src/content/dependencies/generateTextWithTooltip.js
@@ -0,0 +1,71 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    customInteractionCue: {
+      type: 'boolean',
+      default: false,
+    },
+
+    text: {
+      type: 'html',
+      mutable: false,
+    },
+
+    tooltip: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate(slots, {html}) {
+    const hasTooltip =
+      !html.isBlank(slots.tooltip);
+
+    if (slots.attributes.blank && !hasTooltip) {
+      return slots.text;
+    }
+
+    let {attributes} = slots;
+
+    if (hasTooltip) {
+      attributes = attributes.clone();
+      attributes.add({
+        [html.onlyIfContent]: true,
+        [html.joinChildren]: '',
+        [html.noEdgeWhitespace]: true,
+        class: 'text-with-tooltip',
+      });
+    }
+
+    const textPart =
+      (hasTooltip && slots.customInteractionCue
+        ? html.tag('span', {class: 'hoverable'},
+            {[html.onlyIfContent]: true},
+
+            slots.text)
+
+     : hasTooltip
+        ? html.tag('span', {class: 'hoverable'},
+            {[html.onlyIfContent]: true},
+
+            html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
+              {[html.onlyIfContent]: true},
+
+              slots.text))
+
+        : slots.text);
+
+    const content =
+      (hasTooltip
+        ? [textPart, slots.tooltip]
+        : textPart);
+
+    return html.tag('span', attributes, content);
+  },
+};
diff --git a/src/content/dependencies/generateTooltip.js b/src/content/dependencies/generateTooltip.js
new file mode 100644
index 00000000..b09ee230
--- /dev/null
+++ b/src/content/dependencies/generateTooltip.js
@@ -0,0 +1,34 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    contentAttributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('span', {class: 'tooltip'},
+      {[html.noEdgeWhitespace]: true},
+      {[html.onlyIfContent]: true},
+      {[html.onlyIfSiblings]: true},
+      slots.attributes,
+
+      html.tag('span', {class: 'tooltip-content'},
+        {[html.noEdgeWhitespace]: true},
+        {[html.onlyIfContent]: true},
+        slots.contentAttributes,
+
+        slots.content)),
+};
diff --git a/src/content/dependencies/generateTrackArtistCommentarySection.js b/src/content/dependencies/generateTrackArtistCommentarySection.js
new file mode 100644
index 00000000..e3041d3a
--- /dev/null
+++ b/src/content/dependencies/generateTrackArtistCommentarySection.js
@@ -0,0 +1,157 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCommentaryEntry',
+    'generateContentHeading',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    otherSecondaryReleasesWithCommentary:
+      track.otherReleases
+        .filter(track => !track.isMainRelease)
+        .filter(track => !empty(track.commentary)),
+  }),
+
+  relations: (relation, query, track) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+
+    mainReleaseTrackLink:
+      (track.isSecondaryRelease
+        ? relation('linkTrack', track.mainReleaseTrack)
+        : null),
+
+    mainReleaseArtistCommentaryEntries:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.commentary
+            .map(entry => relation('generateCommentaryEntry', entry))
+        : null),
+
+    thisReleaseAlbumLink:
+      relation('linkAlbum', track.album),
+
+    artistCommentaryEntries:
+      track.commentary
+        .map(entry => relation('generateCommentaryEntry', entry)),
+
+    otherReleaseTrackLinks:
+      query.otherSecondaryReleasesWithCommentary
+        .map(track => relation('linkTrack', track)),
+  }),
+
+  data: (query, track) => ({
+    name:
+      track.name,
+
+    isSecondaryRelease:
+      track.isSecondaryRelease,
+
+    mainReleaseName:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.name
+        : null),
+
+    mainReleaseAlbumName:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.album.name
+        : null),
+
+    mainReleaseAlbumColor:
+      (track.isSecondaryRelease
+        ? track.mainReleaseTrack.album.color
+        : null),
+
+    otherReleaseAlbumNames:
+      query.otherSecondaryReleasesWithCommentary
+        .map(track => track.album.name),
+
+    otherReleaseAlbumColors:
+      query.otherSecondaryReleasesWithCommentary
+        .map(track => track.album.color),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('misc.artistCommentary', capsule =>
+      html.tags([
+        relations.contentHeading.clone()
+          .slots({
+            attributes: {id: 'artist-commentary'},
+            title: language.$('misc.artistCommentary'),
+          }),
+
+        data.isSecondaryRelease &&
+          html.tags([
+            html.tag('p', {class: ['drop', 'commentary-drop']},
+              {[html.onlyIfSiblings]: true},
+
+              language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => {
+                const workingOptions = {};
+
+                workingOptions.album =
+                  relations.mainReleaseTrackLink.slots({
+                    content:
+                      data.mainReleaseAlbumName,
+
+                    color:
+                      data.mainReleaseAlbumColor,
+                  });
+
+                if (data.name !== data.mainReleaseName) {
+                  workingCapsule += '.namedDifferently';
+                  workingOptions.name =
+                    html.tag('i', data.mainReleaseName);
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })),
+
+            relations.mainReleaseArtistCommentaryEntries,
+          ]),
+
+        html.tags([
+          data.isSecondaryRelease &&
+          !html.isBlank(relations.mainReleaseArtistCommentaryEntries) &&
+            html.tag('p', {class: ['drop', 'commentary-drop']},
+              {[html.onlyIfSiblings]: true},
+
+              language.$(capsule, 'info.releaseSpecific', {
+                album:
+                  relations.thisReleaseAlbumLink,
+              })),
+
+          relations.artistCommentaryEntries,
+        ]),
+
+        html.tag('p', {class: ['drop', 'commentary-drop']},
+          {[html.onlyIfContent]: true},
+
+          language.encapsulate(capsule, 'info.seeSpecificReleases', workingCapsule => {
+            const workingOptions = {};
+
+            workingOptions[language.onlyIfOptions] = ['albums'];
+
+            workingOptions.albums =
+              language.formatUnitList(
+                stitchArrays({
+                  trackLink: relations.otherReleaseTrackLinks,
+                  albumName: data.otherReleaseAlbumNames,
+                  albumColor: data.otherReleaseAlbumColors,
+                }).map(({trackLink, albumName, albumColor}) =>
+                    trackLink.slots({
+                      content: language.sanitize(albumName),
+                      color: albumColor,
+                    })));
+
+            if (!html.isBlank(relations.artistCommentaryEntries)) {
+              workingCapsule += '.withMainCommentary';
+            }
+
+            return language.$(workingCapsule, workingOptions);
+          })),
+      ])),
+};
diff --git a/src/content/dependencies/generateTrackArtworkColumn.js b/src/content/dependencies/generateTrackArtworkColumn.js
new file mode 100644
index 00000000..f06d735b
--- /dev/null
+++ b/src/content/dependencies/generateTrackArtworkColumn.js
@@ -0,0 +1,33 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+  extraDependencies: ['html'],
+
+  relations: (relation, track) => ({
+    albumCover:
+      (!track.hasUniqueCoverArt && track.album.hasCoverArt
+        ? relation('generateCoverArtwork', track.album.coverArtworks[0])
+        : null),
+
+    trackCovers:
+      (track.hasUniqueCoverArt
+        ? track.trackArtworks.map(artwork =>
+            relation('generateCoverArtwork', artwork))
+        : []),
+  }),
+
+  generate: (relations, {html}) =>
+    html.tags([
+      relations.albumCover?.slots({
+        showOriginDetails: true,
+        showArtTagDetails: true,
+        showReferenceDetails: true,
+      }),
+
+      relations.trackCovers.map(cover =>
+        cover.slots({
+          showOriginDetails: true,
+          showArtTagDetails: true,
+          showReferenceDetails: true,
+        })),
+    ]),
+};
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
new file mode 100644
index 00000000..ca6f82b9
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -0,0 +1,435 @@
+export default {
+  contentDependencies: [
+    'generateAdditionalNamesBox',
+    'generateAlbumAdditionalFilesList',
+    'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
+    'generateAlbumSidebar',
+    'generateAlbumStyleRules',
+    'generateCommentaryEntry',
+    'generateContentHeading',
+    'generateContributionList',
+    'generateLyricsSection',
+    'generatePageLayout',
+    'generateTrackArtistCommentarySection',
+    'generateTrackArtworkColumn',
+    'generateTrackInfoPageFeaturedByFlashesList',
+    'generateTrackInfoPageOtherReleasesList',
+    'generateTrackList',
+    'generateTrackListDividedByGroups',
+    'generateTrackNavLinks',
+    'generateTrackReleaseInfo',
+    'generateTrackSocialEmbed',
+    'linkAlbum',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (track) => ({
+    mainReleaseTrack:
+      (track.isMainRelease
+        ? track
+        : track.mainReleaseTrack),
+  }),
+
+  relations: (relation, query, track) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', track.album, track),
+
+    socialEmbed:
+      relation('generateTrackSocialEmbed', track),
+
+    navLinks:
+      relation('generateTrackNavLinks', track),
+
+    albumNavAccent:
+      relation('generateAlbumNavAccent', track.album, track),
+
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', track.album),
+
+    sidebar:
+      relation('generateAlbumSidebar', track.album, track),
+
+    additionalNamesBox:
+      relation('generateAdditionalNamesBox', track.additionalNames),
+
+    artworkColumn:
+      relation('generateTrackArtworkColumn', track),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    releaseInfo:
+      relation('generateTrackReleaseInfo', track),
+
+    otherReleasesList:
+      relation('generateTrackInfoPageOtherReleasesList', track),
+
+    contributorContributionList:
+      relation('generateContributionList', track.contributorContribs),
+
+    referencedTracksList:
+      relation('generateTrackList', track.referencedTracks),
+
+    sampledTracksList:
+      relation('generateTrackList', track.sampledTracks),
+
+    referencedByTracksList:
+      relation('generateTrackListDividedByGroups',
+        query.mainReleaseTrack.referencedByTracks),
+
+    sampledByTracksList:
+      relation('generateTrackListDividedByGroups',
+        query.mainReleaseTrack.sampledByTracks),
+
+    flashesThatFeatureList:
+      relation('generateTrackInfoPageFeaturedByFlashesList', track),
+
+    lyricsSection:
+      relation('generateLyricsSection', track.lyrics),
+
+    sheetMusicFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        track.album,
+        track.sheetMusicFiles),
+
+    midiProjectFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        track.album,
+        track.midiProjectFiles),
+
+    additionalFilesList:
+      relation('generateAlbumAdditionalFilesList',
+        track.album,
+        track.additionalFiles),
+
+    artistCommentarySection:
+      relation('generateTrackArtistCommentarySection', track),
+
+    creditSourceEntries:
+      track.creditSources
+        .map(entry => relation('generateCommentaryEntry', entry)),
+  }),
+
+  data: (_query, track) => ({
+    name:
+      track.name,
+
+    color:
+      track.color,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('trackPage', pageCapsule =>
+      relations.layout.slots({
+        title:
+          language.$(pageCapsule, 'title', {
+            track: data.name,
+          }),
+
+        headingMode: 'sticky',
+
+        additionalNames: relations.additionalNamesBox,
+
+        color: data.color,
+        styleRules: [relations.albumStyleRules],
+
+        artworkColumnContent:
+          relations.artworkColumn,
+
+        mainContent: [
+          relations.releaseInfo,
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            language.encapsulate('releaseInfo', capsule => [
+              !html.isBlank(relations.sheetMusicFilesList) &&
+                language.encapsulate(capsule, 'sheetMusicFiles.shortcut', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#sheet-music-files'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.midiProjectFilesList) &&
+                language.encapsulate(capsule, 'midiProjectFiles.shortcut', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#midi-project-files'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.additionalFilesList) &&
+                language.encapsulate(capsule, 'additionalFiles.shortcut', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#midi-project-files'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.artistCommentarySection) &&
+                language.encapsulate(capsule, 'readCommentary', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#artist-commentary'},
+                        language.$(capsule, 'link')),
+                  })),
+
+              !html.isBlank(relations.creditSourceEntries) &&
+                language.encapsulate(capsule, 'readCreditSources', capsule =>
+                  language.$(capsule, {
+                    link:
+                      html.tag('a',
+                        {href: '#credit-sources'},
+                        language.$(capsule, 'link')),
+                  })),
+            ])),
+
+          relations.otherReleasesList,
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'contributors'},
+                title: language.$('releaseInfo.contributors'),
+              }),
+
+            relations.contributorContributionList.slots({
+              chronologyKind: 'trackContribution',
+            }),
+          ]),
+
+          html.tags([
+            language.encapsulate('releaseInfo.tracksReferenced', capsule =>
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'references'},
+
+                  title:
+                    language.$(capsule, {
+                      track:
+                        html.tag('i', data.name),
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                })),
+
+            relations.referencedTracksList,
+          ]),
+
+          html.tags([
+            language.encapsulate('releaseInfo.tracksSampled', capsule =>
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'samples'},
+
+                  title:
+                    language.$(capsule, {
+                      track:
+                        html.tag('i', data.name),
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                })),
+
+            relations.sampledTracksList,
+          ]),
+
+          language.encapsulate('releaseInfo.tracksThatReference', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'referenced-by'},
+
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                }),
+
+              relations.referencedByTracksList
+                .slots({
+                  headingString: capsule,
+                }),
+            ])),
+
+          language.encapsulate('releaseInfo.tracksThatSample', capsule =>
+            html.tags([
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'sampled-by'},
+
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                }),
+
+              relations.sampledByTracksList
+                .slots({
+                  headingString: capsule,
+                }),
+            ])),
+
+          html.tags([
+            language.encapsulate('releaseInfo.flashesThatFeature', capsule =>
+              relations.contentHeading.clone()
+                .slots({
+                  attributes: {id: 'featured-in'},
+
+                  title:
+                    language.$(capsule, {
+                      track: html.tag('i', data.name),
+                    }),
+
+                  stickyTitle:
+                    language.$(capsule, 'sticky'),
+                })),
+
+            relations.flashesThatFeatureList,
+          ]),
+
+          relations.lyricsSection,
+
+          // html.tags([
+          //   relations.contentHeading.clone()
+          //     .slots({
+          //       attributes: {id: 'lyrics'},
+          //       title: language.$('releaseInfo.lyrics'),
+          //     }),
+
+          //   html.tag('blockquote',
+          //     {[html.onlyIfContent]: true},
+          //     relations.lyrics.slot('mode', 'lyrics')),
+          // ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'sheet-music-files'},
+                title: language.$('releaseInfo.sheetMusicFiles.heading'),
+              }),
+
+            relations.sheetMusicFilesList,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'midi-project-files'},
+                title: language.$('releaseInfo.midiProjectFiles.heading'),
+              }),
+
+            relations.midiProjectFilesList,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'additional-files'},
+                title: language.$('releaseInfo.additionalFiles.heading'),
+              }),
+
+            relations.additionalFilesList,
+          ]),
+
+          relations.artistCommentarySection,
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                attributes: {id: 'credit-sources'},
+                title: language.$('misc.creditSources'),
+              }),
+
+            relations.creditSourceEntries,
+          ]),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: html.resolve(relations.navLinks),
+
+        navBottomRowContent:
+          relations.albumNavAccent.slots({
+            showTrackNavigation: true,
+            showExtraLinks: false,
+          }),
+
+        secondaryNav:
+          relations.secondaryNav
+            .slot('mode', 'track'),
+
+        leftSidebar: relations.sidebar,
+
+        socialEmbed: relations.socialEmbed,
+      })),
+};
+
+/*
+  const data = {
+    type: 'data',
+    path: ['track', track.directory],
+    data: ({
+      serializeContribs,
+      serializeCover,
+      serializeGroupsForTrack,
+      serializeLink,
+    }) => ({
+      name: track.name,
+      directory: track.directory,
+      dates: {
+        released: track.date,
+        originallyReleased: track.originalDate,
+        coverArtAdded: track.coverArtDate,
+      },
+      duration: track.duration,
+      color: track.color,
+      cover: serializeCover(track, getTrackCover),
+      artistsContribs: serializeContribs(track.artistContribs),
+      contributorContribs: serializeContribs(track.contributorContribs),
+      coverArtistContribs: serializeContribs(track.coverArtistContribs || []),
+      album: serializeLink(track.album),
+      groups: serializeGroupsForTrack(track),
+      references: track.references.map(serializeLink),
+      referencedBy: track.referencedBy.map(serializeLink),
+      alsoReleasedAs: otherReleases.map((track) => ({
+        track: serializeLink(track),
+        album: serializeLink(track.album),
+      })),
+    }),
+  };
+
+  const page = {
+    page: () => {
+      return {
+        theme:
+          getThemeString(track.color, {
+            additionalVariables: [
+              `--album-directory: ${album.directory}`,
+              `--track-directory: ${track.directory}`,
+            ]
+          }),
+      };
+    },
+  };
+*/
diff --git a/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js
new file mode 100644
index 00000000..61654512
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js
@@ -0,0 +1,63 @@
+import {sortFlashesChronologically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkFlash', 'linkTrack'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    enableFlashesAndGames:
+      wikiInfo.enableFlashesAndGames,
+  }),
+
+  query: (sprawl, track) => ({
+    sortedFeatures:
+      (sprawl.enableFlashesAndGames
+        ? sortFlashesChronologically(
+            track.allReleases.flatMap(track =>
+              track.featuredInFlashes.map(flash => ({
+                flash,
+                track,
+
+                // These properties are only used for the sort.
+                act: flash.act,
+                date: flash.date,
+              }))))
+        : []),
+  }),
+
+  relations: (relation, query, _sprawl, track) => ({
+    flashLinks:
+      query.sortedFeatures
+        .map(({flash}) => relation('linkFlash', flash)),
+
+    trackLinks:
+      query.sortedFeatures
+        .map(({track: directlyFeaturedTrack}) =>
+          (directlyFeaturedTrack === track
+            ? null
+         : directlyFeaturedTrack.name === track.name
+            ? null
+            : relation('linkTrack', directlyFeaturedTrack))),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tag('ul',
+      {[html.onlyIfContent]: true},
+
+      stitchArrays({
+        flashLink: relations.flashLinks,
+        trackLink: relations.trackLinks,
+      }).map(({flashLink, trackLink}) => {
+          const attributes = html.attributes();
+          const parts = ['releaseInfo.flashesThatFeature.item'];
+          const options = {flash: flashLink};
+
+          if (trackLink) {
+            parts.push('asDifferentRelease');
+            options.track = trackLink;
+          }
+
+          return html.tag('li', attributes, language.$(...parts, options));
+        })),
+};
diff --git a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js
new file mode 100644
index 00000000..ebd76577
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js
@@ -0,0 +1,42 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    trackLinks:
+      track.otherReleases
+        .map(track => relation('linkTrack', track)),
+  }),
+
+  data: (track) => ({
+    albumNames:
+      track.otherReleases
+        .map(track => track.album.name),
+
+    albumColors:
+      track.otherReleases
+        .map(track => track.album.color),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    html.tag('p',
+      {[html.onlyIfContent]: true},
+
+      language.$('releaseInfo.alsoReleasedOn', {
+        [language.onlyIfOptions]: ['albums'],
+
+        albums:
+          language.formatConjunctionList(
+            stitchArrays({
+              trackLink: relations.trackLinks,
+              albumName: data.albumNames,
+              albumColor: data.albumColors,
+            }).map(({trackLink, albumName, albumColor}) =>
+                trackLink.slots({
+                  content: language.sanitize(albumName),
+                  color: albumColor,
+                }))),
+      })),
+};
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
new file mode 100644
index 00000000..53a32536
--- /dev/null
+++ b/src/content/dependencies/generateTrackList.js
@@ -0,0 +1,28 @@
+export default {
+  contentDependencies: ['generateTrackListItem'],
+  extraDependencies: ['html'],
+
+  relations: (relation, tracks) => ({
+    items:
+      tracks
+        .map(track => relation('generateTrackListItem', track, [])),
+  }),
+
+  slots: {
+    colorMode: {
+      validate: v => v.is('none', 'track', 'line'),
+      default: 'track',
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('ul',
+      {[html.onlyIfContent]: true},
+
+      relations.items.map(item =>
+        item.slots({
+          showArtists: true,
+          showDuration: false,
+          colorMode: slots.colorMode,
+        }))),
+};
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
new file mode 100644
index 00000000..230868d6
--- /dev/null
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -0,0 +1,145 @@
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateTrackList',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    divideTrackListsByGroups:
+      wikiInfo.divideTrackListsByGroups,
+  }),
+
+  query(sprawl, tracks) {
+    const dividingGroups = sprawl.divideTrackListsByGroups;
+
+    const groupings = new Map();
+    const ungroupedTracks = [];
+
+    // Entry order matters! Add blank lists for each group
+    // in the order that those groups are provided.
+    for (const group of dividingGroups) {
+      groupings.set(group, []);
+    }
+
+    for (const track of tracks) {
+      const firstMatchingGroup =
+        dividingGroups.find(group => group.albums.includes(track.album));
+
+      if (firstMatchingGroup) {
+        groupings.get(firstMatchingGroup).push(track);
+      } else {
+        ungroupedTracks.push(track);
+      }
+    }
+
+    const groups = Array.from(groupings.keys());
+    const groupedTracks = Array.from(groupings.values());
+
+    // Drop the empty lists, so just the groups which
+    // at least a single track matched are left.
+    filterMultipleArrays(
+      groups,
+      groupedTracks,
+      (_group, tracks) => !empty(tracks));
+
+    return {groups, groupedTracks, ungroupedTracks};
+  },
+
+  relations: (relation, query, sprawl, tracks) => ({
+    flatList:
+      (empty(sprawl.divideTrackListsByGroups)
+        ? relation('generateTrackList', tracks)
+        : null),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    groupLinks:
+      query.groups
+        .map(group => relation('linkGroup', group)),
+
+    groupedTrackLists:
+      query.groupedTracks
+        .map(tracks => relation('generateTrackList', tracks)),
+
+    ungroupedTrackList:
+      (empty(query.ungroupedTracks)
+        ? null
+        : relation('generateTrackList', query.ungroupedTracks)),
+  }),
+
+  data: (query, _sprawl, _tracks) => ({
+    groupNames:
+      query.groups
+        .map(group => group.name),
+  }),
+
+  slots: {
+    headingString: {
+      type: 'string',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.flatList ??
+
+    html.tag('dl',
+      {[html.onlyIfContent]: true},
+
+      language.encapsulate('trackList', listCapsule => [
+        stitchArrays({
+          groupName: data.groupNames,
+          groupLink: relations.groupLinks,
+          trackList: relations.groupedTrackLists,
+        }).map(({
+            groupName,
+            groupLink,
+            trackList,
+          }) => [
+            language.encapsulate(listCapsule, 'fromGroup', capsule =>
+              (slots.headingString
+                ? relations.contentHeading.clone().slots({
+                    tag: 'dt',
+
+                    title:
+                      language.$(capsule, {
+                        group: groupLink
+                      }),
+
+                    stickyTitle:
+                      language.$(slots.headingString, 'sticky', 'fromGroup', {
+                        group: groupName,
+                      }),
+                  })
+                : html.tag('dt',
+                    language.$(capsule, {
+                      group: groupLink
+                    })))),
+
+            html.tag('dd', trackList),
+          ]),
+
+        relations.ungroupedTrackList && [
+          language.encapsulate(listCapsule, 'fromOther', capsule =>
+            (slots.headingString
+              ? relations.contentHeading.clone().slots({
+                  tag: 'dt',
+
+                  title:
+                    language.$(capsule),
+
+                  stickyTitle:
+                    language.$(slots.headingString, 'sticky', 'fromOther'),
+                })
+              : html.tag('dt',
+                  language.$(capsule)))),
+
+          html.tag('dd', relations.ungroupedTrackList),
+        ],
+      ])),
+};
diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js
new file mode 100644
index 00000000..887b6f03
--- /dev/null
+++ b/src/content/dependencies/generateTrackListItem.js
@@ -0,0 +1,106 @@
+export default {
+  contentDependencies: [
+    'generateArtistCredit',
+    'generateColorStyleAttribute',
+    'generateTrackListMissingDuration',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track, contextContributions) => ({
+    trackLink:
+      relation('linkTrack', track),
+
+    credit:
+      relation('generateArtistCredit',
+        track.artistContribs,
+        contextContributions),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', track.color),
+
+    missingDuration:
+      (track.duration
+        ? null
+        : relation('generateTrackListMissingDuration')),
+  }),
+
+  data: (track, _contextContributions) => ({
+    duration:
+      track.duration ?? 0,
+
+    trackHasDuration:
+      !!track.duration,
+  }),
+
+  slots: {
+    // showArtists enables showing artists *at all.* It doesn't take precedence
+    // over behavior which automatically collapses (certain) artists because of
+    // provided context contributions.
+    showArtists: {
+      type: 'boolean',
+      default: true,
+    },
+
+    // If true and the track doesn't have a duration, a missing-duration cue
+    // will be displayed instead.
+    showDuration: {
+      type: 'boolean',
+      default: false,
+    },
+
+    colorMode: {
+      validate: v => v.is('none', 'track', 'line'),
+      default: 'track',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('trackList.item', itemCapsule =>
+      html.tag('li',
+        slots.colorMode === 'line' &&
+          relations.colorStyle.slot('context', 'primary-only'),
+
+        language.encapsulate(itemCapsule, workingCapsule => {
+          const workingOptions = {};
+
+          workingOptions.track =
+            relations.trackLink
+              .slot('color', slots.colorMode === 'track');
+
+          if (slots.showDuration) {
+            workingCapsule += '.withDuration';
+            workingOptions.duration =
+              (data.trackHasDuration
+                ? language.$(itemCapsule, 'withDuration.duration', {
+                    duration:
+                      language.formatDuration(data.duration),
+                  })
+                : relations.missingDuration);
+          }
+
+          const artistCapsule = language.encapsulate(itemCapsule, 'withArtists');
+
+          relations.credit.setSlots({
+            normalStringKey:
+              artistCapsule + '.by',
+
+            featuringStringKey:
+              artistCapsule + '.featuring',
+
+            normalFeaturingStringKey:
+              artistCapsule + '.by.featuring',
+          });
+
+          if (!html.isBlank(relations.credit)) {
+            workingCapsule += '.withArtists';
+            workingOptions.by =
+              html.tag('span', {class: 'by'},
+                html.metatag('chunkwrap', {split: ','},
+                  html.resolve(relations.credit)));
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }))),
+};
diff --git a/src/content/dependencies/generateTrackListMissingDuration.js b/src/content/dependencies/generateTrackListMissingDuration.js
new file mode 100644
index 00000000..b5917982
--- /dev/null
+++ b/src/content/dependencies/generateTrackListMissingDuration.js
@@ -0,0 +1,35 @@
+export default {
+  contentDependencies: ['generateTextWithTooltip', 'generateTooltip'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateTooltip'),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('trackList.item.withDuration', itemCapsule =>
+      language.encapsulate(itemCapsule, 'duration', durationCapsule =>
+        relations.textWithTooltip.slots({
+          attributes: {class: 'missing-duration'},
+          customInteractionCue: true,
+
+          text:
+            language.$(durationCapsule, {
+              duration:
+                html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
+                  language.$(durationCapsule, 'missing')),
+            }),
+
+          tooltip:
+            relations.tooltip.slots({
+              attributes: {class: 'missing-duration-tooltip'},
+
+              content:
+                language.$(durationCapsule, 'missing.info'),
+            }),
+        }))),
+};
diff --git a/src/content/dependencies/generateTrackNavLinks.js b/src/content/dependencies/generateTrackNavLinks.js
new file mode 100644
index 00000000..6a8b7c64
--- /dev/null
+++ b/src/content/dependencies/generateTrackNavLinks.js
@@ -0,0 +1,64 @@
+export default {
+  contentDependencies: ['linkAlbum', 'linkTrack'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    albumLink:
+      relation('linkAlbum', track.album),
+
+    trackLink:
+      relation('linkTrack', track),
+  }),
+
+  data: (track) => ({
+    hasTrackNumbers:
+      track.album.hasTrackNumbers,
+
+    trackNumber:
+      track.trackNumber,
+  }),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('referenced-art', 'referencing-art'),
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('trackPage.nav', navCapsule => [
+      {auto: 'home'},
+
+      {html: relations.albumLink.slot('color', false)},
+
+      {
+        html:
+          language.encapsulate(navCapsule, 'track', workingCapsule => {
+            const workingOptions = {};
+
+            workingOptions.track =
+              relations.trackLink
+                .slot('attributes', {class: 'current'});
+
+            if (data.hasTrackNumbers) {
+              workingCapsule += '.withNumber';
+              workingOptions.number = data.trackNumber;
+            }
+
+            return language.$(workingCapsule, workingOptions);
+          }),
+
+        accent:
+          html.tag('a',
+            {[html.onlyIfContent]: true},
+
+            {href: ''},
+            {class: 'current'},
+
+            (slots.currentExtra === 'referenced-art'
+              ? language.$('referencedArtworksPage.subtitle')
+           : slots.currentExtra === 'referencing-art'
+              ? language.$('referencingArtworksPage.subtitle')
+              : null)),
+      },
+    ]),
+};
diff --git a/src/content/dependencies/generateTrackReferencedArtworksPage.js b/src/content/dependencies/generateTrackReferencedArtworksPage.js
new file mode 100644
index 00000000..93438c5b
--- /dev/null
+++ b/src/content/dependencies/generateTrackReferencedArtworksPage.js
@@ -0,0 +1,47 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleRules',
+    'generateBackToTrackLink',
+    'generateReferencedArtworksPage',
+    'generateTrackNavLinks',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    page:
+      relation('generateReferencedArtworksPage', track.trackArtworks[0]),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', track.album, track),
+
+    navLinks:
+      relation('generateTrackNavLinks', track),
+
+    backToTrackLink:
+      relation('generateBackToTrackLink', track),
+  }),
+
+  data: (track) => ({
+    name:
+      track.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.page.slots({
+      title:
+        language.$('trackPage.title', {
+          track:
+            data.name,
+        }),
+
+      styleRules: [relations.albumStyleRules],
+
+      navLinks:
+        html.resolve(
+          relations.navLinks
+            .slot('currentExtra', 'referenced-art')),
+
+      navBottomRowContent: relations.backToTrackLink,
+    }),
+};
diff --git a/src/content/dependencies/generateTrackReferencingArtworksPage.js b/src/content/dependencies/generateTrackReferencingArtworksPage.js
new file mode 100644
index 00000000..e9818bad
--- /dev/null
+++ b/src/content/dependencies/generateTrackReferencingArtworksPage.js
@@ -0,0 +1,47 @@
+export default {
+  contentDependencies: [
+    'generateAlbumStyleRules',
+    'generateBackToTrackLink',
+    'generateReferencingArtworksPage',
+    'generateTrackNavLinks',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    page:
+      relation('generateReferencingArtworksPage', track.trackArtworks[0]),
+
+    albumStyleRules:
+      relation('generateAlbumStyleRules', track.album, track),
+
+    navLinks:
+      relation('generateTrackNavLinks', track),
+
+    backToTrackLink:
+      relation('generateBackToTrackLink', track),
+  }),
+
+  data: (track) => ({
+    name:
+      track.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.page.slots({
+      title:
+        language.$('trackPage.title', {
+          track:
+            data.name,
+        }),
+
+      styleRules: [relations.albumStyleRules],
+
+      navLinks:
+        html.resolve(
+          relations.navLinks
+            .slot('currentExtra', 'referencing-art')),
+
+      navBottomRowContent: relations.backToTrackLink,
+    }),
+};
diff --git a/src/content/dependencies/generateTrackReleaseBox.js b/src/content/dependencies/generateTrackReleaseBox.js
new file mode 100644
index 00000000..ef02e2b9
--- /dev/null
+++ b/src/content/dependencies/generateTrackReleaseBox.js
@@ -0,0 +1,46 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generatePageSidebarBox',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, track) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', track.album.color),
+
+    trackLink:
+      relation('linkTrack', track),
+  }),
+
+  data: (track) => ({
+    albumName:
+      track.album.name,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('albumSidebar.releaseBox', boxCapsule =>
+      relations.box.slots({
+        attributes: [
+          {class: 'track-release-sidebar-box'},
+          relations.colorStyle,
+        ],
+
+        content: [
+          html.tag('h1',
+            language.$(boxCapsule, 'title', {
+              album:
+                relations.trackLink.slots({
+                  color: false,
+                  content:
+                    language.sanitize(data.albumName),
+                }),
+            })),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
new file mode 100644
index 00000000..54e462c7
--- /dev/null
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -0,0 +1,82 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateReleaseInfoContributionsLine',
+    'linkExternal',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, track) {
+    const relations = {};
+
+    relations.artistContributionLinks =
+      relation('generateReleaseInfoContributionsLine', track.artistContribs);
+
+    if (!empty(track.urls)) {
+      relations.externalLinks =
+        track.urls.map(url =>
+          relation('linkExternal', url));
+    }
+
+    return relations;
+  },
+
+  data(track) {
+    const data = {};
+
+    data.name = track.name;
+    data.date = track.date;
+    data.duration = track.duration;
+
+    if (
+      track.hasUniqueCoverArt &&
+      +track.coverArtDate !== +track.date
+    ) {
+      data.coverArtDate = track.coverArtDate;
+    }
+
+    return data;
+  },
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule =>
+      html.tags([
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.artistContributionLinks.slots({
+              stringKey: capsule + '.by',
+              featuringStringKey: capsule + '.by.featuring',
+              chronologyKind: 'track',
+            }),
+
+            language.$(capsule, 'released', {
+              [language.onlyIfOptions]: ['date'],
+              date: language.formatDate(data.date),
+            }),
+
+            language.$(capsule, 'duration', {
+              [language.onlyIfOptions]: ['duration'],
+              duration: language.formatDuration(data.duration),
+            }),
+          ]),
+
+        html.tag('p',
+          language.encapsulate(capsule, 'listenOn', capsule =>
+            (relations.externalLinks
+              ? language.$(capsule, {
+                  links:
+                    language.formatDisjunctionList(
+                      relations.externalLinks
+                        .map(link => link.slot('context', 'track'))),
+                })
+              : language.$(capsule, 'noLinks', {
+                  name:
+                    html.tag('i', data.name),
+                })))),
+      ])),
+};
diff --git a/src/content/dependencies/generateTrackSocialEmbed.js b/src/content/dependencies/generateTrackSocialEmbed.js
new file mode 100644
index 00000000..7cb37af2
--- /dev/null
+++ b/src/content/dependencies/generateTrackSocialEmbed.js
@@ -0,0 +1,68 @@
+export default {
+  contentDependencies: [
+    'generateSocialEmbed',
+    'generateTrackSocialEmbedDescription',
+  ],
+
+  extraDependencies: ['absoluteTo', 'language'],
+
+  relations(relation, track) {
+    return {
+      socialEmbed:
+        relation('generateSocialEmbed'),
+
+      description:
+        relation('generateTrackSocialEmbedDescription', track),
+    };
+  },
+
+  data(track) {
+    const {album} = track;
+    const data = {};
+
+    data.trackName = track.name;
+    data.albumName = album.name;
+
+    data.trackDirectory = track.directory;
+    data.albumDirectory = album.directory;
+
+    if (track.hasUniqueCoverArt) {
+      data.imageSource = 'track';
+      data.coverArtFileExtension = track.coverArtFileExtension;
+    } else if (album.hasCoverArt) {
+      data.imageSource = 'album';
+      data.coverArtFileExtension = album.coverArtFileExtension;
+    } else {
+      data.imageSource = 'none';
+    }
+
+    return data;
+  },
+
+  generate: (data, relations, {absoluteTo, language}) =>
+    language.encapsulate('trackPage.socialEmbed', embedCapsule =>
+      relations.socialEmbed.slots({
+        title:
+          language.$(embedCapsule, 'title', {
+            track: data.trackName,
+          }),
+
+        description:
+          relations.description,
+
+        headingContent:
+          language.$(embedCapsule, 'heading', {
+            album: data.albumName,
+          }),
+
+        headingLink:
+          absoluteTo('localized.album', data.albumDirectory),
+
+        imagePath:
+          (data.imageSource === 'album'
+            ? ['media.albumCover', data.albumDirectory, data.coverArtFileExtension]
+         : data.imageSource === 'track'
+            ? ['media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension]
+            : null),
+      })),
+};
diff --git a/src/content/dependencies/generateTrackSocialEmbedDescription.js b/src/content/dependencies/generateTrackSocialEmbedDescription.js
new file mode 100644
index 00000000..4706aa26
--- /dev/null
+++ b/src/content/dependencies/generateTrackSocialEmbedDescription.js
@@ -0,0 +1,39 @@
+import {empty} from '#sugar';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data: (track) => ({
+    artistNames:
+      track.artistContribs
+        .map(contrib => contrib.artist.name),
+
+    coverArtistNames:
+      track.coverArtistContribs
+        .map(contrib => contrib.artist.name),
+  }),
+
+  generate: (data, {html, language}) =>
+    language.encapsulate('trackPage.socialEmbed.body', baseCapsule =>
+      language.encapsulate(baseCapsule, workingCapsule => {
+        const workingOptions = {};
+
+        if (!empty(data.artistNames)) {
+          workingCapsule += '.withArtists';
+          workingOptions.artists =
+            language.formatConjunctionList(data.artistNames);
+        }
+
+        if (!empty(data.coverArtistNames)) {
+          workingCapsule += '.withCoverArtists';
+          workingOptions.coverArtists =
+            language.formatConjunctionList(data.coverArtistNames);
+        }
+
+        if (workingCapsule === baseCapsule) {
+          return html.blank();
+        } else {
+          return language.$(workingCapsule, workingOptions);
+        }
+      })),
+};
diff --git a/src/content/dependencies/generateUnsafeMunchy.js b/src/content/dependencies/generateUnsafeMunchy.js
new file mode 100644
index 00000000..c11aadc7
--- /dev/null
+++ b/src/content/dependencies/generateUnsafeMunchy.js
@@ -0,0 +1,10 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    contentSource: {type: 'string'},
+  },
+
+  generate: (slots, {html}) =>
+    new html.Tag(null, null, slots.contentSource),
+};
diff --git a/src/content/dependencies/generateWikiHomepageActionsRow.js b/src/content/dependencies/generateWikiHomepageActionsRow.js
new file mode 100644
index 00000000..9f501099
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageActionsRow.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['generateGridActionLinks', 'transformContent'],
+
+  relations: (relation, row) => ({
+    template:
+      relation('generateGridActionLinks'),
+
+    links:
+      row.actionLinks
+        .map(content => relation('transformContent', content)),
+  }),
+
+  generate: (relations) =>
+    relations.template.slots({
+      actionLinks:
+        relations.links
+          .map(contents =>
+            contents
+              .slot('mode', 'single-link')
+              .content),
+    }),
+};
diff --git a/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js
new file mode 100644
index 00000000..b45bfc19
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['generateCoverCarousel', 'image', 'linkAlbum'],
+
+  relations: (relation, row) => ({
+    coverCarousel:
+      relation('generateCoverCarousel'),
+
+    links:
+      row.albums
+        .map(album => relation('linkAlbum', album)),
+
+    images:
+      row.albums
+        .map(album => relation('image', album.coverArtworks[0])),
+  }),
+
+  generate: (relations) =>
+    relations.coverCarousel.slots({
+      links: relations.links,
+      images: relations.images,
+    }),
+};
diff --git a/src/content/dependencies/generateWikiHomepageAlbumGridRow.js b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js
new file mode 100644
index 00000000..a00136ba
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js
@@ -0,0 +1,78 @@
+import {empty, stitchArrays} from '#sugar';
+import {getNewAdditions, getNewReleases} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateCoverGrid', 'image', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}, row) {
+    const sprawl = {};
+
+    switch (row.sourceGroup) {
+      case 'new-releases':
+        sprawl.albums = getNewReleases(row.countAlbumsFromGroup, {albumData});
+        break;
+
+      case 'new-additions':
+        sprawl.albums = getNewAdditions(row.countAlbumsFromGroup, {albumData});
+        break;
+
+      default:
+        sprawl.albums =
+          (row.sourceGroup
+            ? row.sourceGroup.albums
+                .slice()
+                .reverse()
+                .filter(album => album.isListedOnHomepage)
+                .slice(0, row.countAlbumsFromGroup)
+            : []);
+    }
+
+    if (!empty(row.sourceAlbums)) {
+      sprawl.albums.push(...row.sourceAlbums);
+    }
+
+    return sprawl;
+  },
+
+  relations: (relation, sprawl, _row) => ({
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      sprawl.albums
+        .map(album => relation('linkAlbum', album)),
+
+    images:
+      sprawl.albums
+        .map(album =>
+          relation('image',
+            (album.hasCoverArt
+              ? album.coverArtworks[0]
+              : null))),
+  }),
+
+  data: (sprawl, _row) => ({
+    names:
+      sprawl.albums
+        .map(album => album.name),
+  }),
+
+  generate: (data, relations, {language}) =>
+    relations.coverGrid.slots({
+      links: relations.links,
+      names: data.names,
+
+      images:
+        stitchArrays({
+          image: relations.images,
+          name: data.names,
+        }).map(({image, name}) =>
+            image.slots({
+              missingSourceContent:
+                language.$('misc.coverGrid.noCoverArt', {
+                  album: name,
+                }),
+              })),
+    }),
+};
diff --git a/src/content/dependencies/generateWikiHomepageNewsBox.js b/src/content/dependencies/generateWikiHomepageNewsBox.js
new file mode 100644
index 00000000..83a27695
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageNewsBox.js
@@ -0,0 +1,86 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkNewsEntry',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({newsData}) => ({
+    entries:
+      newsData.slice(0, 3),
+  }),
+
+  relations: (relation, sprawl) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    entryContents:
+      sprawl.entries
+        .map(entry => relation('transformContent', entry.contentShort)),
+
+    entryMainLinks:
+      sprawl.entries
+        .map(entry => relation('linkNewsEntry', entry)),
+
+    entryReadMoreLinks:
+      sprawl.entries
+        .map(entry =>
+          entry.contentShort !== entry.content &&
+            relation('linkNewsEntry', entry)),
+  }),
+
+  data: (sprawl) => ({
+    entryDates:
+      sprawl.entries
+        .map(entry => entry.date),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('homepage.news', boxCapsule =>
+      relations.box.slots({
+        attributes: {class: 'latest-news-sidebar-box'},
+        collapsible: false,
+
+        content: [
+          html.tag('h1',
+            {[html.onlyIfSiblings]: true},
+            language.$(boxCapsule, 'title')),
+
+          stitchArrays({
+            date: data.entryDates,
+            content: relations.entryContents,
+            mainLink: relations.entryMainLinks,
+            readMoreLink: relations.entryReadMoreLinks,
+          }).map(({
+              date,
+              content,
+              mainLink,
+              readMoreLink,
+            }, index) =>
+              language.encapsulate(boxCapsule, 'entry', entryCapsule =>
+                html.tag('article', {class: 'news-entry'},
+                  index === 0 &&
+                    {class: 'first-news-entry'},
+
+                  [
+                    html.tag('h2', [
+                      html.tag('time', language.formatDate(date)),
+                      mainLink,
+                    ]),
+
+                    content.slot('thumb', 'medium'),
+
+                    html.tag('p',
+                      {[html.onlyIfContent]: true},
+                      readMoreLink
+                        ?.slots({
+                          content: language.$(entryCapsule, 'viewRest'),
+                        })),
+                  ]))),
+        ],
+      })),
+};
diff --git a/src/content/dependencies/generateWikiHomepagePage.js b/src/content/dependencies/generateWikiHomepagePage.js
new file mode 100644
index 00000000..8c09a007
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepagePage.js
@@ -0,0 +1,97 @@
+export default {
+  contentDependencies: [
+    'generatePageLayout',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'generateWikiHomepageNewsBox',
+    'generateWikiHomepageSection',
+    'transformContent',
+  ],
+
+  extraDependencies: ['wikiData'],
+
+  sprawl: ({wikiInfo}) => ({
+    wikiName:
+      wikiInfo.name,
+
+    enableNews:
+      wikiInfo.enableNews,
+  }),
+
+  relations: (relation, sprawl, homepageLayout) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    sidebar:
+      relation('generatePageSidebar'),
+
+    customSidebarBox:
+      relation('generatePageSidebarBox'),
+
+    customSidebarContent:
+      relation('transformContent', homepageLayout.sidebarContent),
+
+    newsSidebarBox:
+      (sprawl.enableNews
+        ? relation('generateWikiHomepageNewsBox')
+        : null),
+
+    customNavLinkContents:
+      homepageLayout.navbarLinks
+        .map(content => relation('transformContent', content)),
+
+    sections:
+      homepageLayout.sections
+        .map(section => relation('generateWikiHomepageSection', section)),
+  }),
+
+  data: (sprawl) => ({
+    wikiName:
+      sprawl.wikiName,
+  }),
+
+  generate: (data, relations) =>
+    relations.layout.slots({
+      title: data.wikiName,
+      showWikiNameInTitle: false,
+
+      mainClasses: ['top-index'],
+      headingMode: 'static',
+
+      mainContent: [
+        relations.sections,
+      ],
+
+      leftSidebar:
+        relations.sidebar.slots({
+          wide: true,
+
+          boxes: [
+            relations.customSidebarBox.slots({
+              attributes: {class: 'custom-content-sidebar-box'},
+              collapsible: false,
+
+              content:
+                relations.customSidebarContent
+                  .slot('mode', 'multiline'),
+            }),
+
+            relations.newsSidebarBox,
+          ],
+        }),
+
+      navLinkStyle: 'index',
+      navLinks: [
+        {auto: 'home', current: true},
+
+        ...
+          relations.customNavLinkContents.map(content => ({
+            html:
+              content.slots({
+                mode: 'single-link',
+                preferShortLinkNames: true,
+              }),
+          })),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateWikiHomepageSection.js b/src/content/dependencies/generateWikiHomepageSection.js
new file mode 100644
index 00000000..49a474da
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomepageSection.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateWikiHomepageActionsRow',
+    'generateWikiHomepageAlbumCarouselRow',
+    'generateWikiHomepageAlbumGridRow',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, homepageSection) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute', homepageSection.color),
+
+    rows:
+      homepageSection.rows.map(row =>
+        (row.type === 'actions'
+          ? relation('generateWikiHomepageActionsRow', row)
+       : row.type === 'album carousel'
+          ? relation('generateWikiHomepageAlbumCarouselRow', row)
+       : row.type === 'album grid'
+          ? relation('generateWikiHomepageAlbumGridRow', row)
+          : null)),
+  }),
+
+  data: (homepageSection) => ({
+    name:
+      homepageSection.name,
+  }),
+
+  generate: (data, relations, {html}) =>
+    html.tag('section',
+      relations.colorStyle,
+
+      [
+        html.tag('h2', data.name),
+        relations.rows,
+      ]),
+};
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
new file mode 100644
index 00000000..bf47b14f
--- /dev/null
+++ b/src/content/dependencies/image.js
@@ -0,0 +1,374 @@
+import {logWarn} from '#cli';
+import {empty} from '#sugar';
+
+export default {
+  extraDependencies: [
+    'checkIfImagePathHasCachedThumbnails',
+    'getDimensionsOfImagePath',
+    'getSizeOfMediaFile',
+    'getThumbnailEqualOrSmaller',
+    'getThumbnailsAvailableForDimensions',
+    'html',
+    'language',
+    'missingImagePaths',
+    'to',
+  ],
+
+  contentDependencies: ['generateColorStyleAttribute'],
+
+  relations: (relation, _artwork) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute'),
+  }),
+
+  data: (artwork) => ({
+    path:
+      (artwork
+        ? artwork.path
+        : null),
+
+    warnings:
+      (artwork
+        ? artwork.artTags
+            .filter(artTag => artTag.isContentWarning)
+            .map(artTag => artTag.name)
+        : null),
+
+    dimensions:
+      (artwork
+        ? artwork.dimensions
+        : null),
+  }),
+
+  slots: {
+    thumb: {type: 'string'},
+
+    reveal: {type: 'boolean', default: true},
+    lazy: {type: 'boolean', default: false},
+    square: {type: 'boolean', default: false},
+
+    link: {
+      validate: v => v.anyOf(v.isBoolean, v.isString),
+      default: false,
+    },
+
+    color: {validate: v => v.isColor},
+
+    // Added to the .image-container.
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    // Added to the <img> itself.
+    alt: {type: 'string'},
+
+    // Specify 'src' or 'path', or the path will be used from the artwork.
+    // If none of the above is present, the message in missingSourceContent
+    // will be displayed instead.
+
+    src: {type: 'string'},
+
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
+    },
+
+    missingSourceContent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    // These will also be used from the artwork if not specified as slots.
+
+    warnings: {
+      validate: v => v.looseArrayOf(v.isString),
+    },
+
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
+  },
+
+  generate(data, relations, slots, {
+    checkIfImagePathHasCachedThumbnails,
+    getDimensionsOfImagePath,
+    getSizeOfMediaFile,
+    getThumbnailEqualOrSmaller,
+    getThumbnailsAvailableForDimensions,
+    html,
+    language,
+    missingImagePaths,
+    to,
+  }) {
+    const originalSrc =
+      (slots.src
+        ? slots.src
+     : slots.path
+        ? to(...slots.path)
+     : data.path
+        ? to(...data.path)
+        : '');
+
+    // TODO: This feels janky. It's necessary to deal with static content that
+    // includes strings like <img src="media/misc/foo.png">, but processing the
+    // src string directly when a parts-formed path *is* available seems wrong.
+    // It should be possible to do urls.from(slots.path[0]).to(...slots.path),
+    // for example, but will require reworking the control flow here a little.
+    let mediaSrc = null;
+    if (originalSrc.startsWith(to('media.root'))) {
+      mediaSrc =
+        originalSrc
+          .slice(to('media.root').length)
+          .replace(/^\//, '');
+    }
+
+    const isMissingImageFile =
+      missingImagePaths.includes(mediaSrc);
+
+    const willLink =
+      !isMissingImageFile &&
+      (typeof slots.link === 'string' || slots.link);
+
+    const warnings = slots.warnings ?? data.warnings;
+    const dimensions = slots.dimensions ?? data.dimensions;
+
+    const willReveal =
+      slots.reveal &&
+      originalSrc &&
+      !isMissingImageFile &&
+      !empty(warnings);
+
+    const imgAttributes = html.attributes([
+      {class: 'image'},
+
+      slots.alt && {alt: slots.alt},
+
+      dimensions &&
+      dimensions[0] &&
+        {width: dimensions[0]},
+
+      dimensions &&
+      dimensions[1] &&
+        {height: dimensions[1]},
+    ]);
+
+    const isPlaceholder =
+      !originalSrc || isMissingImageFile;
+
+    if (isPlaceholder) {
+      return (
+        prepare(
+          html.tag('div', {class: 'image-text-area'},
+            (html.isBlank(slots.missingSourceContent)
+              ? language.$('misc.missingImage')
+              : slots.missingSourceContent)),
+          'visible'));
+    }
+
+    let reveal = null;
+    if (willReveal) {
+      reveal = [
+        html.tag('img', {class: 'reveal-symbol'},
+          {src: to('staticMisc.path', 'warning.svg')}),
+
+        html.tag('br'),
+
+        html.tag('span', {class: 'reveal-warnings'},
+          language.$('misc.contentWarnings.warnings', {
+            warnings: language.formatUnitList(warnings),
+          })),
+
+        html.tag('br'),
+
+        html.tag('span', {class: 'reveal-interaction'},
+          language.$('misc.contentWarnings.reveal')),
+      ];
+    }
+
+    const hasThumbnails =
+      mediaSrc &&
+      checkIfImagePathHasCachedThumbnails(mediaSrc);
+
+    // Warn for images that *should* have cached thumbnail information but are
+    // missing from the thumbs cache.
+    if (
+      slots.thumb &&
+      !hasThumbnails &&
+      !mediaSrc.endsWith('.gif')
+    ) {
+      logWarn`No thumbnail info cached: ${mediaSrc} - displaying original image here (instead of ${slots.thumb})`;
+    }
+
+    let displaySrc = originalSrc;
+
+    // This is only distinguished from displaySrc by being a thumbnail,
+    // so it won't be set if thumbnails aren't available.
+    let revealSrc = null;
+
+    // If thumbnails are available *and* being used, calculate thumbSrc,
+    // and provide some attributes relevant to the large image overlay.
+    if (hasThumbnails && slots.thumb) {
+      const selectedSize =
+        getThumbnailEqualOrSmaller(slots.thumb, mediaSrc);
+
+      const mediaSrcJpeg =
+        mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`);
+
+      displaySrc =
+        to('thumb.path', mediaSrcJpeg);
+
+      if (willReveal) {
+        const miniSize =
+          getThumbnailEqualOrSmaller('mini', mediaSrc);
+
+        const mediaSrcJpeg =
+          mediaSrc.replace(/\.(png|jpg)$/, `.${miniSize}.jpg`);
+
+        revealSrc =
+          to('thumb.path', mediaSrcJpeg);
+      }
+
+      const originalDimensions = getDimensionsOfImagePath(mediaSrc);
+      const availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions);
+
+      const fileSize =
+        (willLink && mediaSrc
+          ? getSizeOfMediaFile(mediaSrc)
+          : null);
+
+      imgAttributes.add([
+        fileSize &&
+          {'data-original-size': fileSize},
+
+        {'data-dimensions': originalDimensions.join('x')},
+
+        !empty(availableThumbs) &&
+          {'data-thumbs':
+              availableThumbs
+                .map(([name, size]) => `${name}:${size}`)
+                .join(' ')},
+      ]);
+    }
+
+    if (!displaySrc) {
+      return (
+        prepare(
+          html.tag('img', imgAttributes),
+          'visible'));
+    }
+
+    const images = {
+      displayStatic:
+        html.tag('img',
+          imgAttributes,
+          {src: displaySrc}),
+
+      displayLazy:
+        slots.lazy &&
+          html.tag('img',
+            imgAttributes,
+            {class: 'lazy', 'data-original': displaySrc}),
+
+      revealStatic:
+        revealSrc &&
+          html.tag('img', {class: 'reveal-thumbnail'},
+            imgAttributes,
+            {src: revealSrc}),
+
+      revealLazy:
+        slots.lazy &&
+        revealSrc &&
+          html.tag('img', {class: 'reveal-thumbnail'},
+            imgAttributes,
+            {class: 'lazy', 'data-original': revealSrc}),
+    };
+
+    const staticImageContent =
+      html.tags([images.displayStatic, images.revealStatic]);
+
+    if (slots.lazy) {
+      const lazyImageContent =
+        html.tags([images.displayLazy, images.revealLazy]);
+
+      return html.tags([
+        html.tag('noscript',
+          prepare(staticImageContent, 'visible')),
+
+        prepare(lazyImageContent, 'hidden'),
+      ]);
+    } else {
+      return prepare(staticImageContent, 'visible');
+    }
+
+    function prepare(imageContent, visibility) {
+      let wrapped = imageContent;
+
+      if (willReveal) {
+        wrapped =
+          html.tags([
+            wrapped,
+            html.tag('span', {class: 'reveal-text-container'},
+              html.tag('span', {class: 'reveal-text'},
+                reveal)),
+          ]);
+      }
+
+      wrapped =
+        html.tag('div', {class: 'image-inner-area'},
+          wrapped);
+
+      if (willLink) {
+        wrapped =
+          html.tag('a', {class: 'image-link'},
+            (typeof slots.link === 'string'
+              ? {href: slots.link}
+              : {href: originalSrc}),
+
+            wrapped);
+      }
+
+      wrapped =
+        html.tag('div', {class: 'image-outer-area'},
+          slots.square &&
+            {class: 'square-content'},
+
+          wrapped);
+
+      wrapped =
+        html.tag('div', {class: 'image-container'},
+          slots.square &&
+            {class: 'square'},
+
+          typeof slots.link === 'string' &&
+            {class: 'no-image-preview'},
+
+          (isPlaceholder
+            ? {class: 'placeholder-image'}
+            : [
+                willLink &&
+                  {class: 'has-link'},
+
+                willReveal &&
+                  {class: 'reveal'},
+
+                revealSrc &&
+                  {class: 'has-reveal-thumbnail'},
+              ]),
+
+          visibility === 'hidden' &&
+            {class: 'js-hide'},
+
+          slots.color &&
+            relations.colorStyle.slots({
+              color: slots.color,
+              context: 'image-box',
+            }),
+
+          slots.attributes,
+
+          wrapped);
+
+      return wrapped;
+    }
+  },
+};
diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js
new file mode 100644
index 00000000..a5009804
--- /dev/null
+++ b/src/content/dependencies/index.js
@@ -0,0 +1,274 @@
+import EventEmitter from 'node:events';
+import {readdir} from 'node:fs/promises';
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+import chokidar from 'chokidar';
+import {ESLint} from 'eslint';
+
+import {showAggregate as _showAggregate} from '#aggregate';
+import {colors, logWarn} from '#cli';
+import contentFunction, {ContentFunctionSpecError} from '#content-function';
+import {annotateFunction} from '#sugar';
+
+function cachebust(filePath) {
+  if (filePath in cachebust.cache) {
+    cachebust.cache[filePath] += 1;
+    return `${filePath}?cachebust${cachebust.cache[filePath]}`;
+  } else {
+    cachebust.cache[filePath] = 0;
+    return filePath;
+  }
+}
+
+cachebust.cache = Object.create(null);
+
+export function watchContentDependencies({
+  mock = null,
+  logging = true,
+  showAggregate = _showAggregate,
+} = {}) {
+  const events = new EventEmitter();
+  const contentDependencies = {};
+
+  let emittedReady = false;
+  let emittedErrorForFunctions = new Set();
+  let closed = false;
+
+  let _close = () => {};
+
+  Object.assign(events, {
+    contentDependencies,
+    close,
+  });
+
+  const eslint = new ESLint();
+
+  const metaPath = fileURLToPath(import.meta.url);
+  const metaDirname = path.dirname(metaPath);
+  const watchPath = metaDirname;
+
+  const mockKeys = new Set();
+  if (mock) {
+    const errors = [];
+
+    for (const [functionName, spec] of Object.entries(mock)) {
+      mockKeys.add(functionName);
+      try {
+        const fn = processFunctionSpec(functionName, spec);
+        contentDependencies[functionName] = fn;
+      } catch (error) {
+        error.message = `(${functionName}) ${error.message}`;
+        errors.push(error);
+      }
+    }
+
+    if (errors.length) {
+      throw new AggregateError(errors, `Errors processing mocked content functions`);
+    }
+  }
+
+  // Chokidar's 'ready' event is supposed to only fire once an 'add' event
+  // has been fired for everything in the watched directory, but it's not
+  // totally reliable. https://github.com/paulmillr/chokidar/issues/1011
+  //
+  // Workaround here is to readdir for the names of all dependencies ourselves,
+  // and enter null for each into the contentDependencies object. We'll emit
+  // 'ready' ourselves only once no nulls remain. And we won't actually start
+  // watching until the readdir is done and nulls are entered (so we don't
+  // prematurely find out there aren't any nulls - before the nulls have
+  // been entered at all!).
+
+  readdir(watchPath).then(files => {
+    if (closed) {
+      return;
+    }
+
+    const filePaths = files.map(file => path.join(watchPath, file));
+    for (const filePath of filePaths) {
+      if (filePath === metaPath) continue;
+      const functionName = getFunctionName(filePath);
+      if (!isMocked(functionName)) {
+        contentDependencies[functionName] = null;
+      }
+    }
+
+    const watcher = chokidar.watch(watchPath);
+
+    watcher.on('all', (event, filePath) => {
+      if (!['add', 'change'].includes(event)) return;
+      if (filePath === metaPath) return;
+      handlePathUpdated(filePath);
+
+    });
+
+    watcher.on('unlink', (filePath) => {
+      if (filePath === metaPath) {
+        console.error(`Yeowzers content dependencies just got nuked.`);
+        return;
+      }
+
+      handlePathRemoved(filePath);
+    });
+
+    _close = () => watcher.close();
+  });
+
+  return events;
+
+  async function close() {
+    closed = true;
+    return _close();
+  }
+
+  function checkReadyConditions() {
+    if (emittedReady) return;
+    if (Object.values(contentDependencies).includes(null)) return;
+
+    events.emit('ready');
+    emittedReady = true;
+  }
+
+  function getFunctionName(filePath) {
+    const shortPath = path.basename(filePath);
+    const functionName = shortPath.slice(0, -path.extname(shortPath).length);
+    return functionName;
+  }
+
+  function isMocked(functionName) {
+    return mockKeys.has(functionName);
+  }
+
+  async function handlePathRemoved(filePath) {
+    const functionName = getFunctionName(filePath);
+    if (isMocked(functionName)) return;
+
+    delete contentDependencies[functionName];
+  }
+
+  async function handlePathUpdated(filePath) {
+    const functionName = getFunctionName(filePath);
+    if (isMocked(functionName)) return;
+
+    let error = null;
+
+    main: {
+      const eslintResults = await eslint.lintFiles([filePath]);
+      const eslintFormatter = await eslint.loadFormatter('stylish');
+      const eslintResultText = eslintFormatter.format(eslintResults);
+      if (eslintResultText.trim().length) {
+        console.log(eslintResultText);
+      }
+
+      let spec;
+      try {
+        const module =
+          await import(
+            cachebust(
+              './' +
+              path
+                .relative(metaDirname, filePath)
+                .split(path.sep)
+                .join('/')));
+        spec = module.default;
+      } catch (caughtError) {
+        error = caughtError;
+        error.message = `Error importing: ${error.message}`;
+        break main;
+      }
+
+      // Just skip newly created files. They'll be processed again when
+      // written.
+      if (spec === undefined) {
+        // For practical purposes the file is treated as though it doesn't
+        // even exist (undefined), rather than not being ready yet (null).
+        // Apart from if existing contents of the file were erased (but not
+        // the file itself), this value might already be set (to null!) by
+        // the readdir performed at the beginning to evaluate which files
+        // should be read and processed at least once before reporting all
+        // dependencies as ready.
+        delete contentDependencies[functionName];
+        return;
+      }
+
+      let fn;
+      try {
+        fn = processFunctionSpec(functionName, spec);
+      } catch (caughtError) {
+        error = caughtError;
+        break main;
+      }
+
+      const emittedError = emittedErrorForFunctions.has(functionName);
+      if (logging && (emittedReady || emittedError)) {
+        const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'});
+        console.log(colors.green(`[${timestamp}] Updated ${functionName}`));
+      }
+
+      contentDependencies[functionName] = fn;
+
+      events.emit('update', functionName);
+      checkReadyConditions();
+    }
+
+    if (!error) {
+      return true;
+    }
+
+    if (!(functionName in contentDependencies)) {
+      contentDependencies[functionName] = null;
+    }
+
+    events.emit('error', functionName, error);
+    emittedErrorForFunctions.add(functionName);
+
+    if (logging) {
+      if (contentDependencies[functionName]) {
+        logWarn`Failed to import ${functionName} - using existing version`;
+      } else {
+        logWarn`Failed to import ${functionName} - no prior version loaded`;
+      }
+
+      if (typeof error === 'string') {
+        console.error(colors.yellow(error));
+      } else if (error instanceof ContentFunctionSpecError) {
+        console.error(colors.yellow(error.message));
+      } else {
+        showAggregate(error);
+      }
+    }
+
+    return false;
+  }
+
+  function processFunctionSpec(functionName, spec) {
+    if (typeof spec?.data === 'function') {
+      annotateFunction(spec.data, {name: functionName, description: 'data'});
+    }
+
+    if (typeof spec?.generate === 'function') {
+      annotateFunction(spec.generate, {name: functionName});
+    }
+
+    return contentFunction(spec);
+  }
+}
+
+export function quickLoadContentDependencies(opts) {
+  return new Promise((resolve, reject) => {
+    const watcher = watchContentDependencies(opts);
+
+    watcher.on('error', (name, error) => {
+      watcher.close().then(() => {
+        error.message = `Error loading dependency ${name}: ${error}`;
+        reject(error);
+      });
+    });
+
+    watcher.on('ready', () => {
+      watcher.close().then(() => {
+        resolve(watcher.contentDependencies);
+      });
+    });
+  });
+}
diff --git a/src/content/dependencies/linkAlbum.js b/src/content/dependencies/linkAlbum.js
new file mode 100644
index 00000000..36b0d13a
--- /dev/null
+++ b/src/content/dependencies/linkAlbum.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.album', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js
new file mode 100644
index 00000000..39e7111e
--- /dev/null
+++ b/src/content/dependencies/linkAlbumAdditionalFile.js
@@ -0,0 +1,24 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+
+  relations(relation) {
+    return {
+      linkTemplate: relation('linkTemplate'),
+    };
+  },
+
+  data(album, file) {
+    return {
+      albumDirectory: album.directory,
+      file,
+    };
+  },
+
+  generate(data, relations) {
+    return relations.linkTemplate
+      .slots({
+        path: ['media.albumAdditionalFile', data.albumDirectory, data.file],
+        content: data.file,
+      });
+  },
+};
diff --git a/src/content/dependencies/linkAlbumCommentary.js b/src/content/dependencies/linkAlbumCommentary.js
new file mode 100644
index 00000000..ab519fd6
--- /dev/null
+++ b/src/content/dependencies/linkAlbumCommentary.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumCommentary', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAlbumDynamically.js b/src/content/dependencies/linkAlbumDynamically.js
new file mode 100644
index 00000000..45f8c2a9
--- /dev/null
+++ b/src/content/dependencies/linkAlbumDynamically.js
@@ -0,0 +1,61 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'pagePath'],
+
+  relations: (relation, album) => ({
+    galleryLink:
+      relation('linkAlbumGallery', album),
+
+    infoLink:
+      relation('linkAlbum', album),
+
+    commentaryLink:
+      relation('linkAlbumCommentary', album),
+  }),
+
+  data: (album) => ({
+    albumDirectory:
+      album.directory,
+
+    albumHasCommentary:
+      !empty(album.commentary),
+  }),
+
+  slots: {
+    linkCommentaryPages: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots, {pagePath}) =>
+     // When linking to an album *from* an album commentary page,
+     // if the link is to the *same* album, then the effective target
+     // of the link is really the album's commentary, so scroll to it.
+    (pagePath[0] === 'albumCommentary' &&
+     pagePath[1] === data.albumDirectory &&
+     data.albumHasCommentary
+      ? relations.infoLink.slots({
+          anchor: true,
+          hash: 'album-commentary',
+        })
+
+     // When linking to *another* album from an album commentary page,
+     // the target is (by default) still just the album (its info page).
+     // But this can be customized per-link!
+   : pagePath[0] === 'albumCommentary' &&
+     slots.linkCommentaryPages
+      ? relations.commentaryLink
+
+   : pagePath[0] === 'albumGallery'
+      ? relations.galleryLink
+
+      : relations.infoLink),
+};
diff --git a/src/content/dependencies/linkAlbumGallery.js b/src/content/dependencies/linkAlbumGallery.js
new file mode 100644
index 00000000..e3f30a29
--- /dev/null
+++ b/src/content/dependencies/linkAlbumGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumGallery', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAlbumReferencedArtworks.js b/src/content/dependencies/linkAlbumReferencedArtworks.js
new file mode 100644
index 00000000..ba51b5e3
--- /dev/null
+++ b/src/content/dependencies/linkAlbumReferencedArtworks.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumReferencedArtworks', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAlbumReferencingArtworks.js b/src/content/dependencies/linkAlbumReferencingArtworks.js
new file mode 100644
index 00000000..4d5e799d
--- /dev/null
+++ b/src/content/dependencies/linkAlbumReferencingArtworks.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, album) =>
+    ({link: relation('linkThing', 'localized.albumReferencingArtworks', album)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkAnythingMan.js b/src/content/dependencies/linkAnythingMan.js
new file mode 100644
index 00000000..e408c1b2
--- /dev/null
+++ b/src/content/dependencies/linkAnythingMan.js
@@ -0,0 +1,28 @@
+export default {
+  contentDependencies: [
+    'linkAlbum',
+    'linkArtwork',
+    'linkFlash',
+    'linkTrack',
+  ],
+
+  query: (thing) => ({
+    referenceType: thing.constructor[Symbol.for('Thing.referenceType')],
+  }),
+
+  relations: (relation, query, thing) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbum', thing)
+     : query.referenceType === 'artwork'
+        ? relation('linkArtwork', thing)
+     : query.referenceType === 'flash'
+        ? relation('linkFlash', thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrack', thing)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.link,
+};
diff --git a/src/content/dependencies/linkArtTagDynamically.js b/src/content/dependencies/linkArtTagDynamically.js
new file mode 100644
index 00000000..964258e1
--- /dev/null
+++ b/src/content/dependencies/linkArtTagDynamically.js
@@ -0,0 +1,14 @@
+export default {
+  contentDependencies: ['linkArtTagGallery', 'linkArtTagInfo'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, artTag) => ({
+    galleryLink: relation('linkArtTagGallery', artTag),
+    infoLink: relation('linkArtTagInfo', artTag),
+  }),
+
+  generate: (relations, {pagePath}) =>
+    (pagePath[0] === 'artTagInfo'
+      ? relations.infoLink
+      : relations.galleryLink),
+};
diff --git a/src/content/dependencies/linkArtTagGallery.js b/src/content/dependencies/linkArtTagGallery.js
new file mode 100644
index 00000000..a92b69c1
--- /dev/null
+++ b/src/content/dependencies/linkArtTagGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artTag) =>
+    ({link: relation('linkThing', 'localized.artTagGallery', artTag)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtTagInfo.js b/src/content/dependencies/linkArtTagInfo.js
new file mode 100644
index 00000000..409cb3c0
--- /dev/null
+++ b/src/content/dependencies/linkArtTagInfo.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artTag) =>
+    ({link: relation('linkThing', 'localized.artTagInfo', artTag)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtist.js b/src/content/dependencies/linkArtist.js
new file mode 100644
index 00000000..718ee6fa
--- /dev/null
+++ b/src/content/dependencies/linkArtist.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artist) =>
+    ({link: relation('linkThing', 'localized.artist', artist)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtistGallery.js b/src/content/dependencies/linkArtistGallery.js
new file mode 100644
index 00000000..66dc172d
--- /dev/null
+++ b/src/content/dependencies/linkArtistGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artist) =>
+    ({link: relation('linkThing', 'localized.artistGallery', artist)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtwork.js b/src/content/dependencies/linkArtwork.js
new file mode 100644
index 00000000..8cd6f359
--- /dev/null
+++ b/src/content/dependencies/linkArtwork.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: ['linkAlbum', 'linkTrack'],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Symbol.for('Thing.referenceType')],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbum', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrack', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.link,
+};
diff --git a/src/content/dependencies/linkCommentaryIndex.js b/src/content/dependencies/linkCommentaryIndex.js
new file mode 100644
index 00000000..5568ff84
--- /dev/null
+++ b/src/content/dependencies/linkCommentaryIndex.js
@@ -0,0 +1,12 @@
+export default {
+  contentDependencies: ['linkStationaryIndex'],
+
+  relations: (relation) =>
+    ({link:
+        relation(
+          'linkStationaryIndex',
+          'localized.commentaryIndex',
+          'commentaryIndex.title')}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
new file mode 100644
index 00000000..c658d461
--- /dev/null
+++ b/src/content/dependencies/linkContribution.js
@@ -0,0 +1,85 @@
+export default {
+  contentDependencies: [
+    'generateContributionTooltip',
+    'generateTextWithTooltip',
+    'linkArtist',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, contribution) => ({
+    artistLink:
+      relation('linkArtist', contribution.artist),
+
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateContributionTooltip', contribution),
+  }),
+
+  data: (contribution) => ({
+    annotation: contribution.annotation,
+    urls: contribution.artist.urls,
+  }),
+
+  slots: {
+    showAnnotation: {type: 'boolean', default: false},
+    showExternalLinks: {type: 'boolean', default: false},
+    showChronology: {type: 'boolean', default: false},
+
+    trimAnnotation: {type: 'boolean', default: false},
+
+    preventWrapping: {type: 'boolean', default: true},
+    preventTooltip: {type: 'boolean', default: false},
+
+    chronologyKind: {type: 'string'},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('span', {class: 'contribution'},
+      {[html.noEdgeWhitespace]: true},
+
+      slots.preventWrapping &&
+        {class: 'nowrap'},
+
+      language.encapsulate('misc.artistLink', workingCapsule => {
+        const workingOptions = {};
+
+        // Filling slots early is necessary to actually give the tooltip
+        // content. Otherwise, the coming-up html.isBlank() always reports
+        // the tooltip as blank!
+        relations.tooltip.setSlots({
+          showExternalLinks: slots.showExternalLinks,
+          showChronology: slots.showChronology,
+          chronologyKind: slots.chronologyKind,
+        });
+
+        workingOptions.artist =
+          (html.isBlank(relations.tooltip) || slots.preventTooltip
+            ? relations.artistLink
+            : relations.textWithTooltip.slots({
+                customInteractionCue: true,
+
+                text:
+                  relations.artistLink.slots({
+                    attributes: {class: 'text-with-tooltip-interaction-cue'},
+                  }),
+
+                tooltip:
+                  relations.tooltip,
+              }));
+
+        const annotation =
+          (slots.trimAnnotation
+            ? data.annotation?.replace(/^edits for wiki(: )?/, '')
+            : data.annotation);
+
+        if (slots.showAnnotation && annotation) {
+          workingCapsule += '.withContribution';
+          workingOptions.contrib = annotation;
+        }
+
+        return language.formatString(workingCapsule, workingOptions);
+      })),
+};
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
new file mode 100644
index 00000000..073c821e
--- /dev/null
+++ b/src/content/dependencies/linkExternal.js
@@ -0,0 +1,151 @@
+import {isExternalLinkContext, isExternalLinkStyle} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    suffixNormalContent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    style: {
+      // This awkward syntax is because the slot descriptor validator can't
+      // differentiate between a function that returns a validator (the usual
+      // syntax) and a function that is itself a validator.
+      validate: () => isExternalLinkStyle,
+      default: 'platform',
+    },
+
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+
+    fromContent: {
+      type: 'boolean',
+      default: false,
+    },
+
+    indicateExternal: {
+      type: 'boolean',
+      default: false,
+    },
+
+    tab: {
+      validate: v => v.is('default', 'separate'),
+      default: 'default',
+    },
+  },
+
+  generate(data, slots, {html, language}) {
+    let urlIsValid;
+    try {
+      new URL(data.url);
+      urlIsValid = true;
+    } catch (error) {
+      urlIsValid = false;
+    }
+
+    let formattedLink;
+    if (urlIsValid) {
+      formattedLink =
+        language.formatExternalLink(data.url, {
+          style: slots.style,
+          context: slots.context,
+        });
+
+      // Fall back to platform if nothing matched the desired style.
+      if (html.isBlank(formattedLink) && slots.style !== 'platform') {
+        formattedLink =
+          language.formatExternalLink(data.url, {
+            style: 'platform',
+            context: slots.context,
+          });
+      }
+    } else {
+      formattedLink = null;
+    }
+
+    const linkAttributes = html.attributes({
+      class: 'external-link',
+    });
+
+    let linkContent;
+    if (urlIsValid) {
+      linkAttributes.set('href', data.url);
+
+      if (html.isBlank(slots.content)) {
+        linkContent = formattedLink;
+      } else {
+        linkContent = slots.content;
+      }
+    } else {
+      if (html.isBlank(slots.content)) {
+        linkContent =
+          html.tag('i',
+            language.$('misc.external.invalidURL.annotation'));
+      } else {
+        linkContent =
+          language.$('misc.external.invalidURL', {
+            link: slots.content,
+            annotation:
+              html.tag('i',
+                language.$('misc.external.invalidURL.annotation')),
+          });
+      }
+    }
+
+    if (slots.fromContent) {
+      linkAttributes.add('class', 'from-content');
+    }
+
+    if (urlIsValid && slots.indicateExternal) {
+      linkAttributes.add('class', 'indicate-external');
+
+      let titleText;
+      if (slots.tab === 'separate') {
+        if (html.isBlank(slots.content)) {
+          titleText =
+            language.$('misc.external.opensInNewTab.annotation');
+        } else {
+          titleText =
+            language.$('misc.external.opensInNewTab', {
+              link: formattedLink,
+              annotation:
+                language.$('misc.external.opensInNewTab.annotation'),
+            });
+        }
+      } else if (!html.isBlank(slots.content)) {
+        titleText = formattedLink;
+      }
+
+      if (titleText) {
+        linkAttributes.set('title', titleText.toString());
+      }
+    }
+
+    if (urlIsValid && slots.tab === 'separate') {
+      linkAttributes.set('target', '_blank');
+    }
+
+    if (!html.isBlank(slots.suffixNormalContent)) {
+      linkContent =
+        html.tags([
+          linkContent,
+
+          html.tag('span', {class: 'normal-content'},
+            slots.suffixNormalContent),
+        ], {[html.joinChildren]: ''});
+    }
+
+    return html.tag('a', linkAttributes, linkContent);
+  },
+};
diff --git a/src/content/dependencies/linkFlash.js b/src/content/dependencies/linkFlash.js
new file mode 100644
index 00000000..93dd5a28
--- /dev/null
+++ b/src/content/dependencies/linkFlash.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, flash) =>
+    ({link: relation('linkThing', 'localized.flash', flash)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkFlashAct.js b/src/content/dependencies/linkFlashAct.js
new file mode 100644
index 00000000..82c23325
--- /dev/null
+++ b/src/content/dependencies/linkFlashAct.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['generateUnsafeMunchy', 'linkThing'],
+
+  relations: (relation, flashAct) => ({
+    unsafeMunchy:
+      relation('generateUnsafeMunchy'),
+
+    link:
+      relation('linkThing', 'localized.flashActGallery', flashAct),
+  }),
+
+  data: (flashAct) => ({
+    name: flashAct.name,
+  }),
+
+  generate: (data, relations) =>
+    relations.link.slots({
+      content:
+        relations.unsafeMunchy
+          .slot('contentSource', data.name),
+    }),
+};
diff --git a/src/content/dependencies/linkFlashIndex.js b/src/content/dependencies/linkFlashIndex.js
new file mode 100644
index 00000000..6dd0710e
--- /dev/null
+++ b/src/content/dependencies/linkFlashIndex.js
@@ -0,0 +1,12 @@
+export default {
+  contentDependencies: ['linkStationaryIndex'],
+
+  relations: (relation) =>
+    ({link:
+        relation(
+          'linkStationaryIndex',
+          'localized.flashIndex',
+          'flashIndex.title')}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkFlashSide.js b/src/content/dependencies/linkFlashSide.js
new file mode 100644
index 00000000..b77ca65a
--- /dev/null
+++ b/src/content/dependencies/linkFlashSide.js
@@ -0,0 +1,22 @@
+export default {
+  contentDependencies: ['linkFlashAct'],
+
+  relations: (relation, flashSide) => ({
+    link:
+      relation('linkFlashAct', flashSide.acts[0]),
+  }),
+
+  data: (flashSide) => ({
+    name:
+      flashSide.name,
+
+    color:
+      flashSide.color,
+  }),
+
+  generate: (data, relations) =>
+    relations.link.slots({
+      content: data.name,
+      color: data.color,
+    }),
+};
diff --git a/src/content/dependencies/linkGroup.js b/src/content/dependencies/linkGroup.js
new file mode 100644
index 00000000..ebab1b5b
--- /dev/null
+++ b/src/content/dependencies/linkGroup.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, group) =>
+    ({link: relation('linkThing', 'localized.groupInfo', group)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkGroupDynamically.js b/src/content/dependencies/linkGroupDynamically.js
new file mode 100644
index 00000000..90303ed1
--- /dev/null
+++ b/src/content/dependencies/linkGroupDynamically.js
@@ -0,0 +1,14 @@
+export default {
+  contentDependencies: ['linkGroupGallery', 'linkGroup'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, group) => ({
+    galleryLink: relation('linkGroupGallery', group),
+    infoLink: relation('linkGroup', group),
+  }),
+
+  generate: (relations, {pagePath}) =>
+    (pagePath[0] === 'groupGallery'
+      ? relations.galleryLink
+      : relations.infoLink),
+};
diff --git a/src/content/dependencies/linkGroupExtra.js b/src/content/dependencies/linkGroupExtra.js
new file mode 100644
index 00000000..bc3c0580
--- /dev/null
+++ b/src/content/dependencies/linkGroupExtra.js
@@ -0,0 +1,34 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'linkGroup',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations(relation, group) {
+    const relations = {};
+
+    relations.info =
+      relation('linkGroup', group);
+
+    if (!empty(group.albums)) {
+      relations.gallery =
+        relation('linkGroupGallery', group);
+    }
+
+    return relations;
+  },
+
+  slots: {
+    extra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(relations, slots) {
+    return relations[slots.extra ?? 'info'] ?? relations.info;
+  },
+};
diff --git a/src/content/dependencies/linkGroupGallery.js b/src/content/dependencies/linkGroupGallery.js
new file mode 100644
index 00000000..86c4a0f3
--- /dev/null
+++ b/src/content/dependencies/linkGroupGallery.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, group) =>
+    ({link: relation('linkThing', 'localized.groupGallery', group)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkListing.js b/src/content/dependencies/linkListing.js
new file mode 100644
index 00000000..ac66919a
--- /dev/null
+++ b/src/content/dependencies/linkListing.js
@@ -0,0 +1,15 @@
+export default {
+  contentDependencies: ['linkThing'],
+  extraDependencies: ['language'],
+
+  relations: (relation, listing) =>
+    ({link: relation('linkThing', 'localized.listing', listing)}),
+
+  data: (listing) =>
+    ({stringsKey: listing.stringsKey}),
+
+  generate: (data, relations, {language}) =>
+    relations.link
+      .slot('content',
+        language.$('listingPage', data.stringsKey, 'title')),
+};
diff --git a/src/content/dependencies/linkListingIndex.js b/src/content/dependencies/linkListingIndex.js
new file mode 100644
index 00000000..1bfaf46e
--- /dev/null
+++ b/src/content/dependencies/linkListingIndex.js
@@ -0,0 +1,12 @@
+export default {
+  contentDependencies: ['linkStationaryIndex'],
+
+  relations: (relation) =>
+    ({link:
+        relation(
+          'linkStationaryIndex',
+          'localized.listingIndex',
+          'listingIndex.title')}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkNewsEntry.js b/src/content/dependencies/linkNewsEntry.js
new file mode 100644
index 00000000..1fb32dd9
--- /dev/null
+++ b/src/content/dependencies/linkNewsEntry.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, newsEntry) =>
+    ({link: relation('linkThing', 'localized.newsEntry', newsEntry)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkNewsIndex.js b/src/content/dependencies/linkNewsIndex.js
new file mode 100644
index 00000000..e911a384
--- /dev/null
+++ b/src/content/dependencies/linkNewsIndex.js
@@ -0,0 +1,12 @@
+export default {
+  contentDependencies: ['linkStationaryIndex'],
+
+  relations: (relation) =>
+    ({link:
+        relation(
+          'linkStationaryIndex',
+          'localized.newsIndex',
+          'newsIndex.title')}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js
new file mode 100644
index 00000000..ec856631
--- /dev/null
+++ b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js
@@ -0,0 +1,62 @@
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+import {chunkArtistTrackContributions} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateColorStyleAttribute'],
+  extraDependencies: ['html', 'language'],
+
+  query(track, artist) {
+    const relevantInfoPageChunkingContributions =
+      track.allReleases
+        .flatMap(release => [
+          ...release.artistContribs,
+          ...release.contributorContribs,
+        ])
+        .filter(c => c.artist === artist);
+
+    sortContributionsChronologically(
+      relevantInfoPageChunkingContributions,
+      sortAlbumsTracksChronologically);
+
+    const contributionChunks =
+      chunkArtistTrackContributions(relevantInfoPageChunkingContributions);
+
+    const trackChunks =
+      contributionChunks
+        .map(chunksInAlbum => chunksInAlbum
+          .map(chunksInTrack => chunksInTrack[0].thing));
+
+    const trackChunksForThisAlbum =
+      trackChunks
+        .filter(tracks => tracks[0].album === track.album);
+
+    const containingChunkIndex =
+      trackChunksForThisAlbum
+        .findIndex(tracks => tracks.includes(track));
+
+    return {containingChunkIndex};
+  },
+
+  relations: (relation, _query, track, _artist) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute', track.album.color),
+  }),
+
+  data: (query, track, _artist) => ({
+    albumName:
+      track.album.name,
+
+    albumDirectory:
+      track.album.directory,
+
+    containingChunkIndex:
+      query.containingChunkIndex,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    html.tag('a',
+      {href: `#tracks-${data.albumDirectory}-${data.containingChunkIndex}`},
+      relations.colorStyle.slot('context', 'primary-only'),
+      language.sanitize(data.albumName)),
+};
diff --git a/src/content/dependencies/linkPathFromMedia.js b/src/content/dependencies/linkPathFromMedia.js
new file mode 100644
index 00000000..d71c69f8
--- /dev/null
+++ b/src/content/dependencies/linkPathFromMedia.js
@@ -0,0 +1,64 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['linkTemplate'],
+
+  extraDependencies: [
+    'checkIfImagePathHasCachedThumbnails',
+    'getDimensionsOfImagePath',
+    'getSizeOfMediaFile',
+    'getThumbnailsAvailableForDimensions',
+    'html',
+    'to',
+  ],
+
+  relations: (relation) =>
+    ({link: relation('linkTemplate')}),
+
+  data: (path) =>
+    ({path}),
+
+  generate(data, relations, {
+    checkIfImagePathHasCachedThumbnails,
+    getDimensionsOfImagePath,
+    getSizeOfMediaFile,
+    getThumbnailsAvailableForDimensions,
+    html,
+    to,
+  }) {
+    const attributes = html.attributes();
+
+    if (checkIfImagePathHasCachedThumbnails(data.path)) {
+      const dimensions = getDimensionsOfImagePath(data.path);
+      const availableThumbs = getThumbnailsAvailableForDimensions(dimensions);
+      const fileSize = getSizeOfMediaFile(data.path);
+
+      const embedSrc =
+        to('thumb.path', data.path.replace(/\.(png|jpg)$/, '.tack.jpg'));
+
+      attributes.add([
+        {class: 'image-media-link'},
+
+        {'data-embed-src': embedSrc},
+
+        fileSize &&
+          {'data-original-size': fileSize},
+
+        {'data-dimensions': dimensions.join('x')},
+
+        !empty(availableThumbs) &&
+          {'data-thumbs':
+              availableThumbs
+                .map(([name, size]) => `${name}:${size}`)
+                .join(' ')},
+      ]);
+    }
+
+    relations.link.setSlots({
+      attributes,
+      path: ['media.path', data.path],
+    });
+
+    return relations.link;
+  },
+};
diff --git a/src/content/dependencies/linkPathFromRoot.js b/src/content/dependencies/linkPathFromRoot.js
new file mode 100644
index 00000000..dab3ac1f
--- /dev/null
+++ b/src/content/dependencies/linkPathFromRoot.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+
+  relations: (relation) =>
+    ({link: relation('linkTemplate')}),
+
+  data: (path) =>
+    ({path}),
+
+  generate: (data, relations) =>
+    relations.link
+      .slot('path', ['shared.path', data.path]),
+};
diff --git a/src/content/dependencies/linkPathFromSite.js b/src/content/dependencies/linkPathFromSite.js
new file mode 100644
index 00000000..64676465
--- /dev/null
+++ b/src/content/dependencies/linkPathFromSite.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+
+  relations: (relation) =>
+    ({link: relation('linkTemplate')}),
+
+  data: (path) =>
+    ({path}),
+
+  generate: (data, relations) =>
+    relations.link
+      .slot('path', ['localized.path', data.path]),
+};
diff --git a/src/content/dependencies/linkReferencedArtworks.js b/src/content/dependencies/linkReferencedArtworks.js
new file mode 100644
index 00000000..c456b808
--- /dev/null
+++ b/src/content/dependencies/linkReferencedArtworks.js
@@ -0,0 +1,24 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'linkAlbumReferencedArtworks',
+    'linkTrackReferencedArtworks',
+  ],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbumReferencedArtworks', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrackReferencedArtworks', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkReferencingArtworks.js b/src/content/dependencies/linkReferencingArtworks.js
new file mode 100644
index 00000000..0cfca4db
--- /dev/null
+++ b/src/content/dependencies/linkReferencingArtworks.js
@@ -0,0 +1,24 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'linkAlbumReferencingArtworks',
+    'linkTrackReferencingArtworks',
+  ],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbumReferencingArtworks', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrackReferencingArtworks', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkStaticPage.js b/src/content/dependencies/linkStaticPage.js
new file mode 100644
index 00000000..032af6c9
--- /dev/null
+++ b/src/content/dependencies/linkStaticPage.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, staticPage) =>
+    ({link: relation('linkThing', 'localized.staticPage', staticPage)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkStationaryIndex.js b/src/content/dependencies/linkStationaryIndex.js
new file mode 100644
index 00000000..d5506e60
--- /dev/null
+++ b/src/content/dependencies/linkStationaryIndex.js
@@ -0,0 +1,24 @@
+// Not to be confused with "html.Stationery".
+
+export default {
+  contentDependencies: ['linkTemplate'],
+  extraDependencies: ['language'],
+
+  relations(relation) {
+    return {
+      linkTemplate: relation('linkTemplate'),
+    };
+  },
+
+  data(pathKey, stringKey) {
+    return {pathKey, stringKey};
+  },
+
+  generate(data, relations, {language}) {
+    return relations.linkTemplate
+      .slots({
+        path: [data.pathKey],
+        content: language.formatString(data.stringKey),
+      });
+  }
+}
diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js
new file mode 100644
index 00000000..4f853dc4
--- /dev/null
+++ b/src/content/dependencies/linkTemplate.js
@@ -0,0 +1,87 @@
+import {empty} from '#sugar';
+
+import striptags from 'striptags';
+
+export default {
+  extraDependencies: [
+    'appendIndexHTML',
+    'html',
+    'language',
+    'to',
+  ],
+
+  slots: {
+    href: {type: 'string'},
+    path: {validate: v => v.validateArrayItems(v.isString)},
+    hash: {type: 'string'},
+    linkless: {type: 'boolean', default: false},
+    tooltip: {type: 'string'},
+
+    attributes: {
+      type: 'attributes',
+      mutable: true,
+    },
+
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    suffixNormalContent: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate(slots, {
+    appendIndexHTML,
+    html,
+    language,
+    to,
+  }) {
+    const {attributes} = slots;
+
+    if (!slots.linkless) {
+      let href =
+        (slots.href
+          ? encodeURI(slots.href)
+       : !empty(slots.path)
+          ? to(...slots.path)
+          : '');
+
+      if (appendIndexHTML) {
+        if (/^(?!https?:\/\/).+\/$/.test(href) && href.endsWith('/')) {
+          href += 'index.html';
+        }
+      }
+
+      if (slots.hash) {
+        href += (slots.hash.startsWith('#') ? '' : '#') + slots.hash;
+      }
+
+      attributes.add({href});
+    }
+
+    if (slots.tooltip) {
+      attributes.set('title', slots.tooltip);
+    }
+
+    const mainContent =
+      (html.isBlank(slots.content)
+        ? language.$('misc.missingLinkContent')
+        : striptags(
+            html.resolve(slots.content, {normalize: 'string'}),
+            {disallowedTags: new Set(['a'])}));
+
+    const allContent =
+      (html.isBlank(slots.suffixNormalContent)
+        ? mainContent
+        : html.tags([
+            mainContent,
+            html.tag('span', {class: 'normal-content'},
+              slots.suffixNormalContent),
+          ], {[html.joinChildren]: ''}));
+
+    return html.tag('a', attributes, allContent);
+  },
+}
diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js
new file mode 100644
index 00000000..3902f380
--- /dev/null
+++ b/src/content/dependencies/linkThing.js
@@ -0,0 +1,154 @@
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generateTextWithTooltip',
+    'generateTooltip',
+    'linkTemplate',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _pathKey, thing) => ({
+    linkTemplate:
+      relation('linkTemplate'),
+
+    colorStyle:
+      relation('generateColorStyleAttribute', thing.color ?? null),
+
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateTooltip'),
+  }),
+
+  data: (pathKey, thing) => ({
+    name: thing.name,
+    nameShort: thing.nameShort ?? thing.shortName,
+
+    path:
+      (pathKey
+        ? [pathKey, thing.directory]
+        : null),
+  }),
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: true,
+    },
+
+    preferShortName: {
+      type: 'boolean',
+      default: false,
+    },
+
+    tooltipStyle: {
+      validate: v => v.is('none', 'auto', 'browser', 'wiki'),
+      default: 'auto',
+    },
+
+    color: {
+      validate: v => v.anyOf(v.isBoolean, v.isColor),
+      default: true,
+    },
+
+    colorContext: {
+      validate: v => v.is(
+        'image-box',
+        'primary-only'),
+
+      default: 'primary-only',
+    },
+
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
+    },
+
+    anchor: {type: 'boolean', default: false},
+    linkless: {type: 'boolean', default: false},
+    hash: {type: 'string'},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const path =
+      slots.path ?? data.path;
+
+    const linkAttributes = slots.attributes;
+    const wrapperAttributes = html.attributes();
+
+    const showShortName =
+      (slots.preferShortName
+        ? data.nameShort && data.nameShort !== data.name
+        : false);
+
+    const name =
+      (showShortName
+        ? data.nameShort
+        : data.name);
+
+    const showWikiTooltip =
+      (slots.tooltipStyle === 'auto'
+        ? showShortName
+        : slots.tooltipStyle === 'wiki');
+
+    const wikiTooltip =
+      showWikiTooltip &&
+        relations.tooltip.slots({
+          attributes: {class: 'thing-name-tooltip'},
+          content: data.name,
+        });
+
+    if (slots.tooltipStyle === 'browser') {
+      linkAttributes.add('title', data.name);
+    }
+
+    if (showWikiTooltip) {
+      linkAttributes.add('class', 'text-with-tooltip-interaction-cue');
+    }
+
+    const content =
+      (html.isBlank(slots.content)
+        ? language.sanitize(name)
+        : slots.content);
+
+    if (slots.color !== false) {
+      const {colorStyle} = relations;
+
+      colorStyle.setSlot('context', slots.colorContext);
+
+      if (typeof slots.color === 'string') {
+        colorStyle.setSlot('color', slots.color);
+      }
+
+      if (showWikiTooltip) {
+        wrapperAttributes.add(colorStyle);
+      } else {
+        linkAttributes.add(colorStyle);
+      }
+    }
+
+    return relations.textWithTooltip.slots({
+      attributes: wrapperAttributes,
+      customInteractionCue: true,
+
+      text:
+        relations.linkTemplate.slots({
+          path: slots.anchor ? [] : path,
+          href: slots.anchor ? '' : null,
+          attributes: linkAttributes,
+          hash: slots.hash,
+          linkless: slots.linkless,
+          content,
+        }),
+
+      tooltip:
+        wikiTooltip ?? null,
+    });
+  },
+}
diff --git a/src/content/dependencies/linkTrack.js b/src/content/dependencies/linkTrack.js
new file mode 100644
index 00000000..d5d96726
--- /dev/null
+++ b/src/content/dependencies/linkTrack.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, track) =>
+    ({link: relation('linkThing', 'localized.track', track)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkTrackDynamically.js b/src/content/dependencies/linkTrackDynamically.js
new file mode 100644
index 00000000..bbcf1c34
--- /dev/null
+++ b/src/content/dependencies/linkTrackDynamically.js
@@ -0,0 +1,36 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, track) => ({
+    infoLink: relation('linkTrack', track),
+  }),
+
+  data: (track) => ({
+    trackDirectory:
+      track.directory,
+
+    albumDirectory:
+      track.album.directory,
+
+    trackHasCommentary:
+      !empty(track.commentary),
+  }),
+
+  generate(data, relations, {pagePath}) {
+    if (
+      pagePath[0] === 'albumCommentary' &&
+      pagePath[1] === data.albumDirectory &&
+      data.trackHasCommentary
+    ) {
+      relations.infoLink.setSlots({
+        anchor: true,
+        hash: data.trackDirectory,
+      });
+    }
+
+    return relations.infoLink;
+  },
+};
diff --git a/src/content/dependencies/linkTrackReferencedArtworks.js b/src/content/dependencies/linkTrackReferencedArtworks.js
new file mode 100644
index 00000000..b4cb08fe
--- /dev/null
+++ b/src/content/dependencies/linkTrackReferencedArtworks.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, track) =>
+    ({link: relation('linkThing', 'localized.trackReferencedArtworks', track)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkTrackReferencingArtworks.js b/src/content/dependencies/linkTrackReferencingArtworks.js
new file mode 100644
index 00000000..c9c9f4d1
--- /dev/null
+++ b/src/content/dependencies/linkTrackReferencingArtworks.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, track) =>
+    ({link: relation('linkThing', 'localized.trackReferencingArtworks', track)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkWikiHomepage.js b/src/content/dependencies/linkWikiHomepage.js
new file mode 100644
index 00000000..d8d3d0a0
--- /dev/null
+++ b/src/content/dependencies/linkWikiHomepage.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+  extraDependencies: ['wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {wikiShortName: wikiInfo.nameShort};
+  },
+
+  relations: (relation) =>
+    ({link: relation('linkTemplate')}),
+
+  data: (sprawl) =>
+    ({wikiShortName: sprawl.wikiShortName}),
+
+  generate: (data, relations) =>
+    relations.link.slots({
+      path: ['localized.home'],
+      content: data.wikiShortName,
+    }),
+};
diff --git a/src/content/dependencies/listAlbumsByDate.js b/src/content/dependencies/listAlbumsByDate.js
new file mode 100644
index 00000000..c83ffc97
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByDate.js
@@ -0,0 +1,52 @@
+import {sortChronologically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    return {
+      spec,
+
+      albums:
+        sortChronologically(albumData.filter(album => album.date)),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      dates:
+        query.albums
+          .map(album => album.date),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.albumLinks,
+          date: data.dates,
+        }).map(({link, date}) => ({
+            album: link,
+            date: language.formatDate(date),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAlbumsByDateAdded.js b/src/content/dependencies/listAlbumsByDateAdded.js
new file mode 100644
index 00000000..d462ad46
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByDateAdded.js
@@ -0,0 +1,60 @@
+import {sortAlphabetically} from '#sort';
+import {chunkByProperties} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    return {
+      spec,
+
+      chunks:
+        chunkByProperties(
+          sortAlphabetically(albumData.filter(a => a.dateAddedToWiki))
+            .sort((a, b) => {
+              if (a.dateAddedToWiki < b.dateAddedToWiki) return -1;
+              if (a.dateAddedToWiki > b.dateAddedToWiki) return 1;
+            }),
+          ['dateAddedToWiki']),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(album => relation('linkAlbum', album))),
+    };
+  },
+
+  data(query) {
+    return {
+      dates:
+        query.chunks.map(({dateAddedToWiki}) => dateAddedToWiki),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        data.dates.map(date => ({
+          date: language.formatDate(date),
+        })),
+
+      chunkRows:
+        relations.albumLinks.map(albumLinks =>
+          albumLinks.map(link => ({
+            album: link,
+          }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAlbumsByDuration.js b/src/content/dependencies/listAlbumsByDuration.js
new file mode 100644
index 00000000..c60685ab
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByDuration.js
@@ -0,0 +1,52 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    const albums = sortAlphabetically(albumData.slice());
+    const durations = albums.map(album => getTotalDuration(album.tracks));
+
+    filterByCount(albums, durations);
+    sortByCount(albums, durations, {greatestFirst: true});
+
+    return {spec, albums, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.albumLinks,
+          duration: data.durations,
+        }).map(({link, duration}) => ({
+            album: link,
+            duration: language.formatDuration(duration),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAlbumsByName.js b/src/content/dependencies/listAlbumsByName.js
new file mode 100644
index 00000000..21419537
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByName.js
@@ -0,0 +1,50 @@
+import {sortAlphabetically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    return {
+      spec,
+      albums: sortAlphabetically(albumData.slice()),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts:
+        query.albums
+          .map(album => album.tracks.length),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.albumLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            album: link,
+            tracks: language.countTracks(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAlbumsByTracks.js b/src/content/dependencies/listAlbumsByTracks.js
new file mode 100644
index 00000000..798e6c2e
--- /dev/null
+++ b/src/content/dependencies/listAlbumsByTracks.js
@@ -0,0 +1,51 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    const albums = sortAlphabetically(albumData.slice());
+    const counts = albums.map(album => album.tracks.length);
+
+    filterByCount(albums, counts);
+    sortByCount(albums, counts, {greatestFirst: true});
+
+    return {spec, albums, counts};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts: query.counts,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.albumLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            album: link,
+            tracks: language.countTracks(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listAllAdditionalFiles.js b/src/content/dependencies/listAllAdditionalFiles.js
new file mode 100644
index 00000000..a6e34b9a
--- /dev/null
+++ b/src/content/dependencies/listAllAdditionalFiles.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listAllAdditionalFilesTemplate'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listAllAdditionalFilesTemplate', spec, 'additionalFiles')}),
+
+  generate: (relations) =>
+    relations.page.slot('stringsKey', 'other.allAdditionalFiles'),
+};
diff --git a/src/content/dependencies/listAllAdditionalFilesTemplate.js b/src/content/dependencies/listAllAdditionalFilesTemplate.js
new file mode 100644
index 00000000..e33ad7b5
--- /dev/null
+++ b/src/content/dependencies/listAllAdditionalFilesTemplate.js
@@ -0,0 +1,209 @@
+import {sortChronologically} from '#sort';
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateListingPage',
+    'generateListAllAdditionalFilesChunk',
+    'linkAlbum',
+    'linkTrack',
+    'linkAlbumAdditionalFile',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({albumData}) => ({albumData}),
+
+  query(sprawl, spec, property) {
+    const albums =
+      sortChronologically(sprawl.albumData.slice());
+
+    const tracks =
+      albums
+        .map(album => album.tracks.slice());
+
+    // Get additional file objects from albums and their tracks.
+    // There's a possibility that albums and tracks don't both implement
+    // the same additional file fields - in this case, just treat them
+    // as though they do implement those fields, but don't have any
+    // additional files of that type.
+
+    const albumAdditionalFileObjects =
+      albums
+        .map(album => album[property] ?? []);
+
+    const trackAdditionalFileObjects =
+      tracks
+        .map(byAlbum => byAlbum
+          .map(track => track[property] ?? []));
+
+    // Filter out tracks that don't have any additional files.
+
+    stitchArrays({tracks, trackAdditionalFileObjects})
+      .forEach(({tracks, trackAdditionalFileObjects}) => {
+        filterMultipleArrays(tracks, trackAdditionalFileObjects,
+          (track, trackAdditionalFileObjects) => !empty(trackAdditionalFileObjects));
+      });
+
+    // Filter out albums that don't have any tracks,
+    // nor any additional files of their own.
+
+    filterMultipleArrays(albums, albumAdditionalFileObjects, tracks, trackAdditionalFileObjects,
+      (album, albumAdditionalFileObjects, tracks, trackAdditionalFileObjects) =>
+        !empty(albumAdditionalFileObjects) ||
+        !empty(trackAdditionalFileObjects));
+
+    // Map additional file objects into titles and lists of file names.
+
+    const albumAdditionalFileTitles =
+      albumAdditionalFileObjects
+        .map(byAlbum => byAlbum
+          .map(({title}) => title));
+
+    const albumAdditionalFileFiles =
+      albumAdditionalFileObjects
+        .map(byAlbum => byAlbum
+          .map(({files}) => files ?? []));
+
+    const trackAdditionalFileTitles =
+      trackAdditionalFileObjects
+        .map(byAlbum => byAlbum
+          .map(byTrack => byTrack
+            .map(({title}) => title)));
+
+    const trackAdditionalFileFiles =
+      trackAdditionalFileObjects
+        .map(byAlbum => byAlbum
+          .map(byTrack => byTrack
+            .map(({files}) => files ?? [])));
+
+    return {
+      spec,
+      albums,
+      tracks,
+      albumAdditionalFileTitles,
+      albumAdditionalFileFiles,
+      trackAdditionalFileTitles,
+      trackAdditionalFileFiles,
+    };
+  },
+
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    albumLinks:
+      query.albums
+        .map(album => relation('linkAlbum', album)),
+
+    trackLinks:
+      query.tracks
+        .map(byAlbum => byAlbum
+          .map(track => relation('linkTrack', track))),
+
+    albumChunks:
+      query.albums
+        .map(() => relation('generateListAllAdditionalFilesChunk')),
+
+    trackChunks:
+      query.tracks
+        .map(byAlbum => byAlbum
+          .map(() => relation('generateListAllAdditionalFilesChunk'))),
+
+    albumAdditionalFileLinks:
+      stitchArrays({
+        album: query.albums,
+        files: query.albumAdditionalFileFiles,
+      }).map(({album, files: byAlbum}) =>
+          byAlbum.map(files => files
+            .map(file =>
+              relation('linkAlbumAdditionalFile', album, file)))),
+
+    trackAdditionalFileLinks:
+      stitchArrays({
+        album: query.albums,
+        files: query.trackAdditionalFileFiles,
+      }).map(({album, files: byAlbum}) =>
+          byAlbum
+            .map(byTrack => byTrack
+              .map(files => files
+                .map(file => relation('linkAlbumAdditionalFile', album, file))))),
+  }),
+
+  data: (query) => ({
+    albumAdditionalFileTitles: query.albumAdditionalFileTitles,
+    trackAdditionalFileTitles: query.trackAdditionalFileTitles,
+    albumAdditionalFileFiles: query.albumAdditionalFileFiles,
+    trackAdditionalFileFiles: query.trackAdditionalFileFiles,
+  }),
+
+  slots: {
+    stringsKey: {type: 'string'},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.page.slots({
+      type: 'custom',
+
+      content:
+        stitchArrays({
+          albumLink: relations.albumLinks,
+          trackLinks: relations.trackLinks,
+          albumChunk: relations.albumChunks,
+          trackChunks: relations.trackChunks,
+          albumAdditionalFileTitles: data.albumAdditionalFileTitles,
+          trackAdditionalFileTitles: data.trackAdditionalFileTitles,
+          albumAdditionalFileLinks: relations.albumAdditionalFileLinks,
+          trackAdditionalFileLinks: relations.trackAdditionalFileLinks,
+          albumAdditionalFileFiles: data.albumAdditionalFileFiles,
+          trackAdditionalFileFiles: data.trackAdditionalFileFiles,
+        }).map(({
+            albumLink,
+            trackLinks,
+            albumChunk,
+            trackChunks,
+            albumAdditionalFileTitles,
+            trackAdditionalFileTitles,
+            albumAdditionalFileLinks,
+            trackAdditionalFileLinks,
+            albumAdditionalFileFiles,
+            trackAdditionalFileFiles,
+          }) => [
+            html.tag('h3', {class: 'content-heading'}, albumLink),
+
+            html.tag('dl', [
+              albumChunk.slots({
+                title:
+                  language.$('listingPage', slots.stringsKey, 'albumFiles'),
+
+                additionalFileTitles: albumAdditionalFileTitles,
+                additionalFileLinks: albumAdditionalFileLinks,
+                additionalFileFiles: albumAdditionalFileFiles,
+
+                stringsKey: slots.stringsKey,
+              }),
+
+              stitchArrays({
+                trackLink: trackLinks,
+                trackChunk: trackChunks,
+                trackAdditionalFileTitles,
+                trackAdditionalFileLinks,
+                trackAdditionalFileFiles,
+              }).map(({
+                  trackLink,
+                  trackChunk,
+                  trackAdditionalFileTitles,
+                  trackAdditionalFileLinks,
+                  trackAdditionalFileFiles,
+                }) =>
+                  trackChunk.slots({
+                    title: trackLink,
+                    additionalFileTitles: trackAdditionalFileTitles,
+                    additionalFileLinks: trackAdditionalFileLinks,
+                    additionalFileFiles: trackAdditionalFileFiles,
+                    stringsKey: slots.stringsKey,
+                  })),
+            ]),
+          ]),
+    }),
+};
diff --git a/src/content/dependencies/listAllMidiProjectFiles.js b/src/content/dependencies/listAllMidiProjectFiles.js
new file mode 100644
index 00000000..31a70ef0
--- /dev/null
+++ b/src/content/dependencies/listAllMidiProjectFiles.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listAllAdditionalFilesTemplate'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listAllAdditionalFilesTemplate', spec, 'midiProjectFiles')}),
+
+  generate: (relations) =>
+    relations.page.slot('stringsKey', 'other.allMidiProjectFiles'),
+};
diff --git a/src/content/dependencies/listAllSheetMusicFiles.js b/src/content/dependencies/listAllSheetMusicFiles.js
new file mode 100644
index 00000000..166b2068
--- /dev/null
+++ b/src/content/dependencies/listAllSheetMusicFiles.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listAllAdditionalFilesTemplate'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listAllAdditionalFilesTemplate', spec, 'sheetMusicFiles')}),
+
+  generate: (relations) =>
+    relations.page.slot('stringsKey', 'other.allSheetMusic'),
+};
diff --git a/src/content/dependencies/listArtTagNetwork.js b/src/content/dependencies/listArtTagNetwork.js
new file mode 100644
index 00000000..93dd4ce8
--- /dev/null
+++ b/src/content/dependencies/listArtTagNetwork.js
@@ -0,0 +1,366 @@
+import {sortAlphabetically} from '#sort';
+import {empty, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtTagInfo'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({artTagData}) {
+    return {artTagData};
+  },
+
+  query(sprawl, spec) {
+    const artTags =
+      sprawl.artTagData.filter(artTag => !artTag.isContentWarning);
+
+    const rootArtTags =
+      artTags
+        .filter(artTag => !empty(artTag.directDescendantArtTags))
+        .filter(artTag =>
+          empty(artTag.directAncestorArtTags) ||
+          artTag.directAncestorArtTags.length >= 2);
+
+    sortAlphabetically(rootArtTags);
+
+    rootArtTags.sort(
+      ({directAncestorArtTags: ancestorsA},
+       {directAncestorArtTags: ancestorsB}) =>
+        ancestorsA.length - ancestorsB.length);
+
+    const getStats = (artTag) => ({
+      directUses:
+        artTag.directlyFeaturedInArtworks.length,
+
+      // Not currently displayed
+      directAndIndirectUses:
+        unique([
+          ...artTag.indirectlyFeaturedInArtworks,
+          ...artTag.directlyFeaturedInArtworks,
+        ]).length,
+
+      totalUses:
+        [
+          ...artTag.directlyFeaturedInArtworks,
+          ...
+            artTag.allDescendantArtTags
+              .flatMap(artTag => artTag.directlyFeaturedInArtworks),
+        ].length,
+
+      descendants:
+        artTag.allDescendantArtTags.length,
+
+      leaves:
+        (empty(artTag.directDescendantArtTags)
+          ? null
+          : artTag.allDescendantArtTags
+              .filter(artTag => empty(artTag.directDescendantArtTags))
+              .length),
+    });
+
+    const recursive = (artTag, depth) => {
+      const descendantNodes =
+        (empty(artTag.directDescendantArtTags)
+          ? null
+       : depth > 0 && artTag.directAncestorArtTags.length >= 2
+          ? null
+          : artTag.directDescendantArtTags
+              .map(artTag => recursive(artTag, depth + 1)));
+
+      descendantNodes?.sort(
+        ({descendantNodes: descendantNodesA},
+         {descendantNodes: descendantNodesB}) =>
+            (descendantNodesA ? 1 : 0)
+          - (descendantNodesB ? 1 : 0));
+
+      const recursiveGetRootAncestor = ancestorArtTag =>
+        (ancestorArtTag.directAncestorArtTags.length === 1
+          ? recursiveGetRootAncestor(ancestorArtTag.directAncestorArtTags[0])
+          : ancestorArtTag);
+
+      const ancestorRootArtTags =
+        (depth === 0 && !empty(artTag.directAncestorArtTags)
+          ? unique(artTag.directAncestorArtTags.map(recursiveGetRootAncestor))
+          : null);
+
+      const stats = getStats(artTag);
+
+      return {
+        artTag,
+        stats,
+        descendantNodes,
+        ancestorRootArtTags,
+      };
+    };
+
+    const uppermostRootTags =
+      artTags
+        .filter(artTag => !empty(artTag.directDescendantArtTags))
+        .filter(artTag => empty(artTag.directAncestorArtTags));
+
+    const orphanArtTags =
+      artTags
+        .filter(artTag => empty(artTag.directDescendantArtTags))
+        .filter(artTag => empty(artTag.directAncestorArtTags));
+
+    return {
+      spec,
+
+      rootNodes:
+        rootArtTags
+          .map(artTag => recursive(artTag, 0)),
+
+      uppermostRootTags,
+      orphanArtTags,
+    };
+  },
+
+  relations(relation, query) {
+    const recursive = queryNode => ({
+      artTagLink:
+        relation('linkArtTagInfo', queryNode.artTag),
+
+      ancestorTagLinks:
+        queryNode.ancestorRootArtTags
+          ?.map(artTag => relation('linkArtTagInfo', artTag))
+          ?? null,
+
+      descendantNodes:
+        queryNode.descendantNodes
+          ?.map(recursive)
+          ?? null,
+    });
+
+    return {
+      page:
+        relation('generateListingPage', query.spec),
+
+      rootNodes:
+        query.rootNodes.map(recursive),
+
+      uppermostRootTagLinks:
+        query.uppermostRootTags
+          .map(artTag => relation('linkArtTagInfo', artTag)),
+
+      orphanArtTagLinks:
+        query.orphanArtTags
+          .map(artTag => relation('linkArtTagInfo', artTag)),
+    };
+  },
+
+  data(query) {
+    const rootArtTags = query.rootNodes.map(({artTag}) => artTag);
+
+    const recursive = queryNode => ({
+      directory:
+        queryNode.artTag.directory,
+
+      directUses:
+        queryNode.stats.directUses,
+
+      totalUses:
+        queryNode.stats.totalUses,
+
+      descendants:
+        queryNode.stats.descendants,
+
+      leaves:
+        queryNode.stats.leaves,
+
+      representsRoot:
+        rootArtTags.includes(queryNode.artTag),
+
+      ancestorTagDirectories:
+        queryNode.ancestorRootArtTags
+          ?.map(artTag => artTag.directory)
+          ?? null,
+
+      descendantNodes:
+        queryNode.descendantNodes
+          ?.map(recursive)
+          ?? null,
+    });
+
+    return {
+      rootNodes:
+        query.rootNodes.map(recursive),
+
+      uppermostRootTagDirectories:
+        query.uppermostRootTags
+          .map(artTag => artTag.directory),
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    const prefix = `listingPage.listArtTags.network`;
+
+    const wrapTagWithJumpTo = (dataNode, relationsNode, depth) =>
+      (depth === 0
+        ? relationsNode.artTagLink
+     : dataNode.representsRoot
+        ? language.$(prefix, 'tag.jumpToRoot', {
+            tag:
+              relationsNode.artTagLink.slots({
+                anchor: true,
+                hash: dataNode.directory,
+              }),
+          })
+        : relationsNode.artTagLink);
+
+    const wrapTagWithStats = (dataNode, relationsNode, depth) => [
+      html.tag('span', {class: 'network-tag'},
+        language.$(prefix, 'tag', {
+          tag:
+            wrapTagWithJumpTo(dataNode, relationsNode, depth),
+        })),
+
+      html.tag('span', {class: 'network-tag'},
+        {class: 'with-stat'},
+        {style: 'display: none'},
+
+        language.$(prefix, 'tag.withStat', {
+          tag:
+            wrapTagWithJumpTo(dataNode, relationsNode, depth),
+
+          stat:
+            html.tag('span', {class: 'network-tag-stat'},
+              language.$(prefix, 'tag.withStat.stat', {
+                stat: [
+                  html.tag('span', {class: 'network-tag-direct-uses-stat'},
+                    dataNode.directUses.toString()),
+
+                  html.tag('span', {class: 'network-tag-total-uses-stat'},
+                    dataNode.totalUses.toString()),
+
+                  html.tag('span', {class: 'network-tag-descendants-stat'},
+                    dataNode.descendants.toString()),
+
+                  html.tag('span', {class: 'network-tag-leaves-stat'},
+                    (dataNode.leaves === null
+                      ? language.$(prefix, 'tag.withStat.notApplicable')
+                      : dataNode.leaves.toString())),
+                ],
+              })),
+        }))
+    ];
+
+    const recursive = (dataNode, relationsNode, depth) => [
+      html.tag('dt',
+        {
+          id: depth === 0 && dataNode.directory,
+          class: depth % 2 === 0 ? 'even' : 'odd',
+        },
+
+        (depth === 0
+          ? (relationsNode.ancestorTagLinks
+              ? language.$(prefix, 'root.withAncestors', {
+                  tag:
+                    wrapTagWithStats(dataNode, relationsNode, depth),
+
+                  ancestors:
+                    language.formatUnitList(
+                      stitchArrays({
+                        link: relationsNode.ancestorTagLinks,
+                        directory: dataNode.ancestorTagDirectories,
+                      }).map(({link, directory}) =>
+                          link.slots({
+                            anchor: true,
+                            hash: directory,
+                          }))),
+                })
+              : language.$(prefix, 'root.jumpToTop', {
+                  tag:
+                    wrapTagWithStats(dataNode, relationsNode, depth),
+
+                  link:
+                    html.tag('a', {href: '#top'},
+                      language.$(prefix, 'root.jumpToTop.link')),
+                }))
+          : wrapTagWithStats(dataNode, relationsNode, depth))),
+
+      dataNode.descendantNodes &&
+      relationsNode.descendantNodes &&
+        html.tag('dd',
+          {class: depth % 2 === 0 ? 'even' : 'odd'},
+          html.tag('dl',
+            stitchArrays({
+              dataNode: dataNode.descendantNodes,
+              relationsNode: relationsNode.descendantNodes,
+            }).map(({dataNode, relationsNode}) =>
+                recursive(dataNode, relationsNode, depth + 1)))),
+    ];
+
+    return relations.page.slots({
+      type: 'custom',
+
+      content: [
+        html.tag('p', {id: 'network-stat-line'},
+          language.$(prefix, 'statLine', {
+            stat: [
+              html.tag('a', {id: 'network-stat-none'},
+                {href: '#'},
+                language.$(prefix, 'statLine.none')),
+
+              html.tag('a', {id: 'network-stat-total-uses'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.totalUses')),
+
+              html.tag('a', {id: 'network-stat-direct-uses'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.directUses')),
+
+              html.tag('a', {id: 'network-stat-descendants'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.descendants')),
+
+              html.tag('a', {id: 'network-stat-leaves'},
+                {href: '#'},
+                {style: 'display: none'},
+                language.$(prefix, 'statLine.leaves')),
+            ],
+          })),
+
+        html.tag('dl', {id: 'network-top-dl'}, [
+          html.tag('dt', {id: 'top'},
+            language.$(prefix, 'jumpToRoot.title')),
+
+          html.tag('dd',
+            html.tag('ul',
+              stitchArrays({
+                link: relations.uppermostRootTagLinks,
+                directory: data.uppermostRootTagDirectories,
+              }).map(({link, directory}) =>
+                  html.tag('li',
+                    language.$(prefix, 'jumpToRoot.item', {
+                      tag:
+                        link.slots({
+                          anchor: true,
+                          hash: directory,
+                        }),
+                    }))))),
+
+          stitchArrays({
+            dataNode: data.rootNodes,
+            relationsNode: relations.rootNodes,
+          }).map(({dataNode, relationsNode}) =>
+              recursive(dataNode, relationsNode, 0)),
+
+          !empty(relations.orphanArtTagLinks) && [
+            html.tag('dt',
+              language.$(prefix, 'orphanArtTags.title')),
+
+            html.tag('dd',
+              html.tag('ul',
+                relations.orphanArtTagLinks.map(orphanArtTagLink =>
+                  html.tag('li',
+                    language.$(prefix, 'orphanArtTags.item', {
+                      tag: orphanArtTagLink,
+                    }))))),
+          ],
+        ]),
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtTagsByName.js b/src/content/dependencies/listArtTagsByName.js
new file mode 100644
index 00000000..1df9dfff
--- /dev/null
+++ b/src/content/dependencies/listArtTagsByName.js
@@ -0,0 +1,57 @@
+import {sortAlphabetically} from '#sort';
+import {stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtTagGallery'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artTagData}) {
+    return {artTagData};
+  },
+
+  query({artTagData}, spec) {
+    return {
+      spec,
+
+      artTags:
+        sortAlphabetically(
+          artTagData
+            .filter(artTag => !artTag.isContentWarning)),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      artTagLinks:
+        query.artTags
+          .map(artTag => relation('linkArtTagGallery', artTag)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts:
+        query.artTags.map(artTag =>
+          unique([
+            ...artTag.indirectlyFeaturedInArtworks,
+            ...artTag.directlyFeaturedInArtworks,
+          ]).length),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artTagLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            tag: link,
+            timesUsed: language.countTimesUsed(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtTagsByUses.js b/src/content/dependencies/listArtTagsByUses.js
new file mode 100644
index 00000000..eca7f1c6
--- /dev/null
+++ b/src/content/dependencies/listArtTagsByUses.js
@@ -0,0 +1,54 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays, unique} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtTagGallery'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl: ({artTagData}) =>
+    ({artTagData}),
+
+  query({artTagData}, spec) {
+    const artTags =
+      sortAlphabetically(
+        artTagData
+          .filter(artTag => !artTag.isContentWarning));
+
+    const counts =
+      artTags.map(artTag =>
+        unique([
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
+        ]).length);
+
+    filterByCount(artTags, counts);
+    sortByCount(artTags, counts, {greatestFirst: true});
+
+    return {spec, artTags, counts};
+  },
+
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    artTagLinks:
+      query.artTags
+        .map(artTag => relation('linkArtTagGallery', artTag)),
+  }),
+
+  data: (query) =>
+    ({counts: query.counts}),
+
+  generate: (data, relations, {language}) =>
+    relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artTagLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            tag: link,
+            timesUsed: language.countTimesUsed(count, {unit: true}),
+          })),
+    }),
+};
diff --git a/src/content/dependencies/listArtistsByCommentaryEntries.js b/src/content/dependencies/listArtistsByCommentaryEntries.js
new file mode 100644
index 00000000..eff2dba3
--- /dev/null
+++ b/src/content/dependencies/listArtistsByCommentaryEntries.js
@@ -0,0 +1,58 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artistData}) {
+    return {artistData};
+  },
+
+  query({artistData}, spec) {
+    const artists =
+      sortAlphabetically(
+        artistData.filter(artist => !artist.isAlias));
+
+    const counts =
+      artists.map(artist =>
+        artist.tracksAsCommentator.length +
+        artist.albumsAsCommentator.length);
+
+    filterByCount(artists, counts);
+    sortByCount(artists, counts, {greatestFirst: true});
+
+    return {artists, counts, spec};
+  },
+
+  relations(relation, query) {
+    return {
+      page:
+        relation('generateListingPage', query.spec),
+
+      artistLinks:
+        query.artists
+          .map(artist => relation('linkArtist', artist)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts: query.counts,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artistLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            artist: link,
+            entries: language.countCommentaryEntries(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js
new file mode 100644
index 00000000..41944959
--- /dev/null
+++ b/src/content/dependencies/listArtistsByContributions.js
@@ -0,0 +1,174 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+
+import {
+  accumulateSum,
+  empty,
+  filterByCount,
+  filterMultipleArrays,
+  stitchArrays,
+  unique,
+} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({artistData, wikiInfo}) {
+    return {
+      artistData,
+      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
+    };
+  },
+
+  query(sprawl, spec) {
+    const query = {
+      spec,
+      enableFlashesAndGames: sprawl.enableFlashesAndGames,
+    };
+
+    const queryContributionInfo = (artistsKey, countsKey, fn) => {
+      const artists =
+        sortAlphabetically(
+          sprawl.artistData.filter(artist => !artist.isAlias));
+
+      const counts =
+        artists.map(artist => fn(artist));
+
+      filterByCount(artists, counts);
+      sortByCount(artists, counts, {greatestFirst: true});
+
+      query[artistsKey] = artists;
+      query[countsKey] = counts;
+    };
+
+    queryContributionInfo(
+      'artistsByTrackContributions',
+      'countsByTrackContributions',
+      artist =>
+        (unique(
+          ([
+            artist.trackArtistContributions,
+            artist.trackContributorContributions,
+          ]).flat()
+            .map(({thing}) => thing)
+        )).length);
+
+    queryContributionInfo(
+      'artistsByArtworkContributions',
+      'countsByArtworkContributions',
+      artist =>
+        accumulateSum(
+          [
+            artist.albumCoverArtistContributions,
+            artist.albumWallpaperArtistContributions,
+            artist.albumBannerArtistContributions,
+            artist.trackCoverArtistContributions,
+          ],
+          contribs => contribs.length));
+
+    if (sprawl.enableFlashesAndGames) {
+      queryContributionInfo(
+        'artistsByFlashContributions',
+        'countsByFlashContributions',
+        artist =>
+          artist.flashContributorContributions.length);
+    }
+
+    return query;
+  },
+
+  relations(relation, query) {
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    relations.artistLinksByTrackContributions =
+      query.artistsByTrackContributions
+        .map(artist => relation('linkArtist', artist));
+
+    relations.artistLinksByArtworkContributions =
+      query.artistsByArtworkContributions
+        .map(artist => relation('linkArtist', artist));
+
+    if (query.enableFlashesAndGames) {
+      relations.artistLinksByFlashContributions =
+        query.artistsByFlashContributions
+          .map(artist => relation('linkArtist', artist));
+    }
+
+    return relations;
+  },
+
+  data(query) {
+    const data = {};
+
+    data.enableFlashesAndGames = query.enableFlashesAndGames;
+
+    data.countsByTrackContributions = query.countsByTrackContributions;
+    data.countsByArtworkContributions = query.countsByArtworkContributions;
+
+    if (query.enableFlashesAndGames) {
+      data.countsByFlashContributions = query.countsByFlashContributions;
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {language}) {
+    const listChunkIDs = ['tracks', 'artworks'];
+    const listTitleStringsKeys = ['trackContributors', 'artContributors'];
+    const listCountFunctions = ['countTracks', 'countArtworks'];
+
+    const listArtistLinks = [
+      relations.artistLinksByTrackContributions,
+      relations.artistLinksByArtworkContributions,
+    ];
+
+    const listArtistCounts = [
+      data.countsByTrackContributions,
+      data.countsByArtworkContributions,
+    ];
+
+    if (data.enableFlashesAndGames) {
+      listChunkIDs.push('flashes');
+      listTitleStringsKeys.push('flashContributors');
+      listCountFunctions.push('countFlashes');
+      listArtistLinks.push(relations.artistLinksByFlashContributions);
+      listArtistCounts.push(data.countsByFlashContributions);
+    }
+
+    filterMultipleArrays(
+      listChunkIDs,
+      listTitleStringsKeys,
+      listCountFunctions,
+      listArtistLinks,
+      listArtistCounts,
+      (_chunkID, _titleStringsKey, _countFunction, artistLinks, _artistCounts) =>
+        !empty(artistLinks));
+
+    return relations.page.slots({
+      type: 'chunks',
+
+      showSkipToSection: true,
+      chunkIDs: listChunkIDs,
+
+      chunkTitles:
+        listTitleStringsKeys.map(stringsKey => ({stringsKey})),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: listArtistLinks,
+          artistCounts: listArtistCounts,
+          countFunction: listCountFunctions,
+        }).map(({artistLinks, artistCounts, countFunction}) =>
+            stitchArrays({
+              artistLink: artistLinks,
+              artistCount: artistCounts,
+            }).map(({artistLink, artistCount}) => ({
+                artist: artistLink,
+                contributions: language[countFunction](artistCount, {unit: true}),
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtistsByDuration.js b/src/content/dependencies/listArtistsByDuration.js
new file mode 100644
index 00000000..6b2a18a0
--- /dev/null
+++ b/src/content/dependencies/listArtistsByDuration.js
@@ -0,0 +1,55 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artistData}) {
+    return {artistData};
+  },
+
+  query({artistData}, spec) {
+    const artists =
+      sortAlphabetically(
+        artistData.filter(artist => !artist.isAlias));
+
+    const durations =
+      artists.map(artist => artist.totalDuration);
+
+    filterByCount(artists, durations);
+    sortByCount(artists, durations, {greatestFirst: true});
+
+    return {spec, artists, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      artistLinks:
+        query.artists
+          .map(artist => relation('linkArtist', artist)),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artistLinks,
+          duration: data.durations,
+        }).map(({link, duration}) => ({
+            artist: link,
+            duration: language.formatDuration(duration),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js
new file mode 100644
index 00000000..17096cfc
--- /dev/null
+++ b/src/content/dependencies/listArtistsByGroup.js
@@ -0,0 +1,157 @@
+import {sortAlphabetically} from '#sort';
+
+import {
+  empty,
+  filterByCount,
+  filterMultipleArrays,
+  stitchArrays,
+  transposeArrays,
+  unique,
+} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artistData, wikiInfo}) {
+    return {artistData, wikiInfo};
+  },
+
+  query(sprawl, spec) {
+    const artists =
+      sortAlphabetically(
+        sprawl.artistData.filter(artist => !artist.isAlias));
+
+    const interestingGroups =
+      sprawl.wikiInfo.divideTrackListsByGroups;
+
+    if (empty(interestingGroups)) {
+      return {spec};
+    }
+
+    // We don't actually care about *which* things belong to each group, only
+    // how many belong to each group. So we'll just compute a list of all the
+    // (interesting) groups that each of each artists' things belongs to.
+    const artistThingGroups =
+      artists.map(artist =>
+        ([
+          (unique(
+            ([
+              artist.albumArtistContributions
+                .map(contrib => contrib.thing),
+              artist.albumCoverArtistContributions
+                .map(contrib => contrib.thing.thing),
+              artist.albumWallpaperArtistContributions
+                .map(contrib => contrib.thing.thing),
+              artist.albumBannerArtistContributions
+                .map(contrib => contrib.thing.thing),
+            ]).flat()
+          )).map(album => album.groups),
+          (unique(
+            ([
+              artist.trackArtistContributions
+                .map(contrib => contrib.thing),
+              artist.trackContributorContributions
+                .map(contrib => contrib.thing),
+              artist.trackCoverArtistContributions
+                .map(contrib => contrib.thing.thing),
+            ]).flat()
+          )).map(track => track.album.groups),
+        ]).flat()
+          .map(groups => groups
+            .filter(group => interestingGroups.includes(group))));
+
+    const [artistsByGroup, countsByGroup] =
+      transposeArrays(interestingGroups.map(group => {
+        const counts =
+          artistThingGroups
+            .map(thingGroups => thingGroups
+              .filter(thingGroups => thingGroups.includes(group))
+              .length);
+
+        const filteredArtists = artists.slice();
+
+        filterByCount(filteredArtists, counts);
+
+        return [filteredArtists, counts];
+      }));
+
+    const groups = interestingGroups;
+
+    filterMultipleArrays(
+      groups,
+      artistsByGroup,
+      countsByGroup,
+      (_group, artists, _counts) => !empty(artists));
+
+    return {
+      spec,
+      groups,
+      artistsByGroup,
+      countsByGroup,
+    };
+  },
+
+  relations(relation, query) {
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    if (query.artistsByGroup) {
+      relations.groupLinks =
+        query.groups
+          .map(group => relation('linkGroup', group));
+
+      relations.artistLinksByGroup =
+        query.artistsByGroup
+          .map(artists => artists
+            .map(artist => relation('linkArtist', artist)));
+    }
+
+    return relations;
+  },
+
+  data(query) {
+    const data = {};
+
+    if (query.artistsByGroup) {
+      data.groupDirectories =
+        query.groups
+          .map(group => group.directory);
+
+      data.countsByGroup =
+        query.countsByGroup;
+    }
+
+    return data;
+  },
+
+  generate: (data, relations, {language}) =>
+    relations.page.slots({
+      type: 'chunks',
+
+      showSkipToSection: true,
+      chunkIDs:
+        data.groupDirectories
+          .map(directory => `contributed-to-${directory}`),
+
+      chunkTitles:
+        relations.groupLinks.map(groupLink => ({
+          group: groupLink,
+        })),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: relations.artistLinksByGroup,
+          counts: data.countsByGroup,
+        }).map(({artistLinks, counts}) =>
+            stitchArrays({
+              link: artistLinks,
+              count: counts,
+            }).map(({link, count}) => ({
+                artist: link,
+                contributions: language.countContributions(count, {unit: true}),
+              }))),
+    }),
+};
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
new file mode 100644
index 00000000..2a8d1b4c
--- /dev/null
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -0,0 +1,323 @@
+import {chunkMultipleArrays, empty, sortMultipleArrays, stitchArrays}
+  from '#sugar';
+import T from '#things';
+
+import {
+  sortAlphabetically,
+  sortAlbumsTracksChronologically,
+  sortFlashesChronologically,
+} from '#sort';
+
+const {Album, Flash} = T;
+
+export default {
+  contentDependencies: [
+    'generateListingPage',
+    'linkAlbum',
+    'linkArtist',
+    'linkFlash',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({albumData, artistData, flashData, trackData, wikiInfo}) =>
+    ({albumData, artistData, flashData, trackData,
+      enableFlashesAndGames: wikiInfo.enableFlashesAndGames}),
+
+  query(sprawl, spec) {
+    //
+    // First main step is to get the latest thing each artist has contributed
+    // to, and the date associated with that contribution! Some notes:
+    //
+    // * Album and track contributions are considered before flashes, so
+    //   they'll take priority if an artist happens to have multiple contribs
+    //   landing on the same date to both an album and a flash.
+    //
+    // * The final (album) contribution list is chunked by album, but also by
+    //   date, because an individual album can cover a variety of dates.
+    //
+    // * If an artist has contributed both artworks and tracks to the album
+    //   containing their latest contribution, then that will be indicated
+    //   in an annotation, but *only if* those contributions were also on
+    //   the same date.
+    //
+    // * If an artist made contributions to multiple albums on the same date,
+    //   then the first of the *albums* sorted chronologically (latest first)
+    //   is the one that will count.
+    //
+    // * Same for artists who've contributed to multiple flashes which were
+    //   released on the same date.
+    //
+    // * The map may exclude artists none of whose contributions were dated.
+    //
+
+    const artistLatestContribMap = new Map();
+
+    const considerDate = (artist, date, thing, contribution) => {
+      if (!date) {
+        return;
+      }
+
+      if (artistLatestContribMap.has(artist)) {
+        const latest = artistLatestContribMap.get(artist);
+        if (latest.date > date) {
+          return;
+        }
+
+        if (latest.date === date) {
+          if (latest.thing === thing) {
+            // May combine differnt contributions to the same thing and date.
+            latest.contribution.add(contribution);
+          }
+
+          // Earlier-processed things of same date take priority.
+          return;
+        }
+      }
+
+      // First entry for artist or more recent contribution than latest date.
+      artistLatestContribMap.set(artist, {
+        date,
+        thing,
+        contribution: new Set([contribution]),
+      });
+    };
+
+    const getArtists = (thing, key) =>
+      thing[key].map(({artist}) => artist);
+
+    const albumsLatestFirst = sortAlbumsTracksChronologically(sprawl.albumData.slice());
+    const tracksLatestFirst = sortAlbumsTracksChronologically(sprawl.trackData.slice());
+    const flashesLatestFirst = sortFlashesChronologically(sprawl.flashData.slice());
+
+    for (const album of albumsLatestFirst) {
+      for (const artist of new Set([
+        ...getArtists(album, 'coverArtistContribs'),
+        ...getArtists(album, 'wallpaperArtistContribs'),
+        ...getArtists(album, 'bannerArtistContribs'),
+      ])) {
+        // Might combine later with 'track' of the same album and date.
+        considerDate(artist, album.coverArtDate ?? album.date, album, 'artwork');
+        // '?? album.date' is kept here because wallpaper and banner may
+        // technically be present for an album w/o cover art, therefore
+        // also no cover art date.
+      }
+    }
+
+    for (const track of tracksLatestFirst) {
+      for (const artist of getArtists(track, 'coverArtistContribs')) {
+        // No special effect if artist already has 'artwork' for the same album and date.
+        considerDate(artist, track.coverArtDate, track.album, 'artwork');
+      }
+
+      for (const artist of new Set([
+        ...getArtists(track, 'artistContribs'),
+        ...getArtists(track, 'contributorContribs'),
+      ])) {
+        // Might be combining with 'artwork' of the same album and date.
+        considerDate(artist, track.date, track.album, 'track');
+      }
+    }
+
+    for (const flash of flashesLatestFirst) {
+      for (const artist of getArtists(flash, 'contributorContribs')) {
+        // Won't take priority above album contributions of the same date.
+        considerDate(artist, flash.date, flash, 'flash');
+      }
+    }
+
+    //
+    // Next up is to sort all the processed artist information!
+    //
+    // Entries with the same album/flash and the same date go together first,
+    // with the following rules for sorting artists therein:
+    //
+    // * If the contributions are different, which can only happen for albums,
+    //   then it's tracks-only first, tracks + artworks next, and artworks-only
+    //   last.
+    //
+    // * If the contributions are the same, then sort alphabetically.
+    //
+    // Entries with different albums/flashes follow another set of rules:
+    //
+    // * Later dates come before earlier dates.
+    //
+    // * On the same date, albums come before flashes.
+    //
+    // * Things of the same type *and* date are sorted alphabetically.
+    //
+
+    const artistsAlphabetically =
+      sortAlphabetically(
+        sprawl.artistData.filter(artist => !artist.isAlias));
+
+    const artists =
+      Array.from(artistLatestContribMap.keys());
+
+    const artistContribEntries =
+      Array.from(artistLatestContribMap.values());
+
+    const artistThings =
+      artistContribEntries.map(({thing}) => thing);
+
+    const artistDates =
+      artistContribEntries.map(({date}) => date);
+
+    const artistContributions =
+      artistContribEntries.map(({contribution}) => contribution);
+
+    sortMultipleArrays(artistThings, artistDates, artistContributions, artists,
+      (thing1, thing2, date1, date2, contrib1, contrib2, artist1, artist2) => {
+        if (date1 === date2 && thing1 === thing2) {
+          // Move artwork-only contribs after contribs with tracks.
+          if (!contrib1.has('track') && contrib2.has('track')) return 1;
+          if (!contrib2.has('track') && contrib1.has('track')) return -1;
+
+          // Move track-only contribs before tracks with tracks and artwork.
+          if (!contrib1.has('artwork') && contrib2.has('artwork')) return -1;
+          if (!contrib2.has('artwork') && contrib1.has('artwork')) return 1;
+
+          // Sort artists of the same type of contribution alphabetically,
+          // referring to a previous sort.
+          const index1 = artistsAlphabetically.indexOf(artist1);
+          const index2 = artistsAlphabetically.indexOf(artist2);
+          return index1 - index2;
+        } else {
+          // Move later dates before earlier ones.
+          if (date1 !== date2) return date2 - date1;
+
+          // Move albums before flashes.
+          if (thing1 instanceof Album && thing2 instanceof Flash) return -1;
+          if (thing1 instanceof Flash && thing2 instanceof Album) return 1;
+
+          // Sort two albums or two flashes alphabetically, referring to a
+          // previous sort (which was chronological but includes the correct
+          // ordering for things released on the same date).
+          const thingsLatestFirst =
+            (thing1 instanceof Album
+              ? albumsLatestFirst
+              : flashesLatestFirst);
+          const index1 = thingsLatestFirst.indexOf(thing1);
+          const index2 = thingsLatestFirst.indexOf(thing2);
+          return index2 - index1;
+        }
+      });
+
+    const chunks =
+      chunkMultipleArrays(artistThings, artistDates, artistContributions, artists,
+        (thing, lastThing, date, lastDate) =>
+          thing !== lastThing ||
+          +date !== +lastDate);
+
+    const chunkThings =
+      chunks.map(([artistThings, , , ]) => artistThings[0]);
+
+    const chunkDates =
+      chunks.map(([, artistDates, , ]) => artistDates[0]);
+
+    const chunkArtistContributions =
+      chunks.map(([, , artistContributions, ]) => artistContributions);
+
+    const chunkArtists =
+      chunks.map(([, , , artists]) => artists);
+
+    // And one bonus step - keep track of all the artists whose contributions
+    // were all without date.
+
+    const datelessArtists =
+      artistsAlphabetically
+        .filter(artist => !artists.includes(artist));
+
+    return {
+      spec,
+      chunkThings,
+      chunkDates,
+      chunkArtistContributions,
+      chunkArtists,
+      datelessArtists,
+    };
+  },
+
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    chunkAlbumLinks:
+      query.chunkThings
+        .map(thing =>
+          (thing instanceof Album
+            ? relation('linkAlbum', thing)
+            : null)),
+
+    chunkFlashLinks:
+      query.chunkThings
+        .map(thing =>
+          (thing instanceof Flash
+            ? relation('linkFlash', thing)
+            : null)),
+
+    chunkArtistLinks:
+      query.chunkArtists
+        .map(artists => artists
+          .map(artist => relation('linkArtist', artist))),
+
+    datelessArtistLinks:
+      query.datelessArtists
+        .map(artist => relation('linkArtist', artist)),
+  }),
+
+  data: (query) => ({
+    chunkDates: query.chunkDates,
+    chunkArtistContributions: query.chunkArtistContributions,
+  }),
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          albumLink: relations.chunkAlbumLinks,
+          flashLink: relations.chunkFlashLinks,
+          date: data.chunkDates,
+        }).map(({albumLink, flashLink, date}) => ({
+            date: language.formatDate(date),
+            ...(albumLink
+              ? {stringsKey: 'album', album: albumLink}
+              : {stringsKey: 'flash', flash: flashLink}),
+          }))
+          .concat(
+            (empty(relations.datelessArtistLinks)
+              ? []
+              : [{stringsKey: 'dateless'}])),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: relations.chunkArtistLinks,
+          contributions: data.chunkArtistContributions,
+        }).map(({artistLinks, contributions}) =>
+            stitchArrays({
+              artistLink: artistLinks,
+              contribution: contributions,
+            }).map(({artistLink, contribution}) => ({
+                artist: artistLink,
+                stringsKey:
+                  (contribution.has('track') && contribution.has('artwork')
+                    ? 'tracksAndArt'
+                 : contribution.has('track')
+                    ? 'tracks'
+                 : contribution.has('artwork')
+                    ? 'art'
+                    : null),
+              })))
+          .concat(
+            (empty(relations.datelessArtistLinks)
+              ? []
+              : [
+                  relations.datelessArtistLinks.map(artistLink => ({
+                    artist: artistLink,
+                  })),
+                ])),
+    });
+  },
+};
diff --git a/src/content/dependencies/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js
new file mode 100644
index 00000000..93218492
--- /dev/null
+++ b/src/content/dependencies/listArtistsByName.js
@@ -0,0 +1,48 @@
+import {sortAlphabetically} from '#sort';
+import {stitchArrays} from '#sugar';
+import {getArtistNumContributions} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl: ({artistData, wikiInfo}) =>
+    ({artistData, wikiInfo}),
+
+  query: (sprawl, spec) => ({
+    spec,
+
+    artists:
+      sortAlphabetically(
+        sprawl.artistData.filter(artist => !artist.isAlias)),
+  }),
+
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    artistLinks:
+      query.artists
+        .map(artist => relation('linkArtist', artist)),
+  }),
+
+  data: (query) => ({
+    counts:
+      query.artists
+        .map(artist => getArtistNumContributions(artist)),
+  }),
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artistLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            artist: link,
+            contributions: language.countContributions(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByAlbums.js b/src/content/dependencies/listGroupsByAlbums.js
new file mode 100644
index 00000000..4adfb6d9
--- /dev/null
+++ b/src/content/dependencies/listGroupsByAlbums.js
@@ -0,0 +1,51 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    const groups = sortAlphabetically(groupData.slice());
+    const counts = groups.map(group => group.albums.length);
+
+    filterByCount(groups, counts);
+    sortByCount(groups, counts, {greatestFirst: true});
+
+    return {spec, groups, counts};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      groupLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts: query.counts,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.groupLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            group: link,
+            albums: language.countAlbums(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByCategory.js b/src/content/dependencies/listGroupsByCategory.js
new file mode 100644
index 00000000..43919bef
--- /dev/null
+++ b/src/content/dependencies/listGroupsByCategory.js
@@ -0,0 +1,76 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupCategoryData}) {
+    return {groupCategoryData};
+  },
+
+  query({groupCategoryData}, spec) {
+    return {
+      spec,
+      groupCategories: groupCategoryData,
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      categoryLinks:
+        query.groupCategories
+          .map(category => relation('linkGroup', category.groups[0])),
+
+      infoLinks:
+        query.groupCategories
+          .map(category =>
+            category.groups
+              .map(group => relation('linkGroup', group))),
+
+      galleryLinks:
+        query.groupCategories
+          .map(category =>
+            category.groups
+              .map(group => relation('linkGroupGallery', group)))
+    };
+  },
+
+  data(query) {
+    return {
+      categoryNames:
+        query.groupCategories
+          .map(category => category.name),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          link: relations.categoryLinks,
+          name: data.categoryNames,
+        }).map(({link, name}) => ({
+            category: link.slot('content', name),
+          })),
+
+      chunkRows:
+        stitchArrays({
+          infoLinks: relations.infoLinks,
+          galleryLinks: relations.galleryLinks,
+        }).map(({infoLinks, galleryLinks}) =>
+            stitchArrays({
+              infoLink: infoLinks,
+              galleryLink: galleryLinks,
+            }).map(({infoLink, galleryLink}) => ({
+                group: infoLink,
+                gallery:
+                  galleryLink
+                    .slot('content', language.$('listingPage.listGroups.byCategory.chunk.item.gallery')),
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByDuration.js b/src/content/dependencies/listGroupsByDuration.js
new file mode 100644
index 00000000..c79e1bc4
--- /dev/null
+++ b/src/content/dependencies/listGroupsByDuration.js
@@ -0,0 +1,56 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    const groups = sortAlphabetically(groupData.slice());
+    const durations =
+      groups.map(group =>
+        getTotalDuration(
+          group.albums.flatMap(album => album.tracks),
+          {mainReleasesOnly: true}));
+
+    filterByCount(groups, durations);
+    sortByCount(groups, durations, {greatestFirst: true});
+
+    return {spec, groups, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      groupLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.groupLinks,
+          duration: data.durations,
+        }).map(({link, duration}) => ({
+            group: link,
+            duration: language.formatDuration(duration),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByLatestAlbum.js b/src/content/dependencies/listGroupsByLatestAlbum.js
new file mode 100644
index 00000000..48319314
--- /dev/null
+++ b/src/content/dependencies/listGroupsByLatestAlbum.js
@@ -0,0 +1,72 @@
+import {compareDates, sortChronologically} from '#sort';
+import {filterMultipleArrays, sortMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateListingPage',
+    'linkAlbum',
+    'linkGroup',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    const groups = sortChronologically(groupData.slice());
+
+    const albums =
+      groups
+        .map(group =>
+          sortChronologically(
+            group.albums.filter(album => album.date),
+            {latestFirst: true}))
+        .map(albums => albums[0]);
+
+    filterMultipleArrays(groups, albums, (group, album) => album);
+
+    const dates = albums.map(album => album.date);
+
+    // Note: After this sort, the groups/dates arrays are misaligned with
+    // albums. That's OK only because we aren't doing anything further with
+    // the albums array.
+    sortMultipleArrays(groups, dates,
+      (groupA, groupB, dateA, dateB) =>
+        compareDates(dateA, dateB, {latestFirst: true}));
+
+    return {spec, groups, dates};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      groupLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return {
+      dates: query.dates,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          groupLink: relations.groupLinks,
+          date: data.dates,
+        }).map(({groupLink, date}) => ({
+            group: groupLink,
+            date: language.formatDate(date),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByName.js b/src/content/dependencies/listGroupsByName.js
new file mode 100644
index 00000000..696a49bd
--- /dev/null
+++ b/src/content/dependencies/listGroupsByName.js
@@ -0,0 +1,49 @@
+import {sortAlphabetically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    return {
+      spec,
+
+      groups: sortAlphabetically(groupData.slice()),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      infoLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+
+      galleryLinks:
+        query.groups
+          .map(group => relation('linkGroupGallery', group)),
+    };
+  },
+
+  generate(relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          infoLink: relations.infoLinks,
+          galleryLink: relations.galleryLinks,
+        }).map(({infoLink, galleryLink}) => ({
+            group: infoLink,
+            gallery:
+              galleryLink
+                .slot('content', language.$('listingPage.listGroups.byName.item.gallery')),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listGroupsByTracks.js b/src/content/dependencies/listGroupsByTracks.js
new file mode 100644
index 00000000..0b5e4e97
--- /dev/null
+++ b/src/content/dependencies/listGroupsByTracks.js
@@ -0,0 +1,55 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {accumulateSum, filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkGroup'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({groupData}) {
+    return {groupData};
+  },
+
+  query({groupData}, spec) {
+    const groups = sortAlphabetically(groupData.slice());
+    const counts =
+      groups.map(group =>
+        accumulateSum(
+          group.albums,
+          ({tracks}) => tracks.length));
+
+    filterByCount(groups, counts);
+    sortByCount(groups, counts, {greatestFirst: true});
+
+    return {spec, groups, counts};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      groupLinks:
+        query.groups
+          .map(group => relation('linkGroup', group)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts: query.counts,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.groupLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            group: link,
+            tracks: language.countTracks(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js
new file mode 100644
index 00000000..79bba441
--- /dev/null
+++ b/src/content/dependencies/listRandomPageLinks.js
@@ -0,0 +1,197 @@
+import {sortChronologically} from '#sort';
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateListingPage',
+    'generateListRandomPageLinksAlbumLink',
+    'linkGroup',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({albumData, wikiInfo}) => ({albumData, wikiInfo}),
+
+  query(sprawl, spec) {
+    const query = {spec};
+
+    const groups = sprawl.wikiInfo.divideTrackListsByGroups;
+
+    query.divideByGroups = !empty(groups);
+
+    if (query.divideByGroups) {
+      query.groups = groups;
+
+      query.groupAlbums =
+        groups
+          .map(group =>
+            group.albums.filter(album => album.tracks.length > 1));
+    } else {
+      query.undividedAlbums =
+        sortChronologically(sprawl.albumData.slice())
+          .filter(album => album.tracks.length > 1);
+    }
+
+    return query;
+  },
+
+  relations(relation, query) {
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    if (query.divideByGroups) {
+      relations.groupLinks =
+        query.groups
+          .map(group => relation('linkGroup', group));
+
+      relations.groupAlbumLinks =
+        query.groupAlbums
+          .map(albums => albums
+            .map(album =>
+              relation('generateListRandomPageLinksAlbumLink', album)));
+    } else {
+      relations.undividedAlbumLinks =
+        query.undividedAlbums
+          .map(album =>
+            relation('generateListRandomPageLinksAlbumLink', album));
+    }
+
+    return relations;
+  },
+
+  data(query) {
+    const data = {};
+
+    if (query.divideByGroups) {
+      data.groupDirectories =
+        query.groups
+          .map(group => group.directory);
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    const capsule = language.encapsulate('listingPage.other.randomPages');
+
+    const miscellaneousChunkRows = [
+      language.encapsulate(capsule, 'chunk.item.randomArtist', capsule => ({
+        stringsKey: 'randomArtist',
+
+        mainLink:
+          html.tag('a',
+            {href: '#', 'data-random': 'artist'},
+            language.$(capsule, 'mainLink')),
+
+        atLeastTwoContributions:
+          html.tag('a',
+            {href: '#', 'data-random': 'artist-more-than-one-contrib'},
+            language.$(capsule, 'atLeastTwoContributions')),
+      })),
+
+      {stringsKey: 'randomAlbumWholeSite'},
+      {stringsKey: 'randomTrackWholeSite'},
+    ];
+
+    const miscellaneousChunkRowAttributes = [
+      null,
+      {href: '#', 'data-random': 'album'},
+      {href: '#','data-random': 'track'},
+    ];
+
+    return relations.page.slots({
+      type: 'chunks',
+
+      content: [
+        html.tag('p',
+          language.encapsulate(capsule, 'chooseLinkLine', capsule =>
+            language.$(capsule, {
+              fromPart:
+                (relations.groupLinks
+                  ? language.$(capsule, 'fromPart.dividedByGroups')
+                  : language.$(capsule, 'fromPart.notDividedByGroups')),
+
+              browserSupportPart:
+                language.$(capsule, 'browserSupportPart'),
+            }))),
+
+        html.tag('p', {id: 'data-loading-line'},
+          language.$(capsule, 'dataLoadingLine')),
+
+        html.tag('p', {id: 'data-loaded-line'},
+          language.$(capsule, 'dataLoadedLine')),
+
+        html.tag('p', {id: 'data-error-line'},
+          language.$(capsule, 'dataErrorLine')),
+      ],
+
+      showSkipToSection: true,
+
+      chunkIDs:
+        (data.groupDirectories
+          ? [null, ...data.groupDirectories]
+          : null),
+
+      chunkTitles: [
+        {stringsKey: 'misc'},
+
+        ...
+          (relations.groupLinks
+            ? relations.groupLinks.map(groupLink => ({
+                stringsKey: 'fromGroup',
+                group: groupLink,
+              }))
+            : [{stringsKey: 'fromAlbum'}]),
+      ],
+
+      chunkTitleAccents: [
+        null,
+
+        ...
+          (relations.groupLinks
+            ? relations.groupLinks.map(() =>
+                language.encapsulate(capsule, 'chunk.title.fromGroup.accent', capsule => ({
+                  randomAlbum:
+                    html.tag('a',
+                      {href: '#', 'data-random': 'album-in-group-dl'},
+                      language.$(capsule, 'randomAlbum')),
+
+                  randomTrack:
+                    html.tag('a',
+                      {href: '#', 'data-random': 'track-in-group-dl'},
+                      language.$(capsule, 'randomTrack')),
+                })))
+            : [null]),
+      ],
+
+      chunkRows: [
+        miscellaneousChunkRows,
+
+        ...
+          (relations.groupAlbumLinks
+            ? relations.groupAlbumLinks.map(albumLinks =>
+                albumLinks.map(albumLink => ({
+                  stringsKey: 'album',
+                  album: albumLink,
+                })))
+            : [
+                relations.undividedAlbumLinks.map(albumLink => ({
+                  stringsKey: 'album',
+                  album: albumLink,
+                })),
+              ]),
+      ],
+
+      chunkRowAttributes: [
+        miscellaneousChunkRowAttributes,
+        ...
+          (relations.groupAlbumLinks
+            ? relations.groupAlbumLinks.map(albumLinks =>
+                albumLinks.map(() => null))
+            : [relations.undividedAlbumLinks.map(() => null)]),
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByAlbum.js b/src/content/dependencies/listTracksByAlbum.js
new file mode 100644
index 00000000..b2405034
--- /dev/null
+++ b/src/content/dependencies/listTracksByAlbum.js
@@ -0,0 +1,48 @@
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    return {
+      spec,
+      albums: albumData,
+      tracks: albumData.map(album => album.tracks),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+    };
+  },
+
+  generate(relations) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        relations.albumLinks
+          .map(albumLink => ({album: albumLink})),
+
+      listStyle: 'ordered',
+
+      chunkRows:
+        relations.trackLinks
+          .map(trackLinks => trackLinks
+            .map(trackLink => ({track: trackLink}))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js
new file mode 100644
index 00000000..dcfaeaf0
--- /dev/null
+++ b/src/content/dependencies/listTracksByDate.js
@@ -0,0 +1,91 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import {chunkByProperties, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl: ({trackData}) => ({trackData}),
+
+  query({trackData}, spec) {
+    const query = {spec};
+
+    query.tracks =
+      sortAlbumsTracksChronologically(
+        trackData.filter(track => track.date));
+
+    query.chunks =
+      chunkByProperties(query.tracks, ['album', 'date']);
+
+    return query;
+  },
+
+  relations: (relation, query) => ({
+    page:
+      relation('generateListingPage', query.spec),
+
+    albumLinks:
+      query.chunks
+        .map(({album}) => relation('linkAlbum', album)),
+
+    trackLinks:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(track => relation('linkTrack', track))),
+  }),
+
+  data: (query) => ({
+    dates:
+      query.chunks
+        .map(({date}) => date),
+
+    rereleases:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(track =>
+            // Check if the index of this track...
+            query.tracks.indexOf(track) >
+            // ...is greater than the *smallest* index
+            // of any of this track's *other* releases.
+            // (It won't be greater than its own index,
+            // so we can use otherReleases here, rather
+            // than allReleases.)
+            Math.min(...
+              track.otherReleases.map(t => query.tracks.indexOf(t))))),
+  }),
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          albumLink: relations.albumLinks,
+          date: data.dates,
+        }).map(({albumLink, date}) => ({
+            album: albumLink,
+            date: language.formatDate(date),
+          })),
+
+      chunkRows:
+        stitchArrays({
+          trackLinks: relations.trackLinks,
+          rereleases: data.rereleases,
+        }).map(({trackLinks, rereleases}) =>
+            stitchArrays({
+              trackLink: trackLinks,
+              rerelease: rereleases,
+            }).map(({trackLink, rerelease}) =>
+                (rerelease
+                  ? {stringsKey: 'rerelease', track: trackLink}
+                  : {track: trackLink}))),
+
+      chunkRowAttributes:
+        data.rereleases.map(rereleases =>
+          rereleases.map(rerelease =>
+            (rerelease
+              ? {class: 'rerelease-line'}
+              : null))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByDuration.js b/src/content/dependencies/listTracksByDuration.js
new file mode 100644
index 00000000..64feb4f1
--- /dev/null
+++ b/src/content/dependencies/listTracksByDuration.js
@@ -0,0 +1,51 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({trackData}) {
+    return {trackData};
+  },
+
+  query({trackData}, spec) {
+    const tracks = sortAlphabetically(trackData.slice());
+    const durations = tracks.map(track => track.duration);
+
+    filterByCount(tracks, durations);
+    sortByCount(tracks, durations, {greatestFirst: true});
+
+    return {spec, tracks, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      trackLinks:
+        query.tracks
+          .map(track => relation('linkTrack', track)),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.trackLinks,
+          duration: data.durations,
+        }).map(({link, duration}) => ({
+            track: link,
+            duration: language.formatDuration(duration),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByDurationInAlbum.js b/src/content/dependencies/listTracksByDurationInAlbum.js
new file mode 100644
index 00000000..c1ea32a1
--- /dev/null
+++ b/src/content/dependencies/listTracksByDurationInAlbum.js
@@ -0,0 +1,87 @@
+import {sortByCount, sortChronologically} from '#sort';
+import {filterByCount, filterMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    const albums = sortChronologically(albumData.slice());
+
+    const tracks =
+      albums.map(album =>
+        album.tracks.slice());
+
+    const durations =
+      tracks.map(tracks =>
+        tracks.map(track =>
+          track.duration));
+
+    // Filter out tracks without any duration.
+    // Sort at the same time, to avoid redundantly stitching again later.
+    const stitched = stitchArrays({tracks, durations});
+    for (const {tracks, durations} of stitched) {
+      filterByCount(tracks, durations);
+      sortByCount(tracks, durations, {greatestFirst: true});
+    }
+
+    // Filter out albums which don't have at least two (remaining) tracks.
+    // If the album only has one track in the first place, or if only one
+    // has any duration, then there aren't any comparisons to be made and
+    // it just takes up space on the listing page.
+    const numTracks = tracks.map(tracks => tracks.length);
+    filterMultipleArrays(albums, tracks, durations, numTracks,
+      (album, tracks, durations, numTracks) =>
+        numTracks >= 2);
+
+    return {spec, albums, tracks, durations};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+    };
+  },
+
+  data(query) {
+    return {
+      durations: query.durations,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        relations.albumLinks
+          .map(albumLink => ({album: albumLink})),
+
+      chunkRows:
+        stitchArrays({
+          trackLinks: relations.trackLinks,
+          durations: data.durations,
+        }).map(({trackLinks, durations}) =>
+            stitchArrays({
+              trackLink: trackLinks,
+              duration: durations,
+            }).map(({trackLink, duration}) => ({
+                track: trackLink,
+                duration: language.formatDuration(duration),
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByName.js b/src/content/dependencies/listTracksByName.js
new file mode 100644
index 00000000..773b0473
--- /dev/null
+++ b/src/content/dependencies/listTracksByName.js
@@ -0,0 +1,36 @@
+import {sortAlphabetically} from '#sort';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkTrack'],
+  extraDependencies: ['wikiData'],
+
+  sprawl({trackData}) {
+    return {trackData};
+  },
+
+  query({trackData}, spec) {
+    return {
+      spec,
+      tracks: sortAlphabetically(trackData.slice()),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      trackLinks:
+        query.tracks
+          .map(track => relation('linkTrack', track)),
+    };
+  },
+
+  generate(relations) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        relations.trackLinks
+          .map(link => ({track: link})),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByTimesReferenced.js b/src/content/dependencies/listTracksByTimesReferenced.js
new file mode 100644
index 00000000..5838ded0
--- /dev/null
+++ b/src/content/dependencies/listTracksByTimesReferenced.js
@@ -0,0 +1,52 @@
+import {sortAlbumsTracksChronologically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({trackData}) {
+    return {trackData};
+  },
+
+  query({trackData}, spec) {
+    const tracks = sortAlbumsTracksChronologically(trackData.slice());
+    const timesReferenced = tracks.map(track => track.referencedByTracks.length);
+
+    filterByCount(tracks, timesReferenced);
+    sortByCount(tracks, timesReferenced, {greatestFirst: true});
+
+    return {spec, tracks, timesReferenced};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      trackLinks:
+        query.tracks
+          .map(track => relation('linkTrack', track)),
+    };
+  },
+
+  data(query) {
+    return {
+      timesReferenced: query.timesReferenced,
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.trackLinks,
+          timesReferenced: data.timesReferenced,
+        }).map(({link, timesReferenced}) => ({
+            track: link,
+            timesReferenced:
+              language.countTimesReferenced(timesReferenced, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksInFlashesByAlbum.js b/src/content/dependencies/listTracksInFlashesByAlbum.js
new file mode 100644
index 00000000..8ca0d993
--- /dev/null
+++ b/src/content/dependencies/listTracksInFlashesByAlbum.js
@@ -0,0 +1,82 @@
+import {sortChronologically} from '#sort';
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkFlash', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query({albumData}, spec) {
+    const albums = sortChronologically(albumData.slice());
+
+    const tracks =
+      albums.map(album =>
+        album.tracks.slice());
+
+    const flashes =
+      tracks.map(tracks =>
+        tracks.map(track =>
+          track.featuredInFlashes));
+
+    // Filter out tracks that aren't featured in any flashes.
+    // This listing doesn't perform any sorting within albums.
+    const stitched = stitchArrays({tracks, flashes});
+    for (const {tracks, flashes} of stitched) {
+      filterMultipleArrays(tracks, flashes,
+        (tracks, flashes) => !empty(flashes));
+    }
+
+    // Filter out albums which don't have at least one remaining track.
+    filterMultipleArrays(albums, tracks, flashes,
+      (album, tracks, _flashes) => !empty(tracks));
+
+    return {spec, albums, tracks, flashes};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+
+      flashLinks:
+        query.flashes
+          .map(flashesByAlbum => flashesByAlbum
+            .map(flashesByTrack => flashesByTrack
+              .map(flash => relation('linkFlash', flash)))),
+    };
+  },
+
+  generate(relations, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        relations.albumLinks
+          .map(albumLink => ({album: albumLink})),
+
+      chunkRows:
+        stitchArrays({
+          trackLinks: relations.trackLinks,
+          flashLinks: relations.flashLinks,
+        }).map(({trackLinks, flashLinks}) =>
+            stitchArrays({
+              trackLink: trackLinks,
+              flashLinks: flashLinks,
+            }).map(({trackLink, flashLinks}) => ({
+                track: trackLink,
+                flashes: language.formatConjunctionList(flashLinks),
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksInFlashesByFlash.js b/src/content/dependencies/listTracksInFlashesByFlash.js
new file mode 100644
index 00000000..6ab954ed
--- /dev/null
+++ b/src/content/dependencies/listTracksInFlashesByFlash.js
@@ -0,0 +1,69 @@
+import {sortFlashesChronologically} from '#sort';
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkFlash', 'linkTrack'],
+  extraDependencies: ['wikiData'],
+
+  sprawl({flashData}) {
+    return {flashData};
+  },
+
+  query({flashData}, spec) {
+    const flashes = sortFlashesChronologically(
+      flashData
+        .filter(flash => !empty(flash.featuredTracks)));
+
+    const tracks =
+      flashes.map(album => album.featuredTracks);
+
+    const albums =
+      tracks.map(tracks =>
+        tracks.map(track => track.album));
+
+    return {spec, flashes, tracks, albums};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      flashLinks:
+        query.flashes
+          .map(flash => relation('linkFlash', flash)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+
+      albumLinks:
+        query.albums
+          .map(albums => albums
+            .map(album => relation('linkAlbum', album))),
+    };
+  },
+
+  generate(relations) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        relations.flashLinks
+          .map(flashLink => ({flash: flashLink})),
+
+      chunkRows:
+        stitchArrays({
+          trackLinks: relations.trackLinks,
+          albumLinks: relations.albumLinks,
+        }).map(({trackLinks, albumLinks}) =>
+            stitchArrays({
+              trackLink: trackLinks,
+              albumLink: albumLinks,
+            }).map(({trackLink, albumLink}) => ({
+                track: trackLink,
+                album: albumLink,
+              }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksWithExtra.js b/src/content/dependencies/listTracksWithExtra.js
new file mode 100644
index 00000000..c7f42f9d
--- /dev/null
+++ b/src/content/dependencies/listTracksWithExtra.js
@@ -0,0 +1,85 @@
+import {sortChronologically} from '#sort';
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({albumData}) {
+    return {albumData};
+  },
+
+  query(sprawl, spec, property, valueMode) {
+    const albums =
+      sortChronologically(sprawl.albumData.slice());
+
+    const tracks =
+      albums
+        .map(album =>
+          album.tracks
+            .filter(track => {
+              switch (valueMode) {
+                case 'truthy': return !!track[property];
+                case 'array': return !empty(track[property]);
+                default: return false;
+              }
+            }));
+
+    filterMultipleArrays(albums, tracks,
+      (album, tracks) => !empty(tracks));
+
+    return {spec, albums, tracks};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      albumLinks:
+        query.albums
+          .map(album => relation('linkAlbum', album)),
+
+      trackLinks:
+        query.tracks
+          .map(tracks => tracks
+            .map(track => relation('linkTrack', track))),
+    };
+  },
+
+  data(query) {
+    return {
+      dates:
+        query.albums.map(album => album.date),
+    };
+  },
+
+  slots: {
+    hash: {type: 'string'},
+  },
+
+  generate(data, relations, slots, {language}) {
+    return relations.page.slots({
+      type: 'chunks',
+
+      chunkTitles:
+        stitchArrays({
+          albumLink: relations.albumLinks,
+          date: data.dates,
+        }).map(({albumLink, date}) =>
+            (date
+              ? {
+                  stringsKey: 'withDate',
+                  album: albumLink,
+                  date: language.formatDate(date),
+                }
+              : {album: albumLink})),
+
+      chunkRows:
+        relations.trackLinks
+          .map(trackLinks => trackLinks
+            .map(trackLink => ({
+              track: trackLink.slot('hash', slots.hash),
+            }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksWithLyrics.js b/src/content/dependencies/listTracksWithLyrics.js
new file mode 100644
index 00000000..a13a76f0
--- /dev/null
+++ b/src/content/dependencies/listTracksWithLyrics.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listTracksWithExtra'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listTracksWithExtra', spec, 'lyrics', 'truthy')}),
+
+  generate: (relations) =>
+    relations.page,
+};
diff --git a/src/content/dependencies/listTracksWithMidiProjectFiles.js b/src/content/dependencies/listTracksWithMidiProjectFiles.js
new file mode 100644
index 00000000..418af4c2
--- /dev/null
+++ b/src/content/dependencies/listTracksWithMidiProjectFiles.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listTracksWithExtra'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listTracksWithExtra', spec, 'midiProjectFiles', 'array')}),
+
+  generate: (relations) =>
+    relations.page.slot('hash', 'midi-project-files'),
+};
diff --git a/src/content/dependencies/listTracksWithSheetMusicFiles.js b/src/content/dependencies/listTracksWithSheetMusicFiles.js
new file mode 100644
index 00000000..0c6761eb
--- /dev/null
+++ b/src/content/dependencies/listTracksWithSheetMusicFiles.js
@@ -0,0 +1,9 @@
+export default {
+  contentDependencies: ['listTracksWithExtra'],
+
+  relations: (relation, spec) =>
+    ({page: relation('listTracksWithExtra', spec, 'sheetMusicFiles', 'array')}),
+
+  generate: (relations) =>
+    relations.page.slot('hash', 'sheet-music-files'),
+};
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
new file mode 100644
index 00000000..1bbd45e2
--- /dev/null
+++ b/src/content/dependencies/transformContent.js
@@ -0,0 +1,756 @@
+import {bindFind} from '#find';
+import {replacerSpec, parseInput} from '#replacer';
+
+import {Marked} from 'marked';
+import striptags from 'striptags';
+
+const commonMarkedOptions = {
+  headerIds: false,
+  mangle: false,
+
+  tokenizer: {
+    url(src) {
+      // Don't link emails
+      const cap = this.rules.inline.url.exec(src);
+      if (cap?.[2] === '@') return;
+
+      // Use normal tokenizer url behavior otherwise
+      // Note that super.url doesn't work here because marked is binding or
+      // applying this function on the tokenizer instance - super.prop would
+      // just read the prototype of the containing object literal, not the
+      // rebound tokenizer. (Thanks MDN.)
+      return Object.getPrototypeOf(this).url.call(this, src);
+    },
+  },
+};
+
+const multilineMarked = new Marked({
+  ...commonMarkedOptions,
+});
+
+const inlineMarked = new Marked({
+  ...commonMarkedOptions,
+
+  renderer: {
+    paragraph(text) {
+      return text;
+    },
+  },
+});
+
+const lyricsMarked = new Marked({
+  ...commonMarkedOptions,
+});
+
+function getPlaceholder(node, content) {
+  return {type: 'text', data: content.slice(node.i, node.iEnd)};
+}
+
+export default {
+  contentDependencies: [
+    ...(
+      Object.values(replacerSpec)
+        .map(description => description.link)
+        .filter(Boolean)),
+    'image',
+    'linkExternal',
+  ],
+
+  extraDependencies: ['html', 'language', 'to', 'wikiData'],
+
+  sprawl(wikiData, content) {
+    const find = bindFind(wikiData);
+
+    const parsedNodes = parseInput(content ?? '');
+
+    return {
+      nodes: parsedNodes
+        .map(node => {
+          if (node.type !== 'tag') {
+            return node;
+          }
+
+          const placeholder = getPlaceholder(node, content);
+
+          const replacerKeyImplied = !node.data.replacerKey;
+          const replacerKey = replacerKeyImplied ? 'track' : node.data.replacerKey.data;
+
+          // TODO: We don't support recursive nodes like before, at the moment. Sorry!
+          // const replacerValue = transformNodes(node.data.replacerValue, opts);
+          const replacerValue = node.data.replacerValue[0].data;
+
+          const spec = replacerSpec[replacerKey];
+
+          if (!spec) {
+            return placeholder;
+          }
+
+          if (spec.link) {
+            let data = {link: spec.link};
+
+            determineData: {
+              // No value at all: this is an index link.
+              if (!replacerValue || replacerValue === '-') {
+                break determineData;
+              }
+
+              // Nothing to find: the link operates on a path or string, not a data object.
+              if (!spec.find) {
+                data.value = replacerValue;
+                break determineData;
+              }
+
+              const thing =
+                find[spec.find](
+                  (replacerKeyImplied
+                    ? replacerValue
+                    : replacerKey + `:` + replacerValue),
+                  wikiData);
+
+              // Nothing was found: this is unexpected, so return placeholder.
+              if (!thing) {
+                return placeholder;
+              }
+
+              // Something was found: the link operates on that thing.
+              data.thing = thing;
+            }
+
+            const {transformName} = spec;
+
+            // TODO: Again, no recursive nodes. Sorry!
+            // const enteredLabel = node.data.label && transformNode(node.data.label, opts);
+            const enteredLabel = node.data.label?.data;
+            const enteredHash = node.data.hash?.data;
+
+            data.label =
+              enteredLabel ??
+                (transformName && data.thing.name
+                  ? transformName(data.thing.name, node, content)
+                  : null);
+
+            data.hash = enteredHash ?? null;
+
+            return {i: node.i, iEnd: node.iEnd, type: 'internal-link', data};
+          }
+
+          // This will be another {type: 'tag'} node which gets processed in
+          // generate. Extract replacerKey and replacerValue now, since it'd
+          // be a pain to deal with later.
+          return {
+            ...node,
+            data: {
+              ...node.data,
+              replacerKey: node.data.replacerKey.data,
+              replacerValue: node.data.replacerValue[0].data,
+            },
+          };
+        }),
+    };
+  },
+
+  data(sprawl, content) {
+    return {
+      content,
+
+      nodes:
+        sprawl.nodes
+          .map(node => {
+            switch (node.type) {
+              // Replace internal link nodes with a stub. It'll be replaced
+              // (by position) with an item from relations.
+              //
+              // TODO: This should be where label and hash get passed through,
+              // rather than in relations... (in which case there's no need to
+              // handle it specially here, and we can really just return
+              // data.nodes = sprawl.nodes)
+              case 'internal-link':
+                return {type: 'internal-link'};
+
+              // Other nodes will get processed in generate.
+              default:
+                return node;
+            }
+          }),
+    };
+  },
+
+  relations(relation, sprawl, content) {
+    const {nodes} = sprawl;
+
+    const relationOrPlaceholder =
+      (node, name, arg) =>
+        (name
+          ? {
+              link: relation(name, arg),
+              label: node.data.label,
+              hash: node.data.hash,
+              name: arg?.name,
+              shortName: arg?.shortName ?? arg?.nameShort,
+            }
+          : getPlaceholder(node, content));
+
+    return {
+      internalLinks:
+        nodes
+          .filter(({type}) => type === 'internal-link')
+          .map(node => {
+            const {link, thing, value} = node.data;
+
+            if (thing) {
+              return relationOrPlaceholder(node, link, thing);
+            } else if (value && value !== '-') {
+              return relationOrPlaceholder(node, link, value);
+            } else {
+              return relationOrPlaceholder(node, link);
+            }
+          }),
+
+      externalLinks:
+        nodes
+          .filter(({type}) => type === 'external-link')
+          .map(node => {
+            const {href} = node.data;
+
+            return relation('linkExternal', href);
+          }),
+
+      images:
+        nodes
+          .filter(({type}) => type === 'image')
+          .filter(({inline}) => !inline)
+          .map(() => relation('image')),
+    };
+  },
+
+  slots: {
+    mode: {
+      validate: v => v.is('inline', 'multiline', 'lyrics', 'single-link'),
+      default: 'multiline',
+    },
+
+    preferShortLinkNames: {
+      type: 'boolean',
+      default: false,
+    },
+
+    indicateExternalLinks: {
+      type: 'boolean',
+      default: true,
+    },
+
+    absorbPunctuationFollowingExternalLinks: {
+      type: 'boolean',
+      default: true,
+    },
+
+    textOnly: {
+      type: 'boolean',
+      default: false,
+    },
+
+    thumb: {
+      validate: v => v.is('small', 'medium', 'large'),
+      default: 'large',
+    },
+  },
+
+  generate(data, relations, slots, {html, language, to}) {
+    let imageIndex = 0;
+    let internalLinkIndex = 0;
+    let externalLinkIndex = 0;
+
+    let offsetTextNode = 0;
+
+    const contentFromNodes =
+      data.nodes.map((node, index) => {
+        const nextNode = data.nodes[index + 1];
+
+        const absorbFollowingPunctuation = template => {
+          if (nextNode?.type !== 'text') {
+            return;
+          }
+
+          const text = nextNode.data;
+          const match = text.match(/^[.,;:?!…]+(?=[^\n]*[a-z])/i);
+          const suffix = match?.[0];
+          if (suffix) {
+            template.setSlot('suffixNormalContent', suffix);
+            offsetTextNode = suffix.length;
+          }
+        };
+
+        switch (node.type) {
+          case 'text': {
+            const text = node.data.slice(offsetTextNode);
+
+            offsetTextNode = 0;
+
+            return {type: 'text', data: text};
+          }
+
+          case 'image': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {
+              link,
+              style,
+              warnings,
+              width,
+              height,
+              align,
+              pixelate,
+            } = node;
+
+            if (node.inline) {
+              let content =
+                html.tag('img',
+                  src && {src},
+                  width && {width},
+                  height && {height},
+                  style && {style},
+
+                  align === 'center' &&
+                  !link &&
+                    {class: 'align-center'},
+
+                  pixelate &&
+                    {class: 'pixelate'});
+
+              if (link) {
+                content =
+                  html.tag('a',
+                    {href: link},
+                    {target: '_blank'},
+
+                    align === 'center' &&
+                      {class: 'align-center'},
+
+                    {title:
+                      language.encapsulate('misc.external.opensInNewTab', capsule =>
+                        language.$(capsule, {
+                          link:
+                            language.formatExternalLink(link, {
+                              style: 'platform',
+                            }),
+
+                          annotation:
+                            language.$(capsule, 'annotation'),
+                        }).toString())},
+
+                    content);
+              }
+
+              return {
+                type: 'processed-image',
+                inline: true,
+                data: content,
+              };
+            }
+
+            const image = relations.images[imageIndex++];
+
+            image.setSlots({
+              src,
+
+              link: link ?? true,
+              warnings: warnings ?? null,
+              thumb: slots.thumb,
+            });
+
+            if (width || height) {
+              image.setSlot('dimensions', [width ?? null, height ?? null]);
+            }
+
+            image.setSlot('attributes', [
+              {class: 'content-image'},
+
+              pixelate &&
+                {class: 'pixelate'},
+            ]);
+
+            return {
+              type: 'processed-image',
+              inline: false,
+              data:
+                html.tag('div', {class: 'content-image-container'},
+                  align === 'center' &&
+                    {class: 'align-center'},
+
+                  image),
+            };
+          }
+
+          case 'video': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {width, height, align, pixelate} = node;
+
+            const content =
+              html.tag('div', {class: 'content-video-container'},
+                align === 'center' &&
+                  {class: 'align-center'},
+
+                html.tag('video',
+                  src && {src},
+                  width && {width},
+                  height && {height},
+
+                  {controls: true},
+
+                  pixelate &&
+                    {class: 'pixelate'}));
+
+            return {
+              type: 'processed-video',
+              data: content,
+            };
+          }
+
+          case 'audio': {
+            const src =
+              (node.src.startsWith('media/')
+                ? to('media.path', node.src.slice('media/'.length))
+                : node.src);
+
+            const {align, inline} = node;
+
+            const audio =
+              html.tag('audio',
+                src && {src},
+
+                align === 'center' &&
+                inline &&
+                  {class: 'align-center'},
+
+                {controls: true});
+
+            const content =
+              (inline
+                ? audio
+                : html.tag('div', {class: 'content-audio-container'},
+                    align === 'center' &&
+                      {class: 'align-center'},
+
+                    audio));
+
+            return {
+              type: 'processed-audio',
+              data: content,
+            };
+          }
+
+          case 'internal-link': {
+            const nodeFromRelations = relations.internalLinks[internalLinkIndex++];
+            if (nodeFromRelations.type === 'text') {
+              return {type: 'text', data: nodeFromRelations.data};
+            }
+
+            // TODO: This is a bit hacky, like the stuff below,
+            // but since we dressed it up in a utility function
+            // maybe it's okay...
+            const link =
+              html.resolve(
+                nodeFromRelations.link,
+                {slots: ['content', 'hash']});
+
+            const {label, hash, shortName, name} = nodeFromRelations;
+
+            if (slots.textOnly) {
+              if (label) {
+                return {type: 'text', data: label};
+              } else if (slots.preferShortLinkNames) {
+                return {type: 'text', data: shortName ?? name};
+              } else {
+                return {type: 'text', data: name};
+              }
+            }
+
+            // These are removed from the typical combined slots({})-style
+            // because we don't want to override slots that were already set
+            // by something that's wrapping the linkTemplate or linkThing
+            // template.
+            if (label) link.setSlot('content', label);
+            if (hash) link.setSlot('hash', hash);
+
+            // TODO: This is obviously hacky.
+            let hasPreferShortNameSlot;
+            try {
+              link.getSlotDescription('preferShortName');
+              hasPreferShortNameSlot = true;
+            } catch (error) {
+              hasPreferShortNameSlot = false;
+            }
+
+            if (hasPreferShortNameSlot) {
+              link.setSlot('preferShortName', slots.preferShortLinkNames);
+            }
+
+            // TODO: The same, the same.
+            let hasTooltipStyleSlot;
+            try {
+              link.getSlotDescription('tooltipStyle');
+              hasTooltipStyleSlot = true;
+            } catch (error) {
+              hasTooltipStyleSlot = false;
+            }
+
+            if (hasTooltipStyleSlot) {
+              link.setSlot('tooltipStyle', 'none');
+            }
+
+            let doTheAbsorbyThing = false;
+
+            // TODO: This is just silly.
+            try {
+              const tag = html.resolve(link, {normalize: 'tag'});
+              doTheAbsorbyThing ||= tag.attributes.has('class', 'image-media-link');
+            } catch {}
+
+            if (doTheAbsorbyThing) {
+              absorbFollowingPunctuation(link);
+            }
+
+            return {type: 'processed-internal-link', data: link};
+          }
+
+          case 'external-link': {
+            const {label} = node.data;
+            const externalLink = relations.externalLinks[externalLinkIndex++];
+
+            if (slots.textOnly) {
+              return {type: 'text', data: label};
+            }
+
+            externalLink.setSlots({
+              content: label,
+              fromContent: true,
+            });
+
+            if (slots.absorbPunctuationFollowingExternalLinks) {
+              absorbFollowingPunctuation(externalLink);
+            }
+
+            if (slots.indicateExternalLinks) {
+              externalLink.setSlots({
+                indicateExternal: true,
+                tab: 'separate',
+                style: 'platform',
+              });
+            }
+
+            return {type: 'processed-external-link', data: externalLink};
+          }
+
+          case 'tag': {
+            const {replacerKey, replacerValue} = node.data;
+
+            const spec = replacerSpec[replacerKey];
+
+            if (!spec) {
+              return getPlaceholder(node, data.content);
+            }
+
+            const {value: valueFn, html: htmlFn} = spec;
+
+            const value =
+              (valueFn
+                ? valueFn(replacerValue)
+                : replacerValue);
+
+            const content =
+              (htmlFn
+                ? htmlFn(value, {html, language})
+                : value);
+
+            const contentText =
+              html.resolve(content, {normalize: 'string'});
+
+            if (slots.textOnly) {
+              return {type: 'text', data: striptags(contentText)};
+            } else {
+              return {type: 'text', data: contentText};
+            }
+          }
+
+          default:
+            return getPlaceholder(node, data.content);
+        }
+      });
+
+    // In single-link mode, return the link node exactly as is - exposing
+    // access to its slots.
+
+    if (slots.mode === 'single-link') {
+      const link =
+        contentFromNodes.find(node =>
+          node.type === 'processed-internal-link' ||
+          node.type === 'processed-external-link');
+
+      if (!link) {
+        return html.blank();
+      }
+
+      return link.data;
+    }
+
+    // Content always goes through marked (i.e. parsing as Markdown).
+    // This does require some attention to detail, mostly to do with line
+    // breaks (in multiline mode) and extracting/re-inserting non-text nodes.
+
+    // The content of non-text nodes can end up getting mangled by marked.
+    // To avoid this, we replace them with mundane placeholders, then
+    // reinsert the content in the correct positions. This also avoids
+    // having to stringify tag content within this generate() function.
+
+    const extractNonTextNodes = ({
+      getTextNodeContents = node => node.data,
+    } = {}) =>
+      contentFromNodes
+        .map((node, index) => {
+          if (node.type === 'text') {
+            return getTextNodeContents(node, index);
+          }
+
+          let attributes = `class="INSERT-NON-TEXT" data-type="${node.type}"`;
+
+          if (node.type === 'processed-image' && node.inline) {
+            attributes += ` data-inline`;
+          }
+
+          return `<span ${attributes}>${index}</span>`;
+        })
+        .join('');
+
+    const reinsertNonTextNodes = (markedOutput) => {
+      markedOutput = markedOutput.trim();
+
+      const tags = [];
+      const regexp = /<span class="INSERT-NON-TEXT" (.*?)>([0-9]+?)<\/span>/g;
+
+      let deleteParagraph = false;
+
+      const addText = (text) => {
+        if (deleteParagraph) {
+          text = text.replace(/^<\/p>/, '');
+          deleteParagraph = false;
+        }
+
+        tags.push(text);
+      };
+
+      let match = null, parseFrom = 0;
+      while (match = regexp.exec(markedOutput)) {
+        addText(markedOutput.slice(parseFrom, match.index));
+        parseFrom = match.index + match[0].length;
+
+        const attributes = html.parseAttributes(match[1]);
+
+        // Images (or videos) that were all on their own line need to be
+        // removed from the surrounding <p> tag that marked generates.
+        // The HTML parser treats a <div> that starts inside a <p> as a
+        // Crocker-class misgiving, and will treat you very badly if you
+        // feed it that.
+        if (
+          (attributes.get('data-type') === 'processed-image' &&
+          !attributes.get('data-inline')) ||
+          attributes.get('data-type') === 'processed-video' ||
+          attributes.get('data-type') === 'processed-audio'
+        ) {
+          tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, '');
+          deleteParagraph = true;
+        }
+
+        const nonTextNodeIndex = match[2];
+        tags.push(contentFromNodes[nonTextNodeIndex].data);
+      }
+
+      if (parseFrom !== markedOutput.length) {
+        addText(markedOutput.slice(parseFrom));
+      }
+
+      return (
+        html.tags(tags, {
+          [html.joinChildren]: '',
+          [html.onlyIfContent]: true,
+        }));
+    };
+
+    if (slots.mode === 'inline') {
+      const markedInput =
+        extractNonTextNodes();
+
+      const markedOutput =
+        inlineMarked.parse(markedInput);
+
+      return reinsertNonTextNodes(markedOutput);
+    }
+
+    // This is separated into its own function just since we're gonna reuse
+    // it in a minute if everything goes to heck in lyrics mode.
+    const transformMultiline = () => {
+      const markedInput =
+        extractNonTextNodes()
+          // Compress multiple line breaks into single line breaks,
+          // except when they're preceding or following indented
+          // text (by at least two spaces).
+          .replace(/(?<!  .*)\n{2,}(?!^  )/gm, '\n') /* eslint-disable-line no-regex-spaces */
+          // Expand line breaks which don't follow a list, quote,
+          // or <br> / "  ", and which don't precede or follow
+          // indented text (by at least two spaces).
+          .replace(/(?<!^ *(?:-|\d+\.).*|^>.*|^  .*\n*|  $|<br>$)\n+(?!  |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
+          // Expand line breaks which are at the end of a list.
+          .replace(/(?<=^ *(?:-|\d+\.).*)\n+(?!^ *(?:-|\d+\.))/gm, '\n\n')
+          // Expand line breaks which are at the end of a quote.
+          .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n');
+
+      const markedOutput =
+        multilineMarked.parse(markedInput);
+
+      return reinsertNonTextNodes(markedOutput);
+    }
+
+    if (slots.mode === 'multiline') {
+      return transformMultiline();
+    }
+
+    // Lyrics mode goes through marked too, but line breaks are processed
+    // differently. Instead of having each line get its own paragraph,
+    // "adjacent" lines are joined together (with blank lines separating
+    // each verse/paragraph).
+
+    if (slots.mode === 'lyrics') {
+      // If it looks like old data, using <br> instead of bunched together
+      // lines... then oh god... just use transformMultiline. Perishes.
+      if (
+        contentFromNodes.some(node =>
+          node.type === 'text' &&
+          node.data.includes('<br'))
+      ) {
+        return transformMultiline();
+      }
+
+      const markedInput =
+        extractNonTextNodes({
+          getTextNodeContents(node) {
+            // Just insert <br> before every line break. The resulting
+            // text will appear all in one paragraph - this is expected
+            // for lyrics, and allows for multiple lines of proportional
+            // space between stanzas.
+            return node.data.replace(/\n/g, '<br>\n');
+          },
+        });
+
+      const markedOutput =
+        lyricsMarked.parse(markedInput);
+
+      return reinsertNonTextNodes(markedOutput);
+    }
+  },
+}
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
new file mode 100644
index 00000000..a089e325
--- /dev/null
+++ b/src/data/cacheable-object.js
@@ -0,0 +1,262 @@
+import {inspect as nodeInspect} from 'node:util';
+
+import {colors, ENABLE_COLOR} from '#cli';
+
+function inspect(value) {
+  return nodeInspect(value, {colors: ENABLE_COLOR});
+}
+
+export default class CacheableObject {
+  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);
+    }
+  }
+
+  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`);
+    }
+
+    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) setSetter: {
+        definition.set = function(newValue) {
+          if (newValue === undefined) {
+            throw new TypeError(`Properties cannot be set to undefined`);
+          }
+
+          const oldValue = this[CacheableObject.updateValue][property];
+
+          if (newValue === oldValue) {
+            return;
+          }
+
+          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});
+            }
+          }
+
+          this[CacheableObject.updateValue][property] = newValue;
+
+          const dependants = this.constructor[CacheableObject.propertyDependants][property];
+          if (dependants) {
+            for (const dependant of dependants) {
+              this[CacheableObject.cacheValid][dependant] = false;
+            }
+          }
+        };
+      }
+
+      if (flags.expose) setGetter: {
+        if (flags.update && !expose?.transform) {
+          definition.get = function() {
+            return this[CacheableObject.updateValue][property];
+          };
+
+          break setGetter;
+        }
+
+        if (flags.update && expose?.compute) {
+          throw new Error(`Updating property ${property} has compute function, should be formatted as transform`);
+        }
+
+        if (!flags.update && !expose?.compute) {
+          throw new Error(`Exposed property ${property} does not update and is missing compute function`);
+        }
+
+        definition.get = function() {
+          if (this[CacheableObject.cacheValid][property]) {
+            return this[CacheableObject.cachedValue][property];
+          }
+
+          const dependencies = Object.create(null);
+          for (const key of expose.dependencies ?? []) {
+            switch (key) {
+              case 'this':
+                dependencies.this = this;
+                break;
+
+              case 'thisProperty':
+                dependencies.thisProperty = property;
+                break;
+
+              default:
+                dependencies[key] = this[CacheableObject.updateValue][key];
+                break;
+            }
+          }
+
+          const value =
+            (flags.update
+              ? expose.transform(this[CacheableObject.updateValue][property], dependencies)
+              : expose.compute(dependencies));
+
+          this[CacheableObject.cachedValue][property] = value;
+          this[CacheableObject.cacheValid][property] = true;
+
+          return value;
+        };
+      }
+
+      if (flags.expose) recordAsDependant: {
+        const dependantsMap = this[CacheableObject.propertyDependants];
+
+        if (flags.update && expose?.transform) {
+          if (dependantsMap[property]) {
+            dependantsMap[property].push(property);
+          } else {
+            dependantsMap[property] = [property];
+          }
+        }
+
+        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];
+              }
+            }
+          }
+        }
+      }
+
+      Object.defineProperty(this.prototype, property, definition);
+    }
+
+    this[CacheableObject.constructorFinalized] = true;
+  }
+
+  static getPropertyDescriptor(property) {
+    return this[CacheableObject.propertyDescriptors][property];
+  }
+
+  static hasPropertyDescriptor(property) {
+    return Object.hasOwn(this[CacheableObject.propertyDescriptors], property);
+  }
+
+  static cacheAllExposedProperties(obj) {
+    if (!(obj instanceof CacheableObject)) {
+      console.warn('Not a CacheableObject:', obj);
+      return;
+    }
+
+    const {[CacheableObject.propertyDescriptors]: propertyDescriptors} =
+      obj.constructor;
+
+    if (!propertyDescriptors) {
+      console.warn('Missing property descriptors:', obj);
+      return;
+    }
+
+    for (const property of Reflect.ownKeys(propertyDescriptors)) {
+      const {flags} = propertyDescriptors[property];
+      if (!flags.expose) {
+        continue;
+      }
+
+      obj[property];
+    }
+  }
+
+  static getUpdateValue(object, key) {
+    if (!object.constructor.hasPropertyDescriptor(key)) {
+      return undefined;
+    }
+
+    return object[CacheableObject.updateValue][key] ?? null;
+  }
+
+  static clone(object) {
+    const newObject = Reflect.construct(object.constructor, []);
+
+    this.copyUpdateValuesOnto(object, newObject);
+
+    return newObject;
+  }
+
+  static copyUpdateValuesOnto(source, target) {
+    Object.assign(target, source[CacheableObject.updateValue]);
+  }
+}
+
+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)} (${inspectOldValue} -> ${inspectNewValue})`,
+      options);
+
+    this.property = property;
+  }
+}
diff --git a/src/data/checks.js b/src/data/checks.js
new file mode 100644
index 00000000..25863d2d
--- /dev/null
+++ b/src/data/checks.js
@@ -0,0 +1,861 @@
+// checks.js - general validation and error/warning reporting for data objects
+
+import {inspect as nodeInspect} from 'node:util';
+import {colors, ENABLE_COLOR} from '#cli';
+
+import CacheableObject from '#cacheable-object';
+import {replacerSpec, parseInput} from '#replacer';
+import {compareArrays, cut, cutStart, empty, getNestedProp, iterateMultiline}
+  from '#sugar';
+import Thing from '#thing';
+import thingConstructors from '#things';
+
+import {
+  annotateErrorWithIndex,
+  conditionallySuppressError,
+  decorateErrorWithIndex,
+  filterAggregate,
+  openAggregate,
+  withAggregate,
+} from '#aggregate';
+
+import {
+  combineWikiDataArrays,
+  commentaryRegexCaseSensitive,
+  oldStyleLyricsDetectionRegex,
+} from '#wiki-data';
+
+function inspect(value, opts = {}) {
+  return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
+}
+
+// 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, thingConstructors)) {
+        continue;
+      }
+
+      const directories =
+        (findSpec.getMatchableDirectories
+          ? findSpec.getMatchableDirectories(thing)
+          : [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);
+        } else {
+          directoryPlaces[directory] = [thing];
+        }
+      }
+    }
+
+    const sortedDuplicateDirectories =
+      Array.from(duplicateDirectories)
+        .sort((a, b) => {
+          const aL = a.toLowerCase();
+          const bL = b.toLowerCase();
+          return aL < bL ? -1 : aL > bL ? 1 : 0;
+        });
+
+    for (const directory of sortedDuplicateDirectories) {
+      const places = directoryPlaces[directory];
+      duplicateSets.push({directory, places});
+    }
+  }
+
+  // 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.
+
+  const seenDuplicateSets = new Map();
+  const deduplicateDuplicateSets = [];
+
+  iterateSets:
+  for (const set of duplicateSets) {
+    if (seenDuplicateSets.has(set.directory)) {
+      const placeLists = seenDuplicateSets.get(set.directory);
+
+      for (const places of placeLists) {
+        // We're iterating globally over all duplicate directories, which may
+        // span multiple kinds of things, but that isn't going to cause an
+        // issue because we're comparing the contents by identity, anyway.
+        // Two artists named Foodog aren't going to match two tracks named
+        // Foodog.
+        if (compareArrays(places, set.places, {checkOrder: false})) {
+          continue iterateSets;
+        }
+      }
+
+      placeLists.push(set.places);
+    } else {
+      seenDuplicateSets.set(set.directory, [set.places]);
+    }
+
+    deduplicateDuplicateSets.push(set);
+  }
+
+  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')));
+    }
+  });
+}
+
+function bindFindArtistOrAlias(boundFind) {
+  return artistRef => {
+    const alias = boundFind.artistAlias(artistRef, {mode: 'quiet'});
+    if (alias) {
+      // No need to check if the original exists here. Aliases are automatically
+      // created from a field on the original, so the original certainly exists.
+      const original = alias.aliasedArtist;
+      throw new Error(`Reference ${colors.red(artistRef)} is to an alias, should be ${colors.green(original.name)}`);
+    }
+
+    return boundFind.artist(artistRef);
+  };
+}
+
+function getFieldPropertyMessage(yamlDocumentSpec, property) {
+  const {fields} = yamlDocumentSpec;
+
+  const field =
+    Object.entries(fields ?? {})
+      .find(([field, fieldSpec]) => fieldSpec.property === property)
+      ?.[0];
+
+  const fieldPropertyMessage =
+    (field
+      ? ` in field ${colors.green(field)}`
+      : ` in property ${colors.green(property)}`);
+
+  return fieldPropertyMessage;
+}
+
+// Warn about references across data which don't match anything.  This involves
+// using the find() functions on all references, setting it to 'error' mode, and
+// collecting everything in a structured logged (which gets logged if there are
+// any errors). At the same time, we remove errored references from the thing's
+// data array.
+export function filterReferenceErrors(wikiData, {
+  find,
+  bindFind,
+}) {
+  const referenceSpec = [
+    ['albumData', {
+      artistContribs: '_contrib',
+      coverArtistContribs: '_contrib',
+      trackCoverArtistContribs: '_contrib',
+      wallpaperArtistContribs: '_contrib',
+      bannerArtistContribs: '_contrib',
+      groups: 'group',
+      artTags: '_artTag',
+      referencedArtworks: '_artwork',
+      commentary: '_commentary',
+    }],
+
+    ['artTagData', {
+      directDescendantArtTags: 'artTag',
+    }],
+
+    ['flashData', {
+      commentary: '_commentary',
+    }],
+
+    ['groupCategoryData', {
+      groups: 'group',
+    }],
+
+    ['homepageLayout.sections.rows', {
+      _include: row => row.type === 'album carousel',
+      albums: 'album',
+    }],
+
+    ['homepageLayout.sections.rows', {
+      _include: row => row.type === 'album grid',
+      sourceGroup: '_homepageSourceGroup',
+      sourceAlbums: 'album',
+    }],
+
+    ['flashData', {
+      contributorContribs: '_contrib',
+      featuredTracks: 'track',
+    }],
+
+    ['flashActData', {
+      flashes: 'flash',
+    }],
+
+    ['groupData', {
+      serieses: '_serieses',
+    }],
+
+    ['trackData', {
+      artistContribs: '_contrib',
+      contributorContribs: '_contrib',
+      coverArtistContribs: '_contrib',
+      referencedTracks: '_trackMainReleasesOnly',
+      sampledTracks: '_trackMainReleasesOnly',
+      artTags: '_artTag',
+      referencedArtworks: '_artwork',
+      mainReleaseTrack: '_trackMainReleasesOnly',
+      commentary: '_commentary',
+    }],
+
+    ['wikiInfo', {
+      divideTrackListsByGroups: 'group',
+    }],
+  ];
+
+  const boundFind = bindFind(wikiData, {mode: 'error'});
+  const findArtistOrAlias = bindFindArtistOrAlias(boundFind);
+
+  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.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;
+
+            switch (findFnKey) {
+              case '_commentary':
+                if (value) {
+                  value =
+                    Array.from(value.matchAll(commentaryRegexCaseSensitive))
+                      .map(({groups}) => groups.artistReferences)
+                      .map(text => text.split(',').map(text => text.trim()));
+                }
+
+                writeProperty = false;
+                break;
+
+              case '_contrib':
+                // Don't write out contributions - these'll be filtered out
+                // for content and data purposes automatically, and they're
+                // handy to keep around when update values get checked for
+                // art tags below. (Possibly no reference-related properties
+                // 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) {
+              push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`));
+              continue;
+            }
+
+            if (value === null) {
+              continue;
+            }
+
+            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;
+
+              case '_commentary':
+                findFn = findArtistOrAlias;
+                break;
+
+              case '_contrib':
+                findFn = contribRef => findArtistOrAlias(contribRef.artist);
+                break;
+
+              case '_homepageSourceGroup':
+                findFn = groupRef => {
+                  if (groupRef === 'new-additions' || groupRef === 'new-releases') {
+                    return true;
+                  }
+
+                  return boundFind.group(groupRef);
+                };
+                break;
+
+              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 mainRef = track && CacheableObject.getUpdateValue(track, 'mainReleaseTrack');
+
+                  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 mainByName =
+                      (main
+                        ? boundFind.track(main.name, {mode: 'quiet'})
+                        : null);
+
+                    const shouldBeMessage =
+                      (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}`);
+                  }
+
+                  return track;
+                };
+                break;
+
+              default:
+                findFn = boundFind[findFnKey];
+                break;
+            }
+
+            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
+                // corresponding tracks yet, so it won't be useful to report such reference
+                // errors until we take the time to address that. But other errors, like
+                // malformed reference strings or miscapitalized existing tracks, should
+                // still be reported, as samples of existing tracks *do* display on the
+                // website!
+                if (error.message.includes(`Didn't match anything`)) {
+                  return true;
+                }
+              }
+              */
+
+              return false;
+            }, fn);
+
+            const fieldPropertyMessage =
+              getFieldPropertyMessage(
+                thing.constructor[Thing.yamlDocumentSpec],
+                property);
+
+            const findFnMessage =
+              (findFnKey.startsWith('_')
+                ? ``
+                : ` (${colors.green('find.' + findFnKey)})`);
+
+            const errorMessage =
+              (Array.isArray(value)
+                ? `Reference errors` + fieldPropertyMessage + findFnMessage
+                : `Reference error` + fieldPropertyMessage + findFnMessage);
+
+            let newPropertyValue = value;
+
+            determineNewPropertyValue: {
+              // TODO: The special-casing for artTag is obviously a bit janky.
+              // It would be nice if this could be moved to processDocument ala
+              // fieldCombinationErrors, but art tags are only an error if the
+              // thing doesn't have an artwork - which can't be determined from
+              // the track document on its own, thanks to inheriting contribs
+              // from the album.
+              if (findFnKey === '_artTag') {
+                let hasCoverArtwork =
+                  !empty(CacheableObject.getUpdateValue(thing, 'coverArtistContribs'));
+
+                if (thing.constructor === thingConstructors.Track) {
+                  if (thing.album) {
+                    hasCoverArtwork ||=
+                      !empty(CacheableObject.getUpdateValue(thing.album, 'trackCoverArtistContribs'));
+                  }
+
+                  if (thing.disableUniqueCoverArt) {
+                    hasCoverArtwork = false;
+                  }
+                }
+
+                if (!hasCoverArtwork) {
+                  nest({message: errorMessage}, ({push}) => {
+                    push(new TypeError(`No cover artwork, so this shouldn't have art tags specified`));
+                  });
+
+                  newPropertyValue = [];
+                  break determineNewPropertyValue;
+                }
+              }
+
+              if (findFnKey === '_commentary') {
+                filter(
+                  value, {message: errorMessage},
+                  decorateErrorWithIndex(refs =>
+                    (refs.length === 1
+                      ? suppress(findFn)(refs[0])
+                      : filterAggregate(
+                          refs, {message: `Errors in entry's artist references`},
+                          decorateErrorWithIndex(suppress(findFn)))
+                            .aggregate
+                            .close())));
+
+                // Commentary doesn't write a property value, so no need to set
+                // anything on `newPropertyValue`.
+                break determineNewPropertyValue;
+              }
+
+              if (Array.isArray(value)) {
+                newPropertyValue = filter(
+                  value, {message: errorMessage},
+                  decorateErrorWithIndex(suppress(findFn)));
+                break determineNewPropertyValue;
+              }
+
+              nest({message: errorMessage},
+                suppress(({call}) => {
+                  try {
+                    call(findFn, value);
+                  } catch (error) {
+                    newPropertyValue = null;
+                    throw error;
+                  }
+                }));
+            }
+
+            if (writeProperty) {
+              thing[property] = newPropertyValue;
+            }
+          }
+        });
+      }
+    });
+  }
+
+  return aggregate;
+}
+
+export class ContentNodeError extends Error {
+  constructor({
+    length,
+    columnNumber,
+    containingLine,
+    where,
+    message,
+  }) {
+    const headingLine =
+      `(${where}) ${message}`;
+
+    const textUpToNode =
+      containingLine.slice(0, columnNumber);
+
+    const nodeText =
+      containingLine.slice(columnNumber, columnNumber + length);
+
+    const textPastNode =
+      containingLine.slice(columnNumber + length);
+
+    const containingLines =
+      containingLine.split('\n');
+
+    const formattedSourceLines =
+      containingLines.map((_, index, {length}) => {
+        let line = ' ⋮ ';
+
+        if (index === 0) {
+          line += colors.dim(cutStart(textUpToNode, 20));
+        }
+
+        line += nodeText;
+
+        if (index === length - 1) {
+          line += colors.dim(cut(textPastNode, 20));
+        }
+
+        return line;
+      });
+
+    super([
+      headingLine,
+      ...formattedSourceLines,
+    ].filter(Boolean).join('\n'));
+  }
+}
+
+export function reportContentTextErrors(wikiData, {
+  bindFind,
+}) {
+  const additionalFileShape = {
+    description: 'description',
+  };
+
+  const commentaryShape = {
+    body: 'commentary body',
+    artistDisplayText: 'commentary artist display text',
+    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',
+    }],
+
+    ['flashData', {
+      commentary: commentaryShape,
+    }],
+
+    ['flashActData', {
+      listTerminology: '_content',
+    }],
+
+    ['flashSideData', {
+      listTerminology: '_content',
+    }],
+
+    ['groupData', {
+      description: '_content',
+    }],
+
+    ['homepageLayout', {
+      sidebarContent: '_content',
+    }],
+
+    ['newsData', {
+      content: '_content',
+    }],
+
+    ['staticPageData', {
+      content: '_content',
+    }],
+
+    ['trackData', {
+      additionalFiles: additionalFileShape,
+      commentary: commentaryShape,
+      creditSources: commentaryShape,
+      lyrics: '_lyrics',
+      midiProjectFiles: additionalFileShape,
+      sheetMusicFiles: additionalFileShape,
+    }],
+
+    ['wikiInfo', {
+      description: '_content',
+      footerContent: '_content',
+    }],
+  ];
+
+  const boundFind = bindFind(wikiData, {mode: 'error'});
+  const findArtistOrAlias = bindFindArtistOrAlias(boundFind);
+
+  function* processContent(input) {
+    const nodes = parseInput(input);
+
+    for (const node of nodes) {
+      const index = node.i;
+      const length = node.iEnd - node.i;
+
+      if (node.type === 'tag') {
+        const replacerKeyImplied = !node.data.replacerKey;
+        const replacerKey = replacerKeyImplied ? 'track' : node.data.replacerKey.data;
+        const spec = replacerSpec[replacerKey];
+
+        if (!spec) {
+          yield {
+            index, length,
+            message:
+              `Unknown tag key ${colors.red(`"${replacerKey}"`)}`,
+          };
+
+          // No spec, no further errors to report.
+          continue;
+        }
+
+        const replacerValue = node.data.replacerValue[0].data;
+
+        if (spec.find) {
+          let findFn;
+
+          switch (spec.find) {
+            case 'artist':
+              findFn = findArtistOrAlias;
+              break;
+
+            default:
+              findFn = boundFind[spec.find];
+              break;
+          }
+
+          const findRef =
+            (replacerKeyImplied
+              ? replacerValue
+              : replacerKey + `:` + replacerValue);
+
+          try {
+            findFn(findRef);
+          } catch (error) {
+            yield {
+              index, length,
+              message: error.message,
+            };
+
+            // It's only possible to have one error per node at the moment.
+            continue;
+          }
+        }
+      } else if (node.type === 'external-link') {
+        try {
+          new URL(node.data.href);
+        } catch (error) {
+          yield {
+            index, length,
+            message:
+              `Invalid URL ${colors.red(`"${node.data.href}"`)}`,
+          };
+        }
+      }
+    }
+  }
+
+  function callProcessContent({
+    nest,
+    push,
+    value,
+    message,
+    annotateError = error => error,
+  }) {
+    const processContentIterator =
+      nest({message}, ({call}) =>
+        call(processContent, value));
+
+    if (!processContentIterator) return;
+
+    const multilineIterator =
+      iterateMultiline(value, processContentIterator, {
+        formatWhere: true,
+        getContainingLine: true,
+      });
+
+    const errors = [];
+
+    for (const result of multilineIterator) {
+      errors.push(new ContentNodeError(result));
+    }
+
+    if (empty(errors)) return;
+
+    push(
+      annotateError(
+        new AggregateError(errors, message)));
+  }
+
+  withAggregate({message: `Errors validating content text`}, ({nest}) => {
+    for (const [thingDataProp, propSpec] of contentTextSpec) {
+      const thingData = getNestedProp(wikiData, thingDataProp);
+      const things = Array.isArray(thingData) ? thingData : [thingData];
+      nest({message: `Content text errors in ${colors.green('wikiData.' + thingDataProp)}`}, ({nest}) => {
+        for (const thing of things) {
+          nest({message: `Content text errors in ${inspect(thing)}`}, ({nest, push}) => {
+
+            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)}`));
+                continue;
+              }
+
+              if (value === null) {
+                continue;
+              }
+
+              if (shape === '_lyrics') {
+                if (oldStyleLyricsDetectionRegex.test(rawValue)) {
+                  value = rawValue;
+                  shape = '_content';
+                } else {
+                  shape = newStyleLyricsShape;
+                }
+              }
+
+              const fieldPropertyMessage =
+                getFieldPropertyMessage(
+                  thing.constructor[Thing.yamlDocumentSpec],
+                  property);
+
+              const topMessage =
+                `Content text errors` + fieldPropertyMessage;
+
+              if (shape === '_content') {
+                callProcessContent({
+                  nest,
+                  push,
+                  value,
+                  message: topMessage,
+                });
+              } else {
+                nest({message: topMessage}, ({push}) => {
+                  for (const [index, entry] of value.entries()) {
+                    for (const [key, annotation] of Object.entries(shape)) {
+                      const value = entry[key];
+
+                      // TODO: Should this check undefined/null similar to above?
+                      if (!value) continue;
+
+                      callProcessContent({
+                        nest,
+                        push,
+                        value,
+                        message: `Error in ${colors.green(annotation)}`,
+                        annotateError: error =>
+                          annotateErrorWithIndex(error, index),
+                      });
+                    }
+                  }
+                });
+              }
+            }
+          });
+        }
+      });
+    }
+  });
+}
+
+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
new file mode 100644
index 00000000..f31c4069
--- /dev/null
+++ b/src/data/composite.js
@@ -0,0 +1,1463 @@
+import {inspect} from 'node:util';
+
+import {decorateErrorWithIndex, openAggregate, withAggregate}
+  from '#aggregate';
+import {colors} from '#cli';
+import {empty, filterProperties, stitchArrays, typeAppearance, unique}
+  from '#sugar';
+import {a} from '#validators';
+import {TupleMap} from '#wiki-data';
+
+const globalCompositeCache = {};
+
+const _valueIntoToken = shape =>
+  (value = null) =>
+    (value === null
+      ? Symbol.for(`hsmusic.composite.${shape}`)
+   : typeof value === 'string'
+      ? Symbol.for(`hsmusic.composite.${shape}:${value}`)
+      : {
+          symbol: Symbol.for(`hsmusic.composite.input`),
+          shape,
+          value,
+        });
+
+export const input = _valueIntoToken('input');
+input.symbol = Symbol.for('hsmusic.composite.input');
+
+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');
+
+input.staticDependency = _valueIntoToken('input.staticDependency');
+input.staticValue = _valueIntoToken('input.staticValue');
+
+function isInputToken(token) {
+  if (token === null) {
+    return false;
+  } else if (typeof token === 'object') {
+    return token.symbol === Symbol.for('hsmusic.composite.input');
+  } else if (typeof token === 'symbol') {
+    return token.description.startsWith('hsmusic.composite.input');
+  } else {
+    return false;
+  }
+}
+
+function getInputTokenShape(token) {
+  if (!isInputToken(token)) {
+    throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`);
+  }
+
+  if (typeof token === 'object') {
+    return token.shape;
+  } else {
+    return token.description.match(/hsmusic\.composite\.(input.*?)(:|$)/)[1];
+  }
+}
+
+function getInputTokenValue(token) {
+  if (!isInputToken(token)) {
+    throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`);
+  }
+
+  if (typeof token === 'object') {
+    return token.value;
+  } else {
+    return token.description.match(/hsmusic\.composite\.input.*?:(.*)/)?.[1] ?? null;
+  }
+}
+
+function getStaticInputMetadata(inputMapping) {
+  const metadata = {};
+
+  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;
+}
+
+function getCompositionName(description) {
+  return (
+    (description.annotation
+      ? description.annotation
+      : `unnamed composite`));
+}
+
+function validateInputValue(value, description) {
+  const tokenValue = getInputTokenValue(description);
+
+  const {acceptsNull, defaultValue, type, validate} = tokenValue || {};
+
+  if (value === null || value === undefined) {
+    if (acceptsNull || defaultValue === null) {
+      return true;
+    } else {
+      throw new TypeError(
+        (type
+          ? `Expected ${a(type)}, got ${typeAppearance(value)}`
+          : `Expected a value, got ${typeAppearance(value)}`));
+    }
+  }
+
+  if (type) {
+    // Note: null is already handled earlier in this function, so it won't
+    // cause any trouble here.
+    const typeofValue =
+      (typeof value === 'object'
+        ? Array.isArray(value) ? 'array' : 'object'
+        : typeof value);
+
+    if (typeofValue !== type) {
+      throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`);
+    }
+  }
+
+  if (validate) {
+    validate(value);
+  }
+
+  return true;
+}
+
+export function templateCompositeFrom(description) {
+  const compositionName = getCompositionName(description);
+
+  withAggregate({message: `Errors in description for ${compositionName}`}, ({map, nest, push}) => {
+    if ('steps' in description) {
+      if (Array.isArray(description.steps)) {
+        push(new TypeError(`Wrap steps array in a function`));
+      } else if (typeof description.steps !== 'function') {
+        push(new TypeError(`Expected steps to be a function (returning an array)`));
+      }
+    }
+
+    validateInputs:
+    if ('inputs' in description) {
+      if (
+        Array.isArray(description.inputs) ||
+        typeof description.inputs !== 'object'
+      ) {
+        push(new Error(`Expected inputs to be object, got ${typeAppearance(description.inputs)}`));
+        break validateInputs;
+      }
+
+      nest({message: `Errors in static input descriptions for ${compositionName}`}, ({push}) => {
+        const missingCallsToInput = [];
+        const wrongCallsToInput = [];
+
+        for (const [name, value] of Object.entries(description.inputs)) {
+          if (!isInputToken(value)) {
+            missingCallsToInput.push(name);
+            continue;
+          }
+
+          if (!['input', 'input.staticDependency', 'input.staticValue'].includes(getInputTokenShape(value))) {
+            wrongCallsToInput.push(name);
+          }
+        }
+
+        for (const name of missingCallsToInput) {
+          push(new Error(`${name}: Missing call to input()`));
+        }
+
+        for (const name of wrongCallsToInput) {
+          const shape = getInputTokenShape(description.inputs[name]);
+          push(new Error(`${name}: Expected call to input, input.staticDependency, or input.staticValue, got ${shape}`));
+        }
+      });
+    }
+
+    validateOutputs:
+    if ('outputs' in description) {
+      if (
+        !Array.isArray(description.outputs) &&
+        typeof description.outputs !== 'function'
+      ) {
+        push(new Error(`Expected outputs to be array or function, got ${typeAppearance(description.outputs)}`));
+        break validateOutputs;
+      }
+
+      if (Array.isArray(description.outputs)) {
+        map(
+          description.outputs,
+          decorateErrorWithIndex(value => {
+            if (typeof value !== 'string') {
+              throw new Error(`${value}: Expected string, got ${typeAppearance(value)}`)
+            } else if (!value.startsWith('#')) {
+              throw new Error(`${value}: Expected "#" at start`);
+            }
+          }),
+          {message: `Errors in output descriptions for ${compositionName}`});
+      }
+    }
+  });
+
+  const expectedInputNames =
+    (description.inputs
+      ? Object.keys(description.inputs)
+      : []);
+
+  const instantiate = (inputOptions = {}) => {
+    withAggregate({message: `Errors in input options passed to ${compositionName}`}, ({push}) => {
+      const providedInputNames = Object.keys(inputOptions);
+
+      const misplacedInputNames =
+        providedInputNames
+          .filter(name => !expectedInputNames.includes(name));
+
+      const missingInputNames =
+        expectedInputNames
+          .filter(name => !providedInputNames.includes(name))
+          .filter(name => {
+            const inputDescription = getInputTokenValue(description.inputs[name]);
+            if (!inputDescription) return true;
+            if ('defaultValue' in inputDescription) return false;
+            if ('defaultDependency' in inputDescription) return false;
+            return true;
+          });
+
+      const wrongTypeInputNames = [];
+
+      const expectedStaticValueInputNames = [];
+      const expectedStaticDependencyInputNames = [];
+      const expectedValueProvidingTokenInputNames = [];
+
+      const validateFailedErrors = [];
+
+      for (const [name, value] of Object.entries(inputOptions)) {
+        if (misplacedInputNames.includes(name)) {
+          continue;
+        }
+
+        if (typeof value !== 'string' && !isInputToken(value)) {
+          wrongTypeInputNames.push(name);
+          continue;
+        }
+
+        const descriptionShape = getInputTokenShape(description.inputs[name]);
+
+        const tokenShape = (isInputToken(value) ? getInputTokenShape(value) : null);
+        const tokenValue = (isInputToken(value) ? getInputTokenValue(value) : null);
+
+        switch (descriptionShape) {
+          case'input.staticValue':
+            if (tokenShape !== 'input.value') {
+              expectedStaticValueInputNames.push(name);
+              continue;
+            }
+            break;
+
+          case 'input.staticDependency':
+            if (typeof value !== 'string' && tokenShape !== 'input.dependency') {
+              expectedStaticDependencyInputNames.push(name);
+              continue;
+            }
+            break;
+
+          case 'input':
+            if (typeof value !== 'string' && ![
+              'input',
+              'input.value',
+              'input.dependency',
+              'input.myself',
+              'input.thisProperty',
+              'input.updateValue',
+            ].includes(tokenShape)) {
+              expectedValueProvidingTokenInputNames.push(name);
+              continue;
+            }
+            break;
+        }
+
+        if (tokenShape === 'input.value') {
+          try {
+            validateInputValue(tokenValue, description.inputs[name]);
+          } catch (error) {
+            error.message = `${name}: ${error.message}`;
+            validateFailedErrors.push(error);
+          }
+        }
+      }
+
+      if (!empty(misplacedInputNames)) {
+        push(new Error(`Unexpected input names: ${misplacedInputNames.join(', ')}`));
+      }
+
+      if (!empty(missingInputNames)) {
+        push(new Error(`Required these inputs: ${missingInputNames.join(', ')}`));
+      }
+
+      const inputAppearance = name =>
+        (isInputToken(inputOptions[name])
+          ? `${getInputTokenShape(inputOptions[name])}() call`
+          : `dependency name`);
+
+      for (const name of expectedStaticDependencyInputNames) {
+        const appearance = inputAppearance(name);
+        push(new Error(`${name}: Expected dependency name, got ${appearance}`));
+      }
+
+      for (const name of expectedStaticValueInputNames) {
+        const appearance = inputAppearance(name)
+        push(new Error(`${name}: Expected input.value() call, got ${appearance}`));
+      }
+
+      for (const name of expectedValueProvidingTokenInputNames) {
+        const appearance = getInputTokenShape(inputOptions[name]);
+        push(new Error(`${name}: Expected dependency name or value-providing input() call, got ${appearance}`));
+      }
+
+      for (const name of wrongTypeInputNames) {
+        const type = typeAppearance(inputOptions[name]);
+        push(new Error(`${name}: Expected dependency name or input() call, got ${type}`));
+      }
+
+      for (const error of validateFailedErrors) {
+        push(error);
+      }
+    });
+
+    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)
+        ? description.outputs
+     : typeof description.outputs === 'function'
+        ? description.outputs(inputMetadata)
+            .map(name =>
+              (name.startsWith('#')
+                ? name
+                : '#' + name))
+        : []);
+
+    const ownUpdateDescription =
+      (typeof description.update === 'object'
+        ? description.update
+     : typeof description.update === 'function'
+        ? description.update(inputMetadata)
+        : null);
+
+    const outputOptions = {};
+
+    const instantiatedTemplate = {
+      symbol: templateCompositeFrom.symbol,
+
+      outputs(providedOptions) {
+        withAggregate({message: `Errors in output options passed to ${compositionName}`}, ({push}) => {
+          const misplacedOutputNames = [];
+          const wrongTypeOutputNames = [];
+
+          for (const [name, value] of Object.entries(providedOptions)) {
+            if (!expectedOutputNames.includes(name)) {
+              misplacedOutputNames.push(name);
+              continue;
+            }
+
+            if (typeof value !== 'string') {
+              wrongTypeOutputNames.push(name);
+              continue;
+            }
+          }
+
+          if (!empty(misplacedOutputNames)) {
+            push(new Error(`Unexpected output names: ${misplacedOutputNames.join(', ')}`));
+          }
+
+          for (const name of wrongTypeOutputNames) {
+            const appearance = typeAppearance(providedOptions[name]);
+            push(new Error(`${name}: Expected string, got ${appearance}`));
+          }
+        });
+
+        Object.assign(outputOptions, providedOptions);
+        return instantiatedTemplate;
+      },
+
+      toDescription() {
+        const finalDescription = {};
+
+        if ('annotation' in description) {
+          finalDescription.annotation = description.annotation;
+        }
+
+        if ('compose' in description) {
+          finalDescription.compose = description.compose;
+        }
+
+        if (ownUpdateDescription) {
+          finalDescription.update = ownUpdateDescription;
+        }
+
+        if ('inputs' in description) {
+          finalDescription.inputMapping = inputMapping;
+          finalDescription.inputDescriptions = description.inputs;
+        }
+
+        if ('outputs' in description) {
+          const finalOutputs = {};
+
+          for (const name of expectedOutputNames) {
+            if (name in outputOptions) {
+              finalOutputs[name] = outputOptions[name];
+            } else {
+              finalOutputs[name] = name;
+            }
+          }
+
+          finalDescription.outputs = finalOutputs;
+        }
+
+        if ('steps' in description) {
+          finalDescription.steps = description.steps;
+        }
+
+        return finalDescription;
+      },
+
+      toResolvedComposition() {
+        const ownDescription = instantiatedTemplate.toDescription();
+
+        const finalDescription = {...ownDescription};
+
+        const aggregate = openAggregate({message: `Errors resolving ${compositionName}`});
+
+        const steps = ownDescription.steps();
+
+        const resolvedSteps =
+          aggregate.map(
+            steps,
+            decorateErrorWithIndex(step =>
+              (step.symbol === templateCompositeFrom.symbol
+                ? compositeFrom(step.toResolvedComposition())
+                : step)),
+            {message: `Errors resolving steps`});
+
+        aggregate.close();
+
+        finalDescription.steps = resolvedSteps;
+
+        return finalDescription;
+      },
+    };
+
+    return instantiatedTemplate;
+  };
+
+  instantiate.inputs = instantiate;
+
+  return instantiate;
+}
+
+templateCompositeFrom.symbol = Symbol();
+
+export const continuationSymbol = Symbol.for('compositeFrom: continuation symbol');
+export const noTransformSymbol = Symbol.for('compositeFrom: no-transform symbol');
+
+export function compositeFrom(description) {
+  const {annotation} = description;
+  const compositionName = getCompositionName(description);
+
+  const debug = fn => {
+    if (compositeFrom.debug === true) {
+      const label =
+        (annotation
+          ? colors.dim(`[composite: ${annotation}]`)
+          : colors.dim(`[composite]`));
+      const result = fn();
+      if (Array.isArray(result)) {
+        console.log(label, ...result.map(value =>
+          (typeof value === 'object'
+            ? inspect(value, {depth: 1, colors: true, compact: true, breakLength: Infinity})
+            : value)));
+      } else {
+        console.log(label, result);
+      }
+    }
+  };
+
+  if (!Array.isArray(description.steps)) {
+    throw new TypeError(
+      `Expected steps to be array, got ${typeAppearance(description.steps)}` +
+      (annotation ? ` (${annotation})` : ''));
+  }
+
+  const composition =
+    description.steps.map(step =>
+      ('toResolvedComposition' in step
+        ? compositeFrom(step.toResolvedComposition())
+        : step));
+
+  const inputMetadata =
+    (description.inputMapping
+      ? getStaticInputMetadata(description.inputMapping)
+      : {});
+
+  function _mapDependenciesToOutputs(providedDependencies) {
+    if (!description.outputs) {
+      return {};
+    }
+
+    if (!providedDependencies) {
+      return {};
+    }
+
+    return (
+      Object.fromEntries(
+        Object.entries(description.outputs)
+          .map(([continuationName, outputName]) => [
+            outputName,
+            (continuationName in providedDependencies
+              ? providedDependencies[continuationName]
+              : providedDependencies[continuationName.replace(/^#/, '')]),
+          ])));
+  }
+
+  // These dependencies were all provided by the composition which this one is
+  // nested inside, so input('name')-shaped tokens are going to be evaluated
+  // in the context of the containing composition.
+  const dependenciesFromInputs =
+    Object.values(description.inputMapping ?? {})
+      .map(token => {
+        const tokenShape = getInputTokenShape(token);
+        const tokenValue = getInputTokenValue(token);
+        switch (tokenShape) {
+          case 'input.dependency':
+            return tokenValue;
+          case 'input':
+          case 'input.updateValue':
+            return token;
+          case 'input.myself':
+            return 'this';
+          case 'input.thisProperty':
+            return 'thisProperty';
+          default:
+            return null;
+        }
+      })
+      .filter(Boolean);
+
+  const anyInputsUseUpdateValue =
+    dependenciesFromInputs
+      .filter(dependency => isInputToken(dependency))
+      .some(token => getInputTokenShape(token) === 'input.updateValue');
+
+  const inputNames =
+    Object.keys(description.inputMapping ?? {});
+
+  const inputSymbols =
+    inputNames.map(name => input(name));
+
+  const inputsMayBeDynamicValue =
+    stitchArrays({
+      mappingToken: Object.values(description.inputMapping ?? {}),
+      descriptionToken: Object.values(description.inputDescriptions ?? {}),
+    }).map(({mappingToken, descriptionToken}) => {
+        if (getInputTokenShape(descriptionToken) === 'input.staticValue') return false;
+        if (getInputTokenShape(mappingToken) === 'input.value') return false;
+        return true;
+      });
+
+  const inputDescriptions =
+    Object.values(description.inputDescriptions ?? {});
+
+  /*
+  const inputsAcceptNull =
+    Object.values(description.inputDescriptions ?? {})
+      .map(token => {
+        const tokenValue = getInputTokenValue(token);
+        if (!tokenValue) return false;
+        if ('acceptsNull' in tokenValue) return tokenValue.acceptsNull;
+        if ('defaultValue' in tokenValue) return tokenValue.defaultValue === null;
+        return false;
+      });
+  */
+
+  // Update descriptions passed as the value in an input.updateValue() token,
+  // as provided as inputs for this composition.
+  const inputUpdateDescriptions =
+    Object.values(description.inputMapping ?? {})
+      .map(token =>
+        (getInputTokenShape(token) === 'input.updateValue'
+          ? getInputTokenValue(token)
+          : null))
+      .filter(Boolean);
+
+  const base = composition.at(-1);
+  const steps = composition.slice();
+
+  const aggregate = openAggregate({
+    message:
+      `Errors preparing composition` +
+      (annotation ? ` (${annotation})` : ''),
+  });
+
+  const compositionNests = description.compose ?? true;
+
+  if (compositionNests && empty(steps)) {
+    aggregate.push(new TypeError(`Expected at least one step`));
+  }
+
+  // Steps default to exposing if using a shorthand syntax where flags aren't
+  // specified at all.
+  const stepsExpose =
+    steps
+      .map(step =>
+        (step.flags
+          ? step.flags.expose ?? false
+          : true));
+
+  // Steps default to composing if using a shorthand syntax where flags aren't
+  // specified at all - *and* aren't the base (final step), unless the whole
+  // composition is nestable.
+  const stepsCompose =
+    steps
+      .map((step, index, {length}) =>
+        (step.flags
+          ? step.flags.compose ?? false
+          : (index === length - 1
+              ? compositionNests
+              : true)));
+
+  // Steps update if the corresponding flag is explicitly set, if a transform
+  // function is provided, or if the dependencies include an input.updateValue
+  // token.
+  const stepsUpdate =
+    steps
+      .map(step =>
+        (step.flags
+          ? step.flags.update ?? false
+          : !!step.transform ||
+            !!step.dependencies?.some(dependency =>
+                isInputToken(dependency) &&
+                getInputTokenShape(dependency) === 'input.updateValue')));
+
+  // The expose description for a step is just the entire step object, when
+  // using the shorthand syntax where {flags: {expose: true}} is left implied.
+  const stepExposeDescriptions =
+    steps
+      .map((step, index) =>
+        (stepsExpose[index]
+          ? (step.flags
+              ? step.expose ?? null
+              : step)
+          : null));
+
+  // The update description for a step, if present at all, is always set
+  // explicitly. There may be multiple per step - namely that step's own
+  // {update} description, and any descriptions passed as the value in an
+  // input.updateValue({...}) token.
+  const stepUpdateDescriptions =
+    steps
+      .map((step, index) =>
+        (stepsUpdate[index]
+          ? [
+              step.update ?? null,
+              ...(stepExposeDescriptions[index]?.dependencies ?? [])
+                .filter(dependency => isInputToken(dependency))
+                .filter(token => getInputTokenShape(token) === 'input.updateValue')
+                .map(token => getInputTokenValue(token)),
+            ].filter(Boolean)
+          : []));
+
+  // Indicates presence of a {compute} function on the expose description.
+  const stepsCompute =
+    stepExposeDescriptions
+      .map(expose => !!expose?.compute);
+
+  // Indicates presence of a {transform} function on the expose description.
+  const stepsTransform =
+    stepExposeDescriptions
+      .map(expose => !!expose?.transform);
+
+  const dependenciesFromSteps =
+    unique(
+      stepExposeDescriptions
+        .flatMap(expose => expose?.dependencies ?? [])
+        .map(dependency => {
+          if (typeof dependency === 'string')
+            return (dependency.startsWith('#') ? null : dependency);
+
+          const tokenShape = getInputTokenShape(dependency);
+          const tokenValue = getInputTokenValue(dependency);
+          switch (tokenShape) {
+            case 'input.dependency':
+              return (tokenValue.startsWith('#') ? null : tokenValue);
+            case 'input.myself':
+              return 'this';
+            case 'input.thisProperty':
+              return 'thisProperty';
+            default:
+              return null;
+          }
+        })
+        .filter(Boolean));
+
+  const anyStepsUseUpdateValue =
+    stepExposeDescriptions
+      .some(expose =>
+        (expose?.dependencies
+          ? expose.dependencies.includes(input.updateValue())
+          : false));
+
+  const anyStepsExpose =
+    stepsExpose.includes(true);
+
+  const anyStepsUpdate =
+    stepsUpdate.includes(true);
+
+  const anyStepsCompute =
+    stepsCompute.includes(true);
+
+  const compositionExposes =
+    anyStepsExpose;
+
+  const compositionUpdates =
+    'update' in description ||
+    anyInputsUseUpdateValue ||
+    anyStepsUseUpdateValue ||
+    anyStepsUpdate;
+
+  const stepsFirstTimeCalling =
+    Array.from({length: steps.length}).fill(true);
+
+  const stepEntries = stitchArrays({
+    step: steps,
+    stepComposes: stepsCompose,
+    stepComputes: stepsCompute,
+    stepTransforms: stepsTransform,
+  });
+
+  for (let i = 0; i < stepEntries.length; i++) {
+    const {
+      step,
+      stepComposes,
+      stepComputes,
+      stepTransforms,
+    } = stepEntries[i];
+
+    const isBase = i === stepEntries.length - 1;
+    const message =
+      `Errors in step #${i + 1}` +
+      (isBase ? ` (base)` : ``) +
+      (step.annotation ? ` (${step.annotation})` : ``);
+
+    aggregate.nest({message}, ({push}) => {
+      if (!isBase && !stepComposes) {
+        return push(new TypeError(
+          `All steps leading up to base must compose`));
+      }
+
+      if (
+        !compositionNests && !compositionUpdates &&
+        stepTransforms && !stepComputes
+      ) {
+        return push(new TypeError(
+          `Steps which only transform can't be used in a composition that doesn't update`));
+      }
+    });
+  }
+
+  if (!compositionNests && !compositionUpdates && !anyStepsCompute) {
+    aggregate.push(new TypeError(`Expected at least one step to compute`));
+  }
+
+  aggregate.close();
+
+  function _prepareContinuation(callingTransformForThisStep) {
+    const continuationStorage = {
+      returnedWith: null,
+      providedDependencies: undefined,
+      providedValue: undefined,
+    };
+
+    const continuation =
+      (callingTransformForThisStep
+        ? (providedValue, providedDependencies = null) => {
+            continuationStorage.returnedWith = 'continuation';
+            continuationStorage.providedDependencies = providedDependencies;
+            continuationStorage.providedValue = providedValue;
+            return continuationSymbol;
+          }
+        : (providedDependencies = null) => {
+            continuationStorage.returnedWith = 'continuation';
+            continuationStorage.providedDependencies = providedDependencies;
+            return continuationSymbol;
+          });
+
+    continuation.exit = (providedValue) => {
+      continuationStorage.returnedWith = 'exit';
+      continuationStorage.providedValue = providedValue;
+      return continuationSymbol;
+    };
+
+    if (compositionNests) {
+      const makeRaiseLike = returnWith =>
+        (callingTransformForThisStep
+          ? (providedValue, providedDependencies = null) => {
+              continuationStorage.returnedWith = returnWith;
+              continuationStorage.providedDependencies = providedDependencies;
+              continuationStorage.providedValue = providedValue;
+              return continuationSymbol;
+            }
+          : (providedDependencies = null) => {
+              continuationStorage.returnedWith = returnWith;
+              continuationStorage.providedDependencies = providedDependencies;
+              return continuationSymbol;
+            });
+
+      continuation.raiseOutput = makeRaiseLike('raiseOutput');
+      continuation.raiseOutputAbove = makeRaiseLike('raiseOutputAbove');
+    }
+
+    return {continuation, continuationStorage};
+  }
+
+  function _computeOrTransform(initialValue, continuationIfApplicable, initialDependencies) {
+    const expectingTransform = initialValue !== noTransformSymbol;
+
+    let valueSoFar =
+      (expectingTransform
+        ? initialValue
+        : undefined);
+
+    const availableDependencies = {...initialDependencies};
+
+    const inputValues =
+      Object.values(description.inputMapping ?? {})
+        .map(token => {
+          const tokenShape = getInputTokenShape(token);
+          const tokenValue = getInputTokenValue(token);
+          switch (tokenShape) {
+            case 'input.dependency':
+              return initialDependencies[tokenValue];
+            case 'input.value':
+              return tokenValue;
+            case 'input.updateValue':
+              if (!expectingTransform)
+                throw new Error(`Unexpected input.updateValue() accessed on non-transform call`);
+              return valueSoFar;
+            case 'input.myself':
+              return initialDependencies['this'];
+            case 'input.thisProperty':
+              return initialDependencies['thisProperty'];
+            case 'input':
+              return initialDependencies[token];
+            default:
+              throw new TypeError(`Unexpected input shape ${tokenShape}`);
+          }
+        });
+
+    withAggregate({message: `Errors in input values provided to ${compositionName}`}, ({push}) => {
+      for (const {dynamic, name, value, description} of stitchArrays({
+        dynamic: inputsMayBeDynamicValue,
+        name: inputNames,
+        value: inputValues,
+        description: inputDescriptions,
+      })) {
+        if (!dynamic) continue;
+        try {
+          validateInputValue(value, description);
+        } catch (error) {
+          error.message = `${name}: ${error.message}`;
+          push(error);
+        }
+      }
+    });
+
+    if (expectingTransform) {
+      debug(() => [colors.bright(`begin composition - transforming from:`), initialValue]);
+    } else {
+      debug(() => colors.bright(`begin composition - not transforming`));
+    }
+
+    for (
+      const [i, {
+        step,
+        stepComposes,
+      }] of
+        stitchArrays({
+          step: steps,
+          stepComposes: stepsCompose,
+        }).entries()
+    ) {
+      const isBase = i === steps.length - 1;
+
+      debug(() => [
+        `step #${i+1}` +
+        (isBase
+          ? ` (base):`
+          : ` of ${steps.length}:`),
+        step]);
+
+      const expose =
+        (step.flags
+          ? step.expose
+          : step);
+
+      if (!expose) {
+        if (!isBase) {
+          debug(() => `step #${i+1} - no expose description, nothing to do for this step`);
+          continue;
+        }
+
+        if (expectingTransform) {
+          debug(() => `step #${i+1} (base) - no expose description, returning so-far update value:`, valueSoFar);
+          if (continuationIfApplicable) {
+            debug(() => colors.bright(`end composition - raise (inferred - composing)`));
+            return continuationIfApplicable(valueSoFar);
+          } else {
+            debug(() => colors.bright(`end composition - exit (inferred - not composing)`));
+            return valueSoFar;
+          }
+        } else {
+          debug(() => `step #${i+1} (base) - no expose description, nothing to continue with`);
+          if (continuationIfApplicable) {
+            debug(() => colors.bright(`end composition - raise (inferred - composing)`));
+            return continuationIfApplicable();
+          } else {
+            debug(() => colors.bright(`end composition - exit (inferred - not composing)`));
+            return null;
+          }
+        }
+      }
+
+      const callingTransformForThisStep =
+        expectingTransform && expose.transform;
+
+      let continuationStorage;
+
+      const inputDictionary =
+        Object.fromEntries(
+          stitchArrays({symbol: inputSymbols, value: inputValues})
+            .map(({symbol, value}) => [symbol, value]));
+
+      const filterableDependencies = {
+        ...availableDependencies,
+        ...inputMetadata,
+        ...inputDictionary,
+        ...
+          (expectingTransform
+            ? {[input.updateValue()]: valueSoFar}
+            : {}),
+
+        [input.myself()]:
+          (initialDependencies && Object.hasOwn(initialDependencies, 'this')
+            ? initialDependencies.this
+            : null),
+
+        [input.thisProperty()]:
+          (initialDependencies && Object.hasOwn(initialDependencies, 'thisProperty')
+            ? initialDependencies.thisProperty
+            : null),
+      };
+
+      const selectDependencies =
+        (expose.dependencies ?? []).map(dependency => {
+          if (!isInputToken(dependency)) return dependency;
+          const tokenShape = getInputTokenShape(dependency);
+          const tokenValue = getInputTokenValue(dependency);
+          switch (tokenShape) {
+            case 'input':
+            case 'input.staticDependency':
+            case 'input.staticValue':
+              return dependency;
+            case 'input.myself':
+              return input.myself();
+            case 'input.thisProperty':
+              return input.thisProperty();
+            case 'input.dependency':
+              return tokenValue;
+            case 'input.updateValue':
+              return input.updateValue();
+            default:
+              throw new Error(`Unexpected token ${tokenShape} as dependency`);
+          }
+        })
+
+      const filteredDependencies =
+        filterProperties(filterableDependencies, selectDependencies);
+
+      debug(() => [
+        `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`,
+        `with dependencies:`, filteredDependencies,
+        `selecting:`, selectDependencies,
+        `from available:`, filterableDependencies,
+        ...callingTransformForThisStep ? [`from value:`, valueSoFar] : []]);
+
+      let result;
+
+      const getExpectedEvaluation = () =>
+        (callingTransformForThisStep
+          ? (filteredDependencies
+              ? ['transform', valueSoFar, continuationSymbol, filteredDependencies]
+              : ['transform', valueSoFar, continuationSymbol])
+          : (filteredDependencies
+              ? ['compute', continuationSymbol, filteredDependencies]
+              : ['compute', continuationSymbol]));
+
+      const naturalEvaluate = () => {
+        const [name, ...argsLayout] = getExpectedEvaluation();
+
+        let args = argsLayout;
+
+        let effectiveDependencies;
+        let reviewAccessedDependencies;
+
+        if (stepsFirstTimeCalling[i]) {
+          const expressedDependencies =
+            selectDependencies;
+
+          const remainingDependencies =
+            new Set(expressedDependencies);
+
+          const unavailableDependencies = [];
+          const accessedDependencies = [];
+
+          effectiveDependencies =
+            new Proxy(filteredDependencies, {
+              get(target, key) {
+                accessedDependencies.push(key);
+                remainingDependencies.delete(key);
+
+                const value = target[key];
+
+                if (value === undefined) {
+                  unavailableDependencies.push(key);
+                }
+
+                return value;
+              },
+            });
+
+          reviewAccessedDependencies = () => {
+            const topAggregate =
+              openAggregate({
+                message: `Errors in accessed dependencies`,
+              });
+
+            const showDependency = dependency =>
+              (isInputToken(dependency)
+                ? getInputTokenShape(dependency) +
+                  `(` +
+                  inspect(getInputTokenValue(dependency), {compact: true}) +
+                  ')'
+                : dependency.toString());
+
+            let anyErrors = false;
+
+            for (const dependency of remainingDependencies) {
+              topAggregate.push(new Error(
+                `Expected to access ${showDependency(dependency)}`));
+
+              anyErrors = true;
+            }
+
+            for (const dependency of unavailableDependencies) {
+              const subAggregate =
+                openAggregate({
+                  message:
+                    `Accessed ${showDependency(dependency)}, which is unavailable`,
+                });
+
+              let reason = false;
+
+              if (!expressedDependencies.includes(dependency)) {
+                subAggregate.push(new Error(
+                  `Missing from step's expressed dependencies`));
+                reason = true;
+              }
+
+              if (filterableDependencies[dependency] === undefined) {
+                subAggregate.push(
+                  new Error(
+                    `Not available` +
+                    (isInputToken(dependency)
+                      ? ` in input()-type dependencies`
+                   : dependency.startsWith('#')
+                      ? ` in local dependencies`
+                      : ` on object dependencies`)));
+                reason = true;
+              }
+
+              if (!reason) {
+                subAggregate.push(new Error(
+                  `Not sure why this is unavailable, sorry!`));
+              }
+
+              topAggregate.call(subAggregate.close);
+
+              anyErrors = true;
+            }
+
+            if (anyErrors) {
+              topAggregate.push(new Error(
+                `These dependencies, in total, were accessed:` +
+                (empty(accessedDependencies)
+                  ? ` (none)`
+               : accessedDependencies.length === 1
+                  ? showDependency(accessedDependencies[0])
+                  : `\n` +
+                    accessedDependencies
+                      .map(showDependency)
+                      .map(line => `  - ${line}`)
+                      .join('\n'))));
+            }
+
+            topAggregate.close();
+          };
+        } else {
+          effectiveDependencies = filteredDependencies;
+          reviewAccessedDependencies = null;
+        }
+
+        args =
+          args.map(arg =>
+            (arg === filteredDependencies
+              ? effectiveDependencies
+              : arg));
+
+        if (stepComposes) {
+          let continuation;
+
+          ({continuation, continuationStorage} =
+            _prepareContinuation(callingTransformForThisStep));
+
+          args =
+            args.map(arg =>
+              (arg === continuationSymbol
+                ? continuation
+                : arg));
+        } else {
+          args =
+            args.filter(arg => arg !== continuationSymbol);
+        }
+
+        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!
+        case 'aggressive': {
+          const hrnow = () => {
+            const hrTime = process.hrtime();
+            return hrTime[0] * 1000000000 + hrTime[1];
+          };
+
+          const [name, ...args] = getExpectedEvaluation();
+
+          let cache = globalCompositeCache[step.annotation];
+          if (!cache) {
+            cache = globalCompositeCache[step.annotation] = {
+              transform: new TupleMap(),
+              compute: new TupleMap(),
+              times: {
+                read: [],
+                evaluate: [],
+              },
+            };
+          }
+
+          const tuplefied = args
+            .flatMap(arg => [
+              Symbol.for('compositeFrom: tuplefied arg divider'),
+              ...(typeof arg !== 'object' || Array.isArray(arg)
+                ? [arg]
+                : Object.entries(arg).flat()),
+            ]);
+
+          const readTime = hrnow();
+          const cacheContents = cache[name].get(tuplefied);
+          cache.times.read.push(hrnow() - readTime);
+
+          if (cacheContents) {
+            ({result, continuationStorage} = cacheContents);
+          } else {
+            const evaluateTime = hrnow();
+            result = naturalEvaluate();
+            cache.times.evaluate.push(hrnow() - evaluateTime);
+            cache[name].set(tuplefied, {result, continuationStorage});
+          }
+
+          break;
+        }
+
+        default: {
+          result = naturalEvaluate();
+          break;
+        }
+      }
+
+      if (result !== continuationSymbol) {
+        debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]);
+        debug(() => colors.bright(`end composition - exit (inferred)`));
+
+        return result;
+      }
+
+      const {returnedWith} = continuationStorage;
+
+      if (returnedWith === 'exit') {
+        const {providedValue} = continuationStorage;
+
+        debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]);
+        debug(() => colors.bright(`end composition - exit (explicit)`));
+
+        if (compositionNests) {
+          return continuationIfApplicable.exit(providedValue);
+        } else {
+          return providedValue;
+        }
+      }
+
+      const {providedValue, providedDependencies} = continuationStorage;
+
+      const continuationArgs = [];
+      if (expectingTransform) {
+        continuationArgs.push(
+          (callingTransformForThisStep
+            ? providedValue ?? null
+            : valueSoFar ?? null));
+      }
+
+      debug(() => {
+        const base = `step #${i+1} - result: ` + returnedWith;
+        const parts = [];
+
+        if (callingTransformForThisStep) {
+          parts.push('value:', providedValue);
+        }
+
+        if (providedDependencies !== null) {
+          parts.push(`deps:`, providedDependencies);
+        } else {
+          parts.push(`(no deps)`);
+        }
+
+        if (empty(parts)) {
+          return base;
+        } else {
+          return [base + ' ->', ...parts];
+        }
+      });
+
+      switch (returnedWith) {
+        case 'raiseOutput':
+          debug(() =>
+            (isBase
+              ? colors.bright(`end composition - raiseOutput (base: explicit)`)
+              : colors.bright(`end composition - raiseOutput`)));
+          continuationArgs.push(_mapDependenciesToOutputs(providedDependencies));
+          return continuationIfApplicable(...continuationArgs);
+
+        case 'raiseOutputAbove':
+          debug(() => colors.bright(`end composition - raiseOutputAbove`));
+          continuationArgs.push(_mapDependenciesToOutputs(providedDependencies));
+          return continuationIfApplicable.raiseOutput(...continuationArgs);
+
+        case 'continuation':
+          if (isBase) {
+            debug(() => colors.bright(`end composition - raiseOutput (inferred)`));
+            continuationArgs.push(_mapDependenciesToOutputs(providedDependencies));
+            return continuationIfApplicable(...continuationArgs);
+          } else {
+            Object.assign(availableDependencies, providedDependencies);
+            if (callingTransformForThisStep && providedValue !== null) {
+              valueSoFar = providedValue;
+            }
+            break;
+          }
+      }
+    }
+  }
+
+  const constructedDescriptor = {};
+
+  if (annotation) {
+    constructedDescriptor.annotation = annotation;
+  }
+
+  constructedDescriptor.flags = {
+    update: compositionUpdates,
+    expose: compositionExposes,
+    compose: compositionNests,
+  };
+
+  if (compositionUpdates) {
+    // TODO: This is a dumb assign statement, and it could probably do more
+    // interesting things, like combining validation functions.
+    constructedDescriptor.update =
+      Object.assign(
+        {...description.update ?? {}},
+        ...inputUpdateDescriptions,
+        ...stepUpdateDescriptions.flat());
+  }
+
+  if (compositionExposes) {
+    const expose = constructedDescriptor.expose = {};
+
+    expose.dependencies =
+      unique([
+        ...dependenciesFromInputs,
+        ...dependenciesFromSteps,
+      ]);
+
+    const _wrapper = (...args) => {
+      try {
+        return _computeOrTransform(...args);
+      } catch (thrownError) {
+        const error = new Error(
+          `Error computing composition` +
+          (annotation ? ` ${annotation}` : ''));
+        error.cause = thrownError;
+        error[Symbol.for('hsmusic.aggregate.translucent')] = true;
+        throw error;
+      }
+    };
+
+    if (compositionNests) {
+      if (compositionUpdates) {
+        expose.transform = (value, continuation, dependencies) =>
+          _wrapper(value, continuation, dependencies);
+      }
+
+      if (anyStepsCompute && !anyStepsUseUpdateValue && !anyInputsUseUpdateValue) {
+        expose.compute = (continuation, dependencies) =>
+          _wrapper(noTransformSymbol, continuation, dependencies);
+      }
+
+      if (base.cacheComposition) {
+        expose.cache = base.cacheComposition;
+      }
+    } else if (compositionUpdates) {
+      if (!empty(steps)) {
+        expose.transform = (value, dependencies) =>
+          _wrapper(value, null, dependencies);
+      }
+    } else {
+      expose.compute = (dependencies) =>
+        _wrapper(noTransformSymbol, null, dependencies);
+    }
+  }
+
+  return constructedDescriptor;
+}
+
+export function displayCompositeCacheAnalysis() {
+  const showTimes = (cache, key) => {
+    const times = cache.times[key].slice().sort();
+
+    const all = times;
+    const worst10pc = times.slice(-times.length / 10);
+    const best10pc = times.slice(0, times.length / 10);
+    const middle50pc = times.slice(times.length / 4, -times.length / 4);
+    const middle80pc = times.slice(times.length / 10, -times.length / 10);
+
+    const fmt = val => `${(val / 1000).toFixed(2)}ms`.padStart(9);
+    const avg = times => times.reduce((a, b) => a + b, 0) / times.length;
+
+    const left = ` - ${key}: `;
+    const indn = ' '.repeat(left.length);
+    console.log(left + `${fmt(avg(all))} (all ${all.length})`);
+    console.log(indn + `${fmt(avg(worst10pc))} (worst 10%)`);
+    console.log(indn + `${fmt(avg(best10pc))} (best 10%)`);
+    console.log(indn + `${fmt(avg(middle80pc))} (middle 80%)`);
+    console.log(indn + `${fmt(avg(middle50pc))} (middle 50%)`);
+  };
+
+  for (const [annotation, cache] of Object.entries(globalCompositeCache)) {
+    console.log(`Cached ${annotation}:`);
+    showTimes(cache, 'evaluate');
+    showTimes(cache, 'read');
+  }
+}
+
+// Evaluates a function with composite debugging enabled, turns debugging
+// off again, and returns the result of the function. This is mostly syntax
+// sugar, but also helps avoid unit tests avoid accidentally printing debug
+// info for a bunch of unrelated composites (due to property enumeration
+// when displaying an unexpected result). Use as so:
+//
+//   Without debugging:
+//     t.same(thing.someProp, value)
+//
+//   With debugging:
+//     t.same(debugComposite(() => thing.someProp), value)
+//
+export function debugComposite(fn) {
+  compositeFrom.debug = true;
+  const value = fn();
+  compositeFrom.debug = false;
+  return value;
+}
diff --git a/src/data/composite/control-flow/exitWithoutDependency.js b/src/data/composite/control-flow/exitWithoutDependency.js
new file mode 100644
index 00000000..c660a7ef
--- /dev/null
+++ b/src/data/composite/control-flow/exitWithoutDependency.js
@@ -0,0 +1,35 @@
+// Early exits if a dependency isn't available.
+// See withResultOfAvailabilityCheck for {mode} options.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js';
+
+export default templateCompositeFrom({
+  annotation: `exitWithoutDependency`,
+
+  inputs: {
+    dependency: input({acceptsNull: true}),
+    mode: inputAvailabilityCheckMode(),
+    value: input({defaultValue: null}),
+  },
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('dependency'),
+      mode: input('mode'),
+    }),
+
+    {
+      dependencies: ['#availability', input('value')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('value')]: value,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.exit(value)),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/exitWithoutUpdateValue.js b/src/data/composite/control-flow/exitWithoutUpdateValue.js
new file mode 100644
index 00000000..244b3233
--- /dev/null
+++ b/src/data/composite/control-flow/exitWithoutUpdateValue.js
@@ -0,0 +1,24 @@
+// Early exits if this property's update value isn't available.
+// See withResultOfAvailabilityCheck for {mode} options.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import exitWithoutDependency from './exitWithoutDependency.js';
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+
+export default templateCompositeFrom({
+  annotation: `exitWithoutUpdateValue`,
+
+  inputs: {
+    mode: inputAvailabilityCheckMode(),
+    value: input({defaultValue: null}),
+  },
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input.updateValue(),
+      mode: input('mode'),
+      value: input('value'),
+    }),
+  ],
+});
diff --git a/src/data/composite/control-flow/exposeConstant.js b/src/data/composite/control-flow/exposeConstant.js
new file mode 100644
index 00000000..e76699c5
--- /dev/null
+++ b/src/data/composite/control-flow/exposeConstant.js
@@ -0,0 +1,26 @@
+// Exposes a constant value exactly as it is; like exposeDependency, this
+// is typically the base of a composition serving as a particular property
+// descriptor. It generally follows steps which will conditionally early
+// exit with some other value, with the exposeConstant base serving as the
+// fallback default value.
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `exposeConstant`,
+
+  compose: false,
+
+  inputs: {
+    value: input.staticValue({acceptsNull: true}),
+  },
+
+  steps: () => [
+    {
+      dependencies: [input('value')],
+      compute: ({
+        [input('value')]: value,
+      }) => value,
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/exposeDependency.js b/src/data/composite/control-flow/exposeDependency.js
new file mode 100644
index 00000000..3aa3d03a
--- /dev/null
+++ b/src/data/composite/control-flow/exposeDependency.js
@@ -0,0 +1,28 @@
+// Exposes a dependency exactly as it is; this is typically the base of a
+// composition which was created to serve as one property's descriptor.
+//
+// Please note that this *doesn't* verify that the dependency exists, so
+// if you provide the wrong name or it hasn't been set by a previous
+// compositional step, the property will be exposed as undefined instead
+// of null.
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `exposeDependency`,
+
+  compose: false,
+
+  inputs: {
+    dependency: input.staticDependency({acceptsNull: true}),
+  },
+
+  steps: () => [
+    {
+      dependencies: [input('dependency')],
+      compute: ({
+        [input('dependency')]: dependency
+      }) => dependency,
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/exposeDependencyOrContinue.js b/src/data/composite/control-flow/exposeDependencyOrContinue.js
new file mode 100644
index 00000000..0f7f223e
--- /dev/null
+++ b/src/data/composite/control-flow/exposeDependencyOrContinue.js
@@ -0,0 +1,34 @@
+// Exposes a dependency as it is, or continues if it's unavailable.
+// See withResultOfAvailabilityCheck for {mode} options.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js';
+
+export default templateCompositeFrom({
+  annotation: `exposeDependencyOrContinue`,
+
+  inputs: {
+    dependency: input({acceptsNull: true}),
+    mode: inputAvailabilityCheckMode(),
+  },
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('dependency'),
+      mode: input('mode'),
+    }),
+
+    {
+      dependencies: ['#availability', input('dependency')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('dependency')]: dependency,
+      }) =>
+        (availability
+          ? continuation.exit(dependency)
+          : continuation()),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/exposeUpdateValueOrContinue.js b/src/data/composite/control-flow/exposeUpdateValueOrContinue.js
new file mode 100644
index 00000000..1f94b332
--- /dev/null
+++ b/src/data/composite/control-flow/exposeUpdateValueOrContinue.js
@@ -0,0 +1,40 @@
+// Exposes the update value of an {update: true} property as it is,
+// or continues if it's unavailable.
+//
+// See withResultOfAvailabilityCheck for {mode} options.
+//
+// Provide {validate} here to conveniently set a custom validation check
+// for this property's update value.
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+import exposeDependencyOrContinue from './exposeDependencyOrContinue.js';
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+
+export default templateCompositeFrom({
+  annotation: `exposeUpdateValueOrContinue`,
+
+  inputs: {
+    mode: inputAvailabilityCheckMode(),
+
+    validate: input({
+      type: 'function',
+      defaultValue: null,
+    }),
+  },
+
+  update: ({
+    [input.staticValue('validate')]: validate,
+  }) =>
+    (validate
+      ? {validate}
+      : {}),
+
+  steps: () => [
+    exposeDependencyOrContinue({
+      dependency: input.updateValue(),
+      mode: input('mode'),
+    }),
+  ],
+});
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
new file mode 100644
index 00000000..7e137a14
--- /dev/null
+++ b/src/data/composite/control-flow/index.js
@@ -0,0 +1,16 @@
+// #composite/control-flow
+//
+// No entries depend on any other entries, except siblings in this directory.
+//
+
+export {default as exitWithoutDependency} from './exitWithoutDependency.js';
+export {default as exitWithoutUpdateValue} from './exitWithoutUpdateValue.js';
+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/inputAvailabilityCheckMode.js b/src/data/composite/control-flow/inputAvailabilityCheckMode.js
new file mode 100644
index 00000000..8008fdeb
--- /dev/null
+++ b/src/data/composite/control-flow/inputAvailabilityCheckMode.js
@@ -0,0 +1,9 @@
+import {input} from '#composite';
+import {is} from '#validators';
+
+export default function inputAvailabilityCheckMode() {
+  return input({
+    validate: is('null', 'empty', 'falsy', 'index'),
+    defaultValue: 'null',
+  });
+}
diff --git a/src/data/composite/control-flow/raiseOutputWithoutDependency.js b/src/data/composite/control-flow/raiseOutputWithoutDependency.js
new file mode 100644
index 00000000..03d8036a
--- /dev/null
+++ b/src/data/composite/control-flow/raiseOutputWithoutDependency.js
@@ -0,0 +1,39 @@
+// Raises if a dependency isn't available.
+// See withResultOfAvailabilityCheck for {mode} options.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js';
+
+export default templateCompositeFrom({
+  annotation: `raiseOutputWithoutDependency`,
+
+  inputs: {
+    dependency: input({acceptsNull: true}),
+    mode: inputAvailabilityCheckMode(),
+    output: input.staticValue({defaultValue: {}}),
+  },
+
+  outputs: ({
+    [input.staticValue('output')]: output,
+  }) => Object.keys(output),
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('dependency'),
+      mode: input('mode'),
+    }),
+
+    {
+      dependencies: ['#availability', input('output')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('output')]: output,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutputAbove(output)),
+    },
+  ],
+});
diff --git a/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js b/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js
new file mode 100644
index 00000000..3c39f5ba
--- /dev/null
+++ b/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js
@@ -0,0 +1,47 @@
+// Raises if this property's update value isn't available.
+// See withResultOfAvailabilityCheck for {mode} options!
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js';
+
+export default templateCompositeFrom({
+  annotation: `raiseOutputWithoutUpdateValue`,
+
+  inputs: {
+    mode: inputAvailabilityCheckMode(),
+    output: input.staticValue({defaultValue: {}}),
+  },
+
+  outputs: ({
+    [input.staticValue('output')]: output,
+  }) => Object.keys(output),
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input.updateValue(),
+      mode: input('mode'),
+    }),
+
+    // TODO: A bit of a kludge, below. Other "do something with the update
+    // value" type functions can get by pretty much just passing that value
+    // as an input (input.updateValue()) into the corresponding "do something
+    // with a dependency/arbitrary value" function. But we can't do that here,
+    // because the special behavior, raiseOutputAbove(), only works to raise
+    // output above the composition it's *directly* nested in. Other languages
+    // have a throw/catch system that might serve as inspiration for something
+    // better here.
+
+    {
+      dependencies: ['#availability', input('output')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('output')]: output,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutputAbove(output)),
+    },
+  ],
+});
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
new file mode 100644
index 00000000..c5221a62
--- /dev/null
+++ b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js
@@ -0,0 +1,54 @@
+// Checks the availability of a dependency and provides the result to later
+// steps under '#availability' (by default). This is mainly intended for use
+// by the more specific utilities, which you should consider using instead.
+//
+// Customize {mode} to select one of these modes, or default to 'null':
+//
+// * 'null':  Check that the value isn't null (and not undefined either).
+// * 'empty': Check that the value is neither null, undefined, nor an empty
+//            array.
+// * 'falsy': Check that the value isn't false when treated as a boolean
+//            (nor an empty array). Keep in mind this will also be false
+//            for values like zero and the empty string!
+// * 'index': Check that the value is a number, and is at least zero.
+//
+// See also:
+//  - exitWithoutDependency
+//  - exitWithoutUpdateValue
+//  - exposeDependencyOrContinue
+//  - exposeUpdateValueOrContinue
+//  - exposeWhetherDependencyAvailable
+//  - raiseOutputWithoutDependency
+//  - raiseOutputWithoutUpdateValue
+//  - withAvailabilityFilter
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js';
+
+import performAvailabilityCheck from './helpers/performAvailabilityCheck.js';
+
+export default templateCompositeFrom({
+  annotation: `withResultOfAvailabilityCheck`,
+
+  inputs: {
+    from: input({acceptsNull: true}),
+    mode: inputAvailabilityCheckMode(),
+  },
+
+  outputs: ['#availability'],
+
+  steps: () => [
+    {
+      dependencies: [input('from'), input('mode')],
+      compute: (continuation, {
+        [input('from')]: value,
+        [input('mode')]: mode,
+      }) => continuation({
+        ['#availability']:
+          performAvailabilityCheck(value, mode),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/excludeFromList.js b/src/data/composite/data/excludeFromList.js
new file mode 100644
index 00000000..2a3e818e
--- /dev/null
+++ b/src/data/composite/data/excludeFromList.js
@@ -0,0 +1,50 @@
+// Filters particular values out of a list. Note that this will always
+// completely skip over null, but can be used to filter out any other
+// primitive or object value.
+//
+// See also:
+//  - fillMissingListItems
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {empty} from '#sugar';
+
+export default templateCompositeFrom({
+  annotation: `excludeFromList`,
+
+  inputs: {
+    list: input(),
+
+    item: input({defaultValue: null}),
+    items: input({type: 'array', defaultValue: null}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list ?? '#list'],
+
+  steps: () => [
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        input('list'),
+        input('item'),
+        input('items'),
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: listName,
+        [input('list')]: listContents,
+        [input('item')]: excludeItem,
+        [input('items')]: excludeItems,
+      }) => continuation({
+        [listName ?? '#list']:
+          listContents.filter(item => {
+            if (excludeItem !== null && item === excludeItem) return false;
+            if (!empty(excludeItems) && excludeItems.includes(item)) return false;
+            return true;
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/fillMissingListItems.js b/src/data/composite/data/fillMissingListItems.js
new file mode 100644
index 00000000..356b1119
--- /dev/null
+++ b/src/data/composite/data/fillMissingListItems.js
@@ -0,0 +1,45 @@
+// Replaces items of a list, which are null or undefined, with some fallback
+// value. By default, this replaces the passed dependency.
+//
+// See also:
+//  - excludeFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `fillMissingListItems`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    fill: input({acceptsNull: true}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list ?? '#list'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('fill')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('fill')]: fill,
+      }) => continuation({
+        ['#filled']:
+          list.map(item => item ?? fill),
+      }),
+    },
+
+    {
+      dependencies: [input.staticDependency('list'), '#filled'],
+      compute: (continuation, {
+        [input.staticDependency('list')]: list,
+        ['#filled']: filled,
+      }) => continuation({
+        [list ?? '#list']:
+          filled,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js
new file mode 100644
index 00000000..46a3dc81
--- /dev/null
+++ b/src/data/composite/data/index.js
@@ -0,0 +1,35 @@
+// #composite/data
+//
+// 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 withMappedList} from './withMappedList.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 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
new file mode 100644
index 00000000..44c1661d
--- /dev/null
+++ b/src/data/composite/data/withFilteredList.js
@@ -0,0 +1,50 @@
+// Applies a filter - an array of truthy and falsy values - to the index-
+// corresponding items in a list. Items which correspond to a truthy value
+// are kept, and the rest are excluded from the output list.
+//
+// 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
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withFilteredList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    filter: input({type: 'array'}),
+
+    flip: input({
+      type: 'boolean',
+      defaultValue: false,
+    }),
+  },
+
+  outputs: ['#filteredList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('filter'), input('flip')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('filter')]: filter,
+        [input('flip')]: flip,
+      }) => continuation({
+        '#filteredList':
+          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
new file mode 100644
index 00000000..31b1a742
--- /dev/null
+++ b/src/data/composite/data/withFlattenedList.js
@@ -0,0 +1,41 @@
+// Flattens an array with one level of nested arrays, providing as dependencies
+// both the flattened array as well as the original starting indices of each
+// successive source array.
+//
+// See also:
+//  - withUnflattenedList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withFlattenedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+  },
+
+  outputs: ['#flattenedList', '#flattenedIndices'],
+
+  steps: () => [
+    {
+      dependencies: [input('list')],
+      compute(continuation, {
+        [input('list')]: sourceList,
+      }) {
+        const flattenedList = sourceList.flat();
+        const indices = [];
+        let lastEndIndex = 0;
+        for (const {length} of sourceList) {
+          indices.push(lastEndIndex);
+          lastEndIndex += length;
+        }
+
+        return continuation({
+          ['#flattenedList']: flattenedList,
+          ['#flattenedIndices']: indices,
+        });
+      },
+    },
+  ],
+});
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
new file mode 100644
index 00000000..cd32058e
--- /dev/null
+++ b/src/data/composite/data/withMappedList.js
@@ -0,0 +1,49 @@
+// 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
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+
+export default templateCompositeFrom({
+  annotation: `withMappedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    map: input({type: 'function'}),
+
+    filter: input({
+      type: 'array',
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ['#mappedList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('map'), input('filter')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('map')]: mapFn,
+        [input('filter')]: filter,
+      }) => continuation({
+        ['#mappedList']:
+          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
new file mode 100644
index 00000000..fb4134bc
--- /dev/null
+++ b/src/data/composite/data/withPropertiesFromList.js
@@ -0,0 +1,86 @@
+// Gets the listed properties from each of a list of objects, providing lists
+// of property values each into a dependency prefixed with the same name as the
+// list (by default).
+//
+// Like withPropertyFromList, this doesn't alter indices.
+//
+// See also:
+//  - withPropertiesFromObject
+//  - withPropertyFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isString, validateArrayItems} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withPropertiesFromList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+
+    properties: input({
+      validate: validateArrayItems(isString),
+    }),
+
+    prefix: input.staticValue({type: 'string', defaultValue: null}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+    [input.staticValue('properties')]: properties,
+    [input.staticValue('prefix')]: prefix,
+  }) =>
+    (properties
+      ? properties.map(property =>
+          (prefix
+            ? `${prefix}.${property}`
+         : list
+            ? `${list}.${property}`
+            : `#list.${property}`))
+      : ['#lists']),
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('properties')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('properties')]: properties,
+      }) => continuation({
+        ['#lists']:
+          Object.fromEntries(
+            properties.map(property => [
+              property,
+              list.map(item => item[property] ?? null),
+            ])),
+      }),
+    },
+
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        input.staticValue('properties'),
+        input.staticValue('prefix'),
+        '#lists',
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: list,
+        [input.staticValue('properties')]: properties,
+        [input.staticValue('prefix')]: prefix,
+        ['#lists']: lists,
+      }) =>
+        (properties
+          ? continuation(
+              Object.fromEntries(
+                properties.map(property => [
+                  (prefix
+                    ? `${prefix}.${property}`
+                 : list
+                    ? `${list}.${property}`
+                    : `#list.${property}`),
+                  lists[property],
+                ])))
+          : continuation({'#lists': lists})),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertiesFromObject.js b/src/data/composite/data/withPropertiesFromObject.js
new file mode 100644
index 00000000..21726b58
--- /dev/null
+++ b/src/data/composite/data/withPropertiesFromObject.js
@@ -0,0 +1,87 @@
+// Gets the listed properties from some object, providing each property's value
+// as a dependency prefixed with the same name as the object (by default).
+// If the object itself is null, all provided dependencies will be null;
+// if it's missing only select properties, those will be provided as null.
+//
+// See also:
+//  - withPropertiesFromList
+//  - withPropertyFromObject
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isString, validateArrayItems} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withPropertiesFromObject`,
+
+  inputs: {
+    object: input({type: 'object', acceptsNull: true}),
+
+    properties: input({
+      type: 'array',
+      validate: validateArrayItems(isString),
+    }),
+
+    prefix: input.staticValue({type: 'string', defaultValue: null}),
+  },
+
+  outputs: ({
+    [input.staticDependency('object')]: object,
+    [input.staticValue('properties')]: properties,
+    [input.staticValue('prefix')]: prefix,
+  }) =>
+    (properties
+      ? properties.map(property =>
+          (prefix
+            ? `${prefix}.${property}`
+         : object
+            ? `${object}.${property}`
+            : `#object.${property}`))
+      : ['#object']),
+
+  steps: () => [
+    {
+      dependencies: [input('object'), input('properties')],
+      compute: (continuation, {
+        [input('object')]: object,
+        [input('properties')]: properties,
+      }) => continuation({
+        ['#entries']:
+          (object === null
+            ? properties.map(property => [property, null])
+            : properties.map(property => [property, object[property]])),
+      }),
+    },
+
+    {
+      dependencies: [
+        input.staticDependency('object'),
+        input.staticValue('properties'),
+        input.staticValue('prefix'),
+        '#entries',
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('object')]: object,
+        [input.staticValue('properties')]: properties,
+        [input.staticValue('prefix')]: prefix,
+        ['#entries']: entries,
+      }) =>
+        (properties
+          ? continuation(
+              Object.fromEntries(
+                entries.map(([property, value]) => [
+                  (prefix
+                    ? `${prefix}.${property}`
+                 : object
+                    ? `${object}.${property}`
+                    : `#object.${property}`),
+                  value ?? null,
+                ])))
+          : continuation({
+              ['#object']:
+                Object.fromEntries(entries),
+            })),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js
new file mode 100644
index 00000000..760095c2
--- /dev/null
+++ b/src/data/composite/data/withPropertyFromList.js
@@ -0,0 +1,94 @@
+// Gets a property from each of a list of objects (in a dependency) and
+// provides the results.
+//
+// This doesn't alter any list indices, so positions which were null in the
+// 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
+//
+
+import CacheableObject from '#cacheable-object';
+import {input, templateCompositeFrom} from '#composite';
+
+function getOutputName({list, property, prefix}) {
+  if (!property) return `#values`;
+  if (prefix) return `${prefix}.${property}`;
+  if (list) return `${list}.${property}`;
+  return `#list.${property}`;
+}
+
+export default templateCompositeFrom({
+  annotation: `withPropertyFromList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    property: input({type: 'string'}),
+    prefix: input.staticValue({type: 'string', defaultValue: null}),
+    internal: input({type: 'boolean', defaultValue: false}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+    [input.staticValue('property')]: property,
+    [input.staticValue('prefix')]: prefix,
+  }) =>
+    [getOutputName({list, property, prefix})],
+
+  steps: () => [
+    {
+      dependencies: [
+        input('list'),
+        input('property'),
+        input('internal'),
+      ],
+
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('property')]: property,
+        [input('internal')]: internal,
+      }) => continuation({
+        ['#values']:
+          list.map(item =>
+            (item === null
+              ? null
+           : internal
+              ? CacheableObject.getUpdateValue(item, property)
+                  ?? null
+              : item[property]
+                  ?? null)),
+      }),
+    },
+
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        input.staticValue('property'),
+        input.staticValue('prefix'),
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: list,
+        [input.staticValue('property')]: property,
+        [input.staticValue('prefix')]: prefix,
+      }) => continuation({
+        ['#outputName']:
+          getOutputName({list, property, prefix}),
+      }),
+    },
+
+    {
+      dependencies: ['#values', '#outputName'],
+      compute: (continuation, {
+        ['#values']: values,
+        ['#outputName']: outputName,
+      }) =>
+        continuation.raiseOutput({[outputName]: values}),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertyFromObject.js b/src/data/composite/data/withPropertyFromObject.js
new file mode 100644
index 00000000..4f240506
--- /dev/null
+++ b/src/data/composite/data/withPropertyFromObject.js
@@ -0,0 +1,89 @@
+// Gets a property of some object (in a dependency) and provides that value.
+// 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({
+  annotation: `withPropertyFromObject`,
+
+  inputs: {
+    object: input({type: 'object', acceptsNull: true}),
+    property: input({type: 'string'}),
+    internal: input({type: 'boolean', defaultValue: false}),
+  },
+
+  outputs: ({
+    [input.staticDependency('object')]: object,
+    [input.staticValue('property')]: property,
+  }) =>
+    (object && property
+      ? (object.startsWith('#')
+          ? [`${object}.${property}`]
+          : [`#${object}.${property}`])
+      : ['#value']),
+
+  steps: () => [
+    {
+      dependencies: [
+        input.staticDependency('object'),
+        input.staticValue('property'),
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('object')]: object,
+        [input.staticValue('property')]: property,
+      }) => continuation({
+        '#output':
+          (object && property
+            ? (object.startsWith('#')
+                ? `${object}.${property}`
+                : `#${object}.${property}`)
+            : '#value'),
+      }),
+    },
+
+    {
+      dependencies: [
+        input('object'),
+        input('property'),
+        input('internal'),
+      ],
+
+      compute: (continuation, {
+        [input('object')]: object,
+        [input('property')]: property,
+        [input('internal')]: internal,
+      }) => continuation({
+        '#value':
+          (object === null
+            ? 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
new file mode 100644
index 00000000..a7d21768
--- /dev/null
+++ b/src/data/composite/data/withSortedList.js
@@ -0,0 +1,115 @@
+// Applies a sort function across pairs of items in a list, just like a normal
+// JavaScript sort. Alongside the sorted results, so are outputted the indices
+// which each item in the unsorted list corresponds to in the sorted one,
+// allowing for the results of this sort to be composed in some more involved
+// operation. For example, using an alphabetical sort, the list ['banana',
+// 'apple', 'pterodactyl'] will output the expected alphabetical items, as well
+// as the indices list [1, 0, 2].
+//
+// If two items are equal (in the eyes of the sort operation), their placement
+// in the sorted list is arbitrary, though every input index will be present in
+// '#sortIndices' exactly once (and equal items will be bunched together).
+//
+// The '#sortIndices' output refers to the "true" index which each source item
+// occupies in the sorted list. This sacrifices information about equal items,
+// which can be obtained through '#unstableSortIndices' instead: each mapped
+// index may appear more than once, and rather than represent exact positions
+// in the sorted list, they represent relational values: if items A and B are
+// mapped to indices 3 and 5, then A certainly is positioned before B (and vice
+// versa); but there may be more than one item in-between. If items C and D are
+// both mapped to index 4, then their position relative to each other is
+// arbitrary - they are equal - but they both certainly appear after item A and
+// before item B.
+//
+// This implementation is based on the one used for sortMultipleArrays.
+//
+// See also:
+//  - withFilteredList
+//  - withMappedList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withSortedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    sort: input({type: 'function'}),
+  },
+
+  outputs: ['#sortedList', '#sortIndices', '#unstableSortIndices'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('sort')],
+      compute(continuation, {
+        [input('list')]: list,
+        [input('sort')]: sortFn,
+      }) {
+        const symbols = [];
+        const symbolToIndex = new Map();
+
+        for (const index of list.keys()) {
+          const symbol = Symbol();
+          symbols.push(symbol);
+          symbolToIndex.set(symbol, index);
+        }
+
+        const equalSymbols = new Map();
+
+        const assertEqual = (symbol1, symbol2) => {
+          if (equalSymbols.has(symbol1)) {
+            equalSymbols.get(symbol1).add(symbol2);
+          } else {
+            equalSymbols.set(symbol1, new Set([symbol2]));
+          }
+        };
+
+        const isEqual = (symbol1, symbol2) =>
+          !!equalSymbols.get(symbol1)?.has(symbol2);
+
+        symbols.sort((symbol1, symbol2) => {
+          const comparison =
+            sortFn(
+              list[symbolToIndex.get(symbol1)],
+              list[symbolToIndex.get(symbol2)]);
+
+          if (comparison === 0) {
+            assertEqual(symbol1, symbol2);
+            assertEqual(symbol2, symbol1);
+          }
+
+          return comparison;
+        });
+
+        const stableSortIndices = [];
+        const unstableSortIndices = [];
+        const sortedList = [];
+
+        let unstableIndex = 0;
+
+        for (const [stableIndex, symbol] of symbols.entries()) {
+          const sourceIndex = symbolToIndex.get(symbol);
+          sortedList.push(list[sourceIndex]);
+
+          if (stableIndex > 0) {
+            const previous = symbols[stableIndex - 1];
+            if (!isEqual(symbol, previous)) {
+              unstableIndex++;
+            }
+          }
+
+          stableSortIndices[sourceIndex] = stableIndex;
+          unstableSortIndices[sourceIndex] = unstableIndex;
+        }
+
+        return continuation({
+          ['#sortedList']: sortedList,
+          ['#sortIndices']: stableSortIndices,
+          ['#unstableSortIndices']: unstableSortIndices,
+        });
+      },
+    },
+  ],
+});
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
new file mode 100644
index 00000000..820d628a
--- /dev/null
+++ b/src/data/composite/data/withUnflattenedList.js
@@ -0,0 +1,66 @@
+// After mapping the contents of a flattened array in-place (being careful to
+// retain the original indices by replacing unmatched results with null instead
+// of filtering them out), this function allows for recombining them. It will
+// filter out null and undefined items by default (pass {filter: false} to
+// disable this).
+//
+// See also:
+//  - withFlattenedList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isWholeNumber, validateArrayItems} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withUnflattenedList`,
+
+  inputs: {
+    list: input({
+      type: 'array',
+      defaultDependency: '#flattenedList',
+    }),
+
+    indices: input({
+      validate: validateArrayItems(isWholeNumber),
+      defaultDependency: '#flattenedIndices',
+    }),
+
+    filter: input({
+      type: 'boolean',
+      defaultValue: true,
+    }),
+  },
+
+  outputs: ['#unflattenedList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('indices'), input('filter')],
+      compute(continuation, {
+        [input('list')]: list,
+        [input('indices')]: indices,
+        [input('filter')]: filter,
+      }) {
+        const unflattenedList = [];
+
+        for (let i = 0; i < indices.length; i++) {
+          const startIndex = indices[i];
+          const endIndex =
+            (i === indices.length - 1
+              ? list.length
+              : indices[i + 1]);
+
+          const values = list.slice(startIndex, endIndex);
+          unflattenedList.push(
+            (filter
+              ? values.filter(value => value !== null && value !== undefined)
+              : values));
+        }
+
+        return continuation({
+          ['#unflattenedList']: unflattenedList,
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/data/withUniqueItemsOnly.js b/src/data/composite/data/withUniqueItemsOnly.js
new file mode 100644
index 00000000..7ee08b08
--- /dev/null
+++ b/src/data/composite/data/withUniqueItemsOnly.js
@@ -0,0 +1,40 @@
+// Excludes duplicate items from a list and provides the results, overwriting
+// the list in-place, if possible.
+
+import {input, templateCompositeFrom} from '#composite';
+import {unique} from '#sugar';
+
+export default templateCompositeFrom({
+  annotation: `withUniqueItemsOnly`,
+
+  inputs: {
+    list: input({type: 'array'}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list ?? '#uniqueItems'],
+
+  steps: () => [
+    {
+      dependencies: [input('list')],
+      compute: (continuation, {
+        [input('list')]: list,
+      }) => continuation({
+        ['#values']:
+          unique(list),
+      }),
+    },
+
+    {
+      dependencies: ['#values', input.staticDependency('list')],
+      compute: (continuation, {
+        '#values': values,
+        [input.staticDependency('list')]: list,
+      }) => continuation({
+        [list ?? '#uniqueItems']:
+          values,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
new file mode 100644
index 00000000..dfc6864f
--- /dev/null
+++ b/src/data/composite/things/album/index.js
@@ -0,0 +1,2 @@
+export {default as withHasCoverArt} from './withHasCoverArt.js';
+export {default as withTracks} from './withTracks.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/withTracks.js b/src/data/composite/things/album/withTracks.js
new file mode 100644
index 00000000..835ee570
--- /dev/null
+++ b/src/data/composite/things/album/withTracks.js
@@ -0,0 +1,29 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withFlattenedList, withPropertyFromList} from '#composite/data';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `withTracks`,
+
+  outputs: ['#tracks'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: 'trackSections',
+      output: input.value({'#tracks': []}),
+    }),
+
+    withPropertyFromList({
+      list: 'trackSections',
+      property: input.value('tracks'),
+    }),
+
+    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/index.js b/src/data/composite/things/flash-act/index.js
new file mode 100644
index 00000000..40fecd2f
--- /dev/null
+++ b/src/data/composite/things/flash-act/index.js
@@ -0,0 +1 @@
+export {default as withFlashSide} from './withFlashSide.js';
diff --git a/src/data/composite/things/flash-act/withFlashSide.js b/src/data/composite/things/flash-act/withFlashSide.js
new file mode 100644
index 00000000..e09f06e6
--- /dev/null
+++ b/src/data/composite/things/flash-act/withFlashSide.js
@@ -0,0 +1,22 @@
+// Gets the flash act's side. This will early exit if flashSideData is missing.
+// If there's no side whose list of flash acts includes this act, the output
+// dependency will be null.
+
+import {templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withFlashSide`,
+
+  outputs: ['#flashSide'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      reverse: soupyReverse.input('flashSidesWhoseActsInclude'),
+    }).outputs({
+      ['#uniqueReferencingThing']: '#flashSide',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/flash/index.js b/src/data/composite/things/flash/index.js
new file mode 100644
index 00000000..63ac13da
--- /dev/null
+++ b/src/data/composite/things/flash/index.js
@@ -0,0 +1 @@
+export {default as withFlashAct} from './withFlashAct.js';
diff --git a/src/data/composite/things/flash/withFlashAct.js b/src/data/composite/things/flash/withFlashAct.js
new file mode 100644
index 00000000..87922aff
--- /dev/null
+++ b/src/data/composite/things/flash/withFlashAct.js
@@ -0,0 +1,22 @@
+// Gets the flash's act. This will early exit if flashActData is missing.
+// If there's no flash whose list of flashes includes this flash, the output
+// dependency will be null.
+
+import {templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withFlashAct`,
+
+  outputs: ['#flashAct'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      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/exitWithoutUniqueCoverArt.js b/src/data/composite/things/track/exitWithoutUniqueCoverArt.js
new file mode 100644
index 00000000..f47086d9
--- /dev/null
+++ b/src/data/composite/things/track/exitWithoutUniqueCoverArt.js
@@ -0,0 +1,26 @@
+// Shorthand for checking if the track has unique cover art and exposing a
+// fallback value if it isn't.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+
+import withHasUniqueCoverArt from './withHasUniqueCoverArt.js';
+
+export default templateCompositeFrom({
+  annotation: `exitWithoutUniqueCoverArt`,
+
+  inputs: {
+    value: input({defaultValue: null}),
+  },
+
+  steps: () => [
+    withHasUniqueCoverArt(),
+
+    exitWithoutDependency({
+      dependency: '#hasUniqueCoverArt',
+      mode: input.value('falsy'),
+      value: input('value'),
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js
new file mode 100644
index 00000000..e789e736
--- /dev/null
+++ b/src/data/composite/things/track/index.js
@@ -0,0 +1,17 @@
+export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.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/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/trackAdditionalNameList.js b/src/data/composite/things/track/trackAdditionalNameList.js
new file mode 100644
index 00000000..65a2263d
--- /dev/null
+++ b/src/data/composite/things/track/trackAdditionalNameList.js
@@ -0,0 +1,38 @@
+// Compiles additional names from various sources.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isAdditionalNameList} from '#validators';
+
+import withInferredAdditionalNames from './withInferredAdditionalNames.js';
+import withSharedAdditionalNames from './withSharedAdditionalNames.js';
+
+export default templateCompositeFrom({
+  annotation: `trackAdditionalNameList`,
+
+  compose: false,
+
+  update: {validate: isAdditionalNameList},
+
+  steps: () => [
+    withInferredAdditionalNames(),
+    withSharedAdditionalNames(),
+
+    {
+      dependencies: [
+        '#inferredAdditionalNames',
+        '#sharedAdditionalNames',
+        input.updateValue(),
+      ],
+
+      compute: ({
+        ['#inferredAdditionalNames']: inferredAdditionalNames,
+        ['#sharedAdditionalNames']: sharedAdditionalNames,
+        [input.updateValue()]: providedAdditionalNames,
+      }) => [
+        ...providedAdditionalNames ?? [],
+        ...sharedAdditionalNames,
+        ...inferredAdditionalNames,
+      ],
+    },
+  ],
+});
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
new file mode 100644
index 00000000..60faeaf4
--- /dev/null
+++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
@@ -0,0 +1,97 @@
+// Controls how find.track works - it'll never be matched by a reference
+// just to the track's name, which means you don't have to always reference
+// some *other* (much more commonly referenced) track by directory instead
+// of more naturally by name.
+
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {isBoolean} from '#validators';
+
+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`,
+
+  outputs: ['#alwaysReferenceByDirectory'],
+
+  steps: () => [
+    exposeUpdateValueOrContinue({
+      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 mainReleaseTrack.
+
+    exitWithoutDependency({
+      dependency: 'trackData',
+      mode: input.value('empty'),
+      value: input.value(false),
+    }),
+
+    exitWithoutDependency({
+      dependency: 'mainReleaseTrack',
+      value: input.value(false),
+    }),
+
+    // 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.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: 'mainReleaseTrack',
+      data: 'trackData',
+      find: input.value(find.trackMainReleasesOnly),
+    }).outputs({
+      '#resolvedReference': '#mainRelease',
+    }),
+
+    exitWithoutDependency({
+      dependency: '#mainRelease',
+      value: input.value(false),
+    }),
+
+    withPropertyFromObject({
+      object: '#mainRelease',
+      property: input.value('name'),
+    }),
+
+    {
+      dependencies: ['name', '#mainRelease.name'],
+      compute: (continuation, {
+        name,
+        ['#mainRelease.name']: mainReleaseName,
+      }) => continuation({
+        ['#alwaysReferenceByDirectory']:
+          name === mainReleaseName,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js
new file mode 100644
index 00000000..3d4d081e
--- /dev/null
+++ b/src/data/composite/things/track/withContainingTrackSection.js
@@ -0,0 +1,20 @@
+// Gets the track section containing this track from its album's track list.
+
+import {templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+import {soupyReverse} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withContainingTrackSection`,
+
+  outputs: ['#trackSection'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      reverse: soupyReverse.input('trackSectionsWhichInclude'),
+    }).outputs({
+      ['#uniqueReferencingThing']: '#trackSection',
+    }),
+  ],
+});
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
new file mode 100644
index 00000000..85d3b92a
--- /dev/null
+++ b/src/data/composite/things/track/withHasUniqueCoverArt.js
@@ -0,0 +1,108 @@
+// Whether or not the track has "unique" cover artwork - a cover which is
+// specifically associated with this track in particular, rather than with
+// the track's album as a whole. This is typically used to select between
+// displaying the track artwork and a fallback, such as the album artwork
+// 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 {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {fillMissingListItems, withFlattenedList, withPropertyFromList}
+  from '#composite/data';
+
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: 'withHasUniqueCoverArt',
+
+  outputs: ['#hasUniqueCoverArt'],
+
+  steps: () => [
+    {
+      dependencies: ['disableUniqueCoverArt'],
+      compute: (continuation, {disableUniqueCoverArt}) =>
+        (disableUniqueCoverArt
+          ? continuation.raiseOutput({
+              ['#hasUniqueCoverArt']: false,
+            })
+          : continuation()),
+    },
+
+    withResultOfAvailabilityCheck({
+      from: 'coverArtistContribs',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability'],
+      compute: (continuation, {
+        ['#availability']: availability,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              ['#hasUniqueCoverArt']: true,
+            })
+          : continuation()),
+    },
+
+    withPropertyFromAlbum({
+      property: input.value('trackCoverArtistContribs'),
+      internal: input.value(true),
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#album.trackCoverArtistContribs',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability'],
+      compute: (continuation, {
+        ['#availability']: availability,
+      }) =>
+        (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/withMainRelease.js b/src/data/composite/things/track/withMainRelease.js
new file mode 100644
index 00000000..3a91edae
--- /dev/null
+++ b/src/data/composite/things/track/withMainRelease.js
@@ -0,0 +1,70 @@
+// 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 {exitWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {withResolvedReference} from '#composite/wiki-data';
+import {soupyFind} from '#composite/wiki-properties';
+
+export default templateCompositeFrom({
+  annotation: `withMainRelease`,
+
+  inputs: {
+    selfIfMain: input({type: 'boolean', defaultValue: false}),
+    notFoundValue: input({defaultValue: null}),
+  },
+
+  outputs: ['#mainRelease'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: 'mainReleaseTrack',
+    }),
+
+    {
+      dependencies: [
+        input.myself(),
+        input('selfIfMain'),
+        '#availability',
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: track,
+        [input('selfIfMain')]: selfIfMain,
+        '#availability': availability,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutput({
+              ['#mainRelease']:
+                (selfIfMain ? track : null),
+            })),
+    },
+
+    withResolvedReference({
+      ref: 'mainReleaseTrack',
+      find: soupyFind.input('track'),
+    }),
+
+    exitWithoutDependency({
+      dependency: '#resolvedReference',
+      value: input('notFoundValue'),
+    }),
+
+    {
+      dependencies: ['#resolvedReference'],
+
+      compute: (continuation, {
+        ['#resolvedReference']: resolvedReference,
+      }) =>
+        continuation({
+          ['#mainRelease']: resolvedReference,
+        }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js
new file mode 100644
index 00000000..0639742f
--- /dev/null
+++ b/src/data/composite/things/track/withOtherReleases.js
@@ -0,0 +1,30 @@
+// 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 withAllReleases from './withAllReleases.js';
+
+export default templateCompositeFrom({
+  annotation: `withOtherReleases`,
+
+  outputs: ['#otherReleases'],
+
+  steps: () => [
+    withAllReleases(),
+
+    {
+      dependencies: [input.myself(), '#allReleases'],
+      compute: (continuation, {
+        [input.myself()]: thisTrack,
+        ['#allReleases']: allReleases,
+      }) => continuation({
+        ['#otherReleases']:
+          allReleases.filter(track => track !== thisTrack),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withPropertyFromAlbum.js b/src/data/composite/things/track/withPropertyFromAlbum.js
new file mode 100644
index 00000000..a203c2e7
--- /dev/null
+++ b/src/data/composite/things/track/withPropertyFromAlbum.js
@@ -0,0 +1,48 @@
+// Gets a single property from this track's album, providing it as the same
+// property name prefixed with '#album.' (by default).
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {withPropertyFromObject} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withPropertyFromAlbum`,
+
+  inputs: {
+    property: input.staticValue({type: 'string'}),
+    internal: input({type: 'boolean', defaultValue: false}),
+  },
+
+  outputs: ({
+    [input.staticValue('property')]: property,
+  }) => ['#album.' + property],
+
+  steps: () => [
+    // 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',
+      property: input('property'),
+      internal: '#internal',
+    }),
+
+    {
+      dependencies: ['#value', input.staticValue('property')],
+      compute: (continuation, {
+        ['#value']: value,
+        [input.staticValue('property')]: property,
+      }) => continuation({
+        ['#album.' + property]: value,
+      }),
+    },
+  ],
+});
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
new file mode 100644
index 00000000..cf52950d
--- /dev/null
+++ b/src/data/composite/wiki-data/exitWithoutContribs.js
@@ -0,0 +1,48 @@
+// Shorthand for exiting if the contribution list (usually a property's update
+// value) resolves to empty - ensuring that the later computed results are only
+// returned if these contributions are present.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList} from '#validators';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+
+import withResolvedContribs from './withResolvedContribs.js';
+
+export default templateCompositeFrom({
+  annotation: `exitWithoutContribs`,
+
+  inputs: {
+    contribs: input({
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+
+    value: input({defaultValue: null}),
+  },
+
+  steps: () => [
+    withResolvedContribs({
+      from: input('contribs'),
+      date: input.value(null),
+    }),
+
+    // TODO: Fairly certain exitWithoutDependency would be sufficient here.
+
+    withResultOfAvailabilityCheck({
+      from: '#resolvedContribs',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability', input('value')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('value')]: value,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.exit(value)),
+    },
+  ],
+});
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
new file mode 100644
index 00000000..1d94f74b
--- /dev/null
+++ b/src/data/composite/wiki-data/index.js
@@ -0,0 +1,32 @@
+// #composite/wiki-data
+//
+// Entries here may depend on entries in #composite/control-flow and in
+// #composite/data.
+//
+
+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 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
new file mode 100644
index 00000000..b9021986
--- /dev/null
+++ b/src/data/composite/wiki-data/inputWikiData.js
@@ -0,0 +1,17 @@
+import {input} from '#composite';
+import {validateWikiData} from '#validators';
+
+// TODO: This doesn't access a class's own ThingSubclass[Thing.referenceType]
+// value because classes aren't initialized by when templateCompositeFrom gets
+// called (see: circular imports). So the reference types have to be hard-coded,
+// which somewhat defeats the point of storing them on the class in the first
+// place...
+export default function inputWikiData({
+  referenceType = '',
+  allowMixedTypes = false,
+} = {}) {
+  return input({
+    validate: validateWikiData({referenceType, allowMixedTypes}),
+    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
new file mode 100644
index 00000000..6794c479
--- /dev/null
+++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -0,0 +1,129 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isCommentary} from '#validators';
+import {commentaryRegexCaseSensitive} 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';
+
+export default templateCompositeFrom({
+  annotation: `withParsedCommentaryEntries`,
+
+  inputs: {
+    from: input({validate: isCommentary}),
+  },
+
+  outputs: ['#parsedCommentaryEntries'],
+
+  steps: () => [
+    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({
+        ['#parsedCommentaryEntries']:
+          stitchArrays({
+            artists,
+            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
new file mode 100644
index 00000000..838c991f
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedContribs.js
@@ -0,0 +1,156 @@
+// Resolves the contribsByRef contained in the provided dependency,
+// providing (named by the second argument) the result. "Resolving"
+// 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 {filterMultipleArrays, stitchArrays} from '#sugar';
+import thingConstructors from '#things';
+import {isContributionList, isDate, isStringNonEmpty} from '#validators';
+
+import {raiseOutputWithoutDependency, withAvailabilityFilter}
+  from '#composite/control-flow';
+import {withPropertyFromList, withPropertiesFromList} from '#composite/data';
+
+import inputNotFoundMode from './inputNotFoundMode.js';
+import raiseResolvedReferenceList from './raiseResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedContribs`,
+
+  inputs: {
+    from: input({
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+
+    date: input({
+      validate: isDate,
+      acceptsNull: true,
+    }),
+
+    notFoundMode: inputNotFoundMode(),
+
+    thingProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ['#resolvedContribs'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('from'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedContribs']: [],
+      }),
+    }),
+
+    {
+      dependencies: [
+        input('thingProperty'),
+        input.staticDependency('from'),
+      ],
+
+      compute: (continuation, {
+        [input('thingProperty')]: thingProperty,
+        [input.staticDependency('from')]: fromDependency,
+      }) => continuation({
+        ['#thingProperty']:
+          (thingProperty
+            ? thingProperty
+         : !fromDependency?.startsWith('#')
+            ? fromDependency
+            : null),
+      }),
+    },
+
+    withPropertiesFromList({
+      list: input('from'),
+      properties: input.value(['artist', 'annotation']),
+      prefix: input.value('#contribs'),
+    }),
+
+    {
+      dependencies: [
+        '#contribs.artist',
+        '#contribs.annotation',
+        input('date'),
+      ],
+
+      compute(continuation, {
+        ['#contribs.artist']: artist,
+        ['#contribs.annotation']: annotation,
+        [input('date')]: date,
+      }) {
+        filterMultipleArrays(artist, annotation, (artist, _annotation) => artist);
+
+        return continuation({
+          ['#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
new file mode 100644
index 00000000..6f422194
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedReference.js
@@ -0,0 +1,57 @@
+// Resolves a reference by using the provided find function to match it
+// 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 {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import gobbleSoupyFind from './gobbleSoupyFind.js';
+import inputSoupyFind from './inputSoupyFind.js';
+import inputWikiData from './inputWikiData.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedReference`,
+
+  inputs: {
+    ref: input({type: 'string', acceptsNull: true}),
+
+    data: inputWikiData({allowMixedTypes: false}),
+    find: inputSoupyFind(),
+  },
+
+  outputs: ['#resolvedReference'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('ref'),
+      output: input.value({
+        ['#resolvedReference']: null,
+      }),
+    }),
+
+    gobbleSoupyFind({
+      find: input('find'),
+    }),
+
+    {
+      dependencies: [
+        input('ref'),
+        input('data'),
+        '#find',
+      ],
+
+      compute: (continuation, {
+        [input('ref')]: ref,
+        [input('data')]: data,
+        ['#find']: findFunction,
+      }) => continuation({
+        ['#resolvedReference']:
+          (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
new file mode 100644
index 00000000..9dc960dd
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedReferenceList.js
@@ -0,0 +1,80 @@
+// Resolves a list of references, with each reference matched with provided
+// 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 {isString, validateArrayItems} from '#validators';
+
+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`,
+
+  inputs: {
+    list: input({
+      validate: validateArrayItems(isString),
+      acceptsNull: true,
+    }),
+
+    data: inputWikiData({allowMixedTypes: true}),
+    find: inputSoupyFind(),
+
+    notFoundMode: inputNotFoundMode(),
+  },
+
+  outputs: ['#resolvedReferenceList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('list'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedReferenceList']: [],
+      }),
+    }),
+
+    gobbleSoupyFind({
+      find: input('find'),
+    }),
+
+    {
+      dependencies: [input('data'), '#find'],
+      compute: (continuation, {
+        [input('data')]: data,
+        ['#find']: findFunction,
+      }) => continuation({
+        ['#map']:
+          (data
+            ? ref => findFunction(ref, data, {mode: 'quiet'})
+            : ref => findFunction(ref, {mode: 'quiet'})),
+      }),
+    },
+
+    withMappedList({
+      list: input('list'),
+      map: '#map',
+    }).outputs({
+      '#mappedList': '#matches',
+    }),
+
+    withAvailabilityFilter({
+      from: '#matches',
+    }),
+
+    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/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js
new file mode 100644
index 00000000..906f5bc5
--- /dev/null
+++ b/src/data/composite/wiki-data/withReverseReferenceList.js
@@ -0,0 +1,36 @@
+// Check out the info on reverseReferenceList!
+// This is its composable form.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import gobbleSoupyReverse from './gobbleSoupyReverse.js';
+import inputSoupyReverse from './inputSoupyReverse.js';
+import inputWikiData from './inputWikiData.js';
+
+import withResolvedReverse from './helpers/withResolvedReverse.js';
+
+export default templateCompositeFrom({
+  annotation: `withReverseReferenceList`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: inputSoupyReverse(),
+  },
+
+  outputs: ['#reverseReferenceList'],
+
+  steps: () => [
+    gobbleSoupyReverse({
+      reverse: input('reverse'),
+    }),
+
+    // TODO: Check that the reverse spec returns a list.
+
+    withResolvedReverse({
+      data: input('data'),
+      reverse: '#reverse',
+    }).outputs({
+      '#resolvedReverse': '#reverseReferenceList',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withThingsSortedAlphabetically.js b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js
new file mode 100644
index 00000000..5e85fa6a
--- /dev/null
+++ b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js
@@ -0,0 +1,122 @@
+// Sorts a list of live, generic wiki data objects alphabetically.
+// Note that this uses localeCompare but isn't specialized to a particular
+// language; where localization is concerned (in content), a follow-up, locale-
+// specific sort should be performed. But this function does serve to organize
+// a list so same-name entries are beside each other.
+
+import {input, templateCompositeFrom} from '#composite';
+import {compareCaseLessSensitive, normalizeName} from '#sort';
+import {validateWikiData} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withMappedList, withSortedList, withPropertiesFromList}
+  from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withThingsSortedAlphabetically`,
+
+  inputs: {
+    things: input({validate: validateWikiData}),
+  },
+
+  outputs: ['#sortedThings'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('things'),
+      mode: input.value('empty'),
+      output: input.value({'#sortedThings': []}),
+    }),
+
+    withPropertiesFromList({
+      list: input('things'),
+      properties: input.value(['name', 'directory']),
+    }).outputs({
+      '#list.name': '#names',
+      '#list.directory': '#directories',
+    }),
+
+    withMappedList({
+      list: '#names',
+      map: input.value(normalizeName),
+    }).outputs({
+      '#mappedList': '#normalizedNames',
+    }),
+
+    withSortedList({
+      list: '#normalizedNames',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#normalizedNameSortIndices',
+    }),
+
+    withSortedList({
+      list: '#names',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#nonNormalizedNameSortIndices',
+    }),
+
+    withSortedList({
+      list: '#directories',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#directorySortIndices',
+    }),
+
+    // TODO: No primitive for the next two-three steps, yet...
+
+    {
+      dependencies: [input('things')],
+      compute: (continuation, {
+        [input('things')]: things,
+      }) => continuation({
+        ['#combinedSortIndices']:
+          Array.from(
+            {length: things.length},
+            (_item, index) => index),
+      }),
+    },
+
+    {
+      dependencies: [
+        '#combinedSortIndices',
+        '#normalizedNameSortIndices',
+        '#nonNormalizedNameSortIndices',
+        '#directorySortIndices',
+      ],
+
+      compute: (continuation, {
+        ['#combinedSortIndices']: combined,
+        ['#normalizedNameSortIndices']: normalized,
+        ['#nonNormalizedNameSortIndices']: nonNormalized,
+        ['#directorySortIndices']: directory,
+      }) => continuation({
+        ['#combinedSortIndices']:
+          combined.sort((index1, index2) => {
+            if (normalized[index1] !== normalized[index2])
+              return normalized[index1] - normalized[index2];
+
+            if (nonNormalized[index1] !== nonNormalized[index2])
+              return nonNormalized[index1] - nonNormalized[index2];
+
+            if (directory[index1] !== directory[index2])
+              return directory[index1] - directory[index2];
+
+            return 0;
+          }),
+      }),
+    },
+
+    {
+      dependencies: [input('things'), '#combinedSortIndices'],
+      compute: (continuation, {
+        [input('things')]: things,
+        ['#combinedSortIndices']: combined,
+      }) => continuation({
+        ['#sortedThings']:
+          combined.map(index => things[index]),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withUniqueReferencingThing.js b/src/data/composite/wiki-data/withUniqueReferencingThing.js
new file mode 100644
index 00000000..7c267038
--- /dev/null
+++ b/src/data/composite/wiki-data/withUniqueReferencingThing.js
@@ -0,0 +1,36 @@
+// Like withReverseReferenceList, but this is specifically for special "unique"
+// references, meaning this thing is referenced by exactly one or zero things
+// in the data list.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import gobbleSoupyReverse from './gobbleSoupyReverse.js';
+import inputSoupyReverse from './inputSoupyReverse.js';
+import inputWikiData from './inputWikiData.js';
+
+import withResolvedReverse from './helpers/withResolvedReverse.js';
+
+export default templateCompositeFrom({
+  annotation: `withUniqueReferencingThing`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: inputSoupyReverse(),
+  },
+
+  outputs: ['#uniqueReferencingThing'],
+
+  steps: () => [
+    gobbleSoupyReverse({
+      reverse: input('reverse'),
+    }),
+
+    withResolvedReverse({
+      data: input('data'),
+      reverse: '#reverse',
+      options: input.value({unique: true}),
+    }).outputs({
+      '#resolvedReverse': '#uniqueReferencingThing',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/additionalFiles.js b/src/data/composite/wiki-properties/additionalFiles.js
new file mode 100644
index 00000000..6760527a
--- /dev/null
+++ b/src/data/composite/wiki-properties/additionalFiles.js
@@ -0,0 +1,30 @@
+// This is a somewhat more involved data structure - it's for additional
+// or "bonus" files associated with albums or tracks (or anything else).
+// It's got this form:
+//
+//   [
+//     {title: 'Booklet', files: ['Booklet.pdf']},
+//     {
+//       title: 'Wallpaper',
+//       description: 'Cool Wallpaper!',
+//       files: ['1440x900.png', '1920x1080.png']
+//     },
+//     {title: 'Alternate Covers', description: null, files: [...]},
+//     ...
+//   ]
+//
+
+import {isAdditionalFileList} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isAdditionalFileList},
+    expose: {
+      transform: (additionalFiles) =>
+        additionalFiles ?? [],
+    },
+  };
+}
diff --git a/src/data/composite/wiki-properties/additionalNameList.js b/src/data/composite/wiki-properties/additionalNameList.js
new file mode 100644
index 00000000..c5971d4a
--- /dev/null
+++ b/src/data/composite/wiki-properties/additionalNameList.js
@@ -0,0 +1,14 @@
+// A list of additional names! These can be used for a variety of purposes,
+// e.g. providing extra searchable titles, localizations, romanizations or
+// original titles, and so on. Each item has a name and, optionally, a
+// descriptive annotation.
+
+import {isAdditionalNameList} from '#validators';
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isAdditionalNameList},
+    expose: {transform: value => value ?? []},
+  };
+}
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/color.js b/src/data/composite/wiki-properties/color.js
new file mode 100644
index 00000000..1bc9888b
--- /dev/null
+++ b/src/data/composite/wiki-properties/color.js
@@ -0,0 +1,12 @@
+// A color! This'll be some CSS-ready value.
+
+import {isColor} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isColor},
+  };
+}
diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js
new file mode 100644
index 00000000..928bbd1b
--- /dev/null
+++ b/src/data/composite/wiki-properties/commentary.js
@@ -0,0 +1,34 @@
+// Artist commentary! Generally present on tracks and albums.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isCommentary} from '#validators';
+
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withParsedCommentaryEntries} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `commentary`,
+
+  compose: false,
+
+  update: {
+    validate: isCommentary,
+  },
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input.updateValue(),
+      mode: input.value('falsy'),
+      value: input.value([]),
+    }),
+
+    withParsedCommentaryEntries({
+      from: input.updateValue(),
+    }),
+
+    exposeDependency({
+      dependency: '#parsedCommentaryEntries',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js
new file mode 100644
index 00000000..c5c14769
--- /dev/null
+++ b/src/data/composite/wiki-properties/commentatorArtists.js
@@ -0,0 +1,49 @@
+// List of artists referenced in commentary entries.
+// This is mostly useful for credits and listings on artist pages.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withFlattenedList, withPropertyFromList, withUniqueItemsOnly}
+  from '#composite/data';
+import {withParsedCommentaryEntries} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `commentatorArtists`,
+
+  compose: false,
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: 'commentary',
+      mode: input.value('falsy'),
+      value: input.value([]),
+    }),
+
+    withParsedCommentaryEntries({
+      from: 'commentary',
+    }),
+
+    withPropertyFromList({
+      list: '#parsedCommentaryEntries',
+      property: input.value('artists'),
+    }).outputs({
+      '#parsedCommentaryEntries.artists': '#artistLists',
+    }),
+
+    withFlattenedList({
+      list: '#artistLists',
+    }).outputs({
+      '#flattenedList': '#artists',
+    }),
+
+    withUniqueItemsOnly({
+      list: '#artists',
+    }),
+
+    exposeDependency({
+      dependency: '#artists',
+    }),
+  ],
+});
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/contentString.js b/src/data/composite/wiki-properties/contentString.js
new file mode 100644
index 00000000..b0e82444
--- /dev/null
+++ b/src/data/composite/wiki-properties/contentString.js
@@ -0,0 +1,15 @@
+// String type that's slightly more specific than simpleString. If the
+// property is a generic piece of human-reading content, this adds some
+// useful valiation on top of simpleString - but still check if more
+// particular properties like `name` are more appropriate.
+//
+// This type adapts validation for single- and multiline content.
+
+import {isContentString} from '#validators';
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isContentString},
+  };
+}
diff --git a/src/data/composite/wiki-properties/contribsPresent.js b/src/data/composite/wiki-properties/contribsPresent.js
new file mode 100644
index 00000000..24f302a5
--- /dev/null
+++ b/src/data/composite/wiki-properties/contribsPresent.js
@@ -0,0 +1,30 @@
+// Nice 'n simple shorthand for an exposed-only flag which is true when any
+// contributions are present in the specified property.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList} from '#validators';
+
+import {exposeDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `contribsPresent`,
+
+  compose: false,
+
+  inputs: {
+    contribs: input.staticDependency({
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+  },
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('contribs'),
+      mode: input.value('empty'),
+    }),
+
+    exposeDependency({dependency: '#availability'}),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/contributionList.js b/src/data/composite/wiki-properties/contributionList.js
new file mode 100644
index 00000000..d9a6b417
--- /dev/null
+++ b/src/data/composite/wiki-properties/contributionList.js
@@ -0,0 +1,58 @@
+// Strong 'n sturdy contribution list, rolling a list of references (provided
+// as this property's update value) and the resolved results (as get exposed)
+// into one property. Update value will look something like this:
+//
+//   [
+//     {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 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, isDate, isStringNonEmpty} from '#validators';
+
+import {exposeConstant, exposeDependencyOrContinue} from '#composite/control-flow';
+import {withResolvedContribs} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `contributionList`,
+
+  compose: false,
+
+  inputs: {
+    date: input({
+      validate: isDate,
+      acceptsNull: true,
+    }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+  },
+
+  update: {validate: isContributionList},
+
+  steps: () => [
+    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/dimensions.js b/src/data/composite/wiki-properties/dimensions.js
new file mode 100644
index 00000000..57a01279
--- /dev/null
+++ b/src/data/composite/wiki-properties/dimensions.js
@@ -0,0 +1,13 @@
+// Plain ol' image dimensions. This is a two-item array of positive integers,
+// corresponding to width and height respectively.
+
+import {isDimensions} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isDimensions},
+  };
+}
diff --git a/src/data/composite/wiki-properties/directory.js b/src/data/composite/wiki-properties/directory.js
new file mode 100644
index 00000000..1756a8e5
--- /dev/null
+++ b/src/data/composite/wiki-properties/directory.js
@@ -0,0 +1,41 @@
+// The all-encompassing "directory" property, used as the unique identifier for
+// almost any data object. Also corresponds to a part of the URL which pages of
+// such objects are visited at.
+
+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/duration.js b/src/data/composite/wiki-properties/duration.js
new file mode 100644
index 00000000..827f282d
--- /dev/null
+++ b/src/data/composite/wiki-properties/duration.js
@@ -0,0 +1,13 @@
+// Duration! This is a number of seconds, possibly floating point, always
+// at minimum zero.
+
+import {isDuration} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isDuration},
+  };
+}
diff --git a/src/data/composite/wiki-properties/externalFunction.js b/src/data/composite/wiki-properties/externalFunction.js
new file mode 100644
index 00000000..c388da6c
--- /dev/null
+++ b/src/data/composite/wiki-properties/externalFunction.js
@@ -0,0 +1,11 @@
+// External function. These should only be used as dependencies for other
+// properties, so they're left unexposed.
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true},
+    update: {validate: (t) => typeof t === 'function'},
+  };
+}
diff --git a/src/data/composite/wiki-properties/fileExtension.js b/src/data/composite/wiki-properties/fileExtension.js
new file mode 100644
index 00000000..c926fa8b
--- /dev/null
+++ b/src/data/composite/wiki-properties/fileExtension.js
@@ -0,0 +1,13 @@
+// A file extension! Or the default, if provided when calling this.
+
+import {isFileExtension} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function(defaultFileExtension = null) {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isFileExtension},
+    expose: {transform: (value) => value ?? defaultFileExtension},
+  };
+}
diff --git a/src/data/composite/wiki-properties/flag.js b/src/data/composite/wiki-properties/flag.js
new file mode 100644
index 00000000..076e663f
--- /dev/null
+++ b/src/data/composite/wiki-properties/flag.js
@@ -0,0 +1,19 @@
+// Straightforward flag descriptor for a variety of property purposes.
+// Provide a default value, true or false!
+
+import {isBoolean} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+// TODO: The description is a lie. This defaults to false. Bad.
+
+export default function(defaultValue = false) {
+  if (typeof defaultValue !== 'boolean') {
+    throw new TypeError(`Always set explicit defaults for flags!`);
+  }
+
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isBoolean, default: defaultValue},
+  };
+}
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
new file mode 100644
index 00000000..892fc44a
--- /dev/null
+++ b/src/data/composite/wiki-properties/index.js
@@ -0,0 +1,38 @@
+// #composite/wiki-properties
+//
+// Entries here may depend on entries in #composite/control-flow,
+// #composite/data, and #composite/wiki-data.
+
+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';
+export {default as dimensions} from './dimensions.js';
+export {default as directory} from './directory.js';
+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 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/name.js b/src/data/composite/wiki-properties/name.js
new file mode 100644
index 00000000..5146488b
--- /dev/null
+++ b/src/data/composite/wiki-properties/name.js
@@ -0,0 +1,11 @@
+// A wiki data object's name! Its directory (i.e. unique identifier) will be
+// computed based on this value if not otherwise specified.
+
+import {isName} from '#validators';
+
+export default function(defaultName) {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isName, default: defaultName},
+  };
+}
diff --git a/src/data/composite/wiki-properties/referenceList.js b/src/data/composite/wiki-properties/referenceList.js
new file mode 100644
index 00000000..4f8207b5
--- /dev/null
+++ b/src/data/composite/wiki-properties/referenceList.js
@@ -0,0 +1,46 @@
+// Stores and exposes a list of references to other data objects; all items
+// 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
+//  - withResolvedReferenceList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {validateReferenceList} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputSoupyFind, inputWikiData, withResolvedReferenceList}
+  from '#composite/wiki-data';
+
+import {referenceListInputDescriptions, referenceListUpdateDescription}
+  from './helpers/reference-list-helpers.js';
+
+export default templateCompositeFrom({
+  annotation: `referenceList`,
+
+  compose: false,
+
+  inputs: {
+    ...referenceListInputDescriptions(),
+
+    data: inputWikiData({allowMixedTypes: true}),
+    find: inputSoupyFind(),
+  },
+
+  update:
+    referenceListUpdateDescription({
+      validateReferenceList: validateReferenceList,
+    }),
+
+  steps: () => [
+    withResolvedReferenceList({
+      list: input.updateValue(),
+      data: input('data'),
+      find: input('find'),
+    }),
+
+    exposeDependency({dependency: '#resolvedReferenceList'}),
+  ],
+});
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/reverseReferenceList.js b/src/data/composite/wiki-properties/reverseReferenceList.js
new file mode 100644
index 00000000..6d590a67
--- /dev/null
+++ b/src/data/composite/wiki-properties/reverseReferenceList.js
@@ -0,0 +1,30 @@
+// 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.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputSoupyReverse, inputWikiData, withReverseReferenceList}
+  from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `reverseReferenceList`,
+
+  compose: false,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: inputSoupyReverse(),
+  },
+
+  steps: () => [
+    withReverseReferenceList({
+      data: input('data'),
+      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/simpleDate.js b/src/data/composite/wiki-properties/simpleDate.js
new file mode 100644
index 00000000..f08d8323
--- /dev/null
+++ b/src/data/composite/wiki-properties/simpleDate.js
@@ -0,0 +1,14 @@
+// General date type, used as the descriptor for a bunch of properties.
+// This isn't dynamic though - it won't inherit from a date stored on
+// another object, for example.
+
+import {isDate} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isDate},
+  };
+}
diff --git a/src/data/composite/wiki-properties/simpleString.js b/src/data/composite/wiki-properties/simpleString.js
new file mode 100644
index 00000000..7bf317ac
--- /dev/null
+++ b/src/data/composite/wiki-properties/simpleString.js
@@ -0,0 +1,12 @@
+// General string type. This should probably generally be avoided in favor
+// of more specific validation, but using it makes it easy to find where we
+// might want to improve later, and it's a useful shorthand meanwhile.
+
+import {isString} from '#validators';
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isString},
+  };
+}
diff --git a/src/data/composite/wiki-properties/singleReference.js b/src/data/composite/wiki-properties/singleReference.js
new file mode 100644
index 00000000..f532ebbe
--- /dev/null
+++ b/src/data/composite/wiki-properties/singleReference.js
@@ -0,0 +1,46 @@
+// Stores and exposes one connection, or reference, to another data object.
+// The reference must be to a specific type, which is specified on the class
+// input.
+//
+// See also:
+//  - referenceList
+//  - withResolvedReference
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isThingClass, validateReference} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputSoupyFind, inputWikiData, withResolvedReference}
+  from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `singleReference`,
+
+  compose: false,
+
+  inputs: {
+    class: input.staticValue({validate: isThingClass}),
+
+    find: inputSoupyFind(),
+    data: inputWikiData({allowMixedTypes: false}),
+  },
+
+  update: ({
+    [input.staticValue('class')]: thingClass,
+  }) => ({
+    validate:
+      validateReference(
+        thingClass[Symbol.for('Thing.referenceType')]),
+  }),
+
+  steps: () => [
+    withResolvedReference({
+      ref: input.updateValue(),
+      data: input('data'),
+      find: input('find'),
+    }),
+
+    exposeDependency({dependency: '#resolvedReference'}),
+  ],
+});
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/urls.js b/src/data/composite/wiki-properties/urls.js
new file mode 100644
index 00000000..3160a0bf
--- /dev/null
+++ b/src/data/composite/wiki-properties/urls.js
@@ -0,0 +1,14 @@
+// A list of URLs! This will always be present on the data object, even if set
+// to an empty array or null.
+
+import {isURL, validateArrayItems} from '#validators';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: validateArrayItems(isURL)},
+    expose: {transform: value => 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/composite/wiki-properties/wikiData.js b/src/data/composite/wiki-properties/wikiData.js
new file mode 100644
index 00000000..3bebed33
--- /dev/null
+++ b/src/data/composite/wiki-properties/wikiData.js
@@ -0,0 +1,27 @@
+// General purpose wiki data constructor, for properties like artistData,
+// trackData, etc.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isThingClass, validateWikiData} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `wikiData`,
+
+  compose: false,
+
+  inputs: {
+    class: input.staticValue({validate: isThingClass}),
+  },
+
+  update: ({
+    [input.staticValue('class')]: thingClass,
+  }) => ({
+    validate:
+      validateWikiData({
+        referenceType:
+          thingClass[Symbol.for('Thing.referenceType')],
+      }),
+  }),
+
+  steps: () => [],
+});
diff --git a/src/data/language.js b/src/data/language.js
new file mode 100644
index 00000000..3edf7e51
--- /dev/null
+++ b/src/data/language.js
@@ -0,0 +1,341 @@
+import EventEmitter from 'node:events';
+import {readFile} from 'node:fs/promises';
+import path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+import chokidar from 'chokidar';
+import he from 'he'; // It stands for "HTML Entities", apparently. Cursed.
+import yaml from 'js-yaml';
+
+import {annotateError, annotateErrorWithFile, showAggregate, withAggregate}
+  from '#aggregate';
+import {externalLinkSpec} from '#external-links';
+import {colors, logWarn} from '#cli';
+import {empty, splitKeys, withEntries} from '#sugar';
+import T from '#things';
+
+const {Language} = T;
+
+export const DEFAULT_STRINGS_FILE = 'strings-default.yaml';
+
+export const internalDefaultStringsFile =
+  path.resolve(
+    path.dirname(fileURLToPath(import.meta.url)),
+    '../',
+    DEFAULT_STRINGS_FILE);
+
+export function processLanguageSpec(spec, {existingCode = null} = {}) {
+  const {
+    'meta.languageCode': code,
+    'meta.languageName': name,
+
+    'meta.languageIntlCode': intlCode = null,
+    'meta.hidden': hidden = false,
+
+    ...strings
+  } = spec;
+
+  withAggregate({message: `Errors validating language spec`}, ({push}) => {
+    if (!code) {
+      push(new Error(`Missing language code`));
+    }
+
+    if (!name) {
+      push(new Error(`Missing language name`));
+    }
+
+    if (code && existingCode && code !== existingCode) {
+      push(new Error(`Language code (${code}) doesn't match previous value\n(You'll have to reload hsmusic to load this)`));
+    }
+  });
+
+  return {code, intlCode, name, hidden, strings};
+}
+
+export function flattenLanguageSpec(spec) {
+  const recursive = (keyPath, value) =>
+    (typeof value === 'object'
+      ? Object.assign({}, ...
+          Object.entries(value)
+            .map(([key, value]) =>
+              (key === '_'
+                ? {[keyPath]: value}
+                : recursive(
+                    (keyPath ? `${keyPath}.${key}` : key),
+                    value))))
+      : {[keyPath]: value});
+
+  return recursive('', spec);
+}
+
+export function unflattenLanguageSpec(flat, reference) {
+  const setNestedProp = (obj, key, value) => {
+    const recursive = (o, k) => {
+      if (k.length === 1) {
+        if (typeof o[k[0]] === 'object') {
+          o[k[0]] = {...o[k[0]], _: value};
+        } else {
+          o[k[0]] = value;
+        }
+        return;
+      }
+
+      if (typeof o[k[0]] === 'undefined') {
+        o[k[0]] = {};
+      } else if (typeof o[k[0]] === 'string') {
+        o[k[0]] = {_: o[k[0]]};
+      }
+
+      recursive(o[k[0]], k.slice(1));
+    };
+
+    return recursive(obj, splitKeys(key));
+  };
+
+  const walkEntries = (ownNode, refNode) => {
+    const recursive = (refKeys, ownNode, refNode) => {
+      const [firstKey, ...restKeys] = refKeys;
+
+      if (typeof ownNode[firstKey] === 'undefined') {
+        return undefined;
+      }
+
+      const result =
+        (empty(restKeys)
+          ? walkEntry(ownNode[firstKey], refNode)
+          : recursive(restKeys, ownNode[firstKey], refNode));
+
+      if (typeof result === 'undefined') {
+        return undefined;
+      }
+
+      if (typeof result === 'string') {
+        // 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};
+      }
+
+      if (refKeys.length > 1) {
+        return withEntries(result, entries =>
+          entries.map(([key, value]) => [`${firstKey}.${key}`, value]));
+      } else {
+        return {[firstKey]: result};
+      }
+    };
+
+    let mapped;
+
+    for (const [key, value] of Object.entries(refNode)) {
+      const result = recursive(splitKeys(key), ownNode, value);
+      if (!result) continue;
+      if (!mapped) mapped = {};
+      Object.assign(mapped, result);
+    }
+
+    return mapped;
+  };
+
+  const walkEntry = (ownNode, refNode) => {
+    if (
+      typeof ownNode === 'object' &&
+      typeof refNode === 'object'
+    ) {
+      return walkEntries(ownNode, refNode);
+    }
+
+    if (
+      typeof ownNode === 'string' &&
+      typeof refNode === 'object' &&
+      typeof refNode._ === 'string'
+    ) {
+      return ownNode;
+    }
+
+    if (
+      typeof ownNode === 'object' &&
+      typeof refNode === 'string' &&
+      typeof ownNode._ === 'string'
+    ) {
+      return ownNode._;
+    }
+
+    if (
+      typeof ownNode === 'string' &&
+      typeof refNode === 'string'
+    ) {
+      return ownNode;
+    }
+
+    return undefined;
+  };
+
+  const clean = node => {
+    if (typeof node === 'string') {
+      return node;
+    }
+
+    const entries = Object.entries(node);
+    if (empty(entries)) {
+      return undefined;
+    }
+
+    let results;
+    for (const [key, value] of entries) {
+      const cleanValue = clean(value);
+      if (typeof cleanValue === 'undefined') continue;
+      if (!results) results = {};
+      results[key] = cleanValue;
+    }
+
+    return results;
+  };
+
+  const storage = {};
+  for (const [key, value] of Object.entries(flat)) {
+    setNestedProp(storage, key, value);
+  }
+
+  const rootResult = walkEntries(storage, reference);
+  const spec = rootResult ?? {};
+
+  const unmapped = clean(storage);
+  if (unmapped) {
+    spec['meta.unmapped'] = unmapped;
+  }
+
+  return spec;
+}
+
+async function processLanguageSpecFromFile(file, processLanguageSpecOpts) {
+  let contents;
+
+  try {
+    contents = await readFile(file, 'utf-8');
+  } catch (caughtError) {
+    throw annotateError(
+      new Error(`Failed to read language file`, {cause: caughtError}),
+      error => annotateErrorWithFile(error, file));
+  }
+
+  let rawSpec;
+  let parseLanguage;
+
+  try {
+    if (path.extname(file) === '.yaml') {
+      parseLanguage = 'YAML';
+      rawSpec = yaml.load(contents);
+    } else {
+      parseLanguage = 'JSON';
+      rawSpec = JSON.parse(contents);
+    }
+  } catch (caughtError) {
+    throw annotateError(
+      new Error(`Failed to parse language file as valid ${parseLanguage}`, {cause: caughtError}),
+      error => annotateErrorWithFile(error, file));
+  }
+
+  const flattenedSpec = flattenLanguageSpec(rawSpec);
+
+  try {
+    return processLanguageSpec(flattenedSpec, processLanguageSpecOpts);
+  } catch (caughtError) {
+    throw annotateErrorWithFile(caughtError, file);
+  }
+}
+
+export function initializeLanguageObject() {
+  const language = new Language();
+
+  language.escapeHTML = string =>
+    he.encode(string, {useNamedReferences: true});
+
+  language.externalLinkSpec = externalLinkSpec;
+
+  return language;
+}
+
+export async function processLanguageFile(file) {
+  const language = initializeLanguageObject();
+  const properties = await processLanguageSpecFromFile(file);
+  return Object.assign(language, properties);
+}
+
+export function watchLanguageFile(file, {
+  logging = true,
+} = {}) {
+  const basename = path.basename(file);
+
+  const events = new EventEmitter();
+  const language = initializeLanguageObject();
+
+  let emittedReady = false;
+  let successfullyAppliedLanguage = false;
+
+  Object.assign(events, {language, close});
+
+  const watcher = chokidar.watch(file);
+  watcher.on('change', () => handleFileUpdated());
+
+  setImmediate(handleFileUpdated);
+
+  return events;
+
+  async function close() {
+    return watcher.close();
+  }
+
+  function checkReadyConditions() {
+    if (emittedReady) return;
+    if (!successfullyAppliedLanguage) return;
+
+    events.emit('ready');
+    emittedReady = true;
+  }
+
+  async function handleFileUpdated() {
+    let properties;
+
+    try {
+      properties = await processLanguageSpecFromFile(file, {
+        existingCode:
+          (successfullyAppliedLanguage
+            ? language.code
+            : null),
+      });
+    } catch (error) {
+      events.emit('error', error);
+
+      if (logging) {
+        const label =
+          (successfullyAppliedLanguage
+            ? `${language.name} (${language.code})`
+            : basename);
+
+        if (successfullyAppliedLanguage) {
+          logWarn`Failed to load language ${label} - using existing version`;
+        } else {
+          logWarn`Failed to load language ${label} - no prior version loaded`;
+        }
+        showAggregate(error, {showTraces: false});
+      }
+
+      return;
+    }
+
+    Object.assign(language, properties);
+    successfullyAppliedLanguage = true;
+
+    if (logging && emittedReady) {
+      const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'});
+      console.log(colors.green(`[${timestamp}] Updated language ${language.name} (${language.code})`));
+    }
+
+    events.emit('update');
+    checkReadyConditions();
+  }
+}
diff --git a/src/data/patches.js b/src/data/patches.js
new file mode 100644
index 00000000..feeaf39b
--- /dev/null
+++ b/src/data/patches.js
@@ -0,0 +1,395 @@
+// --> Patch
+
+export class Patch {
+  static INPUT_NONE = 0;
+  static INPUT_CONSTANT = 1;
+  static INPUT_DIRECT_CONNECTION = 2;
+  static INPUT_MANAGED_CONNECTION = 3;
+
+  static INPUT_UNAVAILABLE = 0;
+  static INPUT_AVAILABLE = 1;
+
+  static OUTPUT_UNAVAILABLE = 0;
+  static OUTPUT_AVAILABLE = 1;
+
+  static inputNames = [];
+  inputNames = null;
+  static outputNames = [];
+  outputNames = null;
+
+  manager = null;
+  inputs = Object.create(null);
+
+  constructor({
+    manager,
+
+    inputNames,
+    outputNames,
+
+    inputs,
+  } = {}) {
+    this.inputNames = inputNames ?? this.constructor.inputNames;
+    this.outputNames = outputNames ?? this.constructor.outputNames;
+
+    manager?.addManagedPatch(this);
+
+    if (inputs) {
+      Object.assign(this.inputs, inputs);
+    }
+
+    this.initializeInputs();
+  }
+
+  initializeInputs() {
+    for (const inputName of this.inputNames) {
+      if (!this.inputs[inputName]) {
+        this.inputs[inputName] = [Patch.INPUT_NONE];
+      }
+    }
+  }
+
+  computeInputs() {
+    const inputs = Object.create(null);
+
+    for (const inputName of this.inputNames) {
+      const input = this.inputs[inputName];
+      switch (input[0]) {
+        case Patch.INPUT_NONE:
+          inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
+          break;
+
+        case Patch.INPUT_CONSTANT:
+          inputs[inputName] = [Patch.INPUT_AVAILABLE, input[1]];
+          break;
+
+        case Patch.INPUT_DIRECT_CONNECTION: {
+          const patch = input[1];
+          const outputName = input[2];
+          const output = patch.computeOutputs()[outputName];
+          switch (output[0]) {
+            case Patch.OUTPUT_UNAVAILABLE:
+              inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
+              break;
+            case Patch.OUTPUT_AVAILABLE:
+              inputs[inputName] = [Patch.INPUT_AVAILABLE, output[1]];
+              break;
+          }
+          throw new Error('Unreachable');
+        }
+
+        case Patch.INPUT_MANAGED_CONNECTION: {
+          if (!this.manager) {
+            inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
+            break;
+          }
+
+          inputs[inputName] = this.manager.getManagedInput(input[1]);
+          break;
+        }
+      }
+    }
+
+    return inputs;
+  }
+
+  computeOutputs() {
+    const inputs = this.computeInputs();
+    const outputs = Object.create(null);
+    console.log(`Compute: ${this.constructor.name}`);
+    this.compute(inputs, outputs);
+    return outputs;
+  }
+
+  compute(inputs, outputs) {
+    // No-op. Return all outputs as unavailable. This should be overridden
+    // in subclasses.
+
+    for (const outputName of this.constructor.outputNames) {
+      outputs[outputName] = [Patch.OUTPUT_UNAVAILABLE];
+    }
+  }
+
+  attachToManager(manager) {
+    manager.addManagedPatch(this);
+  }
+
+  detachFromManager() {
+    if (this.manager) {
+      this.manager.removeManagedPatch(this);
+    }
+  }
+}
+
+// --> PatchManager
+
+export class PatchManager extends Patch {
+  managedPatches = [];
+  managedInputs = {};
+
+  #externalInputPatch = null;
+  #externalOutputPatch = null;
+
+  constructor(...args) {
+    super(...args);
+
+    this.#externalInputPatch = new PatchManagerExternalInputPatch({
+      manager: this,
+    });
+
+    this.#externalOutputPatch = new PatchManagerExternalOutputPatch({
+      manager: this,
+    });
+  }
+
+  addManagedPatch(patch) {
+    if (patch.manager === this) {
+      return false;
+    }
+
+    patch.detachFromManager();
+    patch.manager = this;
+
+    if (patch.manager === this) {
+      this.managedPatches.push(patch);
+      return true;
+    } else {
+      return false;
+    }
+  }
+
+  removeManagedPatch(patch) {
+    if (patch.manager !== this) {
+      return false;
+    }
+
+    patch.manager = null;
+
+    if (patch.manager === this) {
+      return false;
+    }
+
+    for (const inputName of patch.inputNames) {
+      const input = patch.inputs[inputName];
+      if (input[0] === Patch.INPUT_MANAGED_CONNECTION) {
+        this.dropManagedInput(input[1]);
+        patch.inputs[inputName] = [Patch.INPUT_NONE];
+      }
+    }
+
+    this.managedPatches.splice(this.managedPatches.indexOf(patch), 1);
+
+    return true;
+  }
+
+  addManagedInput(patchWithInput, inputName, patchWithOutput, outputName) {
+    if (patchWithInput.manager !== this || patchWithOutput.manager !== this) {
+      throw new Error(`Input and output patches must belong to same manager (this)`);
+    }
+
+    const input = patchWithInput.inputs[inputName];
+    if (input[0] === Patch.INPUT_MANAGED_CONNECTION) {
+      this.managedInputs[input[1]] = [patchWithOutput, outputName, {}];
+    } else {
+      const key = this.getManagedConnectionIdentifier();
+      this.managedInputs[key] = [patchWithOutput, outputName, {}];
+      patchWithInput.inputs[inputName] = [Patch.INPUT_MANAGED_CONNECTION, key];
+    }
+
+    return true;
+  }
+
+  dropManagedInput(identifier) {
+    return delete this.managedInputs[identifier];
+  }
+
+  getManagedInput(identifier) {
+    const connection = this.managedInputs[identifier];
+    const patch = connection[0];
+    const outputName = connection[1];
+    const memory = connection[2];
+    return this.computeManagedInput(patch, outputName, memory);
+  }
+
+  computeManagedInput(patch, outputName) {
+    // Override this function in subclasses to alter behavior of the "wire"
+    // used for connecting patches.
+
+    const output = patch.computeOutputs()[outputName];
+    switch (output[0]) {
+      case Patch.OUTPUT_UNAVAILABLE:
+        return [Patch.INPUT_UNAVAILABLE];
+      case Patch.OUTPUT_AVAILABLE:
+        return [Patch.INPUT_AVAILABLE, output[1]];
+    }
+  }
+
+  #managedConnectionIdentifier = 0;
+  getManagedConnectionIdentifier() {
+    return this.#managedConnectionIdentifier++;
+  }
+
+  addExternalInput(patchWithInput, patchInputName, managerInputName) {
+    return this.addManagedInput(
+      patchWithInput,
+      patchInputName,
+      this.#externalInputPatch,
+      managerInputName
+    );
+  }
+
+  setExternalOutput(managerOutputName, patchWithOutput, patchOutputName) {
+    return this.addManagedInput(
+      this.#externalOutputPatch,
+      managerOutputName,
+      patchWithOutput,
+      patchOutputName
+    );
+  }
+
+  compute(inputs, outputs) {
+    Object.assign(outputs, this.#externalOutputPatch.computeOutputs());
+  }
+}
+
+class PatchManagerExternalInputPatch extends Patch {
+  constructor({manager, ...rest}) {
+    super({
+      manager,
+      inputNames: manager.inputNames,
+      outputNames: manager.inputNames,
+      ...rest,
+    });
+  }
+
+  computeInputs() {
+    return this.manager.computeInputs();
+  }
+
+  compute(inputs, outputs) {
+    for (const name of this.inputNames) {
+      const input = inputs[name];
+      switch (input[0]) {
+        case Patch.INPUT_UNAVAILABLE:
+          outputs[name] = [Patch.OUTPUT_UNAVAILABLE];
+          break;
+        case Patch.INPUT_AVAILABLE:
+          outputs[name] = [Patch.INPUT_AVAILABLE, input[1]];
+          break;
+      }
+    }
+  }
+}
+
+class PatchManagerExternalOutputPatch extends Patch {
+  constructor({manager, ...rest}) {
+    super({
+      manager,
+      inputNames: manager.outputNames,
+      outputNames: manager.outputNames,
+      ...rest,
+    });
+  }
+
+  compute(inputs, outputs) {
+    for (const name of this.inputNames) {
+      const input = inputs[name];
+      switch (input[0]) {
+        case Patch.INPUT_UNAVAILABLE:
+          outputs[name] = [Patch.OUTPUT_UNAVAILABLE];
+          break;
+        case Patch.INPUT_AVAILABLE:
+          outputs[name] = [Patch.INPUT_AVAILABLE, input[1]];
+          break;
+      }
+    }
+  }
+}
+
+// --> demo
+
+const caches = Symbol();
+const common = Symbol();
+
+Patch[caches] = {
+  WireCachedPatchManager: class extends PatchManager {
+    // "Wire" caching for PatchManager: Remembers the last outputs to come
+    // from each patch. As long as the inputs for a patch do not change, its
+    // cached outputs are reused.
+
+    // TODO: This has a unique cache for each managed input. It should
+    // re-use a cache for the same patch and output name. How can we ensure
+    // the cache is dropped when the patch is removed, though? (Spoilers:
+    // probably just override removeManagedPatch)
+    computeManagedInput(patch, outputName, memory) {
+      let cache = true;
+
+      const {previousInputs} = memory;
+      const {inputs} = patch;
+      if (memory.previousInputs) {
+        for (const inputName of patch.inputNames) {
+          // TODO: This doesn't account for connections whose values
+          // have changed (analogous to bubbling cache invalidation).
+          if (inputs[inputName] !== previousInputs[inputName]) {
+            cache = false;
+            break;
+          }
+        }
+      } else {
+        cache = false;
+      }
+
+      if (cache) {
+        return memory.previousOutputs[outputName];
+      }
+
+      const outputs = patch.computeOutputs();
+      memory.previousOutputs = outputs;
+      memory.previousInputs = {...inputs};
+      return outputs[outputName];
+    }
+  },
+};
+
+Patch[common] = {
+  Stringify: class extends Patch {
+    static inputNames = ['value'];
+    static outputNames = ['value'];
+
+    compute(inputs, outputs) {
+      if (inputs.value[0] === Patch.INPUT_AVAILABLE) {
+        outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1].toString()];
+      } else {
+        outputs.value = [Patch.OUTPUT_UNAVAILABLE];
+      }
+    }
+  },
+
+  Echo: class extends Patch {
+    static inputNames = ['value'];
+    static outputNames = ['value'];
+
+    compute(inputs, outputs) {
+      if (inputs.value[0] === Patch.INPUT_AVAILABLE) {
+        outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1]];
+      } else {
+        outputs.value = [Patch.OUTPUT_UNAVAILABLE];
+      }
+    }
+  },
+};
+
+const PM = new Patch[caches].WireCachedPatchManager({
+  inputNames: ['externalInput'],
+  outputNames: ['externalOutput'],
+});
+
+const P1 = new Patch[common].Stringify({manager: PM});
+const P2 = new Patch[common].Echo({manager: PM});
+
+PM.addExternalInput(P1, 'value', 'externalInput');
+PM.addManagedInput(P2, 'value', P1, 'value');
+PM.setExternalOutput('externalOutput', P2, 'value');
+
+PM.inputs.externalInput = [Patch.INPUT_CONSTANT, 123];
+console.log(PM.computeOutputs());
+console.log(PM.computeOutputs());
diff --git a/src/data/serialize.js b/src/data/serialize.js
new file mode 100644
index 00000000..2ecbf76c
--- /dev/null
+++ b/src/data/serialize.js
@@ -0,0 +1,48 @@
+// serialize.js: simple interface and utility functions for converting
+// Things into a directly serializeable format
+
+// Utility functions
+
+export function id(x) {
+  return x;
+}
+
+export function toRef(thing) {
+  return thing?.constructor.getReference(thing);
+}
+
+export function toRefs(things) {
+  return things?.map(toRef);
+}
+
+export function toContribRefs(contribs) {
+  return contribs?.map(({artist, annotation}) => ({
+    artist: toRef(artist),
+    annotation,
+  }));
+}
+
+export function toCommentaryRefs(entries) {
+  return entries?.map(({artist, ...props}) => ({artist: toRef(artist), ...props}));
+}
+
+// Interface
+
+export const serializeDescriptors = Symbol();
+
+export function serializeThing(thing) {
+  const descriptors = thing.constructor[serializeDescriptors];
+
+  if (!descriptors) {
+    throw new Error(`Constructor ${thing.constructor.name} does not provide serialize descriptors`);
+  }
+
+  return Object.fromEntries(
+    Object.entries(descriptors)
+      .map(([property, transform]) => [property, transform(thing[property])])
+  );
+}
+
+export function serializeThings(things) {
+  return things.map(serializeThing);
+}
diff --git a/src/data/thing.js b/src/data/thing.js
new file mode 100644
index 00000000..66f73de5
--- /dev/null
+++ b/src/data/thing.js
@@ -0,0 +1,125 @@
+// Thing: base class for wiki data types, providing interfaces generally useful
+// to all wiki data objects on top of foundational CacheableObject behavior.
+
+import {inspect} from 'node:util';
+
+import CacheableObject from '#cacheable-object';
+import {colors} from '#cli';
+
+export default class Thing extends CacheableObject {
+  static referenceType = Symbol.for('Thing.referenceType');
+  static friendlyName = Symbol.for('Thing.friendlyName');
+
+  static getPropertyDescriptors = Symbol.for('Thing.getPropertyDescriptors');
+  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 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 (
+      (name ? `${constructorName} ${name}` : `${constructorName}`) +
+      (reference ? ` (${reference})` : ''));
+  }
+
+  static getReference(thing) {
+    if (!thing.constructor[Thing.referenceType]) {
+      throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`);
+    }
+
+    if (!thing.directory) {
+      throw TypeError(`Passed ${thing.constructor.name} is missing its directory`);
+    }
+
+    return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
+  }
+
+  static extendDocumentSpec(thingClass, subspec) {
+    const superspec = thingClass[Thing.yamlDocumentSpec];
+
+    const {
+      fields,
+      ignoredFields,
+      invalidFieldCombinations,
+      ...restOfSubspec
+    } = subspec;
+
+    const newFields = Object.keys(fields ?? {});
+
+    return {
+      ...superspec,
+      ...restOfSubspec,
+
+      fields: {
+        ...superspec.fields ?? {},
+        ...fields,
+      },
+
+      ignoredFields:
+        (superspec.ignoredFields ?? [])
+          .filter(field => newFields.includes(field))
+          .concat(ignoredFields ?? []),
+
+      invalidFieldCombinations: [
+        ...superspec.invalidFieldCombinations ?? [],
+        ...invalidFieldCombinations ?? [],
+      ],
+    };
+  }
+}
diff --git a/src/data/things/album.js b/src/data/things/album.js
new file mode 100644
index 00000000..4c85ddfa
--- /dev/null
+++ b/src/data/things/album.js
@@ -0,0 +1,959 @@
+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 {traverse} from '#node-utils';
+import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
+import {accumulateSum, empty} from '#sugar';
+import Thing from '#thing';
+import {isColor, isDate, isDirectory, isNumber} from '#validators';
+
+import {
+  parseAdditionalFiles,
+  parseAdditionalNames,
+  parseAnnotatedReferences,
+  parseArtwork,
+  parseContributors,
+  parseDate,
+  parseDimensions,
+  parseWallpaperParts,
+} from '#yaml';
+
+import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+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,
+  directory,
+  fileExtension,
+  flag,
+  name,
+  referencedArtworkList,
+  referenceList,
+  reverseReferenceList,
+  simpleDate,
+  simpleString,
+  soupyFind,
+  soupyReverse,
+  thing,
+  thingList,
+  urls,
+  wallpaperParts,
+  wikiData,
+} from '#composite/wiki-properties';
+
+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,
+    Artwork,
+    Group,
+    Track,
+    TrackSection,
+    WikiInfo,
+  }) => ({
+    // Update & expose
+
+    name: name('Unnamed Album'),
+    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(),
+
+    date: simpleDate(),
+    trackArtDate: simpleDate(),
+    dateAddedToWiki: simpleDate(),
+
+    coverArtDate: [
+      withCoverArtDate({
+        from: input.updateValue({
+          validate: isDate,
+        }),
+      }),
+
+      exposeDependency({dependency: '#coverArtDate'}),
+    ],
+
+    coverArtFileExtension: [
+      exitWithoutContribs({contribs: 'coverArtistContribs'}),
+      fileExtension('jpg'),
+    ],
+
+    trackCoverArtFileExtension: fileExtension('jpg'),
+
+    wallpaperFileExtension: [
+      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
+      fileExtension('jpg'),
+    ],
+
+    bannerFileExtension: [
+      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
+      fileExtension('jpg'),
+    ],
+
+    wallpaperStyle: [
+      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
+      simpleString(),
+    ],
+
+    wallpaperParts: [
+      exitWithoutContribs({
+        contribs: 'wallpaperArtistContribs',
+        value: input.value([]),
+      }),
+
+      wallpaperParts(),
+    ],
+
+    bannerStyle: [
+      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
+      simpleString(),
+    ],
+
+    coverArtDimensions: [
+      exitWithoutContribs({contribs: 'coverArtistContribs'}),
+      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: thingList({
+      class: input.value(TrackSection),
+    }),
+
+    artistContribs: contributionList({
+      date: 'date',
+      artistProperty: input.value('albumArtistContributions'),
+    }),
+
+    coverArtistContribs: [
+      withCoverArtDate(),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumCoverArtistContributions'),
+      }),
+    ],
+
+    trackCoverArtistContribs: contributionList({
+      // May be null, indicating cover art was added for tracks on the date
+      // each track specifies, or else the track's own release date.
+      date: 'trackArtDate',
+
+      // This is the "correct" value, but it gets overwritten - with the same
+      // value - regardless.
+      artistProperty: input.value('trackCoverArtistContributions'),
+    }),
+
+    wallpaperArtistContribs: [
+      withCoverArtDate(),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumWallpaperArtistContributions'),
+      }),
+    ],
+
+    bannerArtistContribs: [
+      withCoverArtDate(),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumBannerArtistContributions'),
+      }),
+    ],
+
+    groups: referenceList({
+      class: input.value(Group),
+      find: soupyFind.input('group'),
+    }),
+
+    artTags: [
+      exitWithoutContribs({
+        contribs: 'coverArtistContribs',
+        value: input.value([]),
+      }),
+
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
+      }),
+    ],
+
+    referencedArtworks: [
+      exitWithoutContribs({
+        contribs: 'coverArtistContribs',
+        value: input.value([]),
+      }),
+
+      referencedArtworkList(),
+    ],
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // used for referencedArtworkList (mixedFind)
+    artworkData: wikiData({
+      class: input.value(Artwork),
+    }),
+
+    // used for withMatchingContributionPresets (indirectly by Contribution)
+    wikiInfo: thing({
+      class: input.value(WikiInfo),
+    }),
+
+    // Expose only
+
+    commentatorArtists: commentatorArtists(),
+
+    hasCoverArt: [
+      withHasCoverArt(),
+      exposeDependency({dependency: '#hasCoverArt'}),
+    ],
+
+    hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
+    hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
+
+    tracks: [
+      withTracks(),
+      exposeDependency({dependency: '#tracks'}),
+    ],
+  });
+
+  static [Thing.getSerializeDescriptors] = ({
+    serialize: S,
+  }) => ({
+    name: S.id,
+    color: S.id,
+    directory: S.id,
+    urls: S.id,
+
+    date: S.id,
+    coverArtDate: S.id,
+    trackArtDate: S.id,
+    dateAddedToWiki: S.id,
+
+    artistContribs: S.toContribRefs,
+    coverArtistContribs: S.toContribRefs,
+    trackCoverArtistContribs: S.toContribRefs,
+    wallpaperArtistContribs: S.toContribRefs,
+    bannerArtistContribs: S.toContribRefs,
+
+    coverArtFileExtension: S.id,
+    trackCoverArtFileExtension: S.id,
+    wallpaperStyle: S.id,
+    wallpaperFileExtension: S.id,
+    bannerStyle: S.id,
+    bannerFileExtension: S.id,
+    bannerDimensions: S.id,
+
+    hasTrackArt: S.id,
+    isListedOnHomepage: S.id,
+
+    commentary: S.toCommentaryRefs,
+
+    additionalFiles: S.id,
+
+    tracks: S.toRefs,
+    groups: S.toRefs,
+    artTags: S.toRefs,
+    commentatorArtists: S.toRefs,
+  });
+
+  static [Thing.findSpecs] = {
+    album: {
+      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',
+        transform: String,
+      },
+
+      'Bandcamp Artwork ID': {
+        property: 'bandcampArtworkIdentifier',
+        transform: String,
+      },
+
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
+
+      'Color': {property: 'color'},
+      'URLs': {property: 'urls'},
+
+      'Has Track Numbers': {property: 'hasTrackNumbers'},
+      '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,
+      },
+
+      'Default Track Cover Art Date': {
+        property: 'trackArtDate',
+        transform: parseDate,
+      },
+
+      'Date Added': {
+        property: 'dateAddedToWiki',
+        transform: parseDate,
+      },
+
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
+
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
+      'Default Track Dimensions': {
+        property: 'trackDimensions',
+        transform: parseDimensions,
+      },
+
+      'Wallpaper Artists': {
+        property: 'wallpaperArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Wallpaper Style': {property: 'wallpaperStyle'},
+      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
+
+      'Wallpaper Parts': {
+        property: 'wallpaperParts',
+        transform: parseWallpaperParts,
+      },
+
+      'Banner Artists': {
+        property: 'bannerArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Banner Style': {property: 'bannerStyle'},
+      'Banner File Extension': {property: 'bannerFileExtension'},
+
+      'Banner Dimensions': {
+        property: 'bannerDimensions',
+        transform: parseDimensions,
+      },
+
+      'Commentary': {property: 'commentary'},
+      'Credit Sources': {property: 'creditSources'},
+
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
+      },
+
+      'Franchises': {ignore: true},
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Default Track Cover Artists': {
+        property: 'trackCoverArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Groups': {property: 'groups'},
+      'Art Tags': {property: 'artTags'},
+
+      '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},
+  }) => ({
+    title: `Process album files`,
+
+    files: dataPath =>
+      traverse(path.join(dataPath, DATA_ALBUM_DIRECTORY), {
+        filterFile: name => path.extname(name) === '.yaml',
+        prefixPath: DATA_ALBUM_DIRECTORY,
+      }),
+
+    documentMode: headerAndEntries,
+    headerDocumentThing: Album,
+    entryDocumentThing: document =>
+      ('Section' in document
+        ? TrackSection
+        : Track),
+
+    save(results) {
+      const albumData = [];
+      const trackSectionData = [];
+      const trackData = [];
+      const artworkData = [];
+
+      for (const {header: album, entries} of results) {
+        const trackSections = [];
+
+        let currentTrackSection = new TrackSection();
+        let currentTrackSectionTracks = [];
+
+        Object.assign(currentTrackSection, {
+          name: `Default Track Section`,
+          isDefaultTrackSection: true,
+        });
+
+        const albumRef = Thing.getReference(album);
+
+        const closeCurrentTrackSection = () => {
+          if (
+            currentTrackSection.isDefaultTrackSection &&
+            empty(currentTrackSectionTracks)
+          ) {
+            return;
+          }
+
+          currentTrackSection.tracks =
+            currentTrackSectionTracks;
+
+          trackSections.push(currentTrackSection);
+          trackSectionData.push(currentTrackSection);
+        };
+
+        for (const entry of entries) {
+          if (entry instanceof TrackSection) {
+            closeCurrentTrackSection();
+            currentTrackSection = entry;
+            currentTrackSectionTracks = [];
+            continue;
+          }
+
+          currentTrackSectionTracks.push(entry);
+          trackData.push(entry);
+
+          // 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;
+
+          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;
+      }
+
+      return {
+        albumData,
+        trackSectionData,
+        trackData,
+        artworkData,
+      };
+    },
+
+    sort({albumData, trackData}) {
+      sortChronologically(albumData);
+      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 TrackSection extends Thing {
+  static [Thing.friendlyName] = `Track Section`;
+  static [Thing.referenceType] = `track-section`;
+
+  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+    // Update & expose
+
+    name: name('Unnamed Track Section'),
+
+    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(),
+
+    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
new file mode 100644
index 00000000..57e156ee
--- /dev/null
+++ b/src/data/things/art-tag.js
@@ -0,0 +1,192 @@
+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 {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`;
+
+  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+    // Update & expose
+
+    name: name('Unnamed Art Tag'),
+    directory: directory(),
+    color: color(),
+    isContentWarning: flag(false),
+    extraReadingURLs: urls(),
+
+    nameShort: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isName),
+      }),
+
+      {
+        dependencies: ['name'],
+        compute: ({name}) =>
+          name.replace(/ \([^)]*?\)$/, ''),
+      },
+    ],
+
+    additionalNames: additionalNameList(),
+
+    description: contentString(),
+
+    directDescendantArtTags: referenceList({
+      class: input.value(ArtTag),
+      find: soupyFind.input('artTag'),
+    }),
+
+    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
+
+    descriptionShort: [
+      exitWithoutDependency({
+        dependency: 'description',
+        mode: input.value('falsy'),
+      }),
+
+      {
+        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] = {
+    artTag: {
+      referenceTypes: ['tag'],
+      bindTo: 'artTagData',
+
+      getMatchableNames: artTag =>
+        (artTag.isContentWarning
+          ? [`cw: ${artTag.name}`]
+          : [artTag.name]),
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    artTagsWhichDirectlyAncestor: {
+      bindTo: 'artTagData',
+
+      referencing: artTag => [artTag],
+      referenced: artTag => artTag.directDescendantArtTags,
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      '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',
+          }),
+      },
+    },
+  };
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {allInOne},
+    thingConstructors: {ArtTag},
+  }) => ({
+    title: `Process art tags file`,
+    file: ART_TAG_DATA_FILE,
+
+    documentMode: allInOne,
+    documentThing: ArtTag,
+
+    save: (results) => ({artTagData: results}),
+
+    sort({artTagData}) {
+      sortAlphabetically(artTagData);
+    },
+  });
+}
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
new file mode 100644
index 00000000..87e1c563
--- /dev/null
+++ b/src/data/things/artist.js
@@ -0,0 +1,306 @@
+export const ARTIST_DATA_FILE = 'artists.yaml';
+
+import {inspect} from 'node:util';
+
+import CacheableObject from '#cacheable-object';
+import {colors} from '#cli';
+import {input} from '#composite';
+import {sortAlphabetically} from '#sort';
+import {stitchArrays} from '#sugar';
+import Thing from '#thing';
+import {isName, validateArrayItems} from '#validators';
+import {getKebabCase} from '#wiki-data';
+import {parseArtwork} from '#yaml';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+
+import {
+  constitutibleArtwork,
+  contentString,
+  directory,
+  fileExtension,
+  flag,
+  name,
+  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, Group, Track}) => ({
+    // Update & expose
+
+    name: name('Unnamed Artist'),
+    directory: directory(),
+    urls: urls(),
+
+    contextNotes: contentString(),
+
+    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)},
+      expose: {transform: (names) => names ?? []},
+    },
+
+    isAlias: flag(),
+
+    aliasedArtist: singleReference({
+      class: input.value(Artist),
+      find: soupyFind.input('artist'),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // Expose only
+
+    trackArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('trackArtistContributionsBy'),
+    }),
+
+    trackContributorContributions: reverseReferenceList({
+      reverse: soupyReverse.input('trackContributorContributionsBy'),
+    }),
+
+    trackCoverArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('trackCoverArtistContributionsBy'),
+    }),
+
+    tracksAsCommentator: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWithCommentaryBy'),
+    }),
+
+    albumArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumArtistContributionsBy'),
+    }),
+
+    albumCoverArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumCoverArtistContributionsBy'),
+    }),
+
+    albumWallpaperArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumWallpaperArtistContributionsBy'),
+    }),
+
+    albumBannerArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumBannerArtistContributionsBy'),
+    }),
+
+    albumsAsCommentator: reverseReferenceList({
+      reverse: soupyReverse.input('albumsWithCommentaryBy'),
+    }),
+
+    flashContributorContributions: reverseReferenceList({
+      reverse: soupyReverse.input('flashContributorContributionsBy'),
+    }),
+
+    flashesAsCommentator: reverseReferenceList({
+      reverse: soupyReverse.input('flashesWithCommentaryBy'),
+    }),
+
+    closelyLinkedGroups: reverseReferenceList({
+      reverse: soupyReverse.input('groupsCloselyLinkedTo'),
+    }),
+
+    totalDuration: artistTotalDuration(),
+  });
+
+  static [Thing.getSerializeDescriptors] = ({
+    serialize: S,
+  }) => ({
+    name: S.id,
+    directory: S.id,
+    urls: S.id,
+    contextNotes: S.id,
+
+    hasAvatar: S.id,
+    avatarFileExtension: S.id,
+
+    aliasNames: S.id,
+
+    tracksAsCommentator: S.toRefs,
+    albumsAsCommentator: S.toRefs,
+  });
+
+  static [Thing.findSpecs] = {
+    artist: {
+      referenceTypes: ['artist', 'artist-gallery'],
+      bindTo: 'artistData',
+
+      include: artist => !artist.isAlias,
+    },
+
+    artistAlias: {
+      referenceTypes: ['artist', 'artist-gallery'],
+      bindTo: 'artistData',
+
+      include: artist => artist.isAlias,
+
+      getMatchableDirectories(artist) {
+        const originalArtist = artist.aliasedArtist;
+
+        // Aliases never match by the same directory as the original.
+        if (artist.directory === originalArtist.directory) {
+          return [];
+        }
+
+        // Aliases never match by the same directory as some *previous* alias
+        // in the original's alias list. This is honestly a bit awkward, but it
+        // avoids artist aliases conflicting with each other when checking for
+        // duplicate directories.
+        for (const aliasName of originalArtist.aliasNames) {
+          // These are trouble. We should be accessing aliases' directories
+          // directly, but artists currently don't expose a reverse reference
+          // list for aliases. (This is pending a cleanup of "reverse reference"
+          // behavior in general.) It doesn't actually cause problems *here*
+          // because alias directories are computed from their names 100% of the
+          // time, but that *is* an assumption this code makes.
+          if (aliasName === artist.name) continue;
+          if (artist.directory === getKebabCase(aliasName)) {
+            return [];
+          }
+        }
+
+        // And, aliases never return just a blank string. This part is pretty
+        // spooky because it doesn't handle two differently named aliases, on
+        // different artists, who have names that are similar *apart* from a
+        // character that's shortened. But that's also so fundamentally scary
+        // that we can't support it properly with existing code, anyway - we
+        // would need to be able to specifically set a directory *on an alias,*
+        // which currently can't be done in YAML data files.
+        if (artist.directory === '') {
+          return [];
+        }
+
+        return [artist.directory];
+      },
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Artist': {property: 'name'},
+      'Directory': {property: 'directory'},
+      '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'},
+
+      'Aliases': {property: 'aliasNames'},
+
+      'Dead URLs': {ignore: true},
+
+      'Review Points': {ignore: true},
+    },
+  };
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {allInOne},
+    thingConstructors: {Artist},
+  }) => ({
+    title: `Process artists file`,
+    file: ARTIST_DATA_FILE,
+
+    documentMode: allInOne,
+    documentThing: Artist,
+
+    save(results) {
+      const artists = results;
+
+      const artistRefs =
+        artists.map(artist => Thing.getReference(artist));
+
+      const artistAliasNames =
+        artists.map(artist => artist.aliasNames);
+
+      const artistAliases =
+        stitchArrays({
+          originalArtistRef: artistRefs,
+          aliasNames: artistAliasNames,
+        }).flatMap(({originalArtistRef, aliasNames}) =>
+            aliasNames.map(name => {
+              const alias = new Artist();
+              alias.name = name;
+              alias.isAlias = true;
+              alias.aliasedArtist = originalArtistRef;
+              return alias;
+            }));
+
+      const artistData = [...artists, ...artistAliases];
+
+      const artworkData =
+        artistData
+          .filter(artist => artist.hasAvatar)
+          .map(artist => artist.avatarArtwork);
+
+      return {artistData, artworkData};
+    },
+
+    sort({artistData}) {
+      sortAlphabetically(artistData);
+    },
+  });
+
+  [inspect.custom]() {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (CacheableObject.getUpdateValue(this, 'isAlias')) {
+      parts.unshift(`${colors.yellow('[alias]')} `);
+
+      let aliasedArtist;
+      try {
+        aliasedArtist = this.aliasedArtist.name;
+      } catch (_error) {
+        aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist');
+      }
+
+      parts.push(` ${colors.yellow(`[of ${aliasedArtist}]`)}`);
+    }
+
+    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
new file mode 100644
index 00000000..ace18af9
--- /dev/null
+++ b/src/data/things/flash.js
@@ -0,0 +1,452 @@
+export const FLASH_DATA_FILE = 'flashes.yaml';
+
+import {input} from '#composite';
+import {empty} from '#sugar';
+import {sortFlashesChronologically} from '#sort';
+import Thing from '#thing';
+import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
+  from '#validators';
+
+import {
+  parseArtwork,
+  parseAdditionalNames,
+  parseContributors,
+  parseDate,
+  parseDimensions,
+} from '#yaml';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import {
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} 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';
+
+import {withFlashAct} from '#composite/things/flash';
+import {withFlashSide} from '#composite/things/flash-act';
+
+export class Flash extends Thing {
+  static [Thing.referenceType] = 'flash';
+
+  static [Thing.getPropertyDescriptors] = ({
+    Track,
+    FlashAct,
+    WikiInfo,
+  }) => ({
+    // Update & expose
+
+    name: name('Unnamed Flash'),
+
+    directory: {
+      flags: {update: true, expose: true},
+      update: {validate: isDirectory},
+
+      // Flashes expose directory differently from other Things! Their
+      // default directory is dependent on the page number (or ID), not
+      // the name.
+      expose: {
+        dependencies: ['page'],
+        transform(directory, {page}) {
+          if (directory === null && page === null) return null;
+          else if (directory === null) return page;
+          else return directory;
+        },
+      },
+    },
+
+    page: {
+      flags: {update: true, expose: true},
+      update: {validate: anyOf(isString, isNumber)},
+
+      expose: {
+        transform: (value) => (value === null ? null : value.toString()),
+      },
+    },
+
+    color: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isColor),
+      }),
+
+      withFlashAct(),
+
+      withPropertyFromObject({
+        object: '#flashAct',
+        property: input.value('color'),
+      }),
+
+      exposeDependency({dependency: '#flashAct.color'}),
+    ],
+
+    date: simpleDate(),
+
+    coverArtFileExtension: fileExtension('jpg'),
+
+    coverArtDimensions: dimensions(),
+
+    coverArtwork:
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+
+    contributorContribs: contributionList({
+      date: 'date',
+      artistProperty: input.value('flashContributorContributions'),
+    }),
+
+    featuredTracks: referenceList({
+      class: input.value(Track),
+      find: soupyFind.input('track'),
+    }),
+
+    urls: urls(),
+
+    additionalNames: additionalNameList(),
+
+    commentary: commentary(),
+    creditSources: commentary(),
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // used for withMatchingContributionPresets (indirectly by Contribution)
+    wikiInfo: thing({
+      class: input.value(WikiInfo),
+    }),
+
+    // Expose only
+
+    commentatorArtists: commentatorArtists(),
+
+    act: [
+      withFlashAct(),
+      exposeDependency({dependency: '#flashAct'}),
+    ],
+
+    side: [
+      withFlashAct(),
+
+      withPropertyFromObject({
+        object: '#flashAct',
+        property: input.value('side'),
+      }),
+
+      exposeDependency({dependency: '#flashAct.side'}),
+    ],
+  });
+
+  static [Thing.getSerializeDescriptors] = ({
+    serialize: S,
+  }) => ({
+    name: S.id,
+    page: S.id,
+    directory: S.id,
+    date: S.id,
+    contributors: S.toContribRefs,
+    tracks: S.toRefs,
+    urls: S.id,
+    color: S.id,
+  });
+
+  static [Thing.findSpecs] = {
+    flash: {
+      referenceTypes: ['flash'],
+      bindTo: 'flashData',
+    },
+  };
+
+  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'},
+      'Directory': {property: 'directory'},
+      'Page': {property: 'page'},
+      'Color': {property: 'color'},
+      'URLs': {property: 'urls'},
+
+      'Date': {
+        property: 'date',
+        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': {
+        property: 'contributorContribs',
+        transform: parseContributors,
+      },
+
+      '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 {
+  static [Thing.referenceType] = 'flash-act';
+  static [Thing.friendlyName] = `Flash Act`;
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    name: name('Unnamed Flash Act'),
+    directory: directory(),
+    color: color(),
+
+    listTerminology: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isContentString),
+      }),
+
+      withFlashSide(),
+
+      withPropertyFromObject({
+        object: '#flashSide',
+        property: input.value('listTerminology'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#flashSide.listTerminology',
+      }),
+
+      exposeConstant({
+        value: input.value(null),
+      }),
+    ],
+
+    flashes: referenceList({
+      class: input.value(Flash),
+      find: soupyFind.input('flash'),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // Expose only
+
+    side: [
+      withFlashSide(),
+      exposeDependency({dependency: '#flashSide'}),
+    ],
+  });
+
+  static [Thing.findSpecs] = {
+    flashAct: {
+      referenceTypes: ['flash-act'],
+      bindTo: 'flashActData',
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    flashActsWhoseFlashesInclude: {
+      bindTo: 'flashActData',
+
+      referencing: flashAct => [flashAct],
+      referenced: flashAct => flashAct.flashes,
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Act': {property: 'name'},
+      'Directory': {property: 'directory'},
+
+      'Color': {property: 'color'},
+      'List Terminology': {property: 'listTerminology'},
+
+      'Review Points': {ignore: true},
+    },
+  };
+}
+
+export class FlashSide extends Thing {
+  static [Thing.referenceType] = 'flash-side';
+  static [Thing.friendlyName] = `Flash Side`;
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    name: name('Unnamed Flash Side'),
+    directory: directory(),
+    color: color(),
+    listTerminology: contentString(),
+
+    acts: referenceList({
+      class: input.value(FlashAct),
+      find: soupyFind.input('flashAct'),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Side': {property: 'name'},
+      'Directory': {property: 'directory'},
+      'Color': {property: 'color'},
+      'List Terminology': {property: 'listTerminology'},
+    },
+  };
+
+  static [Thing.findSpecs] = {
+    flashSide: {
+      referenceTypes: ['flash-side'],
+      bindTo: 'flashSideData',
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    flashSidesWhoseActsInclude: {
+      bindTo: 'flashSideData',
+
+      referencing: flashSide => [flashSide],
+      referenced: flashSide => flashSide.acts,
+    },
+  };
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {allInOne},
+    thingConstructors: {Flash, FlashAct},
+  }) => ({
+    title: `Process flashes file`,
+    file: FLASH_DATA_FILE,
+
+    documentMode: allInOne,
+    documentThing: document =>
+      ('Side' in document
+        ? FlashSide
+     : 'Act' in document
+        ? FlashAct
+        : Flash),
+
+    save(results) {
+      // JavaScript likes you.
+
+      if (!empty(results) && !(results[0] instanceof FlashSide)) {
+        throw new Error(`Expected a side at top of flash data file`);
+      }
+
+      let index = 0;
+      let thing;
+      for (; thing = results[index]; index++) {
+        const flashSide = thing;
+        const flashActRefs = [];
+
+        if (results[index + 1] instanceof Flash) {
+          throw new Error(`Expected an act to immediately follow a side`);
+        }
+
+        for (
+          index++;
+          (thing = results[index]) && thing instanceof FlashAct;
+          index++
+        ) {
+          const flashAct = thing;
+          const flashRefs = [];
+          for (
+            index++;
+            (thing = results[index]) && thing instanceof Flash;
+            index++
+          ) {
+            flashRefs.push(Thing.getReference(thing));
+          }
+          index--;
+          flashAct.flashes = flashRefs;
+          flashActRefs.push(Thing.getReference(flashAct));
+        }
+        index--;
+        flashSide.acts = flashActRefs;
+      }
+
+      const flashData = results.filter(x => x instanceof Flash);
+      const flashActData = results.filter(x => x instanceof FlashAct);
+      const flashSideData = results.filter(x => x instanceof FlashSide);
+
+      const artworkData = flashData.map(flash => flash.coverArtwork);
+
+      return {flashData, flashActData, flashSideData, artworkData};
+    },
+
+    sort({flashData}) {
+      sortFlashesChronologically(flashData);
+    },
+  });
+}
diff --git a/src/data/things/group.js b/src/data/things/group.js
new file mode 100644
index 00000000..b40d15b4
--- /dev/null
+++ b/src/data/things/group.js
@@ -0,0 +1,242 @@
+export const GROUP_DATA_FILE = 'groups.yaml';
+
+import {input} from '#composite';
+import Thing from '#thing';
+import {parseAnnotatedReferences, parseSerieses} from '#yaml';
+
+import {
+  annotatedReferenceList,
+  color,
+  contentString,
+  directory,
+  name,
+  referenceList,
+  seriesList,
+  soupyFind,
+  urls,
+  wikiData,
+} from '#composite/wiki-properties';
+
+export class Group extends Thing {
+  static [Thing.referenceType] = 'group';
+
+  static [Thing.getPropertyDescriptors] = ({Album, Artist}) => ({
+    // Update & expose
+
+    name: name('Unnamed Group'),
+    directory: directory(),
+
+    description: contentString(),
+
+    urls: urls(),
+
+    closelyLinkedArtists: annotatedReferenceList({
+      class: input.value(Artist),
+      find: soupyFind.input('artist'),
+
+      reference: input.value('artist'),
+      thing: input.value('artist'),
+    }),
+
+    featuredAlbums: referenceList({
+      class: input.value(Album),
+      find: soupyFind.input('album'),
+    }),
+
+    serieses: seriesList({
+      group: input.myself(),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyFind(),
+
+    // Expose only
+
+    descriptionShort: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['description'],
+        compute: ({description}) =>
+          (description
+            ? description.split('<hr class="split">')[0]
+            : null),
+      },
+    },
+
+    albums: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['this', 'reverse'],
+        compute: ({this: group, reverse}) =>
+          reverse.albumsWhoseGroupsInclude(group),
+      },
+    },
+
+    color: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['this', 'reverse'],
+        compute: ({this: group, reverse}) =>
+          reverse.groupCategoriesWhichInclude(group, {unique: true})
+            ?.color,
+      },
+    },
+
+    category: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['this', 'reverse'],
+        compute: ({this: group, reverse}) =>
+          reverse.groupCategoriesWhichInclude(group, {unique: true}) ??
+          null,
+      },
+    },
+  });
+
+  static [Thing.findSpecs] = {
+    group: {
+      referenceTypes: ['group', 'group-gallery'],
+      bindTo: 'groupData',
+    },
+  };
+
+  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'},
+      'Directory': {property: 'directory'},
+      '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},
+    },
+  };
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {allInOne},
+    thingConstructors: {Group, GroupCategory},
+  }) => ({
+    title: `Process groups file`,
+    file: GROUP_DATA_FILE,
+
+    documentMode: allInOne,
+    documentThing: document =>
+      ('Category' in document
+        ? GroupCategory
+        : Group),
+
+    save(results) {
+      let groupCategory;
+      let groupRefs = [];
+
+      if (results[0] && !(results[0] instanceof GroupCategory)) {
+        throw new Error(`Expected a category at top of group data file`);
+      }
+
+      for (const thing of results) {
+        if (thing instanceof GroupCategory) {
+          if (groupCategory) {
+            Object.assign(groupCategory, {groups: groupRefs});
+          }
+
+          groupCategory = thing;
+          groupRefs = [];
+        } else {
+          groupRefs.push(Thing.getReference(thing));
+        }
+      }
+
+      if (groupCategory) {
+        Object.assign(groupCategory, {groups: groupRefs});
+      }
+
+      const groupData = results.filter(x => x instanceof Group);
+      const groupCategoryData = results.filter(x => x instanceof GroupCategory);
+
+      return {groupData, groupCategoryData};
+    },
+
+    // Groups aren't sorted at all, always preserving the order in the data
+    // file as-is.
+    sort: null,
+  });
+}
+
+export class GroupCategory extends Thing {
+  static [Thing.referenceType] = 'group-category';
+  static [Thing.friendlyName] = `Group Category`;
+
+  static [Thing.getPropertyDescriptors] = ({Group}) => ({
+    // Update & expose
+
+    name: name('Unnamed Group Category'),
+    directory: directory(),
+
+    color: color(),
+
+    groups: referenceList({
+      class: input.value(Group),
+      find: soupyFind.input('group'),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+  });
+
+  static [Thing.reverseSpecs] = {
+    groupCategoriesWhichInclude: {
+      bindTo: 'groupCategoryData',
+
+      referencing: groupCategory => [groupCategory],
+      referenced: groupCategory => groupCategory.groups,
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Category': {property: 'name'},
+      'Color': {property: 'color'},
+    },
+  };
+}
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
new file mode 100644
index 00000000..82bad2d3
--- /dev/null
+++ b/src/data/things/homepage-layout.js
@@ -0,0 +1,338 @@
+export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml';
+
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
+import {input} from '#composite';
+import Thing from '#thing';
+import {empty} from '#sugar';
+
+import {
+  anyOf,
+  is,
+  isCountingNumber,
+  isString,
+  isStringNonEmpty,
+  validateArrayItems,
+  validateReference,
+} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withResolvedReference} from '#composite/wiki-data';
+
+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] = ({HomepageLayoutSection}) => ({
+    // Update & expose
+
+    sidebarContent: contentString(),
+
+    navbarLinks: {
+      flags: {update: true, expose: true},
+      update: {validate: validateArrayItems(isStringNonEmpty)},
+      expose: {transform: value => value ?? []},
+    },
+
+    sections: thingList({
+      class: input.value(HomepageLayoutSection),
+    }),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Homepage': {ignore: true},
+
+      'Sidebar Content': {property: 'sidebarContent'},
+      '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] = ({HomepageLayoutSection}) => ({
+    // Update & expose
+
+    section: thing({
+      class: input.value(HomepageLayoutSection),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+
+    // Expose only
+
+    type: {
+      flags: {expose: true},
+
+      expose: {
+        compute() {
+          throw new Error(`'type' property validator must be overridden`);
+        },
+      },
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Row': {ignore: true},
+    },
+  };
+
+  [inspect.custom](depth) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    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)})`);
+    }
+
+    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] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+    fields: {
+      'Actions': {property: 'actionLinks'},
+    },
+  });
+}
+
+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: {expose: true},
+      expose: {compute: () => 'album carousel'},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+    fields: {
+      'Albums': {property: 'albums'},
+    },
+  });
+}
+
+export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow {
+  static [Thing.friendlyName] = `Homepage Album Grid Row`;
+
+  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
+    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
+
+    // Update & expose
+
+    sourceGroup: [
+      {
+        flags: {expose: true, update: true, compose: true},
+
+        update: {
+          validate:
+            anyOf(
+              is('new-releases', 'new-additions'),
+              validateReference(Group[Thing.referenceType])),
+        },
+
+        expose: {
+          transform: (value, continuation) =>
+            (value === 'new-releases' || value === 'new-additions'
+              ? value
+              : continuation(value)),
+        },
+      },
+
+      withResolvedReference({
+        ref: input.updateValue(),
+        find: soupyFind.input('group'),
+      }),
+
+      exposeDependency({dependency: '#resolvedReference'}),
+    ],
+
+    sourceAlbums: referenceList({
+      class: input.value(Album),
+      find: soupyFind.input('album'),
+    }),
+
+    countAlbumsFromGroup: {
+      flags: {update: true, expose: true},
+      update: {validate: isCountingNumber},
+    },
+
+    // Expose only
+
+    type: {
+      flags: {expose: true},
+      expose: {compute: () => 'album grid'},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+    fields: {
+      'Group': {property: 'sourceGroup'},
+      'Count': {property: 'countAlbumsFromGroup'},
+      'Albums': {property: 'sourceAlbums'},
+    },
+  });
+}
diff --git a/src/data/things/index.js b/src/data/things/index.js
new file mode 100644
index 00000000..96cec88e
--- /dev/null
+++ b/src/data/things/index.js
@@ -0,0 +1,227 @@
+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';
+
+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,
+};
+
+let allClasses = Object.create(null);
+
+// src/data/things/index.js -> src/
+const __dirname = path.dirname(
+  path.resolve(
+    fileURLToPath(import.meta.url),
+    '../..'));
+
+function niceShowAggregate(error, ...opts) {
+  showAggregate(error, {
+    pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
+    ...opts,
+  });
+}
+
+function errorDuplicateClassNames() {
+  const locationDict = Object.create(null);
+
+  for (const [location, classes] of Object.entries(allClassLists)) {
+    for (const className of Object.keys(classes)) {
+      if (className in locationDict) {
+        locationDict[className].push(location);
+      } else {
+        locationDict[className] = [location];
+      }
+    }
+  }
+
+  let success = true;
+
+  for (const [className, locations] of Object.entries(locationDict)) {
+    if (locations.length === 1) {
+      continue;
+    }
+
+    logError`Thing class name ${`"${className}"`} is defined more than once: ${locations.join(', ')}`;
+    success = false;
+  }
+
+  return success;
+}
+
+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;
+      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({
+  showFailedClasses,
+  message,
+  op,
+}) {
+  const failureSymbol = Symbol();
+  const aggregate = openAggregate({
+    message,
+    returnOnFail: failureSymbol,
+  });
+
+  const failedClasses = [];
+
+  for (const [name, constructor] of Object.entries(allClasses)) {
+    const result = aggregate.call(op, constructor);
+
+    if (result === failureSymbol) {
+      failedClasses.push(name);
+    }
+  }
+
+  try {
+    aggregate.close();
+    return true;
+  } catch (error) {
+    niceShowAggregate(error);
+    showFailedClasses(failedClasses);
+    return false;
+  }
+}
+
+function evaluatePropertyDescriptors() {
+  const opts = {...allClasses};
+
+  return descriptorAggregateHelper({
+    message: `Errors evaluating Thing class property descriptors`,
+
+    op(constructor) {
+      if (!constructor[Thing.getPropertyDescriptors]) {
+        throw new Error(`Missing [Thing.getPropertyDescriptors] function`);
+      }
+
+      const results = constructor[Thing.getPropertyDescriptors](opts);
+
+      for (const [key, value] of Object.entries(results)) {
+        if (Array.isArray(value)) {
+          results[key] = compositeFrom({
+            annotation: `${constructor.name}.${key}`,
+            compose: false,
+            steps: value,
+          });
+        } else if (value.toResolvedComposition) {
+          results[key] = compositeFrom(value.toResolvedComposition());
+        }
+      }
+
+      constructor[CacheableObject.propertyDescriptors] = {
+        ...constructor[CacheableObject.propertyDescriptors] ?? {},
+        ...results,
+      };
+    },
+
+    showFailedClasses(failedClasses) {
+      logError`Failed to evaluate property descriptors for classes: ${failedClasses.join(', ')}`;
+    },
+  });
+}
+
+function evaluateSerializeDescriptors() {
+  const opts = {...allClasses, serialize};
+
+  return descriptorAggregateHelper({
+    message: `Errors evaluating Thing class serialize descriptors`,
+
+    op(constructor) {
+      if (!constructor[Thing.getSerializeDescriptors]) {
+        return;
+      }
+
+      constructor[serialize.serializeDescriptors] =
+        constructor[Thing.getSerializeDescriptors](opts);
+    },
+
+    showFailedClasses(failedClasses) {
+      logError`Failed to evaluate serialize descriptors for classes: ${failedClasses.join(', ')}`;
+    },
+  });
+}
+
+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);
+
+flattenClassLists();
+
+if (!evaluatePropertyDescriptors())
+  process.exit(1);
+
+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
new file mode 100644
index 00000000..a3f861bd
--- /dev/null
+++ b/src/data/things/language.js
@@ -0,0 +1,913 @@
+import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
+
+import {withAggregate} from '#aggregate';
+import CacheableObject from '#cacheable-object';
+import {logWarn} from '#cli';
+import * as html from '#html';
+import {empty} from '#sugar';
+import {isLanguageCode} from '#validators';
+import Thing from '#thing';
+
+import {
+  getExternalLinkStringOfStyleFromDescriptors,
+  getExternalLinkStringsFromDescriptors,
+  isExternalLinkContext,
+  isExternalLinkSpec,
+  isExternalLinkStyle,
+} from '#external-links';
+
+import {externalFunction, flag, name} from '#composite/wiki-properties';
+
+export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g;
+
+export class Language extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    // General language code. This is used to identify the language distinctly
+    // from other languages (similar to how "Directory" operates in many data
+    // objects).
+    code: {
+      flags: {update: true, expose: true},
+      update: {validate: isLanguageCode},
+    },
+
+    // Human-readable name. This should be the language's own native name, not
+    // localized to any other language.
+    name: name(`Unnamed Language`),
+
+    // Language code specific to JavaScript's Internationalization (Intl) API.
+    // Usually this will be the same as the language's general code, but it
+    // may be overridden to provide Intl constructors an alternative value.
+    intlCode: {
+      flags: {update: true, expose: true},
+      update: {validate: isLanguageCode},
+      expose: {
+        dependencies: ['code'],
+        transform: (intlCode, {code}) => intlCode ?? code,
+      },
+    },
+
+    // Flag which represents whether or not to hide a language from general
+    // access. If a language is hidden, its portion of the website will still
+    // be built (with all strings localized to the language), but it won't be
+    // included in controls for switching languages or the <link rel=alternate>
+    // tags used for search engine optimization. This flag is intended for use
+    // with languages that are currently in development and not ready for
+    // formal release, or which are just kept hidden as "experimental zones"
+    // for wiki development or content testing.
+    hidden: flag(false),
+
+    // Mapping of translation keys to values (strings). Generally, don't
+    // access this object directly - use methods instead.
+    strings: {
+      flags: {update: true, expose: true},
+      update: {validate: (t) => typeof t === 'object'},
+
+      expose: {
+        dependencies: ['inheritedStrings', 'code'],
+        transform(strings, {inheritedStrings, code}) {
+          if (!strings && !inheritedStrings) return null;
+          if (!inheritedStrings) return strings;
+
+          const validStrings = {
+            ...inheritedStrings,
+            ...strings,
+          };
+
+          const optionsFromTemplate = template =>
+            Array.from(template.matchAll(languageOptionRegex))
+              .map(({groups}) => groups.name);
+
+          for (const [key, providedTemplate] of Object.entries(strings)) {
+            const inheritedTemplate = inheritedStrings[key];
+            if (!inheritedTemplate) continue;
+
+            const providedOptions = optionsFromTemplate(providedTemplate);
+            const inheritedOptions = optionsFromTemplate(inheritedTemplate);
+
+            const missingOptionNames =
+              inheritedOptions.filter(name => !providedOptions.includes(name));
+
+            const misplacedOptionNames =
+              providedOptions.filter(name => !inheritedOptions.includes(name));
+
+            if (!empty(missingOptionNames) || !empty(misplacedOptionNames)) {
+              logWarn`Not using ${code ?? '(no code)'} string ${key}:`;
+              if (!empty(missingOptionNames))
+                logWarn`- Missing options: ${missingOptionNames.join(', ')}`;
+              if (!empty(misplacedOptionNames))
+                logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`;
+              validStrings[key] = inheritedStrings[key];
+            }
+          }
+
+          return validStrings;
+        },
+      },
+    },
+
+    // May be provided to specify "default" strings, generally (but not
+    // necessarily) inherited from another Language object.
+    inheritedStrings: {
+      flags: {update: true, expose: true},
+      update: {validate: (t) => typeof t === 'object'},
+    },
+
+    // List of descriptors for providing to external link utilities when using
+    // language.formatExternalLink - refer to #external-links for info.
+    externalLinkSpec: {
+      flags: {update: true, expose: true},
+      update: {validate: isExternalLinkSpec},
+    },
+
+    // Update only
+
+    escapeHTML: externalFunction(),
+
+    // 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'}),
+    intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}),
+    intl_listUnit: this.#intlHelper(Intl.ListFormat, {type: 'unit'}),
+    intl_pluralCardinal: this.#intlHelper(Intl.PluralRules, {type: 'cardinal'}),
+    intl_pluralOrdinal: this.#intlHelper(Intl.PluralRules, {type: 'ordinal'}),
+
+    validKeys: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['strings', 'inheritedStrings'],
+        compute: ({strings, inheritedStrings}) =>
+          Array.from(
+            new Set([
+              ...Object.keys(inheritedStrings ?? {}),
+              ...Object.keys(strings ?? {}),
+            ])
+          ),
+      },
+    },
+
+    // TODO: This currently isn't used. Is it still needed?
+    strings_htmlEscaped: {
+      flags: {expose: true},
+      expose: {
+        dependencies: ['strings', 'inheritedStrings', 'escapeHTML'],
+        compute({strings, inheritedStrings, escapeHTML}) {
+          if (!(strings || inheritedStrings) || !escapeHTML) return null;
+          const allStrings = {...inheritedStrings, ...strings};
+          return Object.fromEntries(
+            Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)])
+          );
+        },
+      },
+    },
+  });
+
+  static #intlHelper (constructor, opts) {
+    return {
+      flags: {expose: true},
+      expose: {
+        dependencies: ['code', 'intlCode'],
+        compute: ({code, intlCode}) => {
+          const constructCode = intlCode ?? code;
+          if (!constructCode) return null;
+          return Reflect.construct(constructor, [constructCode, opts]);
+        },
+      },
+    };
+  }
+
+  $(...args) {
+    return this.formatString(...args);
+  }
+
+  assertIntlAvailable(property) {
+    if (!this[property]) {
+      throw new Error(`Intl API ${property} unavailable`);
+    }
+  }
+
+  getUnitForm(value) {
+    this.assertIntlAvailable('intl_pluralCardinal');
+    return this.intl_pluralCardinal.select(value);
+  }
+
+  formatString(...args) {
+    const hasOptions =
+      typeof args.at(-1) === 'object' &&
+      args.at(-1) !== null;
+
+    const key =
+      this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args);
+
+    const options =
+      (hasOptions
+        ? args.at(-1)
+        : {});
+
+    if (!this.strings) {
+      throw new Error(`Strings unavailable`);
+    }
+
+    if (!this.validKeys.includes(key)) {
+      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]) => [
+          constantCasify(name),
+          value,
+        ]));
+
+    const output = this.#iterateOverTemplate({
+      template: this.strings[key],
+
+      match: languageOptionRegex,
+
+      insert: ({name: optionName}, canceledForming) => {
+        if (!optionsMap.has(optionName)) {
+          missingOptionNames.add(optionName);
+
+          // 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.
+          // 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;
+      },
+    });
+
+    const misplacedOptionNames =
+      Array.from(optionsMap.keys());
+
+    withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => {
+      const names = set => Array.from(set).join(', ');
+
+      if (!empty(missingOptionNames)) {
+        push(new Error(
+          `Missing options: ${names(missingOptionNames)}`));
+      }
+
+      if (!empty(valuelessOptionNames)) {
+        push(new Error(
+          `Valueless options: ${names(valuelessOptionNames)}`));
+      }
+
+      if (!empty(misplacedOptionNames)) {
+        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;
+  }
+
+  #iterateOverTemplate({
+    template,
+    match: regexp,
+    insert: insertFn,
+  }) {
+    const outputParts = [];
+
+    let canceledForming = false;
+
+    let lastIndex = 0;
+    let partInProgress = '';
+
+    for (const match of template.matchAll(regexp)) {
+      const insertion =
+        insertFn(match.groups, canceledForming);
+
+      if (insertion === undefined) {
+        canceledForming = true;
+      }
+
+      // Don't proceed with forming logic if the insertion function has
+      // indicated that's not needed anymore - but continue iterating over
+      // the rest of the template's matches, so other iteration logic (with
+      // side effects) gets to process everything.
+      if (canceledForming) {
+        continue;
+      }
+
+      partInProgress += template.slice(lastIndex, match.index);
+
+      // Sanitize string arguments in particular. These are taken to come from
+      // (raw) data and may include special characters that aren't meant to be
+      // rendered as HTML markup.
+      const sanitizedInsertion =
+        this.#sanitizeValueForInsertion(insertion);
+
+      if (typeof sanitizedInsertion === 'string') {
+        // Join consecutive strings together.
+        partInProgress += sanitizedInsertion;
+      } else if (
+        sanitizedInsertion instanceof html.Tag &&
+        sanitizedInsertion.contentOnly
+      ) {
+        // Collapse string-only tag contents onto the current string part.
+        partInProgress += sanitizedInsertion.toString();
+      } else {
+        // Push the string part in progress, then the insertion as-is.
+        outputParts.push(partInProgress);
+        outputParts.push(sanitizedInsertion);
+        partInProgress = '';
+      }
+
+      lastIndex = match.index + match[0].length;
+    }
+
+    if (canceledForming) {
+      return undefined;
+    }
+
+    // Tack onto the final partInProgress, which may still have a value by this
+    // point, if the final inserted value was a string. (Otherwise, it'll just
+    // be equal to the remaining template text.)
+    if (lastIndex < template.length) {
+      partInProgress += template.slice(lastIndex);
+    }
+
+    if (partInProgress) {
+      outputParts.push(partInProgress);
+    }
+
+    return this.#wrapSanitized(outputParts);
+  }
+
+  // Processes a value so that it's suitable to be inserted into a template.
+  // For strings, this escapes HTML special characters, displaying them as-are
+  // instead of representing HTML markup. For numbers and booleans, this turns
+  // them into string values, so they never accidentally get caught as falsy
+  // by #html stringification. Everything else - most importantly including
+  // html.Tag objects - gets left as-is, preserving the value exactly as it's
+  // provided.
+  #sanitizeValueForInsertion(value) {
+    const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML');
+    if (!escapeHTML) {
+      throw new Error(`escapeHTML unavailable`);
+    }
+
+    switch (typeof value) {
+      case 'string':
+        return escapeHTML(value);
+
+      case 'number':
+      case 'boolean':
+        return value.toString();
+
+      default:
+        return value;
+    }
+  }
+
+  // Wraps the output of a formatting function in a no-name-nor-attributes
+  // HTML tag, which will indicate to other calls to formatString that this
+  // content is a string *that may contain HTML* and doesn't need to
+  // sanitized any further. It'll still .toString() to just the string
+  // contents, if needed.
+  #wrapSanitized(content) {
+    return html.tags(content, {
+      [html.blessAttributes]: true,
+      [html.joinChildren]: '',
+      [html.noEdgeWhitespace]: true,
+    });
+  }
+
+  // Similar to the above internal methods, but this one is public.
+  // It should be used when embedding content that may not have previously
+  // been sanitized directly into an HTML tag or template's contents.
+  // The templating engine usually handles this on its own, as does passing
+  // a value (sanitized or not) directly for inserting into formatting
+  // functions, but if you used a custom slot validation function (for example,
+  // {validate: v => v.isHTML} instead of {type: 'string'} / {type: 'html'})
+  // and are embedding the contents of the slot as a direct child of another
+  // tag, you should manually sanitize those contents with this function.
+  sanitize(value) {
+    if (typeof value === 'string') {
+      return this.#wrapSanitized(this.#sanitizeValueForInsertion(value));
+    } else {
+      return value;
+    }
+  }
+
+  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);
+  }
+
+  formatDateDuration({
+    years: numYears = 0,
+    months: numMonths = 0,
+    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});
+    const months = this.countMonths(numMonths, {unit: true});
+    const days = this.countDays(numDays, {unit: true});
+
+    if (numYears && numMonths && numDays)
+      basis = this.formatString('count.dateDuration.yearsMonthsDays', {years, months, days});
+    else if (numYears && numMonths)
+      basis = this.formatString('count.dateDuration.yearsMonths', {years, months});
+    else if (numYears && numDays)
+      basis = this.formatString('count.dateDuration.yearsDays', {years, days});
+    else if (numYears)
+      basis = this.formatString('count.dateDuration.years', {years});
+    else if (numMonths && numDays)
+      basis = this.formatString('count.dateDuration.monthsDays', {months, days});
+    else if (numMonths)
+      basis = this.formatString('count.dateDuration.months', {months});
+    else if (numDays)
+      basis = this.formatString('count.dateDuration.days', {days});
+    else
+      return this.formatString('count.dateDuration.zero');
+
+    if (approximate) {
+      return this.formatString('count.dateDuration.approximate', {
+        duration: basis,
+      });
+    } else {
+      return basis;
+    }
+  }
+
+  formatRelativeDate(currentDate, referenceDate, {
+    considerRoundingDays = false,
+    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);
+
+    const comparison =
+      Temporal.Instant.compare(currentInstant, referenceInstant);
+
+    if (comparison === 0) {
+      return this.formatString('count.dateDuration.same');
+    }
+
+    const currentTDZ = currentInstant.toZonedDateTimeISO('Etc/UTC');
+    const referenceTDZ = referenceInstant.toZonedDateTimeISO('Etc/UTC');
+
+    const earlierTDZ = (comparison === -1 ? currentTDZ : referenceTDZ);
+    const laterTDZ = (comparison === 1 ? currentTDZ : referenceTDZ);
+
+    const {years, months, days} =
+      laterTDZ.since(earlierTDZ, {
+        largestUnit: 'year',
+        smallestUnit:
+          (considerRoundingDays
+            ? (laterTDZ.since(earlierTDZ, {
+                largestUnit: 'year',
+                smallestUnit: 'day',
+              }).years
+                ? 'month'
+                : 'day')
+            : 'day'),
+        roundingMode: 'halfCeil',
+      });
+
+    const duration =
+      this.formatDateDuration({
+        years, months, days,
+        approximate: false,
+      });
+
+    const relative =
+      this.formatString(
+        'count.dateDuration',
+        (approximate && (years || months || days)
+          ? (comparison === -1
+              ? 'approximateEarlier'
+              : 'approximateLater')
+          : (comparison === -1
+              ? 'earlier'
+              : 'later')),
+        {duration});
+
+    if (absolute) {
+      return this.formatString('count.dateDuration.relativeAbsolute', {
+        relative,
+        absolute: this.formatDate(currentDate),
+      });
+    } else {
+      return relative;
+    }
+  }
+
+  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');
+    }
+
+    const hour = Math.floor(secTotal / 3600);
+    const min = Math.floor((secTotal - hour * 3600) / 60);
+    const sec = Math.floor(secTotal - hour * 3600 - min * 60);
+
+    const pad = (val) => val.toString().padStart(2, '0');
+
+    const stringSubkey = unit ? '.withUnit' : '';
+
+    const duration =
+      hour > 0
+        ? this.formatString('count.duration.hours' + stringSubkey, {
+            hours: hour,
+            minutes: pad(min),
+            seconds: pad(sec),
+          })
+        : this.formatString('count.duration.minutes' + stringSubkey, {
+            minutes: min,
+            seconds: pad(sec),
+          });
+
+    return approximate
+      ? this.formatString('count.duration.approximate', {duration})
+      : duration;
+  }
+
+  formatExternalLink(url, {
+    style = 'platform',
+    context = 'generic',
+  } = {}) {
+    if (!this.externalLinkSpec) {
+      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') {
+      return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, {
+        language: this,
+        context,
+      });
+    }
+
+    isExternalLinkStyle(style);
+
+    const result =
+      getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
+        language: this,
+        context,
+      });
+
+    // It's possible for there to not actually be any string available for the
+    // given URL, style, and context, and we want this to be detectable via
+    // html.blank().
+    return result ?? html.blank();
+  }
+
+  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
+    );
+
+    const words =
+      value > 1000
+        ? this.formatString('count.words.thousand', {words: num})
+        : this.formatString('count.words', {words: num});
+
+    return this.formatString('count.words.withUnit.' + this.getUnitForm(value), {words});
+  }
+
+  #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
+    // at these points afterwards.
+
+    const insertionMarkers =
+      Array.from(
+        {length: array.length},
+        (_item, index) => `<::insertion_${index}>`);
+
+    // Basically the same insertion logic as in formatString. Like there, we
+    // can't assume that insertion markers were kept in the same order as they
+    // were provided, so we'll refer to the marked index. But we don't need to
+    // worry about some of the indices *not* corresponding to a provided source
+    // item, like we do in formatString, so that cuts out a lot of the
+    // validation logic.
+
+    return this.#iterateOverTemplate({
+      template: processFn(insertionMarkers),
+
+      match: /<::insertion_(?<index>[0-9]+)>/g,
+
+      insert: ({index: markerIndex}) => {
+        return array[markerIndex];
+      },
+    });
+  }
+
+  // Conjunction list: A, B, and C
+  formatConjunctionList(array) {
+    this.assertIntlAvailable('intl_listConjunction');
+    return this.#formatListHelper(
+      array,
+      array => this.intl_listConjunction.format(array));
+  }
+
+  // Disjunction lists: A, B, or C
+  formatDisjunctionList(array) {
+    this.assertIntlAvailable('intl_listDisjunction');
+    return this.#formatListHelper(
+      array,
+      array => this.intl_listDisjunction.format(array));
+  }
+
+  // Unit lists: A, B, C
+  formatUnitList(array) {
+    this.assertIntlAvailable('intl_listUnit');
+    return this.#formatListHelper(
+      array,
+      array => this.intl_listUnit.format(array));
+  }
+
+  // Lists without separator: A B C
+  formatListWithoutSeparator(array) {
+    return this.#formatListHelper(
+      array,
+      array => array.join(' '));
+  }
+
+  // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
+  formatFileSize(bytes) {
+    // 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);
+
+    // Non-number bytes is blank content! Wow.
+    if (isNaN(bytes)) {
+      return html.blank();
+    }
+
+    const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10;
+
+    if (bytes >= 10 ** 12) {
+      return this.formatString('count.fileSize.terabytes', {
+        terabytes: round(12),
+      });
+    } else if (bytes >= 10 ** 9) {
+      return this.formatString('count.fileSize.gigabytes', {
+        gigabytes: round(9),
+      });
+    } else if (bytes >= 10 ** 6) {
+      return this.formatString('count.fileSize.megabytes', {
+        megabytes: round(6),
+      });
+    } else if (bytes >= 10 ** 3) {
+      return this.formatString('count.fileSize.kilobytes', {
+        kilobytes: round(3),
+      });
+    } else {
+      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,
+    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)
+        : `count.${stringKey}`,
+      {[optionName]: this.formatNumber(value)});
+  };
+
+// TODO: These are hard-coded. Is there a better way?
+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'),
+  countCoverArts: countHelper('coverArts'),
+  countDays: countHelper('days'),
+  countFlashes: countHelper('flashes'),
+  countMonths: countHelper('months'),
+  countTimesFeatured: countHelper('timesFeatured'),
+  countTimesReferenced: countHelper('timesReferenced'),
+  countTimesUsed: countHelper('timesUsed'),
+  countTracks: countHelper('tracks'),
+  countWeeks: countHelper('weeks'),
+  countYears: countHelper('years'),
+});
diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js
new file mode 100644
index 00000000..43d1638e
--- /dev/null
+++ b/src/data/things/news-entry.js
@@ -0,0 +1,73 @@
+export const NEWS_DATA_FILE = 'news.yaml';
+
+import {sortChronologically} from '#sort';
+import Thing from '#thing';
+import {parseDate} from '#yaml';
+
+import {contentString, directory, name, simpleDate}
+  from '#composite/wiki-properties';
+
+export class NewsEntry extends Thing {
+  static [Thing.referenceType] = 'news-entry';
+  static [Thing.friendlyName] = `News Entry`;
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    name: name('Unnamed News Entry'),
+    directory: directory(),
+    date: simpleDate(),
+
+    content: contentString(),
+
+    // Expose only
+
+    contentShort: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['content'],
+
+        compute: ({content}) => content.split('<hr class="split">')[0],
+      },
+    },
+  });
+
+  static [Thing.findSpecs] = {
+    newsEntry: {
+      referenceTypes: ['news-entry'],
+      bindTo: 'newsData',
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Name': {property: 'name'},
+      'Directory': {property: 'directory'},
+
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
+
+      'Content': {property: 'content'},
+    },
+  };
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {allInOne},
+    thingConstructors: {NewsEntry},
+  }) => ({
+    title: `Process news data file`,
+    file: NEWS_DATA_FILE,
+
+    documentMode: allInOne,
+    documentThing: NewsEntry,
+
+    save: (results) => ({newsData: results}),
+
+    sort({newsData}) {
+      sortChronologically(newsData, {latestFirst: true});
+    },
+  });
+}
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
new file mode 100644
index 00000000..52a09c31
--- /dev/null
+++ b/src/data/things/static-page.js
@@ -0,0 +1,85 @@
+export const DATA_STATIC_PAGE_DIRECTORY = 'static-page';
+
+import * as path from 'node:path';
+
+import {traverse} from '#node-utils';
+import {sortAlphabetically} from '#sort';
+import Thing from '#thing';
+import {isName} from '#validators';
+
+import {contentString, directory, flag, name, simpleString}
+  from '#composite/wiki-properties';
+
+export class StaticPage extends Thing {
+  static [Thing.referenceType] = 'static';
+  static [Thing.friendlyName] = `Static Page`;
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    name: name('Unnamed Static Page'),
+
+    nameShort: {
+      flags: {update: true, expose: true},
+      update: {validate: isName},
+
+      expose: {
+        dependencies: ['name'],
+        transform: (value, {name}) => value ?? name,
+      },
+    },
+
+    directory: directory(),
+
+    stylesheet: simpleString(),
+    script: simpleString(),
+    content: contentString(),
+
+    absoluteLinks: flag(),
+  });
+
+  static [Thing.findSpecs] = {
+    staticPage: {
+      referenceTypes: ['static'],
+      bindTo: 'staticPageData',
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Name': {property: 'name'},
+      'Short Name': {property: 'nameShort'},
+      'Directory': {property: 'directory'},
+
+      'Absolute Links': {property: 'absoluteLinks'},
+
+      'Style': {property: 'stylesheet'},
+      'Script': {property: 'script'},
+      'Content': {property: 'content'},
+
+      'Review Points': {ignore: true},
+    },
+  };
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {onePerFile},
+    thingConstructors: {StaticPage},
+  }) => ({
+    title: `Process static page files`,
+
+    files: dataPath =>
+      traverse(path.join(dataPath, DATA_STATIC_PAGE_DIRECTORY), {
+        filterFile: name => path.extname(name) === '.yaml',
+        prefixPath: DATA_STATIC_PAGE_DIRECTORY,
+      }),
+
+    documentMode: onePerFile,
+    documentThing: StaticPage,
+
+    save: (results) => ({staticPageData: results}),
+
+    sort({staticPageData}) {
+      sortAlphabetically(staticPageData);
+    },
+  });
+}
diff --git a/src/data/things/track.js b/src/data/things/track.js
new file mode 100644
index 00000000..bcf84aa8
--- /dev/null
+++ b/src/data/things/track.js
@@ -0,0 +1,753 @@
+import {inspect} from 'node:util';
+
+import CacheableObject from '#cacheable-object';
+import {colors} from '#cli';
+import {input} from '#composite';
+import Thing from '#thing';
+import {isBoolean, isColor, isContributionList, isDate, isFileExtension}
+  from '#validators';
+
+import {
+  parseAdditionalFiles,
+  parseAdditionalNames,
+  parseAnnotatedReferences,
+  parseArtwork,
+  parseContributors,
+  parseDate,
+  parseDimensions,
+  parseDuration,
+} from '#yaml';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import {
+  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,
+  simpleString,
+  singleReference,
+  soupyFind,
+  soupyReverse,
+  thing,
+  urls,
+  wikiData,
+} from '#composite/wiki-properties';
+
+import {
+  exitWithoutUniqueCoverArt,
+  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,
+    Artwork,
+    Flash,
+    TrackSection,
+    WikiInfo,
+  }) => ({
+    // Update & expose
+
+    name: name('Unnamed Track'),
+
+    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(),
+
+    bandcampTrackIdentifier: simpleString(),
+    bandcampArtworkIdentifier: simpleString(),
+
+    duration: duration(),
+    urls: urls(),
+    dateFirstReleased: simpleDate(),
+
+    color: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isColor),
+      }),
+
+      withContainingTrackSection(),
+
+      withPropertyFromObject({
+        object: '#trackSection',
+        property: input.value('color'),
+      }),
+
+      exposeDependencyOrContinue({dependency: '#trackSection.color'}),
+
+      withPropertyFromAlbum({
+        property: input.value('color'),
+      }),
+
+      exposeDependency({dependency: '#album.color'}),
+    ],
+
+    alwaysReferenceByDirectory: [
+      withAlwaysReferenceByDirectory(),
+      exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
+    ],
+
+    // Disables presenting the track as though it has its own unique artwork.
+    // This flag should only be used in select circumstances, i.e. to override
+    // an album's trackCoverArtists. This flag supercedes that property, as well
+    // as the track's own coverArtists.
+    disableUniqueCoverArt: flag(),
+
+    // File extension for track's corresponding media file. This represents the
+    // track's unique cover artwork, if any, and does not inherit the extension
+    // of the album's main artwork. It does inherit trackCoverArtFileExtension,
+    // if present on the album.
+    coverArtFileExtension: [
+      exitWithoutUniqueCoverArt(),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isFileExtension),
+      }),
+
+      withPropertyFromAlbum({
+        property: input.value('trackCoverArtFileExtension'),
+      }),
+
+      exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}),
+
+      exposeConstant({
+        value: input.value('jpg'),
+      }),
+    ],
+
+    coverArtDate: [
+      withTrackArtDate({
+        from: input.updateValue({
+          validate: isDate,
+        }),
+      }),
+
+      exposeDependency({dependency: '#trackArtDate'}),
+    ],
+
+    coverArtDimensions: [
+      exitWithoutUniqueCoverArt(),
+
+      exposeUpdateValueOrContinue(),
+
+      withPropertyFromAlbum({
+        property: input.value('trackDimensions'),
+      }),
+
+      exposeDependencyOrContinue({dependency: '#album.trackDimensions'}),
+
+      dimensions(),
+    ],
+
+    commentary: commentary(),
+    creditSources: commentary(),
+
+    lyrics: [
+      inheritFromMainRelease(),
+      lyrics(),
+    ],
+
+    additionalFiles: additionalFiles(),
+    sheetMusicFiles: additionalFiles(),
+    midiProjectFiles: additionalFiles(),
+
+    mainReleaseTrack: singleReference({
+      class: input.value(Track),
+      find: soupyFind.input('track'),
+    }),
+
+    artistContribs: [
+      inheritContributionListFromMainRelease(),
+
+      withDate(),
+
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackArtistContributions'),
+        date: '#date',
+      }).outputs({
+        '#resolvedContribs': '#artistContribs',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#artistContribs',
+        mode: input.value('empty'),
+      }),
+
+      withPropertyFromAlbum({
+        property: input.value('artistContribs'),
+      }),
+
+      withRecontextualizedContributionList({
+        list: '#album.artistContribs',
+        artistProperty: input.value('trackArtistContributions'),
+      }),
+
+      withRedatedContributionList({
+        list: '#album.artistContribs',
+        date: '#date',
+      }),
+
+      exposeDependency({dependency: '#album.artistContribs'}),
+    ],
+
+    contributorContribs: [
+      inheritContributionListFromMainRelease(),
+
+      withDate(),
+
+      contributionList({
+        date: '#date',
+        artistProperty: input.value('trackContributorContributions'),
+      }),
+    ],
+
+    coverArtistContribs: [
+      withCoverArtistContribs({
+        from: input.updateValue({
+          validate: isContributionList,
+        }),
+      }),
+
+      exposeDependency({dependency: '#coverArtistContribs'}),
+    ],
+
+    referencedTracks: [
+      inheritFromMainRelease({
+        notFoundValue: input.value([]),
+      }),
+
+      referenceList({
+        class: input.value(Track),
+        find: soupyFind.input('track'),
+      }),
+    ],
+
+    sampledTracks: [
+      inheritFromMainRelease({
+        notFoundValue: input.value([]),
+      }),
+
+      referenceList({
+        class: input.value(Track),
+        find: soupyFind.input('track'),
+      }),
+    ],
+
+    trackArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Track Artwork'),
+    ],
+
+    artTags: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
+
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
+      }),
+    ],
+
+    referencedArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
+
+      referencedArtworkList(),
+    ],
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // 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(),
+
+    date: [
+      withDate(),
+      exposeDependency({dependency: '#date'}),
+    ],
+
+    trackNumber: [
+      withTrackNumber(),
+      exposeDependency({dependency: '#trackNumber'}),
+    ],
+
+    hasUniqueCoverArt: [
+      withHasUniqueCoverArt(),
+      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: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichReference'),
+    }),
+
+    sampledByTracks: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichSample'),
+    }),
+
+    featuredInFlashes: reverseReferenceList({
+      reverse: soupyReverse.input('flashesWhichFeature'),
+    }),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Track': {property: 'name'},
+      'Directory': {property: 'directory'},
+      'Suffix Directory': {property: 'suffixDirectoryFromAlbum'},
+
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
+      'Bandcamp Track ID': {
+        property: 'bandcampTrackIdentifier',
+        transform: String,
+      },
+
+      'Bandcamp Artwork ID': {
+        property: 'bandcampArtworkIdentifier',
+        transform: String,
+      },
+
+      'Duration': {
+        property: 'duration',
+        transform: parseDuration,
+      },
+
+      'Color': {property: 'color'},
+      'URLs': {property: 'urls'},
+
+      'Date First Released': {
+        property: 'dateFirstReleased',
+        transform: parseDate,
+      },
+
+      'Cover Art Date': {
+        property: 'coverArtDate',
+        transform: parseDate,
+      },
+
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
+      'Has Cover Art': {
+        property: 'disableUniqueCoverArt',
+        transform: value =>
+          (typeof value === 'boolean'
+            ? !value
+            : value),
+      },
+
+      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+
+      'Lyrics': {property: 'lyrics'},
+      'Commentary': {property: 'commentary'},
+      'Credit Sources': {property: 'creditSources'},
+
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'Sheet Music Files': {
+        property: 'sheetMusicFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'MIDI Project Files': {
+        property: 'midiProjectFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      '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},
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Contributors': {
+        property: 'contributorContribs',
+        transform: parseContributors,
+      },
+
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        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: `Secondary releases inherit references from the main one`, fields: [
+        'Main Release',
+        'Referenced Tracks',
+      ]},
+
+      {message: `Secondary releases inherit samples from the main one`, fields: [
+        'Main Release',
+        'Sampled Tracks',
+      ]},
+
+      {message: `Secondary releases inherit artists from the main one`, fields: [
+        'Main Release',
+        'Artists',
+      ]},
+
+      {message: `Secondary releases inherit contributors from the main one`, fields: [
+        'Main Release',
+        'Contributors',
+      ]},
+
+      {message: `Secondary releases inherit lyrics from the main one`, fields: [
+        'Main Release',
+        'Lyrics',
+      ]},
+
+      {
+        message: ({'Has Cover Art': hasCoverArt}) =>
+          (hasCoverArt
+            ? `"Has Cover Art: true" is inferred from cover artist credits`
+            : `Tracks without cover art must not have cover artist credits`),
+
+        fields: [
+          'Has Cover Art',
+          'Cover Artists',
+        ],
+      },
+    ],
+  };
+
+  static [Thing.findSpecs] = {
+    track: {
+      referenceTypes: ['track'],
+
+      bindTo: 'trackData',
+
+      getMatchableNames: track =>
+        (track.alwaysReferenceByDirectory
+          ? []
+          : [track.name]),
+    },
+
+    trackMainReleasesOnly: {
+      referenceTypes: ['track'],
+      bindTo: 'trackData',
+
+      include: track =>
+        !CacheableObject.getUpdateValue(track, 'mainReleaseTrack'),
+
+      // It's still necessary to check alwaysReferenceByDirectory here, since
+      // it may be set manually (with `Always Reference By Directory: true`),
+      // and these shouldn't be matched by name (as per usual).
+      // See the definition for that property for more information.
+      getMatchableNames: track =>
+        (track.alwaysReferenceByDirectory
+          ? []
+          : [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, 'mainReleaseTrack')) {
+      parts.unshift(`${colors.yellow('[secrelease]')} `);
+    }
+
+    let album;
+
+    if (depth >= 0) {
+      album = this.album;
+    }
+
+    if (album) {
+      const albumName = album.name;
+      const albumIndex = album.tracks.indexOf(this);
+      const trackNum =
+        (albumIndex === -1
+          ? 'indeterminate position'
+          : `#${albumIndex + 1}`);
+      parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`);
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
new file mode 100644
index 00000000..590598be
--- /dev/null
+++ b/src/data/things/wiki-info.js
@@ -0,0 +1,152 @@
+export const WIKI_INFO_FILE = 'wiki-info.yaml';
+
+import {input} from '#composite';
+import Thing from '#thing';
+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 {
+  static [Thing.friendlyName] = `Wiki Info`;
+
+  static [Thing.getPropertyDescriptors] = ({Group}) => ({
+    // Update & expose
+
+    name: name('Unnamed Wiki'),
+
+    // Displayed in nav bar.
+    nameShort: {
+      flags: {update: true, expose: true},
+      update: {validate: isName},
+
+      expose: {
+        dependencies: ['name'],
+        transform: (value, {name}) => value ?? name,
+      },
+    },
+
+    color: {
+      flags: {update: true, expose: true},
+      update: {validate: isColor},
+
+      expose: {
+        transform: color => color ?? '#0088ff',
+      },
+    },
+
+    // One-line description used for <meta rel="description"> tag.
+    description: contentString(),
+
+    footerContent: contentString(),
+
+    defaultLanguage: {
+      flags: {update: true, expose: true},
+      update: {validate: isLanguageCode},
+    },
+
+    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: soupyFind.input('group'),
+    }),
+
+    contributionPresets: {
+      flags: {update: true, expose: true},
+      update: {validate: isContributionPresetList},
+    },
+
+    // Feature toggles
+    enableFlashesAndGames: flag(false),
+    enableListings: flag(false),
+    enableNews: flag(false),
+    enableArtTagUI: flag(false),
+    enableGroupUI: flag(false),
+
+    enableSearch: [
+      exitWithoutDependency({
+        dependency: 'searchDataAvailable',
+        mode: input.value('falsy'),
+        value: input.value(false),
+      }),
+
+      flag(true),
+    ],
+
+    // Update only
+
+    find: soupyFind(),
+
+    searchDataAvailable: {
+      flags: {update: true},
+      update: {
+        validate: isBoolean,
+        default: false,
+      },
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Name': {property: 'name'},
+      'Short Name': {property: 'nameShort'},
+      'Color': {property: 'color'},
+      'Description': {property: 'description'},
+      'Footer Content': {property: 'footerContent'},
+      'Default Language': {property: 'defaultLanguage'},
+      'Canonical Base': {property: 'canonicalBase'},
+      'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'},
+      'Enable Flashes & Games': {property: 'enableFlashesAndGames'},
+      'Enable Listings': {property: 'enableListings'},
+      'Enable News': {property: 'enableNews'},
+      'Enable Art Tag UI': {property: 'enableArtTagUI'},
+      'Enable Group UI': {property: 'enableGroupUI'},
+
+      'Contribution Presets': {
+        property: 'contributionPresets',
+        transform: parseContributionPresets,
+      },
+    },
+  };
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {oneDocumentTotal},
+    thingConstructors: {WikiInfo},
+  }) => ({
+    title: `Process wiki info file`,
+    file: WIKI_INFO_FILE,
+
+    documentMode: oneDocumentTotal,
+    documentThing: WikiInfo,
+
+    save(wikiInfo) {
+      if (!wikiInfo) {
+        return;
+      }
+
+      return {wikiInfo};
+    },
+  });
+}
diff --git a/src/data/yaml.js b/src/data/yaml.js
new file mode 100644
index 00000000..50317238
--- /dev/null
+++ b/src/data/yaml.js
@@ -0,0 +1,1851 @@
+// yaml.js - specification for HSMusic YAML data file format and utilities for
+// loading, processing, and validating YAML files and documents
+
+import {readFile, stat} from 'node:fs/promises';
+import * as path from 'node:path';
+import {inspect as nodeInspect} from 'node:util';
+
+import yaml from 'js-yaml';
+
+import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
+import {sortByName} from '#sort';
+import Thing from '#thing';
+import thingConstructors from '#things';
+
+import {
+  aggregateThrows,
+  annotateErrorWithFile,
+  decorateErrorWithIndex,
+  decorateErrorWithAnnotation,
+  openAggregate,
+  showAggregate,
+} 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});
+}
+
+// General function for inputting a single document (usually loaded from YAML)
+// and outputting an instance of a provided Thing subclass.
+//
+// makeProcessDocument is a factory function: the returned function will take a
+// document and apply the configuration passed to makeProcessDocument in order
+// to construct a Thing subclass.
+//
+function makeProcessDocument(thingConstructor, {
+  // The bulk of configuration happens here in the spec's `fields` property.
+  // Each key is a field that's expected on the source document; fields that
+  // don't match one of these keys will cause an error. Values are object
+  // entries describing what to do with the field.
+  //
+  // A field entry's `property` tells what property the value for this field
+  // will be put into, on the respective Thing (subclass) instance.
+  //
+  // A field entry's `transform` optionally allows converting the raw value in
+  // YAML into some other format before providing setting it on the Thing
+  // instance.
+  //
+  // If a field entry has `ignore: true`, it will be completely skipped by the
+  // YAML parser - it won't be validated, read, or loaded into data objects.
+  // This is mainly useful for fields that are purely annotational or are
+  // currently placeholders.
+  //
+  fields: fieldSpecs = {},
+
+  // List of fields which are invalid when coexisting in a document.
+  // Data objects are generally allowing with regards to what properties go
+  // together, allowing for properties to be set separately from each other
+  // instead of complaining about invalid or unused-data cases. But it's
+  // useful to see these kinds of errors when actually validating YAML files!
+  //
+  // Each item of this array should itself be an object with a descriptive
+  // message and a list of fields. Of those fields, none should ever coexist
+  // with any other. For example:
+  //
+  //   [
+  //     {message: '...', fields: ['A', 'B', 'C']},
+  //     {message: '...', fields: ['C', 'D']},
+  //   ]
+  //
+  // ...means A can't coexist with B or C, B can't coexist with A or C, and
+  // C can't coexist iwth A, B, or D - but it's okay for D to coexist with
+  // 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`);
+  }
+
+  if (!fieldSpecs) {
+    throw new Error(`Expected fields to be provided`);
+  }
+
+  if (!bouncer) {
+    throw new Error(`Missing processDocument bouncer`);
+  }
+
+  const knownFields = Object.keys(fieldSpecs);
+
+  const ignoredFields =
+    Object.entries(fieldSpecs)
+      .filter(([, {ignore}]) => ignore)
+      .map(([field]) => field);
+
+  const propertyToField =
+    withEntries(fieldSpecs, entries => entries
+      .map(([field, {property}]) => [property, field]));
+
+  // TODO: Is this function even necessary??
+  // Aren't we doing basically the same work in the function it's decorating???
+  const decorateErrorWithName = (fn) => {
+    const nameField = propertyToField.name;
+    if (!nameField) return fn;
+
+    return (document) => {
+      try {
+        return fn(document);
+      } catch (error) {
+        const name = document[nameField];
+        error.message = name
+          ? `(name: ${inspect(name)}) ${error.message}`
+          : `(${colors.dim(`no name found`)}) ${error.message}`;
+        throw error;
+      }
+    };
+  };
+
+  return decorateErrorWithName((document) => {
+    const nameField = propertyToField.name;
+    const namePart =
+      (nameField
+        ? (document[nameField]
+          ? ` named ${colors.green(`"${document[nameField]}"`)}`
+          : ` (name field, "${nameField}", not specified)`)
+        : ``);
+
+    const constructorPart =
+      (thingConstructor[Thing.friendlyName]
+        ? thingConstructor[Thing.friendlyName]
+     : thingConstructor.name
+        ? thingConstructor.name
+        : `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));
+
+    const skippedFields = new Set();
+
+    const unknownFields = documentEntries
+      .map(([field]) => field)
+      .filter((field) => !knownFields.includes(field));
+
+    if (!empty(unknownFields)) {
+      aggregate.push(new UnknownFieldsError(unknownFields));
+
+      for (const field of unknownFields) {
+        skippedFields.add(field);
+      }
+    }
+
+    const presentFields = Object.keys(document);
+
+    const fieldCombinationErrors = [];
+
+    for (const {message, fields} of invalidFieldCombinations) {
+      const fieldsPresent =
+        presentFields.filter(field => fields.includes(field));
+
+      if (fieldsPresent.length >= 2) {
+        const filteredDocument =
+          filterProperties(
+            document,
+            fieldsPresent,
+            {preserveOriginalOrder: true});
+
+        fieldCombinationErrors.push(
+          new FieldCombinationError(filteredDocument, message));
+
+        for (const field of Object.keys(filteredDocument)) {
+          skippedFields.add(field);
+        }
+      }
+    }
+
+    if (!empty(fieldCombinationErrors)) {
+      aggregate.push(new FieldCombinationAggregateError(fieldCombinationErrors));
+    }
+
+    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, transformUtilities)
+          : documentValue);
+
+      // Completely blank items in a YAML list are read as null.
+      // They're handy to have around when filling out a document and shouldn't
+      // be considered an error (or data at all).
+      if (Array.isArray(propertyValue)) {
+        const wasEmpty = empty(propertyValue);
+
+        propertyValue =
+          propertyValue.filter(item => item !== null);
+
+        const isEmpty = empty(propertyValue);
+
+        // Don't set arrays which are empty as a result of the above filter.
+        // Arrays which were originally empty, i.e. `Field: []`, are still
+        // valid data, but if it's just an array not containing any filled out
+        // items, it should be treated as a placeholder and skipped over.
+        if (isEmpty && !wasEmpty) {
+          propertyValue = null;
+        }
+      }
+
+      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 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 = [];
+
+    for (const [field, value] of Object.entries(fieldValues)) {
+      const {property} = fieldSpecs[field];
+
+      try {
+        thing[property] = value;
+      } catch (caughtError) {
+        skippedFields.add(field);
+        fieldValueErrors.push(new FieldValueError(
+          field, value, {cause: caughtError}));
+      }
+    }
+
+    if (!empty(fieldValueErrors)) {
+      aggregate.push(new FieldValueAggregateError(
+        fieldValueErrors, thingConstructor));
+    }
+
+    if (skippedFields.size >= 1) {
+      aggregate.push(
+        new SkippedFieldsSummaryError(
+          filterProperties(
+            document,
+            Array.from(skippedFields),
+            {preserveOriginalOrder: true})));
+    }
+
+    return {thing, aggregate};
+  });
+}
+
+export class ProcessDocumentError extends AggregateError {}
+
+export class UnknownFieldsError extends Error {
+  constructor(fields) {
+    super(`Unknown fields ignored: ${fields.map(field => colors.red(field)).join(', ')}`);
+    this.fields = fields;
+  }
+}
+
+export class FieldCombinationAggregateError extends AggregateError {
+  constructor(errors) {
+    super(errors, `Invalid field combinations - all involved fields ignored`);
+  }
+}
+
+export class FieldCombinationError extends Error {
+  constructor(fields, message) {
+    const fieldNames = Object.keys(fields);
+
+    const fieldNamesText =
+      fieldNames
+        .map(field => colors.red(field))
+        .join(', ');
+
+    const mainMessage = `Don't combine ${fieldNamesText}`;
+
+    const causeMessage =
+      (typeof message === 'function'
+        ? message(fields)
+     : typeof message === 'string'
+        ? message
+        : null);
+
+    super(mainMessage, {
+      cause:
+        (causeMessage
+          ? new Error(causeMessage)
+          : null),
+    });
+
+    this.fields = fields;
+  }
+}
+
+export class FieldValueAggregateError extends AggregateError {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+  constructor(errors, thingConstructor) {
+    const constructorText =
+      colors.green(thingConstructor.name);
+
+    super(
+      errors,
+      `Errors processing field values for ${constructorText}`);
+  }
+}
+
+export class FieldValueError extends Error {
+  constructor(field, value, options) {
+    const fieldText =
+      colors.green(`"${field}"`);
+
+    const valueText =
+      inspect(value, {maxStringLength: 40});
+
+    super(
+      `Failed to set ${fieldText} field to ${valueText}`,
+      options);
+  }
+}
+
+export class SkippedFieldsSummaryError extends Error {
+  constructor(filteredDocument) {
+    const entries = Object.entries(filteredDocument);
+
+    const lines =
+      entries.map(([field, value]) =>
+        ` - ${field}: ` +
+        inspect(value, {maxStringLength: 70})
+          .split('\n')
+          .map((line, index) => index === 0 ? line : `   ${line}`)
+          .join('\n'));
+
+    const numFieldsText =
+      (entries.length === 1
+        ? `1 field`
+        : `${entries.length} fields`);
+
+    super(
+      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);
+}
+
+export function parseDuration(string) {
+  if (typeof string !== 'string') {
+    return string;
+  }
+
+  const parts = string.split(':').map((n) => parseInt(n));
+  if (parts.length === 3) {
+    return parts[0] * 3600 + parts[1] * 60 + parts[2];
+  } else if (parts.length === 2) {
+    return parts[0] * 60 + parts[1];
+  } else {
+    return 0;
+  }
+}
+
+export const extractAccentRegex =
+  /^(?<main>.*?)(?: \((?<accent>.*)\))?$/;
+
+export const extractPrefixAccentRegex =
+  /^(?:\((?<accent>.*)\) )?(?<main>.*?)$/;
+
+// 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(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;
+  }
+
+  const nonNullEntries =
+    entries.filter(value => value !== null);
+
+  // On the other hand, if the array only contains null, it's just
+  // a placeholder, so skip over the field like it's not actually
+  // been put there yet.
+  if (empty(nonNullEntries)) {
+    return null;
+  }
+
+  return entries.map(mapFn);
+}
+
+export function parseContributors(entries) {
+  return parseArrayEntries(entries, item => {
+    if (typeof item === 'object' && item['Who'])
+      return {
+        artist: item['Who'],
+        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;
+
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
+
+    return {
+      artist: match.groups.main,
+      annotation: match.groups.accent ?? null,
+    };
+  });
+}
+
+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'],
+    };
+  });
+}
+
+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;
+
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
+
+    return {
+      name: match.groups.main,
+      annotation: match.groups.accent ?? null,
+    };
+  });
+}
+
+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,
+  // the Thing object's validators will handle the error.
+  if (typeof string !== 'string') {
+    return string;
+  }
+
+  const parts = string.split(/[x,* ]+/g);
+
+  if (parts.length !== 2) {
+    throw new Error(`Invalid dimensions: ${string} (expected "width & height")`);
+  }
+
+  const nums = parts.map((part) => Number(part.trim()));
+
+  if (nums.includes(NaN)) {
+    throw new Error(`Invalid dimensions: ${string} (couldn't parse as numbers)`);
+  }
+
+  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 = {
+  // onePerFile: One document per file. Expects files array (or function) and
+  // processDocument function. Obviously, each specified data file should only
+  // contain one YAML document (an error will be thrown otherwise). Calls save
+  // with an array of processed documents (wiki objects).
+  onePerFile: Symbol('Document mode: onePerFile'),
+
+  // headerAndEntries: One or more documents per file; the first document is
+  // treated as a "header" and represents data which pertains to all following
+  // "entry" documents. Expects files array (or function) and
+  // processHeaderDocument and processEntryDocument functions. Calls save with
+  // an array of {header, entries} objects.
+  //
+  // Please note that the final results loaded from each file may be "missing"
+  // data objects corresponding to entry documents if the processEntryDocument
+  // function throws on any entries, resulting in partial data provided to
+  // save() - errors will be caught and thrown in the final buildSteps
+  // aggregate. However, if the processHeaderDocument function fails, all
+  // following documents in the same file will be ignored as well (i.e. an
+  // entire file will be excempt from the save() function's input).
+  headerAndEntries: Symbol('Document mode: headerAndEntries'),
+
+  // allInOne: One or more documents, all contained in one file. Expects file
+  // string (or function) and processDocument function. Calls save with an
+  // array of processed documents (wiki objects).
+  allInOne: Symbol('Document mode: allInOne'),
+
+  // oneDocumentTotal: Just a single document, represented in one file.
+  // Expects file string (or function) and processDocument function. Calls
+  // save with the single processed wiki document (data object).
+  //
+  // Please note that if the single document fails to process, the save()
+  // function won't be called at all, generally resulting in an altogether
+  // missing property from the global wikiData object. This should be caught
+  // and handled externally.
+  oneDocumentTotal: Symbol('Document mode: oneDocumentTotal'),
+};
+
+// dataSteps: Top-level array of "steps" for loading YAML document files.
+//
+// title:
+//   Name of the step (displayed in build output)
+//
+// documentMode:
+//   Symbol which indicates by which "mode" documents from data files are
+//   loaded and processed. See documentModes export.
+//
+// file, files:
+//   String or array of strings which are paths to YAML data files, or a
+//   function which returns the above (may be async). All paths are appended to
+//   the global dataPath provided externally (e.g. HSMUSIC_DATA env variable).
+//   Which to provide (file or files) depends on documentMode. If this is a
+//   function, it will be provided with dataPath (e.g. so that a sub-path may be
+//   readdir'd), but don't path.join(dataPath) the returned value(s) yourself -
+//   this will be done automatically.
+//
+// processDocument, processHeaderDocument, processEntryDocument:
+//   Functions which take a YAML document and return an actual wiki data object;
+//   all actual conversion between YAML and wiki data happens here. Which to
+//   provide (one or a combination) depend on documentMode.
+//
+// save:
+//   Function which takes all documents processed (now as wiki data objects) and
+//   actually applies them to a global wiki data object, for use in page
+//   generation and other behavior. Returns an object to be assigned over the
+//   global wiki data object (so specify any new properties here). This is also
+//   the place to perform any final post-processing on data objects (linking
+//   them to each other, setting additional properties, etc). Input argument
+//   format depends on documentMode.
+//
+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,
+    }));
+  }
+
+  sortByName(steps, {getName: step => step.title});
+
+  return steps;
+}
+
+export async function getFilesFromDataStep(dataStep, {dataPath}) {
+  const {documentMode} = dataStep;
+
+  switch (documentMode) {
+    case documentModes.allInOne:
+    case documentModes.oneDocumentTotal: {
+      if (!dataStep.file) {
+        throw new Error(`Expected 'file' property for ${documentMode.toString()}`);
+      }
+
+      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 (statResult) {
+        return [fileUnderDataPath];
+      } else {
+        return [];
+      }
+    }
+
+    case documentModes.headerAndEntries:
+    case documentModes.onePerFile: {
+      if (!dataStep.files) {
+        throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
+      }
+
+      const localFiles =
+        (typeof dataStep.files === 'function'
+          ? await dataStep.files(dataPath).then(
+              files => files,
+              error => {
+                if (error.code === 'ENOENT') {
+                  return [];
+                } else {
+                  throw error;
+                }
+              })
+          : dataStep.files);
+
+      const filesUnderDataPath =
+        localFiles
+          .map(file => path.join(dataPath, file));
+
+      return filesUnderDataPath;
+    }
+
+    default:
+      throw new Error(`Unknown document mode ${documentMode.toString()}`);
+  }
+}
+
+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 aggregate = openAggregate({
+    message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`,
+  });
+
+  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('')));
+    }
+  }
+
+  return {result: filteredDocuments, aggregate};
+}
+
+// Mapping from dataStep (spec) object each to a sub-map, from thing class to
+// processDocument function.
+const processDocumentFns = new WeakMap();
+
+export function processThingsFromDataStep(documents, dataStep) {
+  let submap;
+  if (processDocumentFns.has(dataStep)) {
+    submap = processDocumentFns.get(dataStep);
+  } else {
+    submap = new Map();
+    processDocumentFns.set(dataStep, submap);
+  }
+
+  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)}`);
+      }
+
+      if (!(thingClass.prototype instanceof Thing)) {
+        throw new Error(`Expected a thing class, got ${thingClass.name}`);
+      }
+
+      const spec = thingClass[Thing.yamlDocumentSpec];
+
+      if (!spec) {
+        throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
+      }
+
+      fn = makeProcessDocument(thingClass, {...spec, processDocument});
+      submap.set(thingClass, fn);
+    }
+
+    return fn(document);
+  }
+
+  const {documentMode} = dataStep;
+
+  switch (documentMode) {
+    case documentModes.allInOne: {
+      const result = [];
+      const aggregate = openAggregate({message: `Errors processing documents`});
+
+      documents.forEach(
+        decorateErrorWithIndex((document, index) => {
+          const {thing, aggregate: subAggregate} =
+            processDocument(document, dataStep.documentThing);
+
+          thing[Thing.yamlSourceDocument] = document;
+          thing[Thing.yamlSourceDocumentPlacement] =
+            [documentModes.allInOne, index];
+
+          result.push(thing);
+          aggregate.call(subAggregate.close);
+        }));
+
+      return {
+        aggregate,
+        result,
+        things: result,
+      };
+    }
+
+    case documentModes.oneDocumentTotal: {
+      if (documents.length > 1)
+        throw new Error(`Only expected one document to be present, got ${documents.length}`);
+
+      const {thing, aggregate} =
+        processDocument(documents[0], dataStep.documentThing);
+
+      thing[Thing.yamlSourceDocument] = documents[0];
+      thing[Thing.yamlSourceDocumentPlacement] =
+        [documentModes.oneDocumentTotal];
+
+      return {
+        aggregate,
+        result: thing,
+        things: [thing],
+      };
+    }
+
+    case documentModes.headerAndEntries: {
+      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 "---"?)`);
+
+      const aggregate = openAggregate({message: `Errors processing documents`});
+
+      const {thing: headerThing, aggregate: headerAggregate} =
+        processDocument(headerDocument, dataStep.headerDocumentThing);
+
+      headerThing[Thing.yamlSourceDocument] = headerDocument;
+      headerThing[Thing.yamlSourceDocumentPlacement] =
+        [documentModes.headerAndEntries, 'header'];
+
+      try {
+        headerAggregate.close();
+      } catch (caughtError) {
+        caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`;
+        aggregate.push(caughtError);
+      }
+
+      const entryThings = [];
+
+      for (const [index, entryDocument] of entryDocuments.entries()) {
+        const {thing: entryThing, aggregate: entryAggregate} =
+          processDocument(entryDocument, dataStep.entryDocumentThing);
+
+        entryThing[Thing.yamlSourceDocument] = entryDocument;
+        entryThing[Thing.yamlSourceDocumentPlacement] =
+          [documentModes.headerAndEntries, 'entry', index];
+
+        entryThings.push(entryThing);
+
+        try {
+          entryAggregate.close();
+        } catch (caughtError) {
+          caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`;
+          aggregate.push(caughtError);
+        }
+      }
+
+      return {
+        aggregate,
+        result: {
+          header: headerThing,
+          entries: entryThings,
+        },
+        things: [headerThing, ...entryThings],
+      };
+    }
+
+    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 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);
+            }
+
+            const close = decorateErrorWithFileFromDataPath(aggregate.close, {dataPath});
+            aggregate.close = () => close({file});
+
+            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 thingLists =
+    aggregate
+      .receive(await Promise.all(dataStepPromises));
+
+  return {aggregate, result: thingLists};
+}
+
+// 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;
+
+  switch (documentMode) {
+    case documentModes.allInOne: {
+      const things =
+        (empty(thingLists)
+          ? []
+          : thingLists[0]);
+
+      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()}`);
+  }
+}
+
+// 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, {bindFind, bindReverse}) {
+  const linkWikiDataSpec = new Map([
+    // entries must be present here even without any properties to explicitly
+    // link if the 'find' or 'reverse' properties will be implicitly linked
+
+    ['albumData', [
+      'artworkData',
+      'wikiInfo',
+    ]],
+
+    ['artTagData', [/* reverse */]],
+
+    ['artistData', [/* find, reverse */]],
+
+    ['artworkData', ['artworkData']],
+
+    ['flashData', [
+      'wikiInfo',
+    ]],
+
+    ['flashActData', [/* find, reverse */]],
+
+    ['flashSideData', [/* find */]],
+
+    ['groupData', [/* find, reverse */]],
+
+    ['groupCategoryData', [/* find */]],
+
+    ['homepageLayout.sections.rows', [/* find */]],
+
+    ['trackData', [
+      'artworkData',
+      'trackData',
+      'wikiInfo',
+    ]],
+
+    ['trackSectionData', [/* reverse */]],
+
+    ['wikiInfo', [/* find */]],
+  ]);
+
+  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(dataSteps, wikiData, {bindFind, bindReverse}) {
+  for (const [key, value] of Object.entries(wikiData)) {
+    if (!Array.isArray(value)) continue;
+    wikiData[key] = value.slice();
+  }
+
+  for (const step of dataSteps) {
+    if (!step.sort) continue;
+    step.sort(wikiData);
+  }
+
+  // Re-link data arrays, so that every object has the new, sorted versions.
+  // Note that the sorting step deliberately creates new arrays (mutating
+  // 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, {bindFind, bindReverse});
+}
+
+// Utility function for loading all wiki data from the provided YAML data
+// directory (e.g. the root of the hsmusic-data repository). This doesn't
+// provide much in the way of customization; it's meant to be used more as
+// a boilerplate for more specialized output, or as a quick start in utilities
+// 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(dataSteps, {dataPath});
+
+    wikiData = result;
+
+    try {
+      aggregate.close();
+      logInfo`Loaded data without errors. (complete data)`;
+    } catch (error) {
+      showAggregate(error);
+      logWarn`Loaded data with errors. (partial data)`;
+    }
+  }
+
+  linkWikiDataArrays(wikiData, {bindFind, bindReverse});
+
+  try {
+    reportDirectoryErrors(wikiData, {getAllFindSpecs});
+    logInfo`No duplicate directories found. (complete data)`;
+  } catch (error) {
+    showAggregate(error);
+    logWarn`Duplicate directories found. (partial data)`;
+  }
+
+  try {
+    filterReferenceErrors(wikiData, {find, bindFind}).close();
+    logInfo`No reference errors found. (complete data)`;
+  } catch (error) {
+    showAggregate(error);
+    logWarn`Reference errors found. (partial data)`;
+  }
+
+  try {
+    reportContentTextErrors(wikiData, {bindFind});
+    logInfo`No content text errors found.`;
+  } catch (error) {
+    showAggregate(error);
+    logWarn`Content text errors found.`;
+  }
+
+  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) {
+  const dividerRegex = /^-{3,}\n?/gm;
+  let previousDivider = '';
+
+  while (true) {
+    const {lastIndex} = dividerRegex;
+    const match = dividerRegex.exec(sourceText);
+    if (match) {
+      const nextDivider = match[0].trim();
+
+      yield {
+        previousDivider,
+        nextDivider,
+        text: sourceText.slice(lastIndex, match.index),
+      };
+
+      previousDivider = nextDivider;
+    } else {
+      const nextDivider = '';
+
+      yield {
+        previousDivider,
+        nextDivider,
+        text: sourceText.slice(lastIndex).replace(/(?<!\n)$/, '\n'),
+      };
+
+      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 + '\n';
+    }
+
+    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/external-links.js b/src/external-links.js
new file mode 100644
index 00000000..1055a391
--- /dev/null
+++ b/src/external-links.js
@@ -0,0 +1,1035 @@
+import {empty, stitchArrays, withEntries} from '#sugar';
+
+import {
+  anyOf,
+  is,
+  isBoolean,
+  isObject,
+  isStringNonEmpty,
+  looseArrayOf,
+  optional,
+  validateAllPropertyValues,
+  validateArrayItems,
+  validateInstanceOf,
+  validateProperties,
+} from '#validators';
+
+export const externalLinkStyles = [
+  'platform',
+  'handle',
+  'icon-id',
+];
+
+export const isExternalLinkStyle = is(...externalLinkStyles);
+
+export const externalLinkContexts = [
+  'album',
+  'albumOneTrack',
+  'albumMultipleTracks',
+  'albumNoTracks',
+  'artist',
+  'flash',
+  'generic',
+  'group',
+  'track',
+];
+
+export const isExternalLinkContext =
+  anyOf(
+    is(...externalLinkContexts),
+    looseArrayOf(is(...externalLinkContexts)));
+
+// This might need to be adjusted for YAML importing...
+const isRegExp =
+  validateInstanceOf(RegExp);
+
+export const isExternalLinkTransformCommand =
+  is(...[
+    'decode-uri',
+    'find-replace',
+  ]);
+
+export const isExternalLinkTransformSpec =
+  anyOf(
+    isExternalLinkTransformCommand,
+    validateProperties({
+      [validateProperties.allowOtherKeys]: true,
+      command: isExternalLinkTransformCommand,
+    }));
+
+export const isExternalLinkExtractSpec =
+  validateProperties({
+    prefix: optional(isStringNonEmpty),
+    transform: optional(validateArrayItems(isExternalLinkTransformSpec)),
+    url: optional(isRegExp),
+    domain: optional(isRegExp),
+    pathname: optional(isRegExp),
+    query: optional(isRegExp),
+  });
+
+export const isExternalLinkSpec =
+  validateArrayItems(
+    validateProperties({
+      match: validateProperties({
+        // TODO: Don't allow providing both of these, and require providing one
+        domain: optional(isStringNonEmpty),
+        domains: optional(validateArrayItems(isStringNonEmpty)),
+
+        // TODO: Don't allow providing both of these
+        pathname: optional(isRegExp),
+        pathnames: optional(validateArrayItems(isRegExp)),
+
+        // TODO: Don't allow providing both of these
+        query: optional(isRegExp),
+        queries: optional(validateArrayItems(isRegExp)),
+
+        context: optional(isExternalLinkContext),
+      }),
+
+      platform: isStringNonEmpty,
+
+      handle: optional(isExternalLinkExtractSpec),
+
+      detail:
+        optional(anyOf(
+          isStringNonEmpty,
+          validateProperties({
+            [validateProperties.validateOtherKeys]:
+              isExternalLinkExtractSpec,
+
+            substring: isStringNonEmpty,
+          }))),
+
+      unusualDomain: optional(isBoolean),
+
+      icon: optional(isStringNonEmpty),
+    }));
+
+export const fallbackDescriptor = {
+  platform: 'external',
+  icon: 'globe',
+};
+
+// TODO: Define all this stuff in data as YAML!
+export const externalLinkSpec = [
+  // Special handling for album links
+
+  {
+    match: {
+      context: 'album',
+      domain: 'youtube.com',
+      pathname: /^playlist/,
+    },
+
+    platform: 'youtube',
+    detail: 'playlist',
+
+    icon: 'youtube',
+  },
+
+  {
+    match: {
+      context: 'albumMultipleTracks',
+      domain: 'youtube.com',
+      pathname: /^watch/,
+    },
+
+    platform: 'youtube',
+    detail: 'fullAlbum',
+
+    icon: 'youtube',
+  },
+
+  {
+    match: {
+      context: 'albumMultipleTracks',
+      domain: 'youtu.be',
+    },
+
+    platform: 'youtube',
+    detail: 'fullAlbum',
+
+    icon: 'youtube',
+  },
+
+  // Special handling for flash links
+
+  {
+    match: {
+      context: 'flash',
+      domain: 'bgreco.net',
+    },
+
+    platform: 'bgreco',
+    detail: 'flash',
+
+    icon: 'globe',
+  },
+
+  // This takes precedence over the secretPage match below.
+  {
+    match: {
+      context: 'flash',
+      domain: 'homestuck.com',
+    },
+
+    platform: 'homestuck',
+
+    detail: {
+      substring: 'page',
+      page: {pathname: /^story\/([0-9]+)\/?$/,},
+    },
+
+    icon: 'globe',
+  },
+
+  {
+    match: {
+      context: 'flash',
+      domain: 'homestuck.com',
+      pathname: /^story\/.+\/?$/,
+    },
+
+    platform: 'homestuck',
+    detail: 'secretPage',
+
+    icon: 'globe',
+  },
+
+  {
+    match: {
+      context: 'flash',
+      domains: ['youtube.com', 'youtu.be'],
+    },
+
+    platform: 'youtube',
+    detail: 'flash',
+
+    icon: 'youtube',
+  },
+
+  // 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',
+  },
+
+  {
+    match: {domain: 'artstation.com'},
+
+    platform: 'artstation',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'artstation',
+  },
+
+  {
+    match: {domain: '.artstation.com'},
+
+    platform: 'artstation',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'artstation',
+  },
+
+  {
+    match: {domains: ['bc.s3m.us', 'music.solatrus.com']},
+
+    platform: 'bandcamp',
+    handle: {domain: /.+/},
+    unusualDomain: true,
+
+    icon: 'bandcamp',
+  },
+
+  {
+    match: {domain: '.bandcamp.com'},
+
+    platform: 'bandcamp',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'bandcamp',
+  },
+
+  {
+    match: {domain: 'bsky.app'},
+
+    platform: 'bluesky',
+    handle: {pathname: /^profile\/([^/]+?)(?:\.bsky\.social)?\/?$/},
+
+    icon: 'bluesky',
+  },
+
+  {
+    match: {domain: '.carrd.co'},
+
+    platform: 'carrd',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'carrd',
+  },
+
+  {
+    match: {domain: 'cohost.org'},
+
+    platform: 'cohost',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'cohost',
+  },
+
+  {
+    match: {domain: 'music.deconreconstruction.com'},
+    platform: 'deconreconstruction.music',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'deconreconstruction.com'},
+    platform: 'deconreconstruction',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: '.deviantart.com'},
+
+    platform: 'deviantart',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'deviantart',
+  },
+
+  {
+    match: {domain: 'deviantart.com'},
+
+    platform: 'deviantart',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'deviantart',
+  },
+
+  {
+    match: {domain: 'deviantart.com'},
+    platform: 'deviantart',
+    icon: 'deviantart',
+  },
+
+  {
+    match: {domain: 'facebook.com'},
+
+    platform: 'facebook',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'facebook',
+  },
+
+  {
+    match: {domain: 'facebook.com'},
+
+    platform: 'facebook',
+    handle: {pathname: /^(?:pages|people)\/([^/]+)\/[0-9]+\/?$/},
+
+    icon: 'facebook',
+  },
+
+  {
+    match: {domain: 'facebook.com'},
+    platform: 'facebook',
+    icon: 'facebook',
+  },
+
+  {
+    match: {domain: 'm.nintendo.com'},
+
+    platform: 'nintendoMusic',
+
+    icon: 'nintendoMusic',
+  },
+
+  {
+    match: {domain: 'mspaintadventures.fandom.com'},
+
+    platform: 'fandom.mspaintadventures',
+
+    detail: {
+      substring: 'page',
+      page: {
+        pathname: /^wiki\/(.+)\/?$/,
+        transform: [
+          {command: 'decode-uri'},
+          {command: 'find-replace', find: /_/g, replace: ' '},
+        ],
+      },
+    },
+
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'mspaintadventures.fandom.com'},
+
+    platform: 'fandom.mspaintadventures',
+
+    icon: 'globe',
+  },
+
+  {
+    match: {domains: ['fandom.com', '.fandom.com']},
+    platform: 'fandom',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'gamebanana.com'},
+    platform: 'gamebanana',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'homestuck.com'},
+    platform: 'homestuck',
+    icon: 'globe',
+  },
+
+  {
+    match: {
+      domain: 'hsmusic.wiki',
+      pathname: /^media\/misc\/archive/,
+    },
+
+    platform: 'hsmusic.archive',
+
+    icon: 'globe',
+  },
+
+  {
+    match: {
+      domain: 'media.hsmusic.wiki',
+      pathname: /^misc\/archive/,
+    },
+
+    platform: 'hsmusic.archive',
+
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'hsmusic.wiki'},
+    platform: 'hsmusic',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'instagram.com'},
+
+    platform: 'instagram',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'instagram',
+  },
+
+  {
+    match: {domain: 'instagram.com'},
+    platform: 'instagram',
+    icon: 'instagram',
+  },
+
+  // The Wayback Machine is a separate entry.
+  {
+    match: {domain: 'archive.org'},
+    platform: 'internetArchive',
+    icon: 'internetArchive',
+  },
+
+  {
+    match: {domain: '.itch.io'},
+
+    platform: 'itch',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'itch',
+  },
+
+  {
+    match: {domain: 'itch.io'},
+
+    platform: 'itch',
+    handle: {pathname: /^profile\/([^/]+)\/?$/},
+
+    icon: 'itch',
+  },
+
+  {
+    match: {domain: 'ko-fi.com'},
+
+    platform: 'kofi',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'kofi',
+  },
+
+  {
+    match: {domain: 'linktr.ee'},
+
+    platform: 'linktree',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'linktree',
+  },
+
+  {
+    match: {domains: [
+      'mastodon.social',
+      'shrike.club',
+      'types.pl',
+    ]},
+
+    platform: 'mastodon',
+    handle: {domain: /.+/},
+    unusualDomain: true,
+
+    icon: 'mastodon',
+  },
+
+  {
+    match: {domains: ['mspfa.com', '.mspfa.com']},
+    platform: 'mspfa',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: '.neocities.org'},
+
+    platform: 'neocities',
+    handle: {domain: /.+/},
+
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: '.newgrounds.com'},
+
+    platform: 'newgrounds',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'newgrounds',
+  },
+
+  {
+    match: {domain: 'newgrounds.com'},
+    platform: 'newgrounds',
+    icon: 'newgrounds',
+  },
+
+  {
+    match: {domain: 'patreon.com'},
+
+    platform: 'patreon',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'patreon',
+  },
+
+  {
+    match: {domain: 'patreon.com'},
+    platform: 'patreon',
+    icon: 'patreon',
+  },
+
+  {
+    match: {domain: 'poetryfoundation.org'},
+    platform: 'poetryFoundation',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'soundcloud.com'},
+
+    platform: 'soundcloud',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'soundcloud',
+  },
+
+  {
+    match: {domain: 'soundcloud.com'},
+    platform: 'soundcloud',
+    icon: 'soundcloud',
+  },
+
+  {
+    match: {domains: ['spotify.com', 'open.spotify.com']},
+    platform: 'spotify',
+    icon: 'spotify',
+  },
+
+  {
+    match: {domains: ['store.steampowered.com', 'steamcommunity.com']},
+    platform: 'steam',
+    icon: 'steam',
+  },
+
+  {
+    match: {domain: 'tiktok.com'},
+
+    platform: 'tiktok',
+    handle: {pathname: /^@?([^/]+)\/?$/},
+
+    icon: 'tiktok',
+  },
+
+  {
+    match: {domain: 'toyhou.se'},
+
+    platform: 'toyhouse',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'toyhouse',
+  },
+
+  {
+    match: {domain: '.tumblr.com'},
+
+    platform: 'tumblr',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'tumblr',
+  },
+
+  {
+    match: {domain: 'tumblr.com'},
+
+    platform: 'tumblr',
+    handle: {pathname: /^([^/]+)\/?$/},
+
+    icon: 'tumblr',
+  },
+
+  {
+    match: {domain: 'tumblr.com'},
+    platform: 'tumblr',
+    icon: 'tumblr',
+  },
+
+  {
+    match: {domain: 'twitch.tv'},
+
+    platform: 'twitch',
+    handle: {pathname: /^(.+)\/?/},
+
+    icon: 'twitch',
+  },
+
+  {
+    match: {domain: 'twitter.com'},
+
+    platform: 'twitter',
+    handle: {pathname: /^@?([^/]+)\/?$/},
+
+    icon: 'twitter',
+  },
+
+  {
+    match: {domain: 'twitter.com'},
+    platform: 'twitter',
+    icon: 'twitter',
+  },
+
+  {
+    match: {domain: 'web.archive.org'},
+    platform: 'waybackMachine',
+    icon: 'internetArchive',
+  },
+
+  {
+    match: {domains: ['wikipedia.org', '.wikipedia.org']},
+    platform: 'wikipedia',
+    icon: 'misc',
+  },
+
+  {
+    match: {domain: 'youtube.com'},
+
+    platform: 'youtube',
+    handle: {pathname: /^@([^/]+)\/?$/},
+
+    icon: 'youtube',
+  },
+
+  {
+    match: {domains: ['youtube.com', 'youtu.be']},
+    platform: 'youtube',
+    icon: 'youtube',
+  },
+];
+
+function urlParts(url) {
+  const {
+    hostname: domain,
+    pathname,
+    search: query,
+  } = new URL(url);
+
+  return {domain, pathname, query};
+}
+
+function createEmptyResults() {
+  return Object.fromEntries(externalLinkStyles.map(style => [style, null]));
+}
+
+export function getMatchingDescriptorsForExternalLink(url, descriptors, {
+  context = 'generic',
+} = {}) {
+  const {domain, pathname, query} = urlParts(url);
+
+  const compareDomain = string => {
+    // A dot at the start of the descriptor's domain indicates
+    // we're looking to match a subdomain.
+    if (string.startsWith('.')) matchSubdomain: {
+      // "www" is never an acceptable subdomain for this purpose.
+      // Sorry to people whose usernames are www!!
+      if (domain.startsWith('www.')) {
+        return false;
+      }
+
+      return domain.endsWith(string);
+    }
+
+    // No dot means we're looking for an exact/full domain match.
+    // But let "www" pass here too, implicitly.
+    return domain === string || domain === 'www.' + string;
+  };
+
+  const comparePathname = regex => regex.test(pathname.slice(1));
+  const compareQuery = regex => regex.test(query.slice(1));
+
+  const compareExtractSpec = extract =>
+    extractPartFromExternalLink(url, extract, {mode: 'test'});
+
+  const contextArray =
+    (Array.isArray(context)
+      ? context
+      : [context]).filter(Boolean);
+
+  const matchingDescriptors =
+    descriptors
+      .filter(({match}) =>
+        (match.domain
+          ? compareDomain(match.domain)
+       : match.domains
+          ? match.domains.some(compareDomain)
+          : false))
+
+      .filter(({match}) =>
+        (Array.isArray(match.context)
+          ? match.context.some(c => contextArray.includes(c))
+       : match.context
+          ? contextArray.includes(match.context)
+          : true))
+
+      .filter(({match}) =>
+        (match.pathname
+          ? comparePathname(match.pathname)
+       : match.pathnames
+          ? match.pathnames.some(comparePathname)
+          : true))
+
+      .filter(({match}) =>
+        (match.query
+          ? compareQuery(match.query)
+       : match.queries
+          ? match.quieries.some(compareQuery)
+          : true))
+
+      .filter(({handle}) =>
+        (handle
+          ? compareExtractSpec(handle)
+          : true))
+
+      .filter(({detail}) =>
+        (typeof detail === 'object'
+          ? Object.entries(detail)
+              .filter(([key]) => key !== 'substring')
+              .map(([_key, value]) => value)
+              .every(compareExtractSpec)
+          : true));
+
+  return [...matchingDescriptors, fallbackDescriptor];
+}
+
+export function extractPartFromExternalLink(url, extract, {
+  // Set to 'test' to just see if this would extract anything.
+  // This disables running custom transformations.
+  mode = 'extract',
+} = {}) {
+  const {domain, pathname, query} = urlParts(url);
+
+  let regexen = [];
+  let tests = [];
+  let transform = [];
+  let prefix = '';
+
+  if (extract instanceof RegExp) {
+    regexen.push(extract);
+    tests.push(url);
+  } else {
+    for (const [key, value] of Object.entries(extract)) {
+      switch (key) {
+        case 'prefix':
+          prefix = value;
+          continue;
+
+        case 'transform':
+          for (const entry of value) {
+            const command =
+              (typeof entry === 'string'
+                ? command
+                : entry.command);
+
+            const options =
+              (typeof entry === 'string'
+                ? {}
+                : entry);
+
+            switch (command) {
+              case 'decode-uri':
+                transform.push(value =>
+                  decodeURIComponent(value));
+                break;
+
+              case 'find-replace':
+                transform.push(value =>
+                  value.replace(options.find, options.replace));
+                break;
+            }
+          }
+          continue;
+
+        case 'url':
+          tests.push(url);
+          break;
+
+        case 'domain':
+          tests.push(domain);
+          break;
+
+        case 'pathname':
+          tests.push(pathname.slice(1));
+          break;
+
+        case 'query':
+          tests.push(query.slice(1));
+          break;
+
+        default:
+          tests.push('');
+          break;
+      }
+
+      regexen.push(value);
+    }
+  }
+
+  let value;
+  for (const {regex, test} of stitchArrays({
+    regex: regexen,
+    test: tests,
+  })) {
+    const match = test.match(regex);
+    if (match) {
+      value = match[1] ?? match[0];
+      break;
+    }
+  }
+
+  if (mode === 'test') {
+    return !!value;
+  }
+
+  if (!value) {
+    return null;
+  }
+
+  if (prefix) {
+    value = prefix + value;
+  }
+
+  for (const fn of transform) {
+    value = fn(value);
+  }
+
+  return value;
+}
+
+export function extractAllCustomPartsFromExternalLink(url, custom) {
+  const customParts = {};
+
+  // All or nothing: if one part doesn't match, all results are scrapped.
+  for (const [key, value] of Object.entries(custom)) {
+    customParts[key] = extractPartFromExternalLink(url, value);
+    if (!customParts[key]) return null;
+  }
+
+  return customParts;
+}
+
+export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) {
+  const prefix = 'misc.external';
+
+  function getDetail() {
+    if (!descriptor.detail) {
+      return null;
+    }
+
+    if (typeof descriptor.detail === 'string') {
+      return language.$(prefix, descriptor.platform, descriptor.detail);
+    } else {
+      const {substring, ...rest} = descriptor.detail;
+
+      const opts =
+        withEntries(rest, entries => entries
+          .map(([key, value]) => [
+            key,
+            extractPartFromExternalLink(url, value),
+          ]));
+
+      return language.$(prefix, descriptor.platform, substring, opts);
+    }
+  }
+
+  switch (style) {
+    case 'platform': {
+      const platform = language.$(prefix, descriptor.platform);
+      const domain = urlParts(url).domain;
+
+      if (descriptor === fallbackDescriptor) {
+        // The fallback descriptor has a "platform" which is just
+        // the word "External". This isn't really useful when you're
+        // looking for platform info!
+        if (domain) {
+          return language.sanitize(domain.replace(/^www\./, ''));
+        } else {
+          return platform;
+        }
+      } else if (descriptor.detail) {
+        return getDetail();
+      } else if (descriptor.unusualDomain && domain) {
+        return language.$(prefix, 'withDomain', {platform, domain});
+      } else {
+        return platform;
+      }
+    }
+
+    case 'handle': {
+      if (descriptor.handle) {
+        return extractPartFromExternalLink(url, descriptor.handle);
+      } else {
+        return null;
+      }
+    }
+
+    case 'icon-id': {
+      if (descriptor.icon) {
+        return descriptor.icon;
+      } else {
+        return null;
+      }
+    }
+  }
+}
+
+export function couldDescriptorSupportStyle(descriptor, style) {
+  if (style === 'platform') {
+    return true;
+  }
+
+  if (style === 'handle') {
+    return !!descriptor.handle;
+  }
+
+  if (style === 'icon-id') {
+    return !!descriptor.icon;
+  }
+}
+
+export function getExternalLinkStringOfStyleFromDescriptors(url, style, descriptors, {
+  language,
+  context = 'generic',
+}) {
+  const matchingDescriptors =
+    getMatchingDescriptorsForExternalLink(url, descriptors, {context});
+
+  const styleFilteredDescriptors =
+    matchingDescriptors.filter(descriptor =>
+      couldDescriptorSupportStyle(descriptor, style));
+
+  for (const descriptor of styleFilteredDescriptors) {
+    const descriptorResult =
+      getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language});
+
+    if (descriptorResult) {
+      return descriptorResult;
+    }
+  }
+
+  return null;
+}
+
+export function getExternalLinkStringsFromDescriptor(url, descriptor, {language}) {
+  return (
+    Object.fromEntries(
+      externalLinkStyles.map(style =>
+        getExternalLinkStringOfStyleFromDescriptor(
+          url,
+          style,
+          descriptor, {language}))));
+}
+
+export function getExternalLinkStringsFromDescriptors(url, descriptors, {
+  language,
+  context = 'generic',
+}) {
+  const results = createEmptyResults();
+  const remainingKeys = new Set(Object.keys(results));
+
+  const matchingDescriptors =
+    getMatchingDescriptorsForExternalLink(url, descriptors, {context});
+
+  for (const descriptor of matchingDescriptors) {
+    const descriptorResults =
+      getExternalLinkStringsFromDescriptor(url, descriptor, {language});
+
+    const descriptorKeys =
+      new Set(
+        Object.entries(descriptorResults)
+          .filter(entry => entry[1])
+          .map(entry => entry[0]));
+
+    for (const key of remainingKeys) {
+      if (descriptorKeys.has(key)) {
+        results[key] = descriptorResults[key];
+        remainingKeys.delete(key);
+      }
+    }
+
+    if (empty(remainingKeys)) {
+      return results;
+    }
+  }
+
+  return results;
+}
diff --git a/src/file-size-preloader.js b/src/file-size-preloader.js
new file mode 100644
index 00000000..b2a55407
--- /dev/null
+++ b/src/file-size-preloader.js
@@ -0,0 +1,155 @@
+// Very simple, bare-bones file size loader which takes a bunch of file
+// paths, gets their filesizes, and resolves a promise when it's done.
+//
+// Once the size of a path has been loaded, it's available synchronously -
+// so this may be provided to code areas which don't support async code!
+//
+// This class also supports loading more paths after the initial batch is
+// done (it uses a queue system) - but make sure you pause any sync code
+// depending on the results until it's finished. waitUntilDoneLoading will
+// always hold until the queue is completely emptied, including waiting for
+// any entries to finish which were added after the wait function itself was
+// called. (Same if you decide to await loadPaths. Sorry that function won't
+// resolve as soon as just the paths it provided are finished - that's not
+// really a worthwhile feature to support for its complexity here, since
+// basically all this should process almost instantaneously anyway!)
+//
+// This only processes files one at a time because I'm lazy and stat calls
+// 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 = [];
+  #sizes = [];
+  #loadedPathIndex = -1;
+
+  #loadingPromise = null;
+  #resolveLoadingPromise = null;
+
+  hadErrored = false;
+
+  constructor({prefix = ''} = {}) {
+    this.prefix = prefix;
+  }
+
+  loadPaths(...paths) {
+    this.#paths.push(...paths.filter((p) => !this.#paths.includes(p)));
+    return this.#startLoadingPaths();
+  }
+
+  waitUntilDoneLoading() {
+    return this.#loadingPromise ?? Promise.resolve();
+  }
+
+  #startLoadingPaths() {
+    if (this.#loadingPromise) {
+      return this.#loadingPromise;
+    }
+
+    ({promise: this.#loadingPromise,
+      resolve: this.#resolveLoadingPromise} =
+        Promise.withResolvers());
+
+    this.#loadNextPath();
+
+    return this.#loadingPromise;
+  }
+
+  async #loadNextPath() {
+    if (this.#loadedPathIndex === this.#paths.length - 1) {
+      return this.#doneLoadingPaths();
+    }
+
+    let size;
+
+    const path = this.#paths[this.#loadedPathIndex + 1];
+
+    try {
+      size = await this.readFileSize(path);
+    } catch (error) {
+      // Oops! Discard that path, and don't increment the index before
+      // moving on, since the next path will now be in its place.
+      this.#paths.splice(this.#loadedPathIndex + 1, 1);
+      this.hasErrored = true;
+      logWarn`Failed to process file size for ${path}: ${error.message}`;
+      return this.#loadNextPath();
+    }
+
+    this.#sizes.push(size);
+    this.#loadedPathIndex++;
+    return this.#loadNextPath();
+  }
+
+  #doneLoadingPaths() {
+    this.#resolveLoadingPromise();
+    this.#loadingPromise = null;
+    this.#resolveLoadingPromise = null;
+  }
+
+  // Override me if you want?
+  // The rest of the code here is literally just a queue system, so you could
+  // pretty much repurpose it for anything... but there are probably cleaner
+  // ways than making an instance or subclass of this and overriding this one
+  // method!
+  async readFileSize(path) {
+    const stats = await stat(path);
+    return stats.size;
+  }
+
+  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
new file mode 100644
index 00000000..e7f5cda1
--- /dev/null
+++ b/src/find.js
@@ -0,0 +1,426 @@
+import {inspect} from 'node:util';
+
+import {colors, logWarn} from '#cli';
+import {compareObjects, stitchArrays, typeAppearance} from '#sugar';
+import thingConstructors from '#things';
+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') {
+    throw new Error(message);
+  }
+
+  if (mode === 'warn') {
+    logWarn(message);
+  }
+
+  return null;
+}
+
+export const keyRefRegex =
+  new RegExp(String.raw`^(?:(?<key>[a-z-]*):(?=\S))?(?<ref>.*)$`);
+
+export function processAvailableMatchesByName(data, {
+  include = _thing => true,
+
+  getMatchableNames = thing =>
+    (thing.constructor.hasPropertyDescriptor('name')
+      ? [thing.name]
+      : []),
+
+  results = Object.create(null),
+  multipleNameMatches = Object.create(null),
+}) {
+  for (const thing of data) {
+    if (!include(thing, thingConstructors)) continue;
+
+    for (const name of getMatchableNames(thing)) {
+      if (typeof name !== 'string') {
+        logWarn`Unexpected ${typeAppearance(name)} returned in names for ${inspect(thing)}`;
+        continue;
+      }
+
+      const normalizedName = name.toLowerCase();
+
+      if (normalizedName in results) {
+        if (normalizedName in multipleNameMatches) {
+          multipleNameMatches[normalizedName].push(thing);
+        } else {
+          multipleNameMatches[normalizedName] = [results[normalizedName], thing];
+          results[normalizedName] = null;
+        }
+      } else {
+        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,
+
+  include = undefined,
+  getMatchableNames = undefined,
+  getMatchableDirectories = undefined,
+}) {
+  // 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
+  // hasn't changed!
+  const cache = new WeakMap();
+
+  // The mode argument here may be 'warn', 'error', or 'quiet'. 'error' throws
+  // errors for null matches (with details about the error), while 'warn' and
+  // 'quiet' both return null, with 'warn' logging details directly to the
+  // console.
+  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) {
+      subcache =
+        processAllAvailableMatches(data, {
+          include,
+          getMatchableNames,
+          getMatchableDirectories,
+        });
+
+      cache.set(data, subcache);
+    }
+
+    const {byDirectory, byName, multipleNameMatches} = subcache;
+
+    return matchHelper(fullRef, mode, {
+      matchByDirectory:
+        prepareMatchByDirectory(mode, {
+          referenceTypes,
+          byDirectory,
+        }),
+
+      matchByName:
+        prepareMatchByName(mode, {
+          byName,
+          multipleNameMatches,
+        }),
+    });
+  };
+}
+
+const hardcodedFindSpecs = {
+  // Listings aren't Thing objects, so this find spec isn't provided by any
+  // Thing constructor.
+  listing: {
+    referenceTypes: ['listing'],
+    bindTo: 'listingSpec',
+  },
+};
+
+const findReverseHelperConfig = {
+  word: `find`,
+  constructorKey: Symbol.for('Thing.findSpecs'),
+
+  hardcodedSpecs: hardcodedFindSpecs,
+  postprocessSpec: postprocessFindSpec,
+};
+
+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 newSpec;
+}
+
+export function getAllFindSpecs() {
+  return fr.getAllSpecs(findReverseHelperConfig);
+}
+
+export function findFindSpec(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 {
+    validateArrayItems(token => {
+      isFunction(token);
+
+      if (token[boundFindData])
+        throw new Error(`find.mixed doesn't work with bindFind yet`);
+
+      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});
+  }
+
+  let behavior = (...args) => {
+    // findMixedHelper will error if find specs aren't available yet,
+    // canceling overwriting `behavior` here.
+    return (behavior = findMixedHelper(config))(...args);
+  };
+
+  findMixedStore.set(config, (...args) => behavior(...args));
+  return findMixedStore.get(config);
+}
+
+export default fr.tokenProxy({
+  findSpec: findFindSpec,
+  prepareBehavior: findHelper,
+
+  handle(key) {
+    if (key === 'mixed') {
+      return findMixed;
+    }
+  },
+});
+
+// Handy utility function for binding the find.thing() functions to a complete
+// wikiData object, optionally taking default options to provide to the find
+// 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, opts) {
+  const boundFind = fr.bind(wikiData, opts, {
+    getAllSpecs: getAllFindSpecs,
+    prepareBehavior: findHelper,
+  });
+
+  boundFind.mixed = findMixed;
+
+  return boundFind;
+}
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index d636d2fd..97cf74a9 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -74,233 +74,1418 @@
 
 'use strict';
 
-const CACHE_FILE = 'thumbnail-cache.json';
+export const CACHE_FILE = 'thumbnail-cache.json';
 const WARNING_DELAY_TIME = 10000;
 
-import { spawn } from 'child_process';
-import { createHash } from 'crypto';
-import * as path from 'path';
+// Thumbnail spec details:
+//
+// * `currentSpecbust` is the current version of the thumbnail specification
+//   format, which will be written to new or updated cache entries;
+//   it represents just the overall format for reading and writing macro
+//   details, not individual thumbnail spec versions.
+//
+// For each spec entry:
+//
+// * `tackbust` is the current version of this thumbtack's specification.
+//   If a cache entry's tackbust for this thumbtack is less than this value,
+//   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; 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.
+//
+// * `quality` represents how much to trade a lower file size for better JPEG
+//   image quality. Maximum is 100, but very high values tend to yield
+//   diminishing returns.
+//
+const currentSpecbust = 2;
+const thumbnailSpec = {
+  'huge': {
+    tackbust: 0,
+    size: 1600,
+    quality: 90,
+  },
 
-import {
-    readdir,
-    readFile,
-    writeFile
-} from 'fs/promises'; // Whatcha know! Nice.
+  'semihuge': {
+    tackbust: 0,
+    size: 1200,
+    quality: 92,
+  },
 
-import {
-    createReadStream
-} from 'fs'; // Still gotta import from 8oth tho, for createReadStream.
+  'large': {
+    tackbust: 0,
+    size: 800,
+    quality: 93,
+  },
+
+  'medium': {
+    tackbust: 0,
+    size: 400,
+    quality: 95,
+  },
+
+  'small': {
+    tackbust: 0,
+    size: 250,
+    quality: 85,
+  },
+
+  'adorb': {
+    tackbust: 1,
+    size: 64,
+    quality: 90,
+  },
+
+  'mini': {
+    tackbust: 2,
+    size: 8,
+    quality: 95,
+  },
+};
+
+import {spawn} from 'node:child_process';
+import {createHash} from 'node:crypto';
+import {createReadStream} from 'node:fs';
+import * as path from 'node:path';
 
 import {
-    logError,
-    logInfo,
-    logWarn,
-    parseOptions,
-    progressPromiseAll
-} from './util/cli.js';
+  mkdir,
+  readdir,
+  readFile,
+  rename,
+  stat,
+  writeFile,
+} from 'node:fs/promises';
+
+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';
 
 import {
-    promisifyProcess,
-} from './util/node-utils.js';
+  colors,
+  fileIssue,
+  logError,
+  logInfo,
+  logWarn,
+  logicalPathTo,
+  parseOptions,
+  progressPromiseAll,
+} from '#cli';
 
 import {
-    delay,
-    queue,
-} from './util/sugar.js';
-
-function traverse(startDirPath, {
-    filterFile = () => true,
-    filterDir = () => true
-} = {}) {
-    const recursive = (names, subDirPath) => Promise
-        .all(names.map(name => readdir(path.join(startDirPath, subDirPath, name)).then(
-            names => filterDir(name) ? recursive(names, path.join(subDirPath, name)) : [],
-            err => filterFile(name) ? [path.join(subDirPath, name)] : [])))
-        .then(pathArrays => pathArrays.flatMap(x => x));
-
-    return readdir(startDirPath)
-        .then(names => recursive(names, ''));
+  delay,
+  empty,
+  chunkMultipleArrays,
+  filterMultipleArrays,
+  queue,
+  stitchArrays,
+  unique,
+} from '#sugar';
+
+export const defaultMagickThreads = 8;
+
+function getSpecbustForCacheEntry(entry) {
+  const nope = () => {
+    logWarn`Couldn't determine a spec version for:`;
+    console.error(entry);
+    return null;
+  };
+
+  if (!entry) {
+    return null;
+  }
+
+  if (Array.isArray(entry)) {
+    if (entry.length === 3) {
+      return 0;
+    } else if (entry.length > 3) {
+      return entry[0];
+    } else {
+      return nope();
+    }
+  } else if (typeof entry === 'object') {
+    if (entry.specbust) {
+      return entry.specbust;
+    } else {
+      return nope();
+    }
+  } else {
+    return nope();
+  }
+}
+
+export function thumbnailCacheEntryToDetails(entry) {
+  // Empty entries get no details.
+  if (!entry) {
+    return null;
+  }
+
+  // First attempt to identify a specification version (aka "bust") for this
+  // entry. It'll be used to actually parse the contents.
+
+  const bust = getSpecbustForCacheEntry(entry);
+  if (bust === null) {
+    return null;
+  }
+
+  // Now extract details, reading based on the spec version.
+
+  const details = {
+    bust,
+    md5: null,
+    width: null,
+    height: null,
+    mtime: null,
+    tackbust: {},
+  };
+
+  if (bust === 0) {
+    ([details.md5,
+      details.width,
+      details.height] =
+      entry);
+  }
+
+  if (bust === 1) {
+    ([details.md5,
+      details.width,
+      details.height,
+      details.mtime] =
+      entry.slice(1))
+  }
+
+  if (bust >= 2 && bust <= Infinity) {
+    ({tackbust: details.tackbust,
+      md5: details.md5,
+      width: details.width,
+      height: details.height,
+      mtime: details.mtime} = entry);
+  }
+
+  return details;
+}
+
+function detailsToThumbnailCacheEntry({
+  specbust,
+  tackbust,
+  md5,
+  width,
+  height,
+  mtime,
+}) {
+  // It's not necessarily impossible to write an earlier version of the cache
+  // entry format, but doing so would be a lie - an entry's bust should always
+  // identify the version of the spec it was generated with, so that other code
+  // can make assumptions about not only the format of the entry, but also the
+  // content of the generated thumbnail.
+  if (specbust !== currentSpecbust) {
+    throw new Error(`Writing a different specbust than current is unsupported`);
+  }
+
+  return {
+    specbust,
+    tackbust,
+    md5,
+    width,
+    height,
+    mtime,
+  };
+}
+
+export function getThumbnailsAvailableForDimensions([width, height]) {
+  // This function is intended to be portable, so it can be used both for
+  // calculating which thumbnails to generate, and which ones will be ready
+  // to reference in generated code. Sizes are in array [name, size] form
+  // with larger sizes earlier in return. Keep in mind this isn't a direct
+  // 1:1 mapping with the sizes listed in the thumbnail spec, because the
+  // largest thumbnail (first in return) will be adjusted to the provided
+  // dimensions.
+
+  const {all} = getThumbnailsAvailableForDimensions;
+
+  // Find the largest size which is beneath the passed dimensions. We use the
+  // longer edge here (of width and height) so that each resulting thumbnail is
+  // fully constrained within the size*size square defined by its spec.
+  const longerEdge = Math.max(width, height);
+  const index = all.findIndex(([name, size]) => size <= longerEdge);
+
+  // Literal edge cases are handled specially. For dimensions which are bigger
+  // than the biggest thumbnail in the spec, return all possible results.
+  // These don't need any adjustments since the largest is already smaller than
+  // the provided dimensions.
+  if (index === 0) {
+    return [
+      ...all,
+    ];
+  }
+
+  // For dimensions which are smaller than the smallest thumbnail, return only
+  // the smallest, adjusted to the provided dimensions.
+  if (index === -1) {
+    const smallest = all[all.length - 1];
+    return [
+      [smallest[0], longerEdge],
+    ];
+  }
+
+  // For non-edge cases, we return the largest size below the dimensions
+  // as well as everything smaller, but also the next size larger - that way
+  // there's a size which is as big as the original, but still JPEG compressed.
+  // The size larger is adjusted to the provided dimensions to represent the
+  // actual dimensions it'll provide.
+  const larger = all[index - 1];
+  const rest = all.slice(index);
+  return [
+    [larger[0], longerEdge],
+    ...rest,
+  ];
+}
+
+getThumbnailsAvailableForDimensions.all =
+  Object.entries(thumbnailSpec)
+    .map(([name, {size}]) => [name, size])
+    .sort((a, b) => b[1] - a[1]);
+
+function getCacheEntryForMediaPath(mediaPath, cache) {
+  // Gets the cache entry for the provided image path, which should always be
+  // a forward-slashes path (i.e. suitable for display online). Since the cache
+  // file may have forward or back-slashes, this checks both.
+
+  const entryFromMediaPath = cache[mediaPath];
+  if (entryFromMediaPath) return entryFromMediaPath;
+
+  const winPath = mediaPath.split(path.posix.sep).join(path.win32.sep);
+  const entryFromWinPath = cache[winPath];
+  if (entryFromWinPath) return entryFromWinPath;
+
+  return null;
+}
+
+export function checkIfImagePathHasCachedThumbnails(mediaPath, cache) {
+  // Generic utility for checking if the thumbnail cache includes any info for
+  // the provided image path, so that the other functions don't hard-code the
+  // cache format.
+
+  return !!getCacheEntryForMediaPath(mediaPath, cache);
+}
+
+export function getDimensionsOfImagePath(mediaPath, cache) {
+  // This function is really generic. It takes the gen-thumbs image cache and
+  // returns the dimensions in that cache, so that other functions don't need
+  // to hard-code the cache format.
+
+  const cacheEntry = getCacheEntryForMediaPath(mediaPath, cache);
+
+  if (!cacheEntry) {
+    throw new Error(`Expected mediaPath to be included in cache, got ${mediaPath}`);
+  }
+
+  const details = thumbnailCacheEntryToDetails(cacheEntry);
+
+  if (!details) {
+    throw new Error(`Couldn't determine any details for this image (${mediaPath})`);
+  }
+
+  const {width, height} = details;
+
+  if (typeof width !== 'number' && typeof height !== 'number') {
+    throw new Error(`Details for this image don't appear to contain dimensions (${mediaPath})`);
+  }
+
+  return [width, height];
+}
+
+export function getThumbnailEqualOrSmaller(preferred, mediaPath, cache) {
+  // This function is totally exclusive to page generation. It's a shorthand
+  // for accessing dimensions from the thumbnail cache, calculating all the
+  // thumbnails available, and selecting the one which is equal to or smaller
+  // than the provided size. Since the path provided might not be the actual
+  // one which is being thumbnail-ified, this just returns the name of the
+  // selected thumbnail size.
+
+  if (!getCacheEntryForMediaPath(mediaPath, cache)) {
+    throw new Error(`Expected mediaPath to be included in cache, got ${mediaPath}`);
+  }
+
+  const {size: preferredSize} = thumbnailSpec[preferred];
+  const [width, height] = getDimensionsOfImagePath(mediaPath, cache);
+  const available = getThumbnailsAvailableForDimensions([width, height]);
+  const [selected] = available.find(([name, size]) => size <= preferredSize);
+  return selected;
 }
 
 function readFileMD5(filePath) {
-    return new Promise((resolve, reject) => {
-        const md5 = createHash('md5');
-        const stream = createReadStream(filePath);
-        stream.on('data', data => md5.update(data));
-        stream.on('end', data => resolve(md5.digest('hex')));
-        stream.on('error', err => reject(err));
-    });
+  return new Promise((resolve, reject) => {
+    const md5 = createHash('md5');
+    const stream = createReadStream(filePath);
+    stream.on('data', (data) => md5.update(data));
+    stream.on('end', () => resolve(md5.digest('hex')));
+    stream.on('error', (err) => reject(err));
+  });
 }
 
-function generateImageThumbnails(filePath) {
-    const dirname = path.dirname(filePath);
-    const extname = path.extname(filePath);
-    const basename = path.basename(filePath, extname);
-    const output = name => path.join(dirname, basename + name + '.jpg');
-
-    const convert = (name, {size, quality}) => spawn('convert', [
-        '-strip',
-        '-resize', `${size}x${size}>`,
-        '-interlace', 'Plane',
-        '-quality', `${quality}%`,
-        filePath,
-        output(name)
-    ]);
-
-    return Promise.all([
-        promisifyProcess(convert('.medium', {size: 400, quality: 95}), false),
-        promisifyProcess(convert('.small', {size: 250, quality: 85}), false)
-    ]);
-
-    return new Promise((resolve, reject) => {
-        if (Math.random() < 0.2) {
-            reject(new Error(`Them's the 8r8ks, kiddo!`));
-        } else {
-            resolve();
-        }
-    });
+async function identifyImageDimensions(filePath) {
+  // See: https://github.com/image-size/image-size/issues/96
+  const buffer = await readFile(filePath);
+  const dimensions = dimensionsOf(buffer);
+  return [dimensions.width, dimensions.height];
+}
+
+async function getImageMagickVersion(binary) {
+  const proc = spawn(binary, ['--version']);
+
+  let allData = '';
+  proc.stdout.on('data', (data) => {
+    allData += data.toString();
+  });
+
+  try {
+    await promisifyProcess(proc, false);
+  } catch (error) {
+    return null;
+  }
+
+  if (!allData.match(/ImageMagick/i)) {
+    return null;
+  }
+
+  const match = allData.match(/Version: (.*)/i);
+  if (!match) {
+    return 'unknown version';
+  }
+
+  return match[1];
+}
+
+async function getSpawnMagick(tool) {
+  if (tool !== 'identify' && tool !== 'convert') {
+    throw new Error(`Expected identify or convert`);
+  }
+
+  let fn = null;
+  let description = null;
+  let version = null;
+
+  if (await commandExists(tool)) {
+    version = await getImageMagickVersion(tool);
+    if (version !== null) {
+      fn = (args) => spawn(tool, args);
+      description = tool;
+    }
+  }
+
+  if (fn === null && await commandExists('magick')) {
+    version = await getImageMagickVersion('magick');
+    if (version !== null) {
+      fn = (args) => spawn('magick', [tool, ...args]);
+      description = `magick ${tool}`;
+    }
+  }
+
+  if (fn === null) {
+    return [`no ${tool} or magick binary`, null];
+  }
+
+  return [`${description} (${version})`, fn];
 }
 
-export default async function genThumbs(mediaPath, {
-    queueSize = 0,
-    quiet = false
-} = {}) {
-    if (!mediaPath) {
-        throw new Error('Expected mediaPath to be passed');
+// TODO: This function may read MD5, mtime (stats), and image dimensions, and
+// all of those values are needed for writing a cache entry. Reusing them from
+// the cache if they *weren't* checked is fine, but if they were checked, we
+// don't have any way to extract the results of the check - and reuse them for
+// writing the cache. This function probably needs a bit of a restructure to
+// avoid duplicating that work.
+async function determineThumbtacksNeededForFile({
+  filePath,
+  mediaPath,
+  cache,
+
+  reuseMismatchedMD5 = false,
+  reuseFutureBust = false,
+  reusePastBust = false,
+}) {
+  const allRightSize = async () => {
+    const dimensions = await identifyImageDimensions(filePath);
+    const sizes = getThumbnailsAvailableForDimensions(dimensions);
+    return sizes.map(([thumbtack]) => thumbtack);
+  };
+
+  const cacheEntry = getCacheEntryForMediaPath(mediaPath, cache);
+  const cacheDetails = thumbnailCacheEntryToDetails(cacheEntry);
+
+  if (!cacheDetails) {
+    return await allRightSize();
+  }
+
+  if (!reuseMismatchedMD5) checkMD5: {
+    // Reading MD5 is expensive because it means reading all the file contents.
+    // Skip out if the file's date modified matches what's recorded on the
+    // cache.
+    const results = await stat(filePath);
+    if (+results.mtime === cacheDetails.mtime) {
+      break checkMD5;
     }
 
-    const quietInfo = (quiet
-        ? () => null
-        : logInfo);
+    const md5 = await readFileMD5(filePath);
+    if (md5 !== cacheDetails.md5) {
+      return await allRightSize();
+    }
+  }
+
+  const mismatchedBusts =
+    Object.entries(thumbnailSpec)
+      .filter(([thumbtack, specEntry]) =>
+        (!reusePastBust && (cacheDetails.tackbust[thumbtack] ?? 0) < specEntry.tackbust) ||
+        (!reuseFutureBust && (cacheDetails.tackbust[thumbtack] ?? 0) > specEntry.tackbust))
+      .map(([thumbtack]) => thumbtack);
+
+  if (empty(mismatchedBusts)) {
+    return [];
+  }
+
+  const rightSize = new Set(await allRightSize());
+  const mismatchedWithinRightSize =
+    mismatchedBusts.filter(size => rightSize.has(size));
+
+  return mismatchedWithinRightSize;
+}
+
+async function generateImageThumbnail(imagePath, thumbtack, {
+  mediaPath,
+  mediaCachePath,
+  spawnConvert,
+}) {
+  const filePathInMedia =
+    path.join(mediaPath, imagePath);
 
-    const filterFile = name => {
-        // TODO: Why is this not working????????
-        // thumbnail-cache.json is 8eing passed through, for some reason.
+  const dirnameInCache =
+    path.join(mediaCachePath, path.dirname(imagePath));
 
-        const ext = path.extname(name);
-        if (ext !== '.jpg' && ext !== '.png') return false;
+  const filename =
+    path.basename(imagePath, path.extname(imagePath)) +
+    `.${thumbtack}.jpg`;
 
-        const rest = path.basename(name, ext);
-        if (rest.endsWith('.medium') || rest.endsWith('.small')) return false;
+  const filePathInCache =
+    path.join(dirnameInCache, filename);
 
-        return true;
+  await mkdir(dirnameInCache, {recursive: true});
+
+  const specEntry = thumbnailSpec[thumbtack];
+  const {size, quality} = specEntry;
+
+  const convertProcess = spawnConvert([
+    filePathInMedia,
+    '-strip',
+    '-resize',
+    `${size}x${size}>`,
+    '-interlace',
+    'Plane',
+    '-quality',
+    `${quality}%`,
+    filePathInCache,
+  ]);
+
+  await promisifyProcess(convertProcess, false);
+}
+
+export async function determineMediaCachePath({
+  mediaPath,
+  wikiCachePath,
+  providedMediaCachePath,
+
+  disallowDoubling = false,
+  regenerateMissingThumbnailCache = false,
+}) {
+  if (!mediaPath) {
+    return {
+      annotation: 'media path not provided',
+      mediaCachePath: null,
     };
+  }
 
-    const filterDir = name => {
-        if (name === '.git') return false;
-        return true;
+  if (providedMediaCachePath) {
+    return {
+      annotation: 'custom path provided',
+      mediaCachePath: providedMediaCachePath,
     };
+  }
 
-    let cache, firstRun = false, failedReadingCache = false;
-    try {
-        cache = JSON.parse(await readFile(path.join(mediaPath, CACHE_FILE)));
-        quietInfo`Cache file successfully read.`;
-    } catch (error) {
-        cache = {};
-        if (error.code === 'ENOENT') {
-            firstRun = true;
-        } else {
-            failedReadingCache = true;
-            logWarn`Malformed or unreadable cache file: ${error}`;
-            logWarn`You may want to cancel and investigate this!`;
-            logWarn`All-new thumbnails and cache will be generated for this run.`;
-            await delay(WARNING_DELAY_TIME);
+  if (!wikiCachePath) {
+    return {
+      annotation: 'wiki cache path not provided',
+      mediaCachePath: null,
+    };
+  }
+
+  let mediaIncludesThumbnailCache;
+
+  try {
+    const files = await readdir(mediaPath);
+    mediaIncludesThumbnailCache = files.includes(CACHE_FILE);
+  } catch (error) {
+    mediaIncludesThumbnailCache = false;
+  }
+
+  if (mediaIncludesThumbnailCache === true && !disallowDoubling) {
+    return {
+      annotation: 'media path doubles as cache',
+      mediaCachePath: mediaPath,
+    };
+  }
+
+  // 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 containedIncludesThumbnailCache;
+  let adjacentIncludesThumbnailCache;
+
+  try {
+    const files = await readdir(containedInferredPath);
+    containedIncludesThumbnailCache = files.includes(CACHE_FILE);
+  } catch (error) {
+    if (error.code === 'ENOENT') {
+      containedIncludesThumbnailCache = null;
+    } else {
+      containedIncludesThumbnailCache = undefined;
+    }
+  }
+
+  try {
+    const files = await readdir(adjacentInferredPath);
+    adjacentIncludesThumbnailCache = files.includes(CACHE_FILE);
+  } catch (error) {
+    if (error.code === 'ENOENT') {
+      adjacentIncludesThumbnailCache = null;
+    } else {
+      adjacentIncludesThumbnailCache = undefined;
+    }
+  }
+
+  // Go ahead with the contained path if it exists and contains a cache -
+  // no other conditions matter.
+  if (containedIncludesThumbnailCache === true) {
+    return {
+      annotation: `contained path has cache`,
+      mediaCachePath: containedInferredPath,
+    };
+  }
+
+  // Reuse an existing adjacent cache before figuring out what to do
+  // if there's no extant cache at all.
+  if (adjacentIncludesThumbnailCache === true) {
+    return {
+      annotation: `adjacent path has cache`,
+      mediaCachePath: adjacentInferredPath,
+    };
+  }
+
+  // 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: `contained path not readable`,
+      mediaCachePath: null,
+    };
+  }
+
+  // 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: `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({
+  mediaPath,
+  mediaCachePath,
+
+  queueSize = 0,
+}) {
+  if (!mediaPath) {
+    throw new Error('Expected mediaPath');
+  }
+
+  if (!mediaCachePath) {
+    throw new Error(`Expected mediaCachePath`);
+  }
+
+  logInfo`Migrating thumbnail files into dedicated directory.`;
+  logInfo`Moving thumbs from: ${mediaPath}`;
+  logInfo`Moving thumbs into: ${mediaCachePath}`;
+
+  const thumbFiles = await traverse(mediaPath, {
+    pathStyle: 'device',
+    filterFile: file => isThumb(file),
+    filterDir: name => name !== '.git',
+  });
+
+  if (thumbFiles.length) {
+    // Double-check files.
+    const thumbtacks = Object.keys(thumbnailSpec);
+    const unsafeFiles = thumbFiles.filter(file => {
+      if (path.extname(file) !== '.jpg') return true;
+      if (thumbtacks.every(tack => !file.includes(tack))) return true;
+      if (path.relative(mediaPath, file).startsWith('../')) return true;
+      return false;
+    });
+
+    if (unsafeFiles.length > 0) {
+      logError`Detected files which we thought were safe, but don't actually seem to be thumbnails!`;
+      logError`List of files that were invalid: ${`(Please remove any personal files before reporting)`}`;
+      for (const file of unsafeFiles) {
+        console.error(file);
+      }
+      fileIssue();
+      return {success: false};
+    }
+
+    logInfo`Moving ${thumbFiles.length} thumbs.`;
+
+    await mkdir(mediaCachePath, {recursive: true});
+
+    const errored = [];
+
+    await progressPromiseAll(`Moving thumbnail files`, queue(
+      thumbFiles.map(file => async () => {
+        try {
+          const filePathInMedia = file;
+          const filePath = path.relative(mediaPath, filePathInMedia);
+          const filePathInCache = path.join(mediaCachePath, filePath);
+          await mkdir(path.dirname(filePathInCache), {recursive: true});
+          await rename(filePathInMedia, filePathInCache);
+        } catch (error) {
+          if (error.code !== 'ENOENT') {
+            errored.push(file);
+          }
         }
+      }),
+      queueSize));
+
+    if (errored.length) {
+      logError`Couldn't move these paths (${errored.length}):`;
+      for (const file of errored) {
+        console.error(file);
+      }
+      logError`It's possible there were permission errors. After you've`;
+      logError`investigated, running again should work to move these.`;
+      return {success: false};
+    } else {
+      logInfo`Successfully moved all ${thumbFiles.length} thumbnail files!`;
+    }
+  } else {
+    logInfo`Didn't find any thumbnails to move.`;
+  }
+
+  let cacheExists = false;
+  try {
+    await stat(path.join(mediaPath, CACHE_FILE));
+    cacheExists = true;
+  } catch (error) {
+    if (error.code === 'ENOENT') {
+      logInfo`No cache file present here. (${CACHE_FILE})`;
+    } else {
+      logWarn`Failed to access cache file. Check its permissions?`;
     }
+  }
 
+  if (cacheExists) {
     try {
-        await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(cache));
-        quietInfo`Writing to cache file appears to be working.`;
+      await rename(
+        path.join(mediaPath, CACHE_FILE),
+        path.join(mediaCachePath, CACHE_FILE));
+      logInfo`Moved thumbnail cache file.`;
     } catch (error) {
-        logWarn`Test of cache file writing failed: ${error}`;
-        if (cache) {
-            logWarn`Cache read succeeded: Any newly written thumbs will be unnecessarily regenerated on the next run.`;
-        } else if (firstRun) {
-            logWarn`No cache found: All thumbs will be generated now, and will be unnecessarily regenerated next run.`;
-        } else {
-            logWarn`Cache read failed: All thumbs will be regenerated now, and will be unnecessarily regenerated again next run.`;
-        }
-        logWarn`You may want to cancel and investigate this!`;
-        await delay(WARNING_DELAY_TIME);
-    }
-
-    const imagePaths = await traverse(mediaPath, {filterFile, filterDir});
-
-    const imageToMD5Entries = await progressPromiseAll(`Generating MD5s of image files`, queue(
-        imagePaths.map(imagePath => () => readFileMD5(path.join(mediaPath, imagePath)).then(
-            md5 => [imagePath, md5],
-            error => [imagePath, {error}]
-        )),
-        queueSize
-    ));
-
-    {
-        let error = false;
-        for (const entry of imageToMD5Entries) {
-            if (entry[1].error) {
-                logError`Failed to read ${entry[0]}: ${entry[1].error}`;
-                error = true;
+      logWarn`Failed to move cache file. (${CACHE_FILE})`;
+      logWarn`Check its permissions, or try copying/pasting.`;
+    }
+  }
+
+  return {success: true};
+}
+
+// Fill in missing details (usually on older entries) that don't actually
+// require generating any thumbnails to determine. This makes sure all
+// entries are using the latest specbust, too.
+export async function refreshThumbnailCache(cache, {mediaPath, queueSize}) {
+  if (Object.keys(cache).length === 0) {
+    return;
+  }
+
+  await progressPromiseAll(`Refreshing existing entries on thumbnail cache`,
+    queue(
+      Object.entries(cache)
+        .map(([imagePath, cacheEntry]) => async () => {
+          const details = thumbnailCacheEntryToDetails(cacheEntry);
+
+          // Couldn't parse this entry, it won't be used later on.
+          // But leave it around just in case some other version of hsmusic
+          // can get use out of it.
+          if (!details) {
+            return;
+          }
+
+          const {tackbust, width, height} = details;
+
+          let {md5, mtime} = details;
+          let updatedAnything = false;
+
+          try {
+            const filePathInMedia = path.join(mediaPath, imagePath);
+
+            if (md5 === null) {
+              md5 = await readFileMD5(filePathInMedia);
+              updatedAnything = true;
             }
-        }
-        if (error) {
-            logError`Failed to read at least one image file!`;
-            logError`This implies a thumbnail probably won't be generatable.`;
-            logError`So, exiting early.`;
-            return false;
-        } else {
-            quietInfo`All image files successfully read.`;
-        }
+
+            if (mtime === null) {
+              const statResults = await stat(filePathInMedia);
+              mtime = +statResults.mtime;
+              updatedAnything = true;
+            }
+          } catch (error) {
+            if (error.code === 'ENOENT') {
+              if (md5 === null) {
+                md5 = "-";
+                updatedAnything = true;
+              }
+
+              if (mtime === null) {
+                mtime = 0;
+                updatedAnything = true;
+              }
+            } else {
+              throw error;
+            }
+          }
+
+          if (!updatedAnything) {
+            return;
+          }
+
+          cache[imagePath] = detailsToThumbnailCacheEntry({
+            specbust: currentSpecbust,
+            tackbust,
+            width,
+            height,
+            md5,
+            mtime,
+          });
+        }),
+      queueSize));
+}
+
+export default async function genThumbs({
+  mediaPath,
+  mediaCachePath,
+
+  queueSize = 0,
+  magickThreads = defaultMagickThreads,
+  quiet = false,
+}) {
+  if (!mediaPath) {
+    throw new Error('Expected mediaPath to be passed');
+  }
+
+  const quietInfo = quiet ? () => null : logInfo;
+
+  const [convertInfo, spawnConvert] = await getSpawnMagick('convert');
+
+  if (!spawnConvert) {
+    logError`${`It looks like you don't have ImageMagick installed.`}`;
+    logError`ImageMagick is required to generate thumbnails for display on the wiki.`;
+    for (const error of [convertInfo].filter(Boolean)) {
+      logError`(Error message: ${error})`;
     }
+    logInfo`You can find info to help install ImageMagick on Linux, Windows, or macOS`;
+    logInfo`from its official website: ${`https://imagemagick.org/script/download.php`}`;
+    logInfo`If you have trouble working ImageMagick and would like some help, feel free`;
+    logInfo`to drop a message in the HSMusic Discord server! ${'https://hsmusic.wiki/discord/'}`;
+    return {success: false};
+  } else {
+    logInfo`Found ImageMagick binary:  ${convertInfo}`;
+  }
 
-    // Technically we could pro8a8ly mut8te the cache varia8le in-place?
-    // 8ut that seems kinda iffy.
-    const updatedCache = Object.assign({}, cache);
+  quietInfo`Running up to ${magickThreads + ' magick threads'} simultaneously.`;
 
-    const entriesToGenerate = imageToMD5Entries
-        .filter(([filePath, md5]) => md5 !== cache[filePath]);
+  let cache = {};
+  let firstRun = false;
 
-    if (entriesToGenerate.length === 0) {
-        logInfo`All image thumbnails are already up-to-date - nice!`;
-        return true;
+  try {
+    cache = JSON.parse(await readFile(path.join(mediaCachePath, CACHE_FILE)));
+    quietInfo`Cache file successfully read.`;
+  } catch (error) {
+    if (error.code === 'ENOENT') {
+      firstRun = true;
+    } else {
+      logWarn`Malformed or unreadable cache file: ${error}`;
+      logWarn`You may want to cancel and investigate this!`;
+      logWarn`All-new thumbnails and cache will be generated for this run.`;
+      await delay(WARNING_DELAY_TIME);
     }
+  }
 
-    const failed = [];
-    const succeeded = [];
-    const writeMessageFn = () => `Writing image thumbnails. [failed: ${failed.length}]`;
+  await refreshThumbnailCache(cache, {mediaPath, queueSize});
 
-    // This is actually sort of a lie, 8ecause we aren't doing synchronicity.
-    // (We pass queueSize = 1 to queue().) 8ut we still use progressPromiseAll,
-    // 'cuz the progress indic8tor is very cool and good.
-    await progressPromiseAll(writeMessageFn, queue(entriesToGenerate.map(([filePath, md5]) =>
-        () => generateImageThumbnails(path.join(mediaPath, filePath)).then(
-            () => {
-                updatedCache[filePath] = md5;
-                succeeded.push(filePath);
-            },
-            error => {
-                failed.push([filePath, error]);
-            }
-        )
-    )));
+  try {
+    await mkdir(mediaCachePath, {recursive: true});
+  } catch (error) {
+    logError`Couldn't create the media cache directory: ${error.code}`;
+    logError`That's where the media files are going to go, so you'll`;
+    logError`have to investigate this - it's likely a permissions error.`;
+    return {success: false};
+  }
 
-    if (failed.length > 0) {
-        for (const [path, error] of failed) {
-            logError`Thumbnails failed to generate for ${path} - ${error}`;
-        }
-        logWarn`Result is incomplete - the above ${failed.length} thumbnails should be checked for errors.`;
-        logWarn`${succeeded.length} successfully generated images won't be regenerated next run, though!`;
+  try {
+    await writeFile(
+      path.join(mediaCachePath, CACHE_FILE),
+      stringifyCache(cache));
+    quietInfo`Writing to cache file appears to be working.`;
+  } catch (error) {
+    logWarn`Test of cache file writing failed: ${error}`;
+    if (cache) {
+      logWarn`Cache read succeeded: Any newly written thumbs will be unnecessarily regenerated on the next run.`;
+    } else if (firstRun) {
+      logWarn`No cache found: All thumbs will be generated now, and will be unnecessarily regenerated next run.`;
+      logWarn`You may also have to provide ${'--media-cache-path'} ${mediaCachePath} next run.`;
     } else {
-        logInfo`Generated all (updated) thumbnails successfully!`;
+      logWarn`Cache read failed: All thumbs will be regenerated now, and will be unnecessarily regenerated again next run.`;
     }
+    logWarn`You may want to cancel and investigate this!`;
+    await delay(WARNING_DELAY_TIME);
+  }
 
-    try {
-        await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(updatedCache));
-        quietInfo`Updated cache file successfully written!`;
-    } catch (error) {
-        logWarn`Failed to write updated cache file: ${error}`;
-        logWarn`Any newly (re)generated thumbnails will be regenerated next run.`;
-        logWarn`Sorry about that!`;
+  if (firstRun) {
+    cache = {};
+  }
+
+  const imagePaths = await traverseSourceImagePaths(mediaPath, {target: 'generate'});
+
+  const imageThumbtacksNeeded =
+    await progressPromiseAll(`Determining thumbtacks needed`,
+      queue(
+        imagePaths.map(imagePath => () =>
+          determineThumbtacksNeededForFile({
+            filePath: path.join(mediaPath, imagePath),
+            mediaPath: imagePath,
+            cache,
+          }).catch(error => ({error}))),
+        queueSize));
+
+  {
+    const erroredPaths = imagePaths.slice();
+    const errors = imageThumbtacksNeeded.map(({error}) => error);
+    filterMultipleArrays(erroredPaths, errors, (_imagePath, error) => error);
+
+    for (const {imagePath, error} of stitchArrays({imagePath: erroredPaths, error: errors})) {
+      logError`Failed to identify thumbs needed for ${imagePath}: ${error}`;
+    }
+
+    if (!empty(errors)) {
+      logError`Failed to determine needed thumbnails for least one image file!`;
+      logError`This indicates a thumbnail probably wouldn't be generated.`;
+      logError`So, exiting early.`;
+      return {success: false};
+    } else {
+      quietInfo`All image files successfully read.`;
+    }
+  }
+
+  // We aren't going to need the original value of `imagePaths` again, so it's
+  // fine to filter it here.
+  filterMultipleArrays(
+    imagePaths,
+    imageThumbtacksNeeded,
+    (_imagePath, needed) => !empty(needed));
+
+  if (empty(imagePaths)) {
+    logInfo`All image thumbnails are already up-to-date - nice!`;
+    return {success: true, cache};
+  }
+
+  const imageDimensions =
+    await progressPromiseAll(`Identifying dimensions of image files`,
+      queue(
+        imagePaths.map(imagePath => () =>
+          identifyImageDimensions(path.join(mediaPath, imagePath))
+            .catch(error => ({error}))),
+        queueSize));
+
+  {
+    const erroredPaths = imagePaths.slice();
+    const errors = imageDimensions.map(result => result.error);
+    filterMultipleArrays(erroredPaths, errors, (_imagePath, error) => error);
+
+    for (const {imagePath, error} of stitchArrays({imagePath: erroredPaths, error: errors})) {
+      logError`Failed to identify dimensions for ${imagePath}: ${error}`;
+    }
+
+    if (!empty(errors)) {
+      logError`Failed to identify dimensions for at least one image file!`;
+      logError`This indicates a thumbnail probably wouldn't be generated.`;
+      logError`So, exiting early.`;
+      return {success: false};
+    } else {
+      quietInfo`All image files successfully had dimensions identified.`;
+    }
+  }
+
+  let numFailed = 0;
+  const writeMessageFn = () =>
+    `Writing image thumbnails. [failed: ${numFailed}]`;
+
+  const generateCallImageIndices =
+    imageThumbtacksNeeded
+      .flatMap(({length}, index) =>
+        Array.from({length}, () => index));
+
+  const generateCallImagePaths =
+    generateCallImageIndices
+      .map(index => imagePaths[index]);
+
+  const generateCallThumbtacks =
+    imageThumbtacksNeeded.flat();
+
+  const generateCallFns =
+    stitchArrays({
+      imagePath: generateCallImagePaths,
+      thumbtack: generateCallThumbtacks,
+    }).map(({imagePath, thumbtack}) => () =>
+        generateImageThumbnail(imagePath, thumbtack, {
+          mediaPath,
+          mediaCachePath,
+          spawnConvert,
+        }).catch(error => {
+            numFailed++;
+            return ({error});
+          }));
+
+  logInfo`Generating ${generateCallFns.length} thumbnails for ${imagePaths.length} media files.`;
+  if (generateCallFns.length > 500) {
+    logInfo`Go get a latte - this could take a while!`;
+  }
+
+  const generateCallResults =
+    await progressPromiseAll(writeMessageFn,
+      queue(generateCallFns, magickThreads));
+
+  let successfulIndices;
+
+  {
+    const erroredIndices = generateCallImageIndices.slice();
+    const erroredPaths = generateCallImagePaths.slice();
+    const erroredThumbtacks = generateCallThumbtacks.slice();
+    const errors = generateCallResults.map(result => result?.error);
+
+    const {removed} =
+      filterMultipleArrays(
+        erroredIndices,
+        erroredPaths,
+        erroredThumbtacks,
+        errors,
+        (_index, _imagePath, _thumbtack, error) => error);
+
+    successfulIndices = new Set(removed[0]);
+
+    const chunks =
+      chunkMultipleArrays(erroredPaths, erroredThumbtacks, errors,
+        (imagePath, lastImagePath) => imagePath !== lastImagePath);
+
+    // TODO: This should obviously be an aggregate error.
+    // ...Just like every other error report here, and those dang aggregates
+    // should be constructable from within the queue, rather than after.
+    for (const [[imagePath], thumbtacks, errors] of chunks) {
+      logError`Failed to generate thumbnails for ${imagePath}:`;
+      for (const {thumbtack, error} of stitchArrays({thumbtack: thumbtacks, error: errors})) {
+        logError`- ${thumbtack}: ${error}`;
+      }
+    }
+
+    if (empty(errors)) {
+      logInfo`All needed thumbnails generated successfully - nice!`;
+    } else {
+      logWarn`Result is incomplete - the above thumbnails should be checked for errors.`;
+      logWarn`Successfully generated images won't be regenerated next run, though!`;
     }
+  }
+
+  filterMultipleArrays(
+    imagePaths,
+    imageThumbtacksNeeded,
+    imageDimensions,
+    (_imagePath, _thumbtacksNeeded, _dimensions, index) =>
+      successfulIndices.has(index));
+
+  for (const {
+    imagePath,
+    thumbtacksNeeded,
+    dimensions,
+  } of stitchArrays({
+    imagePath: imagePaths,
+    thumbtacksNeeded: imageThumbtacksNeeded,
+    dimensions: imageDimensions,
+  })) {
+    const cacheEntry = cache[imagePath];
+    const cacheDetails = thumbnailCacheEntryToDetails(cacheEntry);
+
+    const updatedTackbust =
+      (cacheDetails
+        ? {...cacheDetails.tackbust}
+        : {});
+
+    for (const thumbtack of thumbtacksNeeded) {
+      updatedTackbust[thumbtack] = thumbnailSpec[thumbtack].tackbust;
+    }
+
+    // We could reuse md5 and mtime values from the preivous cache entry, if
+    // extant, but we can't identify the *reason* for why those thumbtacks
+    // were generated, so it's possible these values were at fault. No getting
+    // around computing them again here, at the moment.
+
+    const filePathInMedia = path.join(mediaPath, imagePath);
+    const md5 = await readFileMD5(filePathInMedia);
+
+    const statResults = await stat(filePathInMedia);
+    const mtime = +statResults.mtime;
+
+    cache[imagePath] = detailsToThumbnailCacheEntry({
+      specbust: currentSpecbust,
+      tackbust: updatedTackbust,
+      width: dimensions[0],
+      height: dimensions[1],
+      md5,
+      mtime,
+    });
+  }
+
+  try {
+    await writeFile(
+      path.join(mediaCachePath, CACHE_FILE),
+      stringifyCache(cache));
+    quietInfo`Updated cache file successfully written!`;
+  } catch (error) {
+    logWarn`Failed to write updated cache file: ${error}`;
+    logWarn`Any newly (re)generated thumbnails will be regenerated next run.`;
+    logWarn`Sorry about that!`;
+  }
+
+  return {success: true, cache};
+}
+
+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.wallpaperParts
+        .filter(part => part.asset)
+        .map(part =>
+          fromRoot.to('media.albumWallpaperPart', album.directory, part.asset))),
+  ].flat();
+
+  sortByName(paths, {getName: path => path});
+
+  return paths;
+}
+
+export function checkMissingMisplacedMediaFiles(expectedImagePaths, extantImagePaths) {
+  expectedImagePaths = expectedImagePaths.map(path => path.toLowerCase());
+  extantImagePaths = extantImagePaths.map(path => path.toLowerCase());
+
+  return {
+    missing:
+      expectedImagePaths
+        .filter(f => !extantImagePaths.includes(f)),
+
+    misplaced:
+      extantImagePaths
+        .filter(f =>
+          // todo: This is a hack to match only certain directories - the ones
+          // which expectedImagePaths will detect. The rest of the code here is
+          // urls-agnostic (meaning you could swap out a different URL spec and
+          // it would still work), but this part is hard-coded.
+          f.includes('album-art/') ||
+          f.includes('artist-avatar/') ||
+          f.includes('flash-art/'))
+        .filter(f => !expectedImagePaths.includes(f)),
+  };
+}
+
+export async function verifyImagePaths(mediaPath, {urls, wikiData}) {
+  const expectedPaths = getExpectedImagePaths(mediaPath, {urls, wikiData});
+  const extantPaths = await traverseSourceImagePaths(mediaPath, {target: 'verify'});
+
+  const {missing: missingPaths, misplaced: misplacedPaths} =
+    checkMissingMisplacedMediaFiles(expectedPaths, extantPaths);
+
+  if (empty(missingPaths) && empty(misplacedPaths)) {
+    logInfo`All image paths are good - nice! None are missing or misplaced.`;
+    return {missing: [], misplaced: []};
+  }
+
+  const relativeMediaPath = await logicalPathTo(mediaPath);
+
+  const dirnamesOfExpectedPaths =
+    unique(expectedPaths.map(file => path.dirname(file)));
+
+  const dirnamesOfExtantPaths =
+    unique(extantPaths.map(file => path.dirname(file)));
+
+  const dirnamesOfMisplacedPaths =
+    unique(misplacedPaths.map(file => path.dirname(file)));
+
+  const completelyMisplacedDirnames =
+    dirnamesOfMisplacedPaths
+      .filter(dirname => !dirnamesOfExpectedPaths.includes(dirname));
+
+  const completelyMissingDirnames =
+    dirnamesOfExpectedPaths
+      .filter(dirname => !dirnamesOfExtantPaths.includes(dirname));
+
+  const individuallyMisplacedPaths =
+    misplacedPaths
+      .filter(file => !completelyMisplacedDirnames.includes(path.dirname(file)));
+
+  const individuallyMissingPaths =
+    missingPaths
+      .filter(file => !completelyMissingDirnames.includes(path.dirname(file)));
+
+  const wrongExtensionPaths =
+    misplacedPaths
+      .map(file => {
+        const stripExtension = file =>
+          path.join(
+            path.dirname(file),
+            path.basename(file, path.extname(file)));
+
+        const extantExtension = path.extname(file);
+        const basename = stripExtension(file);
+
+        const expectedPath =
+          missingPaths
+            .find(file => stripExtension(file) === basename);
+
+        if (!expectedPath) return null;
+
+        const expectedExtension = path.extname(expectedPath);
+        return {basename, extantExtension, expectedExtension};
+      })
+      .filter(Boolean);
+
+  if (!empty(missingPaths)) {
+    if (missingPaths.length === 1) {
+      logWarn`${1} expected image file is missing from ${relativeMediaPath}:`;
+    } else {
+      logWarn`${missingPaths.length} expected image files are missing:`;
+    }
+
+    for (const dirname of completelyMissingDirnames) {
+      console.log(` - (missing) All files under ${colors.bright(dirname)}`);
+    }
+
+    for (const file of individuallyMissingPaths) {
+      console.log(` - (missing) ${file}`);
+    }
+  }
+
+  if (!empty(misplacedPaths)) {
+    if (misplacedPaths.length === 1) {
+      logWarn`${1} image file, present in ${relativeMediaPath}, wasn't expected:`;
+    } else {
+      logWarn`${misplacedPaths.length} image files, present in ${relativeMediaPath}, weren't expected:`;
+    }
+
+    for (const dirname of completelyMisplacedDirnames) {
+      console.log(` - (misplaced) All files under ${colors.bright(dirname)}`);
+    }
+
+    for (const file of individuallyMisplacedPaths) {
+      console.log(` - (misplaced) ${file}`);
+    }
+  }
+
+  if (!empty(wrongExtensionPaths)) {
+    if (wrongExtensionPaths.length === 1) {
+      logWarn`Of these, ${1} has an unexpected file extension:`;
+    } else {
+      logWarn`Of these, ${wrongExtensionPaths.length} have an unexpected file extension:`;
+    }
+
+    for (const {basename, extantExtension, expectedExtension} of wrongExtensionPaths) {
+      console.log(` - (expected ${colors.green(expectedExtension)}) ${basename + colors.red(extantExtension)}`);
+    }
+
+    logWarn`To handle unexpected file extensions:`;
+    logWarn` * Source and ${`replace`} with the correct file, or`;
+    logWarn` * Add ${`"Cover Art File Extension"`} field (or similar)`;
+    logWarn`   to the respective document in YAML data files.`;
+  }
+
+  return {missing: missingPaths, misplaced: misplacedPaths};
+}
+
+// Recursively traverses the provided (extant) media path, filtering so only
+// "source" images are returned - no thumbnails and no non-images. Provide
+// target as 'generate' or 'verify' to indicate the desired use of the results.
+//
+// Under 'verify':
+//
+// * All source files are returned, so that their existence can be verified
+//   against a list of expected source files.
+//
+// * Source files are returned in "wiki" path style, AKA with POSIX-style
+//   forward slashes, regardless the system being run on.
+//
+// Under 'generate':
+//
+// * All files which shouldn't actually have thumbnails generated are excluded.
+//
+// * Source files are returned in device-style, with backslashes on Windows.
+//   These are suitable to be passed as command-line arguments to ImageMagick.
+//
+// Both modes return paths relative to mediaPath, with no ./ or .\ at the
+// front.
+//
+export async function traverseSourceImagePaths(mediaPath, {target}) {
+  if (target !== 'verify' && target !== 'generate') {
+    throw new Error(`Expected target to be 'verify' or 'generate', got ${target}`);
+  }
+
+  const paths = await traverse(mediaPath, {
+    pathStyle: (target === 'verify' ? 'posix' : 'device'),
+    prefixPath: '',
+
+    filterFile(name) {
+      const ext = path.extname(name);
+
+      if (!['.jpg', '.png', '.gif'].includes(ext)) {
+        return false;
+      }
+
+      if (target === 'generate' && ext === '.gif') {
+        return false;
+      }
+
+      if (isThumb(name)) {
+        return false;
+      }
+
+      return true;
+    },
+
+    filterDir(name) {
+      if (name === '.git') {
+        return false;
+      }
+
+      return true;
+    },
+  });
+
+  sortByName(paths, {getName: path => path});
+
+  return paths;
+}
+
+export function isThumb(file) {
+  const thumbnailLabel = file.match(/\.([^.]+)\.jpg$/)?.[1];
+  return Object.keys(thumbnailSpec).includes(thumbnailLabel);
+}
+
+if (isMain(import.meta.url)) {
+  (async function () {
+    const miscOptions = await parseOptions(process.argv.slice(2), {
+      'media-path': {
+        type: 'value',
+      },
+
+      'queue-size': {
+        type: 'value',
+        validate(size) {
+          if (parseInt(size) !== parseFloat(size)) return 'an integer';
+          if (parseInt(size) < 0) return 'a counting number or zero';
+          return true;
+        },
+      },
+
+      queue: {alias: 'queue-size'},
+    });
+
+    const mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA;
+    const queueSize = +(miscOptions['queue-size'] ?? 0);
 
-    return true;
+    await genThumbs(mediaPath, {queueSize});
+  })().catch((err) => {
+    console.error(err);
+  });
 }
diff --git a/src/html.js b/src/html.js
new file mode 100644
index 00000000..0fe424df
--- /dev/null
+++ b/src/html.js
@@ -0,0 +1,2017 @@
+// Some really, really simple functions for formatting HTML content.
+
+import {inspect} from 'node:util';
+
+import {withAggregate} from '#aggregate';
+import {colors} from '#cli';
+import {empty, typeAppearance, unique} from '#sugar';
+import * as commonValidators from '#validators';
+
+const {
+  anyOf,
+  is,
+  isArray,
+  isBoolean,
+  isNumber,
+  isString,
+  isSymbol,
+  looseArrayOf,
+  validateAllPropertyValues,
+  validateArrayItems,
+  validateInstanceOf,
+} = commonValidators;
+
+// COMPREHENSIVE!
+// https://html.spec.whatwg.org/multipage/syntax.html#void-elements
+export const selfClosingTags = [
+  'area',
+  'base',
+  'br',
+  'col',
+  'embed',
+  'hr',
+  'img',
+  'input',
+  'link',
+  'meta',
+  'source',
+  'track',
+  'wbr',
+];
+
+// Not so comprehensive!!
+export const attributeSpec = {
+  'class': {
+    arraylike: true,
+    join: ' ',
+    unique: true,
+  },
+
+  'style': {
+    arraylike: true,
+    join: '; ',
+  },
+};
+
+// 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
+// effect if the tag content is passed as an array of children and not a single
+// string.
+export const joinChildren = Symbol();
+
+// Pass to tag() as an attributes key to prevent additional whitespace from
+// being added to the inner start and end of the tag's content - basically,
+// ensuring that the start of the content begins immediately after the ">"
+// ending the opening tag, and ends immediately before the "<" at the start of
+// the closing tag. This has effect when a single child spans multiple lines,
+// or when there are multiple children.
+export const noEdgeWhitespace = Symbol();
+
+// Pass as a value on an object-shaped set of attributes to indicate that it's
+// always, absolutely, no matter what, a valid attribute addition. It will be
+// completely exempt from validation, which may provide a significant speed
+// boost IF THIS OPERATION IS REPEATED MANY TENS OF THOUSANDS OF TIMES.
+// Basically, don't use this unless you're 1) providing a constant set of
+// attributes, and 2) writing a very basic building block which loads of other
+// content will build off of!
+export const blessAttributes = Symbol();
+
+// Don't pass this directly, use html.metatag('blockwrap') instead.
+// Causes *following* content (past the metatag) to be placed inside a span
+// which is styled 'inline-block', which ensures that the words inside the
+// metatag all stay together, line-breaking only if needed, and following
+// text is displayed immediately after the last character of the last line of
+// the metatag (provided there's room on that line for the following word or
+// character).
+export const blockwrap = Symbol();
+
+// Don't pass this directly, use html.metatag('chunkwrap') instead.
+// Causes *contained* content to be split by the metatag's "split" attribute,
+// and each chunk to be considered its own unit for word wrapping. All these
+// units are *not* wrapped in any containing element, so only the chunks are
+// 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
+// content nor any templates, it returns true; if it saw templates, but no
+// other content, then those templates are returned in a flat array, to be
+// traversed externally.
+function isBlankArrayHelper(content) {
+  // First look for string items. These are the easiest to
+  // test blankness.
+
+  const nonStringContent = [];
+
+  for (const item of content) {
+    if (typeof item === 'string') {
+      if (item.length > 0) {
+        return false;
+      }
+    } else {
+      nonStringContent.push(item);
+    }
+  }
+
+  // Analyze the content more closely. Put arrays (and
+  // 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.onlyIfSiblings) {
+        continue;
+      } else if (item.onlyIfContent || item.contentOnly) {
+        arrayContent.push(item.content);
+      } else {
+        return false;
+      }
+    } else if (Array.isArray(item)) {
+      arrayContent.push(item);
+    } else if (item instanceof Template) {
+      templateContent.push(item);
+    } else {
+      return false;
+    }
+  }
+
+  // Iterate over arrays and tag content recursively.
+  // The result will always be true/false (blank or not),
+  // or an array of templates. Defer accessing templates
+  // until later - we'll check on them from the outside
+  // end only if nothing else matches.
+
+  for (const item of arrayContent) {
+    const result = isBlankArrayHelper(item);
+    if (result === false) {
+      return false;
+    } else if (Array.isArray(result)) {
+      templateContent.push(...result);
+    }
+  }
+
+  // Return templates, if there are any. We don't actually
+  // handle the base case of evaluating these templates
+  // inside this recursive function - the topmost caller
+  // will handle that.
+
+  if (!empty(templateContent)) {
+    return templateContent;
+  }
+
+  // If there weren't any templates found (as direct or
+  // indirect descendants), then we're good to go!
+  // This content is definitely blank.
+
+  return true;
+}
+
+// Checks if the content provided would be represented as nothing if included
+// on a page. This can be used on its own, and is the underlying "interface"
+// layer for specific classes' `blank` getters, so its definition and usage
+// tend to be recursive.
+//
+// Note that this shouldn't be used to infer anything about non-content values
+// (e.g. attributes) - it's only suited for actual page content.
+export function isBlank(content) {
+  if (typeof content === 'string') {
+    return content.length === 0;
+  }
+
+  if (content instanceof Tag || content instanceof Template) {
+    return content.blank;
+  }
+
+  if (Array.isArray(content)) {
+    const result = isBlankArrayHelper(content);
+
+    // If the result is true or false, the helper came to
+    // a conclusive decision on its own.
+    if (typeof result === 'boolean') {
+      return result;
+    }
+
+    // Otherwise, it couldn't immediately find any content,
+    // but did come across templates that prospectively
+    // could include content. These need to be checked too.
+    // Check each of the templates one at a time.
+    for (const template of result) {
+      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,
+    // then there really isn't any content to find in this
+    // tree at all. It's blank!
+    return true;
+  }
+
+  return false;
+}
+
+export const validators = {
+  isBlank(value) {
+    if (!isBlank(value)) {
+      throw new TypeError(`Expected blank content`);
+    }
+
+    return true;
+  },
+
+  isTag(value) {
+    return isTag(value);
+  },
+
+  isTemplate(value) {
+    return isTemplate(value);
+  },
+
+  isHTML(value) {
+    return isHTML(value);
+  },
+
+  isAttributes(value) {
+    return isAttributesAdditionSinglet(value);
+  },
+};
+
+export function blank() {
+  return [];
+}
+
+export function blankAttributes() {
+  return new Attributes();
+}
+
+export function tag(tagName, ...args) {
+  const lastArg = args.at(-1);
+
+  const lastArgIsAttributes =
+    typeof lastArg === 'object' && lastArg !== null &&
+    !Array.isArray(lastArg) &&
+    !(lastArg instanceof Tag) &&
+    !(lastArg instanceof Template);
+
+  const content =
+    (lastArgIsAttributes
+      ? null
+      : args.at(-1));
+
+  const attributes =
+    (lastArgIsAttributes
+      ? args
+      : args.slice(0, -1));
+
+  return new Tag(tagName, attributes, content);
+}
+
+export function tags(content, ...attributes) {
+  return new Tag(null, attributes, content);
+}
+
+export function metatag(identifier, ...args) {
+  let content;
+  let opts = {};
+
+  if (
+    typeof args[0] === 'object' &&
+    !(Array.isArray(args[0]) ||
+      args[0] instanceof Tag ||
+      args[0] instanceof Template)
+  ) {
+    opts = args[0];
+    content = args[1];
+  } else {
+    content = args[0];
+  }
+
+  switch (identifier) {
+    case 'blockwrap':
+      return new Tag(null, {[blockwrap]: true}, content);
+
+    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}"`);
+  }
+}
+
+export function normalize(content) {
+  return Tag.normalize(content);
+}
+
+export class Tag {
+  #tagName = '';
+  #content = null;
+  #attributes = null;
+
+  #traceError = null;
+
+  constructor(tagName, attributes, content) {
+    this.tagName = tagName;
+    this.attributes = attributes;
+    this.content = content;
+
+    this.#traceError = new Error();
+  }
+
+  clone() {
+    return Reflect.construct(this.constructor, [
+      this.tagName,
+      this.attributes,
+      this.content,
+    ]);
+  }
+
+  set tagName(value) {
+    if (value === undefined || value === null) {
+      this.tagName = '';
+      return;
+    }
+
+    if (typeof value !== 'string') {
+      throw new Error(`Expected tagName to be a string`);
+    }
+
+    if (selfClosingTags.includes(value) && this.content.length) {
+      throw new Error(`Tag <${value}> is self-closing but this tag has content`);
+    }
+
+    this.#tagName = value;
+  }
+
+  get tagName() {
+    return this.#tagName;
+  }
+
+  set attributes(attributes) {
+    if (attributes instanceof Attributes) {
+      this.#attributes = attributes;
+    } else {
+      this.#attributes = new Attributes(attributes);
+    }
+  }
+
+  get attributes() {
+    if (this.#attributes === null) {
+      this.attributes = {};
+    }
+
+    return this.#attributes;
+  }
+
+  set content(value) {
+    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)
+     : value
+        ? [value]
+        : []);
+
+    if (this.chunkwrap) {
+      if (contentArray.some(content => content?.blockwrap)) {
+        throw new Error(`No support for blockwrap as a direct descendant of chunkwrap`);
+      }
+    }
+
+    this.#content = contentArray;
+    this.#content.toString = () => this.#stringifyContent();
+  }
+
+  get content() {
+    if (this.#content === null) {
+      this.#content = [];
+    }
+
+    return this.#content;
+  }
+
+  get selfClosing() {
+    if (this.tagName) {
+      return selfClosingTags.includes(this.tagName);
+    } else {
+      return false;
+    }
+  }
+
+  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;
+    }
+
+    if (this.contentOnly && isBlank(this.content)) {
+      return true;
+    }
+
+    return false;
+  }
+
+  get contentOnly() {
+    if (this.tagName !== '') return false;
+    if (this.chunkwrap) return true;
+    if (!this.attributes.blank) return false;
+    if (this.blockwrap) return false;
+    return true;
+  }
+
+  #setAttributeFlag(attribute, value) {
+    if (value) {
+      this.attributes.set(attribute, true);
+    } else {
+      this.attributes.remove(attribute);
+    }
+  }
+
+  #getAttributeFlag(attribute) {
+    return !!this.attributes.get(attribute);
+  }
+
+  #setAttributeString(attribute, value) {
+    // Note: This function accepts and records the empty string ('')
+    // distinctly from null/undefined.
+
+    if (value === undefined || value === null) {
+      this.attributes.remove(attribute);
+      return undefined;
+    } else {
+      this.attributes.set(attribute, String(value));
+    }
+  }
+
+  #getAttributeString(attribute) {
+    const value = this.attributes.get(attribute);
+
+    if (value === undefined || value === null) {
+      return undefined;
+    } else {
+      return String(value);
+    }
+  }
+
+  set onlyIfContent(value) {
+    this.#setAttributeFlag(onlyIfContent, value);
+  }
+
+  get onlyIfContent() {
+    return this.#getAttributeFlag(onlyIfContent);
+  }
+
+  set onlyIfSiblings(value) {
+    this.#setAttributeFlag(onlyIfSiblings, value);
+  }
+
+  get onlyIfSiblings() {
+    return this.#getAttributeFlag(onlyIfSiblings);
+  }
+
+  set joinChildren(value) {
+    this.#setAttributeString(joinChildren, value);
+  }
+
+  get joinChildren() {
+    // A chunkwrap - which serves as the top layer of a smush() when
+    // stringifying that chunkwrap - is only meant to be an invisible
+    // layer, so its own children are never specially joined.
+    if (this.chunkwrap) {
+      return '';
+    }
+
+    return this.#getAttributeString(joinChildren);
+  }
+
+  set noEdgeWhitespace(value) {
+    this.#setAttributeFlag(noEdgeWhitespace, value);
+  }
+
+  get noEdgeWhitespace() {
+    return this.#getAttributeFlag(noEdgeWhitespace);
+  }
+
+  set blockwrap(value) {
+    this.#setAttributeFlag(blockwrap, value);
+  }
+
+  get blockwrap() {
+    return this.#getAttributeFlag(blockwrap);
+  }
+
+  set chunkwrap(value) {
+    this.#setAttributeFlag(chunkwrap, value);
+
+    try {
+      this.content = this.content;
+    } catch (error) {
+      this.#setAttributeFlag(chunkwrap, false);
+      throw error;
+    }
+  }
+
+  get chunkwrap() {
+    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 '';
+    }
+
+    const attributesString = this.attributes.toString();
+    const contentString = this.content.toString();
+
+    if (!this.tagName) {
+      return contentString;
+    }
+
+    const openTag = (attributesString
+      ? `<${this.tagName} ${attributesString}>`
+      : `<${this.tagName}>`);
+
+    if (this.selfClosing) {
+      return openTag;
+    }
+
+    const closeTag = `</${this.tagName}>`;
+
+    if (!this.content.length) {
+      return openTag + closeTag;
+    }
+
+    if (!contentString.includes('\n')) {
+      return openTag + contentString + closeTag;
+    }
+
+    const parts = [
+      openTag,
+      contentString
+        .split('\n')
+        .map((line, i) =>
+          (i === 0 && this.noEdgeWhitespace
+            ? line
+            : '    ' + line))
+        .join('\n'),
+      closeTag,
+    ];
+
+    return parts.join(
+      (this.noEdgeWhitespace
+        ? ''
+        : '\n'));
+  }
+
+  #getContentJoiner() {
+    if (this.joinChildren === undefined) {
+      return '\n';
+    }
+
+    if (this.joinChildren === '') {
+      return '';
+    }
+
+    return `\n${this.joinChildren}\n`;
+  }
+
+  #stringifyContent() {
+    if (this.selfClosing) {
+      return '';
+    }
+
+    const joiner = this.#getContentJoiner();
+
+    let content = '';
+    let blockwrapClosers = '';
+
+    let seenSiblingIndependentContent = false;
+
+    const chunkwrapSplitter =
+      (this.chunkwrap
+        ? this.#getAttributeString('split')
+        : null);
+
+    let seenChunkwrapSplitter =
+      (this.chunkwrap
+        ? false
+        : null);
+
+    let contentItems;
+
+    determineContentItems: {
+      if (this.chunkwrap) {
+        contentItems = smush(this).content;
+        break determineContentItems;
+      }
+
+      contentItems = this.content;
+    }
+
+    for (const [index, item] of contentItems.entries()) {
+      const nonTemplateItem =
+        Template.resolve(item);
+
+      if (nonTemplateItem instanceof Tag && nonTemplateItem.imaginarySibling) {
+        seenSiblingIndependentContent = true;
+        continue;
+      }
+
+      let itemContent;
+      try {
+        itemContent = nonTemplateItem.toString();
+      } catch (caughtError) {
+        const indexPart = colors.yellow(`child #${index + 1}`);
+
+        const error =
+          new Error(
+            `Error in ${indexPart} ` +
+            `of ${inspect(this, {compact: true})}`,
+            {cause: caughtError});
+
+        error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true;
+        error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError;
+
+        error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [
+          /content-function\.js/,
+          /util\/html\.js/,
+        ];
+
+        error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
+          /content\/dependencies\/(.*\.js:.*(?=\)))/,
+        ];
+
+        throw error;
+      }
+
+      if (!itemContent) {
+        continue;
+      }
+
+      if (!(nonTemplateItem instanceof Tag) || !nonTemplateItem.onlyIfSiblings) {
+        seenSiblingIndependentContent = true;
+      }
+
+      const chunkwrapChunks =
+        (typeof nonTemplateItem === 'string' && chunkwrapSplitter
+          ? itemContent.split(chunkwrapSplitter)
+          : null);
+
+      const itemIncludesChunkwrapSplit =
+        (chunkwrapChunks
+          ? chunkwrapChunks.length > 1
+          : null);
+
+      if (content) {
+        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 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.
+        content = `<span class="chunkwrap">`;
+      }
+
+      if (itemIncludesChunkwrapSplit) {
+        seenChunkwrapSplitter = true;
+      }
+
+      // Blockwraps only apply if they actually contain some content whose
+      // words should be kept together, so it's okay to put them beneath the
+      // 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 (nonTemplateItem instanceof Tag && nonTemplateItem.blockwrap && content) {
+        content += `<span class="blockwrap">`;
+        blockwrapClosers += `</span>`;
+      }
+
+      appendItemContent: {
+        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+/) ?? '';
+              content += chunkwrapSplitter;
+              content += '</span>';
+              content += whitespace;
+              content += '<span class="chunkwrap">';
+              content += chunk.slice(whitespace.length);
+            }
+          }
+
+          break appendItemContent;
+        }
+
+        content += itemContent;
+      }
+    }
+
+    // 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>';
+      } else {
+        // Since chunkwraps take responsibility for wrapping *away* from the
+        // parent element, we generally always want there to be at least one
+        // chunk that gets wrapped as a single unit. So if no chunkwrap has
+        // been seen at all, just wrap everything in one now.
+        content = `<span class="chunkwrap">${content}</span>`;
+      }
+    }
+
+    content += blockwrapClosers;
+
+    return content;
+  }
+
+  static normalize(content) {
+    // Normalizes contents that are valid from an `isHTML` perspective so
+    // that it's always a pure, single Tag object.
+
+    if (content instanceof Template) {
+      return Tag.normalize(Template.resolve(content));
+    }
+
+    if (content instanceof Tag) {
+      return content;
+    }
+
+    return new Tag(null, null, content);
+  }
+
+  smush() {
+    if (!this.contentOnly) {
+      return tags([this]);
+    }
+
+    const joiner = this.#getContentJoiner();
+
+    const result = [];
+    const attributes = {};
+
+    // Don't use built-in item joining, since we'll be handling it here -
+    // we need to account for descendants having custom joiners too, and
+    // simply using *this* tag's joiner would overwrite those descendants'
+    // differing joiners.
+    attributes[joinChildren] = '';
+
+    let workingText = '';
+
+    for (const item of this.content) {
+      const smushed = smush(item);
+      const smushedItems = smushed.content.slice();
+
+      if (empty(smushedItems)) {
+        continue;
+      }
+
+      if (typeof smushedItems[0] === 'string') {
+        if (workingText) {
+          workingText += joiner;
+        }
+
+        workingText += smushedItems.shift();
+      }
+
+      if (empty(smushedItems)) {
+        continue;
+      }
+
+      if (workingText) {
+        result.push(workingText + joiner);
+      } else if (!empty(result)) {
+        result.push(joiner);
+      }
+
+      if (typeof smushedItems.at(-1) === 'string') {
+        // The last smushed item already had its joiner processed from its own
+        // parent - this isn't an appropriate place for us to insert our own
+        // joiner.
+        workingText = smushedItems.pop();
+      } else {
+        workingText = '';
+      }
+
+      result.push(...smushedItems);
+    }
+
+    if (workingText) {
+      result.push(workingText);
+    }
+
+    return new Tag(null, attributes, result);
+  }
+
+  [inspect.custom](depth, opts) {
+    const lines = [];
+
+    const niceAttributes = ['id', 'class'];
+    const attributes = blankAttributes();
+
+    for (const attribute of niceAttributes) {
+      if (this.attributes.has(attribute)) {
+        const value = this.attributes.get(attribute);
+
+        if (!value) continue;
+        if (Array.isArray(value) && empty(value)) continue;
+
+        let string;
+        let suffix = '';
+
+        if (Array.isArray(value)) {
+          string = value[0].toString();
+          if (value.length > 1) {
+            suffix = ` (+${value.length - 1})`;
+          }
+        } else {
+          string = value.toString();
+        }
+
+        const trim =
+          (string.length > 15
+            ? `${string.slice(0, 12)}...`
+            : string);
+
+        attributes.set(attribute, trim + suffix);
+      }
+    }
+
+    const attributesPart =
+      (attributes.blank
+        ? ``
+        : ` ${attributes.toString({color: true})}`);
+
+    const tagNamePart =
+      (this.tagName
+        ? colors.bright(colors.blue(this.tagName))
+        : ``);
+
+    const tagPart =
+      (this.tagName
+        ? [
+            `<`,
+            tagNamePart,
+            attributesPart,
+            (empty(this.content) ? ` />` : `>`),
+          ].join(``)
+        : ``);
+
+    const accentText =
+      (this.tagName
+        ? (empty(this.content)
+            ? ``
+            : `(${this.content.length} items)`)
+        : (empty(this.content)
+            ? `(no name)`
+            : `(no name, ${this.content.length} items)`));
+
+    const accentPart =
+      (accentText
+        ? `${colors.dim(accentText)}`
+        : ``);
+
+    const headingParts = [
+      `Tag`,
+      tagPart,
+      accentPart,
+    ];
+
+    const heading = headingParts.filter(Boolean).join(` `);
+
+    lines.push(heading);
+
+    if (!opts.compact && (depth === null || depth >= 0)) {
+      const nextDepth =
+        (depth === null
+          ? null
+          : depth - 1);
+
+      for (const child of this.content) {
+        const childLines = [];
+
+        if (typeof child === 'string') {
+          const childFlat = child.replace(/\n/g, String.raw`\n`);
+          const childTrim =
+            (childFlat.length >= 40
+              ? childFlat.slice(0, 37) + '...'
+              : childFlat);
+
+          childLines.push(
+            `  Text: ${opts.stylize(`"${childTrim}"`, 'string')}`);
+        } else {
+          childLines.push(...
+            inspect(child, {depth: nextDepth})
+              .split('\n')
+              .map(line => `  ${line}`));
+        }
+
+        lines.push(...childLines);
+      }
+    }
+
+    return lines.join('\n');
+  }
+}
+
+export function attributes(attributes) {
+  return new Attributes(attributes);
+}
+
+export function parseAttributes(string) {
+  return Attributes.parse(string);
+}
+
+export class Attributes {
+  #attributes = Object.create(null);
+
+  constructor(attributes) {
+    this.attributes = attributes;
+  }
+
+  clone() {
+    return new Attributes(this);
+  }
+
+  set attributes(value) {
+    this.#attributes = Object.create(null);
+
+    if (value === undefined || value === null) {
+      return;
+    }
+
+    this.add(value);
+  }
+
+  get attributes() {
+    return this.#attributes;
+  }
+
+  get blank() {
+    const keepAnyAttributes =
+      Object.entries(this.attributes).some(([attribute, value]) =>
+        this.#keepAttributeValue(attribute, value));
+
+    return !keepAnyAttributes;
+  }
+
+  set(attribute, value) {
+    if (value instanceof Template) {
+      value = Template.resolve(value);
+    }
+
+    if (Array.isArray(value)) {
+      value = value.flat(Infinity);
+    }
+
+    if (value === null || value === undefined) {
+      this.remove(attribute);
+    } else {
+      this.#attributes[attribute] = value;
+    }
+
+    return value;
+  }
+
+  add(...args) {
+    switch (args.length) {
+      case 1:
+        isAttributesAdditionSinglet(args[0]);
+        return this.#addMultipleAttributes(args[0]);
+
+      case 2:
+        isAttributesAdditionPair(args);
+        return this.#addOneAttribute(args[0], args[1]);
+
+      default:
+        throw new Error(
+          `Expected array or object, or attribute and value`);
+    }
+  }
+
+  with(...args) {
+    const clone = this.clone();
+    clone.add(...args);
+    return clone;
+  }
+
+  #addMultipleAttributes(attributes) {
+    const flatInputAttributes =
+      [attributes].flat(Infinity).filter(Boolean);
+
+    const attributeSets =
+      flatInputAttributes.map(attributes => this.#getAttributeSet(attributes));
+
+    const resultList = [];
+
+    for (const set of attributeSets) {
+      const setResults = {};
+
+      for (const key of Reflect.ownKeys(set)) {
+        if (key === blessAttributes) continue;
+
+        const value = set[key];
+        setResults[key] = this.#addOneAttribute(key, value);
+      }
+
+      resultList.push(setResults);
+    }
+
+    return resultList;
+  }
+
+  #getAttributeSet(attributes) {
+    if (attributes instanceof Attributes) {
+      return attributes.attributes;
+    }
+
+    if (attributes instanceof Template) {
+      const resolved = Template.resolve(attributes);
+      isAttributesAdditionSinglet(resolved);
+      return resolved;
+    }
+
+    if (typeof attributes === 'object') {
+      return attributes;
+    }
+
+    throw new Error(
+      `Expected Attributes, Template, or object, ` +
+      `got ${typeAppearance(attributes)}`);
+  }
+
+  #addOneAttribute(attribute, value) {
+    if (value === null || value === undefined) {
+      return;
+    }
+
+    if (value instanceof Template) {
+      return this.#addOneAttribute(attribute, Template.resolve(value));
+    }
+
+    if (Array.isArray(value)) {
+      value = value.flat(Infinity);
+    }
+
+    if (!this.has(attribute)) {
+      return this.set(attribute, value);
+    }
+
+    const descriptor = attributeSpec[attribute];
+    const existingValue = this.get(attribute);
+
+    let newValue = value;
+
+    if (descriptor?.arraylike) {
+      const valueArray =
+        (Array.isArray(value)
+          ? value
+          : [value]);
+
+      const existingValueArray =
+        (Array.isArray(existingValue)
+          ? existingValue
+          : [existingValue]);
+
+      newValue = existingValueArray.concat(valueArray);
+
+      if (descriptor.unique) {
+        newValue = unique(newValue);
+      }
+
+      if (newValue.length === 1) {
+        newValue = newValue[0];
+      }
+    }
+
+    return this.set(attribute, newValue);
+  }
+
+  get(attribute) {
+    return this.#attributes[attribute];
+  }
+
+  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) {
+    return delete this.#attributes[attribute];
+  }
+
+  push(attribute, ...values) {
+    const oldValue = this.get(attribute);
+    const newValue =
+      (Array.isArray(oldValue)
+        ? oldValue.concat(values)
+     : oldValue
+        ? [oldValue, ...values]
+        : values);
+    this.set(attribute, newValue);
+    return newValue;
+  }
+
+  toString({color = false} = {}) {
+    const attributeKeyValues =
+      Object.entries(this.attributes)
+        .map(([key, value]) =>
+          (this.#keepAttributeValue(key, value)
+            ? [key, this.#transformAttributeValue(key, value), true]
+            : [key, undefined, false]))
+        .filter(([_key, _value, keep]) => keep)
+        .map(([key, value]) => [key, value]);
+
+    const attributeParts =
+      attributeKeyValues
+        .map(([key, value]) => {
+          const keyPart = key;
+          const escapedValue = this.#escapeAttributeValue(value);
+          const valuePart =
+            (color
+              ? colors.green(`"${escapedValue}"`)
+              : `"${escapedValue}"`);
+
+          return (
+            (typeof value === 'boolean'
+              ? `${keyPart}`
+              : `${keyPart}=${valuePart}`));
+        });
+
+    return attributeParts.join(' ');
+  }
+
+  #keepAttributeValue(attribute, value) {
+    switch (typeof value) {
+      case 'undefined':
+        return false;
+
+      case 'object':
+        if (Array.isArray(value)) {
+          return value.some(Boolean);
+        } else if (value === null) {
+          return false;
+        } else {
+          // Other objects are an error.
+          break;
+        }
+
+      case 'boolean':
+        return value;
+
+      case 'string':
+      case 'number':
+        return true;
+
+      case 'array':
+        return value.some(Boolean);
+    }
+
+    throw new Error(
+      `Value for attribute "${attribute}" should be primitive or array, ` +
+      `got ${typeAppearance(value)}: ${inspect(value)}`);
+  }
+
+  #transformAttributeValue(attribute, value) {
+    const descriptor = attributeSpec[attribute];
+
+    switch (typeof value) {
+      case 'boolean':
+        return value;
+
+      case 'number':
+        return value.toString();
+
+      // If it's a kept object, it's an array.
+      case 'object': {
+        const joiner =
+          (descriptor?.arraylike && descriptor?.join)
+            ?? ' ';
+
+        return value.filter(Boolean).join(joiner);
+      }
+
+      default:
+        return value;
+    }
+  }
+
+  #escapeAttributeValue(value) {
+    return value
+      .toString()
+      .replaceAll('"', '&quot;')
+      .replaceAll("'", '&apos;');
+  }
+
+  static parse(string) {
+    const attributes = Object.create(null);
+
+    const skipWhitespace = i => {
+      if (!/\s/.test(string[i])) {
+        return i;
+      }
+
+      const match = string.slice(i).match(/[^\s]/);
+      if (match) {
+        return i + match.index;
+      }
+
+      return string.length;
+    };
+
+    for (let i = 0; i < string.length; ) {
+      i = skipWhitespace(i);
+      const aStart = i;
+      const aEnd = i + string.slice(i).match(/[\s=]|$/).index;
+      const attribute = string.slice(aStart, aEnd);
+      i = skipWhitespace(aEnd);
+      if (string[i] === '=') {
+        i = skipWhitespace(i + 1);
+        let end, endOffset;
+        if (string[i] === '"' || string[i] === "'") {
+          end = string[i];
+          endOffset = 1;
+          i++;
+        } else {
+          end = '\\s';
+          endOffset = 0;
+        }
+        const vStart = i;
+        const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index;
+        const value = string.slice(vStart, vEnd);
+        i = vEnd + endOffset;
+        attributes[attribute] = value;
+      } else {
+        attributes[attribute] = attribute;
+      }
+    }
+
+    return (
+      Reflect.construct(this, [
+        Object.fromEntries(
+          Object.entries(attributes)
+            .map(([key, val]) => [
+              key,
+              (val === 'true'
+                ? true
+             : val === 'false'
+                ? false
+             : val === key
+                ? true
+                : val),
+            ])),
+      ]));
+  }
+
+  [inspect.custom]() {
+    const visiblePart = this.toString({color: true});
+
+    const numSymbols = Object.getOwnPropertySymbols(this.#attributes).length;
+    const numSymbolsPart =
+      (numSymbols >= 2
+        ? `${numSymbols} symbol`
+     : numSymbols === 1
+        ? `1 symbol`
+        : ``);
+
+    const symbolPart =
+      (visiblePart && numSymbolsPart
+        ? `(+${numSymbolsPart})`
+     : numSymbols
+        ? `(${numSymbolsPart})`
+        : ``);
+
+    const contentPart =
+      (visiblePart && symbolPart
+        ? `<${visiblePart} ${symbolPart}>`
+     : visiblePart || symbolPart
+        ? `<${visiblePart || symbolPart}>`
+        : `<no attributes>`);
+
+    return `Attributes ${contentPart}`;
+  }
+}
+
+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();
+  } else if (normalize) {
+    throw new TypeError(`Expected normalize to be 'tag', 'string', or null`);
+  } else {
+    return Template.resolve(tagOrTemplate);
+  }
+}
+
+export function smush(smushee) {
+  if (
+    typeof smushee === 'string' ||
+    typeof smushee === 'number'
+  ) {
+    return tags([smushee.toString()]);
+  }
+
+  if (smushee instanceof Template) {
+    // Smushing is only really useful if the contents are resolved, because
+    // otherwise we can't actually inspect the boundaries. However, as usual
+    // for smushing, we don't care at all about the contents of tags (which
+    // aren't contentOnly) *within* the content we're smushing, so this won't
+    // for example smush a template nested within a *tag* within the contents
+    // of this template.
+    return smush(Template.resolve(smushee));
+  }
+
+  if (smushee instanceof Tag) {
+    return smushee.smush();
+  }
+
+  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);
+}
+
+export class Template {
+  #description = {};
+  #slotValues = {};
+
+  constructor(description) {
+    if (!description[Stationery.validated]) {
+      Template.validateDescription(description);
+    }
+
+    this.#description = description;
+  }
+
+  clone() {
+    const clone = Reflect.construct(this.constructor, [
+      this.#description,
+    ]);
+
+    // 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;
+  }
+
+  static validateDescription(description) {
+    if (typeof description !== 'object') {
+      throw new TypeError(`Expected object, got ${typeAppearance(description)}`);
+    }
+
+    if (description === null) {
+      throw new TypeError(`Expected object, got null`);
+    }
+
+    const topErrors = [];
+
+    if (!('content' in description)) {
+      topErrors.push(new TypeError(`Expected description.content`));
+    } else if (typeof description.content !== 'function') {
+      topErrors.push(new TypeError(`Expected description.content to be function`));
+    }
+
+    if ('annotation' in description) {
+      if (typeof description.annotation !== 'string') {
+        topErrors.push(new TypeError(`Expected annotation to be string`));
+      }
+    }
+
+    if ('slots' in description) validateSlots: {
+      if (typeof description.slots !== 'object') {
+        topErrors.push(new TypeError(`Expected description.slots to be object`));
+        break validateSlots;
+      }
+
+      try {
+        this.validateSlotsDescription(description.slots);
+      } catch (slotError) {
+        topErrors.push(slotError);
+      }
+    }
+
+    if (!empty(topErrors)) {
+      throw new AggregateError(topErrors,
+        (typeof description.annotation === 'string'
+          ? `Errors validating template "${description.annotation}" description`
+          : `Errors validating template description`));
+    }
+
+    return true;
+  }
+
+  static validateSlotsDescription(slots) {
+    const slotErrors = [];
+
+    for (const [slotName, slotDescription] of Object.entries(slots)) {
+      if (typeof slotDescription !== 'object' || slotDescription === null) {
+        slotErrors.push(new TypeError(`(${slotName}) Expected slot description to be object`));
+        continue;
+      }
+
+      if ('default' in slotDescription) validateDefault: {
+        if (
+          slotDescription.default === undefined ||
+          slotDescription.default === null
+        ) {
+          slotErrors.push(new TypeError(`(${slotName}) Leave slot default unspecified instead of undefined or null`));
+          break validateDefault;
+        }
+
+        try {
+          Template.validateSlotValueAgainstDescription(slotDescription.default, slotDescription);
+        } catch (error) {
+          error.message = `(${slotName}) Error validating slot default value: ${error.message}`;
+          slotErrors.push(error);
+        }
+      }
+
+      if ('validate' in slotDescription && 'type' in slotDescription) {
+        slotErrors.push(new TypeError(`(${slotName}) Don't specify both slot validate and type`));
+      } else if (!('validate' in slotDescription || 'type' in slotDescription)) {
+        slotErrors.push(new TypeError(`(${slotName}) Expected either slot validate or type`));
+      } else if ('validate' in slotDescription) {
+        if (typeof slotDescription.validate !== 'function') {
+          slotErrors.push(new TypeError(`(${slotName}) Expected slot validate to be function`));
+        }
+      } else if ('type' in slotDescription) {
+        const acceptableSlotTypes = [
+          'string',
+          'number',
+          'bigint',
+          'boolean',
+          'symbol',
+          'html',
+          'attributes',
+        ];
+
+        if (slotDescription.type === 'function') {
+          slotErrors.push(new TypeError(`(${slotName}) Functions shouldn't be provided to slots`));
+        } else if (slotDescription.type === 'object') {
+          slotErrors.push(new TypeError(`(${slotName}) Provide validate function instead of type: object`));
+        } else if (
+          (slotDescription.type === 'html' || slotDescription.type === 'attributes') &&
+          !('mutable' in slotDescription)
+        ) {
+          slotErrors.push(new TypeError(`(${slotName}) Specify mutable: true/false alongside type: ${slotDescription.type}`));
+        } else if (!acceptableSlotTypes.includes(slotDescription.type)) {
+          slotErrors.push(new TypeError(`(${slotName}) Expected slot type to be one of ${acceptableSlotTypes.join(', ')}`));
+        }
+      }
+
+      if ('mutable' in slotDescription) {
+        if (slotDescription.type !== 'html' && slotDescription.type !== 'attributes') {
+          slotErrors.push(new TypeError(`(${slotName}) Only specify mutable alongside type: html or attributes`));
+        }
+
+        if (typeof slotDescription.mutable !== 'boolean') {
+          slotErrors.push(new TypeError(`(${slotName}) Expected slot mutable to be boolean`));
+        }
+      }
+    }
+
+    if (!empty(slotErrors)) {
+      throw new AggregateError(slotErrors, `Errors in slot descriptions`);
+    }
+
+    return true;
+  }
+
+  slot(slotName, value) {
+    this.setSlot(slotName, value);
+    return this;
+  }
+
+  slots(slotNamesToValues) {
+    this.setSlots(slotNamesToValues);
+    return this;
+  }
+
+  setSlot(slotName, value) {
+    const description = this.#getSlotDescriptionOrError(slotName);
+
+    try {
+      Template.validateSlotValueAgainstDescription(value, description);
+    } catch (error) {
+      error.message =
+        (this.description.annotation
+          ? `Error validating template "${this.description.annotation}" slot "${slotName}" value: ${error.message}`
+          : `Error validating template slot "${slotName}" value: ${error.message}`);
+      throw error;
+    }
+
+    this.#slotValues[slotName] = value;
+  }
+
+  setSlots(slotNamesToValues) {
+    if (
+      typeof slotNamesToValues !== 'object' ||
+      Array.isArray(slotNamesToValues) ||
+      slotNamesToValues === null
+    ) {
+      throw new TypeError(`Expected object mapping of slot names to values`);
+    }
+
+    const slotErrors = [];
+
+    for (const [slotName, value] of Object.entries(slotNamesToValues)) {
+      const description = this.#getSlotDescriptionNoError(slotName);
+      if (!description) {
+        slotErrors.push(new TypeError(`(${slotName}) Template doesn't have a "${slotName}" slot`));
+        continue;
+      }
+
+      try {
+        Template.validateSlotValueAgainstDescription(value, description);
+      } catch (error) {
+        error.message = `(${slotName}) ${error.message}`;
+        slotErrors.push(error);
+      }
+    }
+
+    if (!empty(slotErrors)) {
+      throw new AggregateError(slotErrors,
+        (this.description.annotation
+          ? `Error validating template "${this.description.annotation}" slots`
+          : `Error validating template slots`));
+    }
+
+    Object.assign(this.#slotValues, slotNamesToValues);
+  }
+
+  static validateSlotValueAgainstDescription(value, description) {
+    if (value === undefined) {
+      throw new TypeError(`Specify value as null or don't specify at all`);
+    }
+
+    // Null is always an acceptable slot value.
+    if (value === null) {
+      return true;
+    }
+
+    if (Object.hasOwn(description, 'validate')) {
+      description.validate({
+        ...commonValidators,
+        ...validators,
+      })(value);
+
+      return true;
+    }
+
+    if (Object.hasOwn(description, 'type')) {
+      switch (description.type) {
+        case 'html': {
+          return isHTML(value);
+        }
+
+        case 'attributes': {
+          return isAttributesAdditionSinglet(value);
+        }
+
+        case 'string': {
+          if (typeof value === 'string')
+            return true;
+
+          // Tags and templates are valid in string arguments - they'll be
+          // stringified when exposed to the description's .content() function.
+          if (value instanceof Tag || value instanceof Template)
+            return true;
+
+          return true;
+        }
+
+        default: {
+          if (typeof value !== description.type)
+            throw new TypeError(`Slot expects ${description.type}, got ${typeof value}`);
+
+          return true;
+        }
+      }
+    }
+
+    return true;
+  }
+
+  getSlotValue(slotName) {
+    const description = this.#getSlotDescriptionOrError(slotName);
+    const providedValue = this.#slotValues[slotName] ?? null;
+
+    if (description.type === 'html') {
+      if (!providedValue) {
+        return blank();
+      }
+
+      if (
+        (providedValue instanceof Tag || providedValue instanceof Template) &&
+        description.mutable
+      ) {
+        return providedValue.clone();
+      }
+
+      return providedValue;
+    }
+
+    if (description.type === 'attributes') {
+      if (!providedValue) {
+        return blankAttributes();
+      }
+
+      if (providedValue instanceof Attributes) {
+        if (description.mutable) {
+          return providedValue.clone();
+        } else {
+          return providedValue;
+        }
+      }
+
+      return new Attributes(providedValue);
+    }
+
+    if (description.type === 'string') {
+      if (providedValue instanceof Tag || providedValue instanceof Template) {
+        return providedValue.toString();
+      }
+
+      if (isBlank(providedValue)) {
+        return null;
+      }
+    }
+
+    if (providedValue !== null) {
+      return providedValue;
+    }
+
+    if ('default' in description) {
+      return description.default;
+    }
+
+    return null;
+  }
+
+  getSlotDescription(slotName) {
+    return this.#getSlotDescriptionOrError(slotName);
+  }
+
+  #getSlotDescriptionNoError(slotName) {
+    if (this.#description.slots) {
+      if (Object.hasOwn(this.#description.slots, slotName)) {
+        return this.#description.slots[slotName];
+      }
+    }
+
+    return null;
+  }
+
+  #getSlotDescriptionOrError(slotName) {
+    const description = this.#getSlotDescriptionNoError(slotName);
+
+    if (!description) {
+      throw new TypeError(
+        (this.description.annotation
+          ? `Template "${this.description.annotation}" doesn't have a "${slotName}" slot`
+          : `Template doesn't have a "${slotName}" slot`));
+    }
+
+    return description;
+  }
+
+  #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) {
+      throw new Error(
+        `Error in content of ${inspect(this, {compact: true})}`,
+        {cause: caughtError});
+    }
+  }
+
+  set description(_value) {
+    throw new Error(`Template description can't be changed after constructed`);
+  }
+
+  get description() {
+    return this.#description;
+  }
+
+  get blank() {
+    return isBlank(this.content);
+  }
+
+  toString() {
+    return this.content.toString();
+  }
+
+  static resolve(tagOrTemplate) {
+    // Flattens contents of a template, recursively "resolving" until a
+    // non-template is ready (or just returns a provided non-template
+    // argument as-is).
+
+    if (!(tagOrTemplate instanceof Template)) {
+      return tagOrTemplate;
+    }
+
+    let {content} = tagOrTemplate;
+
+    while (content instanceof Template) {
+      content = content.content;
+    }
+
+    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;
+
+    return (
+      (annotation
+        ? `Template ${colors.bright(colors.blue(`"${annotation}"`))}`
+        : `Template ${colors.dim(`(no annotation)`)}`));
+  }
+}
+
+export function stationery(description) {
+  return new Stationery(description);
+}
+
+export class Stationery {
+  #templateDescription = null;
+
+  static validated = Symbol('Stationery.validated');
+
+  constructor(templateDescription) {
+    Template.validateDescription(templateDescription);
+    templateDescription[Stationery.validated] = true;
+    this.#templateDescription = templateDescription;
+  }
+
+  template() {
+    return new Template(this.#templateDescription);
+  }
+
+  [inspect.custom]() {
+    const {annotation} = this.description;
+
+    return (
+      (annotation
+        ? `Stationery ${colors.bright(colors.blue(`"${annotation}"`))}`
+        : `Stationery ${colors.dim(`(no annotation)`)}`));
+  }
+}
+
+export const isTag =
+  validateInstanceOf(Tag);
+
+export const isTemplate =
+  validateInstanceOf(Template);
+
+export const isArrayOfHTML =
+  validateArrayItems(value => isHTML(value));
+
+export const isHTML =
+  anyOf(
+    is(null, undefined, false),
+    isString,
+    isTag,
+    isTemplate,
+
+    value => {
+      isArray(value);
+      return value.length === 0;
+    },
+
+    isArrayOfHTML);
+
+export const isAttributeKey =
+  anyOf(isString, isSymbol);
+
+export const isAttributeValue =
+  anyOf(
+    isString, isNumber, isBoolean, isArray,
+    isTag, isTemplate,
+    validateArrayItems(item => isAttributeValue(item)));
+
+export const isAttributesAdditionPair = pair => {
+  isArray(pair);
+
+  if (pair.length !== 2) {
+    throw new TypeError(`Expected attributes pair to have two items`);
+  }
+
+  withAggregate({message: `Error validating attributes pair`}, ({push}) => {
+    try {
+      isAttributeKey(pair[0]);
+    } catch (caughtError) {
+      push(new Error(`Error validating key`, {cause: caughtError}));
+    }
+
+    try {
+      isAttributeValue(pair[1]);
+    } catch (caughtError) {
+      push(new Error(`Error validating value`, {cause: caughtError}));
+    }
+  });
+
+  return true;
+};
+
+const isAttributesAdditionSingletHelper =
+  anyOf(
+    validateInstanceOf(Template),
+    validateInstanceOf(Attributes),
+    validateAllPropertyValues(isAttributeValue),
+    looseArrayOf(value => isAttributesAdditionSinglet(value)));
+
+export const isAttributesAdditionSinglet = (value) => {
+  if (typeof value === 'object' && value !== null) {
+    if (Object.hasOwn(value, blessAttributes)) {
+      return true;
+    }
+
+    if (
+      Array.isArray(value) &&
+      value.length === 1 &&
+      typeof value[0] === 'object' &&
+      value[0] !== null &&
+      Object.hasOwn(value[0], blessAttributes)
+    ) {
+      return true;
+    }
+  }
+
+  return isAttributesAdditionSingletHelper(value);
+};
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 c5b9429e..142c5976 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -1,819 +1,340 @@
-import fixWS from 'fix-whitespace';
-
-import {
-    UNRELEASED_TRACKS_DIRECTORY
-} from './util/magic-constants.js';
-
-import {
-    chunkByProperties,
-    getArtistNumContributions,
-    getTotalDuration,
-    sortByDate,
-    sortByName
-} from './util/wiki-data.js';
-
-const listingSpec = [
-    {
-        directory: 'albums/by-name',
-        stringsKey: 'listAlbums.byName',
-
-        data({wikiData}) {
-            return wikiData.albumData.slice()
-                .sort(sortByName);
-        },
-
-        row(album, {link, strings}) {
-            return strings('listingPage.listAlbums.byName.item', {
-                album: link.album(album),
-                tracks: strings.count.tracks(album.tracks.length, {unit: true})
-            });
+import {showAggregate} from '#aggregate';
+import {empty} from '#sugar';
+
+const listingSpec = [];
+
+listingSpec.push({
+  directory: 'albums/by-name',
+  stringsKey: 'listAlbums.byName',
+  contentFunction: 'listAlbumsByName',
+
+  seeAlso: [
+    'tracks/by-album',
+  ],
+});
+
+listingSpec.push({
+  directory: 'albums/by-tracks',
+  stringsKey: 'listAlbums.byTracks',
+  contentFunction: 'listAlbumsByTracks',
+});
+
+listingSpec.push({
+  directory: 'albums/by-duration',
+  stringsKey: 'listAlbums.byDuration',
+  contentFunction: 'listAlbumsByDuration',
+});
+
+listingSpec.push({
+  directory: 'albums/by-date',
+  stringsKey: 'listAlbums.byDate',
+  contentFunction: 'listAlbumsByDate',
+
+  seeAlso: [
+    'tracks/by-date',
+  ],
+});
+
+listingSpec.push({
+  directory: 'albums/by-date-added',
+  stringsKey: 'listAlbums.byDateAdded',
+  contentFunction: 'listAlbumsByDateAdded',
+});
+
+listingSpec.push({
+  directory: 'artists/by-name',
+  stringsKey: 'listArtists.byName',
+  contentFunction: 'listArtistsByName',
+  seeAlso: ['artists/by-contribs', 'artists/by-group'],
+});
+
+listingSpec.push({
+  directory: 'artists/by-contribs',
+  stringsKey: 'listArtists.byContribs',
+  contentFunction: 'listArtistsByContributions',
+  seeAlso: ['artists/by-name', 'artists/by-group'],
+});
+
+listingSpec.push({
+  directory: 'artists/by-commentary',
+  stringsKey: 'listArtists.byCommentary',
+  contentFunction: 'listArtistsByCommentaryEntries',
+});
+
+listingSpec.push({
+  directory: 'artists/by-duration',
+  stringsKey: 'listArtists.byDuration',
+  contentFunction: 'listArtistsByDuration',
+});
+
+// TODO: hide if divideTrackListsByGroups empty...
+listingSpec.push({
+  directory: 'artists/by-group',
+  stringsKey: 'listArtists.byGroup',
+  contentFunction: 'listArtistsByGroup',
+  featureFlag: 'enableGroupUI',
+  seeAlso: ['artists/by-name', 'artists/by-contribs'],
+});
+
+listingSpec.push({
+  directory: 'artists/by-latest',
+  stringsKey: 'listArtists.byLatest',
+  contentFunction: 'listArtistsByLatestContribution',
+});
+
+listingSpec.push({
+  directory: 'groups/by-name',
+  stringsKey: 'listGroups.byName',
+  contentFunction: 'listGroupsByName',
+  featureFlag: 'enableGroupUI',
+});
+
+listingSpec.push({
+  directory: 'groups/by-category',
+  stringsKey: 'listGroups.byCategory',
+  contentFunction: 'listGroupsByCategory',
+  featureFlag: 'enableGroupUI',
+});
+
+listingSpec.push({
+  directory: 'groups/by-albums',
+  stringsKey: 'listGroups.byAlbums',
+  contentFunction: 'listGroupsByAlbums',
+  featureFlag: 'enableGroupUI',
+});
+
+listingSpec.push({
+  directory: 'groups/by-tracks',
+  stringsKey: 'listGroups.byTracks',
+  contentFunction: 'listGroupsByTracks',
+  featureFlag: 'enableGroupUI',
+});
+
+listingSpec.push({
+  directory: 'groups/by-duration',
+  stringsKey: 'listGroups.byDuration',
+  contentFunction: 'listGroupsByDuration',
+  featureFlag: 'enableGroupUI',
+});
+
+listingSpec.push({
+  directory: 'groups/by-latest-album',
+  stringsKey: 'listGroups.byLatest',
+  contentFunction: 'listGroupsByLatestAlbum',
+  featureFlag: 'enableGroupUI',
+});
+
+listingSpec.push({
+  directory: 'tracks/by-name',
+  stringsKey: 'listTracks.byName',
+  contentFunction: 'listTracksByName',
+});
+
+listingSpec.push({
+  directory: 'tracks/by-album',
+  stringsKey: 'listTracks.byAlbum',
+  contentFunction: 'listTracksByAlbum',
+});
+
+listingSpec.push({
+  directory: 'tracks/by-date',
+  stringsKey: 'listTracks.byDate',
+  contentFunction: 'listTracksByDate',
+});
+
+listingSpec.push({
+  directory: 'tracks/by-duration',
+  stringsKey: 'listTracks.byDuration',
+  contentFunction: 'listTracksByDuration',
+});
+
+listingSpec.push({
+  directory: 'tracks/by-duration-in-album',
+  stringsKey: 'listTracks.byDurationInAlbum',
+  contentFunction: 'listTracksByDurationInAlbum',
+});
+
+listingSpec.push({
+  directory: 'tracks/by-times-referenced',
+  stringsKey: 'listTracks.byTimesReferenced',
+  contentFunction: 'listTracksByTimesReferenced',
+});
+
+listingSpec.push({
+  directory: 'tracks/in-flashes/by-album',
+  stringsKey: 'listTracks.inFlashes.byAlbum',
+  contentFunction: 'listTracksInFlashesByAlbum',
+  featureFlag: 'enableFlashesAndGames',
+});
+
+listingSpec.push({
+  directory: 'tracks/in-flashes/by-flash',
+  stringsKey: 'listTracks.inFlashes.byFlash',
+  contentFunction: 'listTracksInFlashesByFlash',
+  featureFlag: 'enableFlashesAndGames',
+});
+
+listingSpec.push({
+  directory: 'tracks/with-lyrics',
+  stringsKey: 'listTracks.withLyrics',
+  contentFunction: 'listTracksWithLyrics',
+});
+
+listingSpec.push({
+  directory: 'tracks/with-sheet-music-files',
+  stringsKey: 'listTracks.withSheetMusicFiles',
+  contentFunction: 'listTracksWithSheetMusicFiles',
+  seeAlso: ['all-sheet-music-files'],
+});
+
+listingSpec.push({
+  directory: 'tracks/with-midi-project-files',
+  stringsKey: 'listTracks.withMidiProjectFiles',
+  contentFunction: 'listTracksWithMidiProjectFiles',
+  seeAlso: ['all-midi-project-files'],
+});
+
+listingSpec.push({
+  directory: 'tags/by-name',
+  stringsKey: 'listArtTags.byName',
+  contentFunction: 'listArtTagsByName',
+  featureFlag: 'enableArtTagUI',
+});
+
+listingSpec.push({
+  directory: 'tags/by-uses',
+  stringsKey: 'listArtTags.byUses',
+  contentFunction: 'listArtTagsByUses',
+  featureFlag: 'enableArtTagUI',
+});
+
+listingSpec.push({
+  directory: 'tags/network',
+  stringsKey: 'listArtTags.network',
+  contentFunction: 'listArtTagNetwork',
+  featureFlag: 'enableArtTagUI',
+});
+
+listingSpec.push({
+  directory: 'all-sheet-music-files',
+  stringsKey: 'other.allSheetMusic',
+  contentFunction: 'listAllSheetMusicFiles',
+  seeAlso: ['tracks/with-sheet-music-files'],
+  groupUnderOther: true,
+});
+
+listingSpec.push({
+  directory: 'all-midi-project-files',
+  stringsKey: 'other.allMidiProjectFiles',
+  contentFunction: 'listAllMidiProjectFiles',
+  seeAlso: ['tracks/with-midi-project-files'],
+  groupUnderOther: true,
+});
+
+listingSpec.push({
+  directory: 'all-additional-files',
+  stringsKey: 'other.allAdditionalFiles',
+  contentFunction: 'listAllAdditionalFiles',
+  groupUnderOther: true,
+});
+
+listingSpec.push({
+  directory: 'random',
+  stringsKey: 'other.randomPages',
+  contentFunction: 'listRandomPageLinks',
+  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 = [];
+
+  for (const listing of listingSpec) {
+    if (listing.seeAlso) {
+      const suberrors = [];
+
+      for (let i = 0; i < listing.seeAlso.length; i++) {
+        const directory = listing.seeAlso[i];
+        const match = listingSpec.find(listing => listing.directory === directory);
+
+        if (match) {
+          listing.seeAlso[i] = match;
+        } else {
+          listing.seeAlso[i] = null;
+          suberrors.push(new Error(`(index: ${i}) Didn't find a listing matching ${directory}`))
         }
-    },
-
-    {
-        directory: 'albums/by-tracks',
-        stringsKey: 'listAlbums.byTracks',
-
-        data({wikiData}) {
-            return wikiData.albumData.slice()
-                .sort((a, b) => b.tracks.length - a.tracks.length);
-        },
-
-        row(album, {link, strings}) {
-            return strings('listingPage.listAlbums.byTracks.item', {
-                album: link.album(album),
-                tracks: strings.count.tracks(album.tracks.length, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'albums/by-duration',
-        stringsKey: 'listAlbums.byDuration',
-
-        data({wikiData}) {
-            return wikiData.albumData
-                .map(album => ({album, duration: getTotalDuration(album.tracks)}))
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({album, duration}, {link, strings}) {
-            return strings('listingPage.listAlbums.byDuration.item', {
-                album: link.album(album),
-                duration: strings.count.duration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'albums/by-date',
-        stringsKey: 'listAlbums.byDate',
-
-        data({wikiData}) {
-            return sortByDate(wikiData.albumData
-                .filter(album => album.directory !== UNRELEASED_TRACKS_DIRECTORY));
-        },
-
-        row(album, {link, strings}) {
-            return strings('listingPage.listAlbums.byDate.item', {
-                album: link.album(album),
-                date: strings.count.date(album.date)
-            });
-        }
-    },
-
-    {
-        directory: 'albusm/by-date-added',
-        stringsKey: 'listAlbums.byDateAdded',
-
-        data({wikiData}) {
-            return chunkByProperties(wikiData.albumData.slice().sort((a, b) => {
-                if (a.dateAdded < b.dateAdded) return -1;
-                if (a.dateAdded > b.dateAdded) return 1;
-            }), ['dateAdded']);
-        },
-
-        html(chunks, {link, strings}) {
-            return fixWS`
-                <dl>
-                    ${chunks.map(({dateAdded, chunk: albums}) => fixWS`
-                        <dt>${strings('listingPage.listAlbums.byDateAdded.date', {
-                            date: strings.count.date(dateAdded)
-                        })}</dt>
-                        <dd><ul>
-                            ${(albums
-                                .map(album => strings('listingPage.listAlbums.byDateAdded.album', {
-                                    album: link.album(album)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul></dd>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'artists/by-name',
-        stringsKey: 'listArtists.byName',
-
-        data({wikiData}) {
-            return wikiData.artistData.slice()
-                .sort(sortByName)
-                .map(artist => ({artist, contributions: getArtistNumContributions(artist)}));
-        },
-
-        row({artist, contributions}, {link, strings}) {
-            return strings('listingPage.listArtists.byName.item', {
-                artist: link.artist(artist),
-                contributions: strings.count.contributions(contributions, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'artists/by-contribs',
-        stringsKey: 'listArtists.byContribs',
-
-        data({wikiData}) {
-            return {
-                toTracks: (wikiData.artistData
-                    .map(artist => ({
-                        artist,
-                        contributions: (
-                            artist.tracks.asContributor.length +
-                            artist.tracks.asArtist.length
-                        )
-                    }))
-                    .sort((a, b) => b.contributions - a.contributions)
-                    .filter(({ contributions }) => contributions)),
-
-                toArtAndFlashes: (wikiData.artistData
-                    .map(artist => ({
-                        artist,
-                        contributions: (
-                            artist.tracks.asCoverArtist.length +
-                            artist.albums.asCoverArtist.length +
-                            artist.albums.asWallpaperArtist.length +
-                            artist.albums.asBannerArtist.length +
-                            (wikiData.wikiInfo.features.flashesAndGames
-                                ? artist.flashes.asContributor.length
-                                : 0)
-                        )
-                    }))
-                    .sort((a, b) => b.contributions - a.contributions)
-                    .filter(({ contributions }) => contributions)),
-
-                // This is a kinda naughty hack, 8ut like, it's the only place
-                // we'd 8e passing wikiData to html() otherwise, so like....
-                // (Ok we do do this again once later.)
-                showAsFlashes: wikiData.wikiInfo.features.flashesAndGames
-            };
-        },
-
-        html({toTracks, toArtAndFlashes, showAsFlashes}, {link, strings}) {
-            return fixWS`
-                <div class="content-columns">
-                    <div class="column">
-                        <h2>${strings('listingPage.misc.trackContributors')}</h2>
-                        <ul>
-                            ${(toTracks
-                                .map(({ artist, contributions }) => strings('listingPage.listArtists.byContribs.item', {
-                                    artist: link.artist(artist),
-                                    contributions: strings.count.contributions(contributions, {unit: true})
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                         </ul>
-                    </div>
-                    <div class="column">
-                        <h2>${strings('listingPage.misc' +
-                            (showAsFlashes
-                                ? '.artAndFlashContributors'
-                                : '.artContributors'))}</h2>
-                        <ul>
-                            ${(toArtAndFlashes
-                                .map(({ artist, contributions }) => strings('listingPage.listArtists.byContribs.item', {
-                                    artist: link.artist(artist),
-                                    contributions: strings.count.contributions(contributions, {unit: true})
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul>
-                    </div>
-                </div>
-            `;
-        }
-    },
-
-    {
-        directory: 'artists/by-commentary',
-        stringsKey: 'listArtists.byCommentary',
-
-        data({wikiData}) {
-            return wikiData.artistData
-                .map(artist => ({artist, entries: artist.tracks.asCommentator.length + artist.albums.asCommentator.length}))
-                .filter(({ entries }) => entries)
-                .sort((a, b) => b.entries - a.entries);
-        },
-
-        row({artist, entries}, {link, strings}) {
-            return strings('listingPage.listArtists.byCommentary.item', {
-                artist: link.artist(artist),
-                entries: strings.count.commentaryEntries(entries, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'artists/by-duration',
-        stringsKey: 'listArtists.byDuration',
-
-        data({wikiData}) {
-            return wikiData.artistData
-                .map(artist => ({artist, duration: getTotalDuration(
-                    [...artist.tracks.asArtist, ...artist.tracks.asContributor].filter(track => track.album.directory !== UNRELEASED_TRACKS_DIRECTORY))
-                }))
-                .filter(({ duration }) => duration > 0)
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({artist, duration}, {link, strings}) {
-            return strings('listingPage.listArtists.byDuration.item', {
-                artist: link.artist(artist),
-                duration: strings.count.duration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'artists/by-latest',
-        stringsKey: 'listArtists.byLatest',
-
-        data({wikiData}) {
-            const reversedTracks = wikiData.trackData.slice().reverse();
-            const reversedArtThings = wikiData.justEverythingSortedByArtDateMan.slice().reverse();
-
-            return {
-                toTracks: sortByDate(wikiData.artistData
-                    .filter(artist => !artist.alias)
-                    .map(artist => ({
-                        artist,
-                        date: reversedTracks.find(({ album, artists, contributors }) => (
-                            album.directory !== UNRELEASED_TRACKS_DIRECTORY &&
-                            [...artists, ...contributors].some(({ who }) => who === artist)
-                        ))?.date
-                    }))
-                    .filter(({ date }) => date)
-                    .sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0)).reverse(),
-
-                toArtAndFlashes: sortByDate(wikiData.artistData
-                    .filter(artist => !artist.alias)
-                    .map(artist => {
-                        const thing = reversedArtThings.find(({ album, coverArtists, contributors }) => (
-                            album?.directory !== UNRELEASED_TRACKS_DIRECTORY &&
-                            [...coverArtists || [], ...!album && contributors || []].some(({ who }) => who === artist)
-                        ));
-                        return thing && {
-                            artist,
-                            date: (thing.coverArtists?.some(({ who }) => who === artist)
-                                ? thing.coverArtDate
-                                : thing.date)
-                        };
-                    })
-                    .filter(Boolean)
-                    .sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0)
-                ).reverse(),
-
-                // (Ok we did it again.)
-                // This is a kinda naughty hack, 8ut like, it's the only place
-                // we'd 8e passing wikiData to html() otherwise, so like....
-                showAsFlashes: wikiData.wikiInfo.features.flashesAndGames
-            };
-        },
-
-        html({toTracks, toArtAndFlashes, showAsFlashes}, {link, strings}) {
-            return fixWS`
-                <div class="content-columns">
-                    <div class="column">
-                        <h2>${strings('listingPage.misc.trackContributors')}</h2>
-                        <ul>
-                            ${(toTracks
-                                .map(({ artist, date }) => strings('listingPage.listArtists.byLatest.item', {
-                                    artist: link.artist(artist),
-                                    date: strings.count.date(date)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul>
-                    </div>
-                    <div class="column">
-                        <h2>${strings('listingPage.misc' +
-                            (showAsFlashes
-                                ? '.artAndFlashContributors'
-                                : '.artContributors'))}</h2>
-                        <ul>
-                            ${(toArtAndFlashes
-                                .map(({ artist, date }) => strings('listingPage.listArtists.byLatest.item', {
-                                    artist: link.artist(artist),
-                                    date: strings.count.date(date)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul>
-                    </div>
-                </div>
-            `;
-        }
-    },
-
-    {
-        directory: 'groups/by-name',
-        stringsKey: 'listGroups.byName',
-        condition: ({wikiData}) => wikiData.wikiInfo.features.groupUI,
-        data: ({wikiData}) => wikiData.groupData.slice().sort(sortByName),
-
-        row(group, {link, strings}) {
-            return strings('listingPage.listGroups.byCategory.group', {
-                group: link.groupInfo(group),
-                gallery: link.groupGallery(group, {
-                    text: strings('listingPage.listGroups.byCategory.group.gallery')
-                })
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-category',
-        stringsKey: 'listGroups.byCategory',
-        condition: ({wikiData}) => wikiData.wikiInfo.features.groupUI,
-        data: ({wikiData}) => wikiData.groupCategoryData,
-
-        html(groupCategoryData, {link, strings}) {
-            return fixWS`
-                <dl>
-                    ${groupCategoryData.map(category => fixWS`
-                        <dt>${strings('listingPage.listGroups.byCategory.category', {
-                            category: link.groupInfo(category.groups[0], {text: category.name})
-                        })}</dt>
-                        <dd><ul>
-                            ${(category.groups
-                                .map(group => strings('listingPage.listGroups.byCategory.group', {
-                                    group: link.groupInfo(group),
-                                    gallery: link.groupGallery(group, {
-                                        text: strings('listingPage.listGroups.byCategory.group.gallery')
-                                    })
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul></dd>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'groups/by-albums',
-        stringsKey: 'listGroups.byAlbums',
-        condition: ({wikiData}) => wikiData.wikiInfo.features.groupUI,
-
-        data({wikiData}) {
-            return wikiData.groupData
-                .map(group => ({group, albums: group.albums.length}))
-                .sort((a, b) => b.albums - a.albums);
-        },
-
-        row({group, albums}, {link, strings}) {
-            return strings('listingPage.listGroups.byAlbums.item', {
-                group: link.groupInfo(group),
-                albums: strings.count.albums(albums, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-tracks',
-        stringsKey: 'listGroups.byTracks',
-        condition: ({wikiData}) => wikiData.wikiInfo.features.groupUI,
-
-        data({wikiData}) {
-            return wikiData.groupData
-                .map(group => ({group, tracks: group.albums.reduce((acc, album) => acc + album.tracks.length, 0)}))
-                .sort((a, b) => b.tracks - a.tracks);
-        },
-
-        row({group, tracks}, {link, strings}) {
-            return strings('listingPage.listGroups.byTracks.item', {
-                group: link.groupInfo(group),
-                tracks: strings.count.tracks(tracks, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-duration',
-        stringsKey: 'listGroups.byDuration',
-        condition: ({wikiData}) => wikiData.wikiInfo.features.groupUI,
-
-        data({wikiData}) {
-            return wikiData.groupData
-                .map(group => ({group, duration: getTotalDuration(group.albums.flatMap(album => album.tracks))}))
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({group, duration}, {link, strings}) {
-            return strings('listingPage.listGroups.byDuration.item', {
-                group: link.groupInfo(group),
-                duration: strings.count.duration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-latest-album',
-        stringsKey: 'listGroups.byLatest',
-        condition: ({wikiData}) => wikiData.wikiInfo.features.groupUI,
-
-        data({wikiData}) {
-            return sortByDate(wikiData.groupData
-                .map(group => ({group, date: group.albums[group.albums.length - 1].date}))
-                // So this is kinda tough to explain, 8ut 8asically, when we
-                // reverse the list after sorting it 8y d8te (so that the latest
-                // d8tes come first), it also flips the order of groups which
-                // share the same d8te.  This happens mostly when a single al8um
-                // is the l8test in two groups. So, say one such al8um is in the
-                // groups "Fandom" and "UMSPAF". Per category order, Fandom is
-                // meant to show up 8efore UMSPAF, 8ut when we do the reverse
-                // l8ter, that flips them, and UMSPAF ends up displaying 8efore
-                // Fandom. So we do an extra reverse here, which will fix that
-                // and only affect groups that share the same d8te (8ecause
-                // groups that don't will 8e moved 8y the sortByDate call
-                // surrounding this).
-                .reverse()).reverse()
-        },
-
-        row({group, date}, {link, strings}) {
-            return strings('listingPage.listGroups.byLatest.item', {
-                group: link.groupInfo(group),
-                date: strings.count.date(date)
-            });
-        }
-    },
-
-    {
-        directory: 'tracks/by-name',
-        stringsKey: 'listTracks.byName',
+      }
 
-        data({wikiData}) {
-            return wikiData.trackData.slice().sort(sortByName);
-        },
+      listing.seeAlso = listing.seeAlso.filter(Boolean);
 
-        row(track, {link, strings}) {
-            return strings('listingPage.listTracks.byName.item', {
-                track: link.track(track)
-            });
-        }
-    },
-
-    {
-        directory: 'tracks/by-album',
-        stringsKey: 'listTracks.byAlbum',
-        data: ({wikiData}) => wikiData.albumData,
-
-        html(albumData, {link, strings}) {
-            return fixWS`
-                <dl>
-                    ${albumData.map(album => fixWS`
-                        <dt>${strings('listingPage.listTracks.byAlbum.album', {
-                            album: link.album(album)
-                        })}</dt>
-                        <dd><ol>
-                            ${(album.tracks
-                                .map(track => strings('listingPage.listTracks.byAlbum.track', {
-                                    track: link.track(track)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ol></dd>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'tracks/by-date',
-        stringsKey: 'listTracks.byDate',
-
-        data({wikiData}) {
-            return chunkByProperties(
-                sortByDate(wikiData.trackData.filter(track => track.album.directory !== UNRELEASED_TRACKS_DIRECTORY)),
-                ['album', 'date']
-            );
-        },
-
-        html(chunks, {link, strings}) {
-            return fixWS`
-                <dl>
-                    ${chunks.map(({album, date, chunk: tracks}) => fixWS`
-                        <dt>${strings('listingPage.listTracks.byDate.album', {
-                            album: link.album(album),
-                            date: strings.count.date(date)
-                        })}</dt>
-                        <dd><ul>
-                            ${(tracks
-                                .map(track => track.aka
-                                    ? `<li class="rerelease">${strings('listingPage.listTracks.byDate.track.rerelease', {
-                                        track: link.track(track)
-                                    })}</li>`
-                                    : `<li>${strings('listingPage.listTracks.byDate.track', {
-                                        track: link.track(track)
-                                    })}</li>`)
-                                .join('\n'))}
-                        </ul></dd>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'tracks/by-duration',
-        stringsKey: 'listTracks.byDuration',
-
-        data({wikiData}) {
-            return wikiData.trackData
-                .filter(track => track.album.directory !== UNRELEASED_TRACKS_DIRECTORY)
-                .map(track => ({track, duration: track.duration}))
-                .filter(({ duration }) => duration > 0)
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({track, duration}, {link, strings}) {
-            return strings('listingPage.listTracks.byDuration.item', {
-                track: link.track(track),
-                duration: strings.count.duration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'tracks/by-duration-in-album',
-        stringsKey: 'listTracks.byDurationInAlbum',
-
-        data({wikiData}) {
-            return wikiData.albumData.map(album => ({
-                album,
-                tracks: album.tracks.slice().sort((a, b) => b.duration - a.duration)
-            }));
-        },
-
-        html(albums, {link, strings}) {
-            return fixWS`
-                <dl>
-                    ${albums.map(({album, tracks}) => fixWS`
-                        <dt>${strings('listingPage.listTracks.byDurationInAlbum.album', {
-                            album: link.album(album)
-                        })}</dt>
-                        <dd><ul>
-                            ${(tracks
-                                .map(track => strings('listingPage.listTracks.byDurationInAlbum.track', {
-                                    track: link.track(track),
-                                    duration: strings.count.duration(track.duration)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </dd></ul>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'tracks/by-times-referenced',
-        stringsKey: 'listTracks.byTimesReferenced',
-
-        data({wikiData}) {
-            return wikiData.trackData
-                .map(track => ({track, timesReferenced: track.referencedBy.length}))
-                .filter(({ timesReferenced }) => timesReferenced > 0)
-                .sort((a, b) => b.timesReferenced - a.timesReferenced);
-        },
-
-        row({track, timesReferenced}, {link, strings}) {
-            return strings('listingPage.listTracks.byTimesReferenced.item', {
-                track: link.track(track),
-                timesReferenced: strings.count.timesReferenced(timesReferenced, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'tracks/in-flashes/by-album',
-        stringsKey: 'listTracks.inFlashes.byAlbum',
-        condition: ({wikiData}) => wikiData.wikiInfo.features.flashesAndGames,
-
-        data({wikiData}) {
-            return chunkByProperties(wikiData.trackData
-                .filter(t => t.flashes.length > 0), ['album'])
-                .filter(({ album }) => album.directory !== UNRELEASED_TRACKS_DIRECTORY);
-        },
-
-        html(chunks, {link, strings}) {
-            return fixWS`
-                <dl>
-                    ${chunks.map(({album, chunk: tracks}) => fixWS`
-                        <dt>${strings('listingPage.listTracks.inFlashes.byAlbum.album', {
-                            album: link.album(album),
-                            date: strings.count.date(album.date)
-                        })}</dt>
-                        <dd><ul>
-                            ${(tracks
-                                .map(track => strings('listingPage.listTracks.inFlashes.byAlbum.track', {
-                                    track: link.track(track),
-                                    flashes: strings.list.and(track.flashes.map(link.flash))
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </dd></ul>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'tracks/in-flashes/by-flash',
-        stringsKey: 'listTracks.inFlashes.byFlash',
-        condition: ({wikiData}) => wikiData.wikiInfo.features.flashesAndGames,
-        data: ({wikiData}) => wikiData.flashData,
-
-        html(flashData, {link, strings}) {
-            return fixWS`
-                <dl>
-                    ${sortByDate(flashData.slice()).map(flash => fixWS`
-                        <dt>${strings('listingPage.listTracks.inFlashes.byFlash.flash', {
-                            flash: link.flash(flash),
-                            date: strings.count.date(flash.date)
-                        })}</dt>
-                        <dd><ul>
-                            ${(flash.tracks
-                                .map(track => strings('listingPage.listTracks.inFlashes.byFlash.track', {
-                                    track: link.track(track),
-                                    album: link.album(track.album)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul></dd>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'tracks/with-lyrics',
-        stringsKey: 'listTracks.withLyrics',
-
-        data({wikiData}) {
-            return chunkByProperties(wikiData.trackData.filter(t => t.lyrics), ['album']);
-        },
-
-        html(chunks, {link, strings}) {
-            return fixWS`
-                <dl>
-                    ${chunks.map(({album, chunk: tracks}) => fixWS`
-                        <dt>${strings('listingPage.listTracks.withLyrics.album', {
-                            album: link.album(album),
-                            date: strings.count.date(album.date)
-                        })}</dt>
-                        <dd><ul>
-                            ${(tracks
-                                .map(track => strings('listingPage.listTracks.withLyrics.track', {
-                                    track: link.track(track),
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </dd></ul>
-                    `).join('\n')}
-                </dl>
-            `;
-        }
-    },
-
-    {
-        directory: 'tags/by-name',
-        stringsKey: 'listTags.byName',
-        condition: ({wikiData}) => wikiData.wikiInfo.features.artTagUI,
-
-        data({wikiData}) {
-            return wikiData.tagData
-                .filter(tag => !tag.isCW)
-                .sort(sortByName)
-                .map(tag => ({tag, timesUsed: tag.things.length}));
-        },
-
-        row({tag, timesUsed}, {link, strings}) {
-            return strings('listingPage.listTags.byName.item', {
-                tag: link.tag(tag),
-                timesUsed: strings.count.timesUsed(timesUsed, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'tags/by-uses',
-        stringsKey: 'listTags.byUses',
-        condition: ({wikiData}) => wikiData.wikiInfo.features.artTagUI,
-
-        data({wikiData}) {
-            return wikiData.tagData
-                .filter(tag => !tag.isCW)
-                .map(tag => ({tag, timesUsed: tag.things.length}))
-                .sort((a, b) => b.timesUsed - a.timesUsed);
-        },
-
-        row({tag, timesUsed}, {link, strings}) {
-            return strings('listingPage.listTags.byUses.item', {
-                tag: link.tag(tag),
-                timesUsed: strings.count.timesUsed(timesUsed, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'random',
-        stringsKey: 'other.randomPages',
-
-        data: ({wikiData}) => ({
-            officialAlbumData: wikiData.officialAlbumData,
-            fandomAlbumData: wikiData.fandomAlbumData
-        }),
-
-        html: ({officialAlbumData, fandomAlbumData}, {
-            getLinkThemeString,
-            strings
-        }) => fixWS`
-            <p>Choose a link to go to a random page in that category or album! If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry.</p>
-            <p class="js-hide-once-data">(Data files are downloading in the background! Please wait for data to load.)</p>
-            <p class="js-show-once-data">(Data files have finished being downloaded. The links should work!)</p>
-            <dl>
-                <dt>Miscellaneous:</dt>
-                <dd><ul>
-                    <li>
-                        <a href="#" data-random="artist">Random Artist</a>
-                        (<a href="#" data-random="artist-more-than-one-contrib">&gt;1 contribution</a>)
-                    </li>
-                    <li><a href="#" data-random="album">Random Album (whole site)</a></li>
-                    <li><a href="#" data-random="track">Random Track (whole site)</a></li>
-                </ul></dd>
-                ${[
-                    {name: 'Official', albumData: officialAlbumData, code: 'official'},
-                    {name: 'Fandom', albumData: fandomAlbumData, code: 'fandom'}
-                ].map(category => fixWS`
-                    <dt>${category.name}: (<a href="#" data-random="album-in-${category.code}">Random Album</a>, <a href="#" data-random="track-in-${category.code}">Random Track</a>)</dt>
-                    <dd><ul>${category.albumData.map(album => fixWS`
-                        <li><a style="${getLinkThemeString(album.color)}; --album-directory: ${album.directory}" href="#" data-random="track-in-album">${album.name}</a></li>
-                    `).join('\n')}</ul></dd>
-                `).join('\n')}
-            </dl>
-        `
+      if (!empty(suberrors)) {
+        errors.push(new AggregateError(suberrors, `Errors matching "see also" listings for ${listing.directory}`));
+      }
+    } else {
+      listing.seeAlso = null;
     }
-];
+  }
 
-const filterListings = directoryPrefix => listingSpec
-    .filter(l => l.directory.startsWith(directoryPrefix));
+  if (!empty(errors)) {
+    const aggregate = new AggregateError(errors, `Errors validating listings`);
+    showAggregate(aggregate, {showTraces: false});
+  }
+}
+
+const filterListings = (directoryPrefix) =>
+  listingSpec.filter(l => l.directory.startsWith(directoryPrefix));
 
 const listingTargetSpec = [
-    {
-        title: ({strings}) => strings('listingPage.target.album'),
-        listings: filterListings('album')
-    },
-    {
-        title: ({strings}) => strings('listingPage.target.artist'),
-        listings: filterListings('artist')
-    },
-    {
-        title: ({strings}) => strings('listingPage.target.group'),
-        listings: filterListings('group')
-    },
-    {
-        title: ({strings}) => strings('listingPage.target.track'),
-        listings: filterListings('track')
-    },
-    {
-        title: ({strings}) => strings('listingPage.target.tag'),
-        listings: filterListings('tag')
-    },
-    {
-        title: ({strings}) => strings('listingPage.target.other'),
-        listings: [
-            listingSpec.find(l => l.directory === 'random')
-        ]
-    }
+  {
+    stringsKey: 'album',
+    listings: filterListings('album'),
+  },
+  {
+    stringsKey: 'artist',
+    listings: filterListings('artist'),
+  },
+  {
+    stringsKey: 'group',
+    listings: filterListings('group'),
+  },
+  {
+    stringsKey: 'track',
+    listings: filterListings('track'),
+  },
+  {
+    stringsKey: 'tag',
+    listings: filterListings('tag'),
+  },
+  {
+    stringsKey: 'other',
+    listings: listingSpec.filter(l => l.groupUnderOther),
+  },
 ];
 
+for (const target of listingTargetSpec) {
+  for (const listing of target.listings) {
+    listing.target = target;
+  }
+}
+
 export {listingSpec, listingTargetSpec};
diff --git a/src/misc-templates.js b/src/misc-templates.js
deleted file mode 100644
index a6b39b99..00000000
--- a/src/misc-templates.js
+++ /dev/null
@@ -1,379 +0,0 @@
-// Miscellaneous utility functions which are useful across page specifications.
-// These are made available right on a page spec's ({wikiData, strings, ...})
-// args object!
-
-import fixWS from 'fix-whitespace';
-
-import * as html from './util/html.js';
-
-import {
-    getColors
-} from './util/colors.js';
-
-import {
-    UNRELEASED_TRACKS_DIRECTORY
-} from './util/magic-constants.js';
-
-import {
-    unique
-} from './util/sugar.js';
-
-import {
-    getTotalDuration,
-    sortByDate
-} from './util/wiki-data.js';
-
-// Artist strings
-
-export function getArtistString(artists, {
-    iconifyURL, link, strings,
-    showIcons = false,
-    showContrib = false
-}) {
-    return strings.list.and(artists.map(({ who, what }) => {
-        const { urls, directory, name } = who;
-        return [
-            link.artist(who),
-            showContrib && what && `(${what})`,
-            showIcons && urls.length && `<span class="icons">(${
-                strings.list.unit(urls.map(url => iconifyURL(url, {strings})))
-            })</span>`
-        ].filter(Boolean).join(' ');
-    }));
-}
-
-// Chronology links
-
-export function generateChronologyLinks(currentThing, {
-    dateKey = 'date',
-    contribKey,
-    getThings,
-    headingString,
-    link,
-    linkAnythingMan,
-    strings,
-    wikiData
-}) {
-    const { albumData } = wikiData;
-
-    const contributions = currentThing[contribKey];
-    if (!contributions) {
-        return '';
-    }
-
-    if (contributions.length > 8) {
-        return `<div class="chronology">${strings('misc.chronology.seeArtistPages')}</div>`;
-    }
-
-    return contributions.map(({ who: artist }) => {
-        const things = sortByDate(unique(getThings(artist)), dateKey);
-        const releasedThings = things.filter(thing => {
-            const album = albumData.includes(thing) ? thing : thing.album;
-            return !(album && album.directory === UNRELEASED_TRACKS_DIRECTORY);
-        });
-        const index = releasedThings.indexOf(currentThing);
-
-        if (index === -1) return '';
-
-        // TODO: This can pro8a8ly 8e made to use generatePreviousNextLinks?
-        // We'd need to make generatePreviousNextLinks use toAnythingMan tho.
-        const previous = releasedThings[index - 1];
-        const next = releasedThings[index + 1];
-        const parts = [
-            previous && linkAnythingMan(previous, {
-                color: false,
-                text: strings('misc.nav.previous')
-            }),
-            next && linkAnythingMan(next, {
-                color: false,
-                text: strings('misc.nav.next')
-            })
-        ].filter(Boolean);
-
-        const stringOpts = {
-            index: strings.count.index(index + 1, {strings}),
-            artist: link.artist(artist)
-        };
-
-        return fixWS`
-            <div class="chronology">
-                <span class="heading">${strings(headingString, stringOpts)}</span>
-                ${parts.length && `<span class="buttons">(${parts.join(', ')})</span>`}
-            </div>
-        `;
-    }).filter(Boolean).join('\n');
-}
-
-// Content warning tags
-
-export function getRevealStringFromWarnings(warnings, {strings}) {
-    return strings('misc.contentWarnings', {warnings}) + `<br><span class="reveal-interaction">${strings('misc.contentWarnings.reveal')}</span>`
-}
-
-export function getRevealStringFromTags(tags, {strings}) {
-    return tags && tags.some(tag => tag.isCW) && (
-        getRevealStringFromWarnings(strings.list.unit(tags.filter(tag => tag.isCW).map(tag => tag.name)), {strings}));
-}
-
-// Cover art links
-
-export function generateCoverLink({
-    img, link, strings, to, wikiData,
-    src,
-    path,
-    alt,
-    tags = []
-}) {
-    const { wikiInfo } = wikiData;
-
-    if (!src && path) {
-        src = to(...path);
-    }
-
-    if (!src) {
-        throw new Error(`Expected src or path`);
-    }
-
-    return fixWS`
-        <div id="cover-art-container">
-            ${img({
-                src,
-                alt,
-                thumb: 'medium',
-                id: 'cover-art',
-                link: true,
-                square: true,
-                reveal: getRevealStringFromTags(tags, {strings})
-            })}
-            ${wikiInfo.features.artTagUI && tags.filter(tag => !tag.isCW).length && fixWS`
-                <p class="tags">
-                    ${strings('releaseInfo.artTags')}
-                    ${(tags
-                        .filter(tag => !tag.isCW)
-                        .map(link.tag)
-                        .join(',\n'))}
-                </p>
-            `}
-        </div>
-    `;
-}
-
-// CSS & color shenanigans
-
-export function getThemeString(color, additionalVariables = []) {
-    if (!color) return '';
-
-    const { primary, dim, bg } = getColors(color);
-
-    const variables = [
-        `--primary-color: ${primary}`,
-        `--dim-color: ${dim}`,
-        `--bg-color: ${bg}`,
-        ...additionalVariables
-    ].filter(Boolean);
-
-    if (!variables.length) return '';
-
-    return (
-        `:root {\n` +
-        variables.map(line => `    ` + line + ';\n').join('') +
-        `}`
-    );
-}
-export function getAlbumStylesheet(album, {to}) {
-    return [
-        album.wallpaperArtists && fixWS`
-            body::before {
-                background-image: url("${to('media.albumWallpaper', album.directory)}");
-                ${album.wallpaperStyle}
-            }
-        `,
-        album.bannerStyle && fixWS`
-            #banner img {
-                ${album.bannerStyle}
-            }
-        `
-    ].filter(Boolean).join('\n');
-}
-
-// Fancy lookin' links
-
-export function fancifyURL(url, {strings, album = false} = {}) {
-    let local = Symbol();
-    let domain;
-    try {
-        domain = new URL(url).hostname;
-    } catch (error) {
-        // No support for relative local URLs yet, sorry! (I.e, local URLs must
-        // be absolute relative to the domain name in order to work.)
-        domain = local;
-    }
-    return fixWS`<a href="${url}" class="nowrap">${
-        domain === local ? strings('misc.external.local') :
-        domain.includes('bandcamp.com') ? strings('misc.external.bandcamp') :
-        [
-            'music.solatrux.com'
-        ].includes(domain) ? strings('misc.external.bandcamp.domain', {domain}) :
-        [
-            'types.pl'
-        ].includes(domain) ? strings('misc.external.mastodon.domain', {domain}) :
-        domain.includes('youtu') ? (album
-            ? (url.includes('list=')
-                ? strings('misc.external.youtube.playlist')
-                : strings('misc.external.youtube.fullAlbum'))
-            : strings('misc.external.youtube')) :
-        domain.includes('soundcloud') ? strings('misc.external.soundcloud') :
-        domain.includes('tumblr.com') ? strings('misc.external.tumblr') :
-        domain.includes('twitter.com') ? strings('misc.external.twitter') :
-        domain.includes('deviantart.com') ? strings('misc.external.deviantart') :
-        domain.includes('wikipedia.org') ? strings('misc.external.wikipedia') :
-        domain.includes('poetryfoundation.org') ? strings('misc.external.poetryFoundation') :
-        domain.includes('instagram.com') ? strings('misc.external.instagram') :
-        domain.includes('patreon.com') ? strings('misc.external.patreon') :
-        domain
-    }</a>`;
-}
-
-export function fancifyFlashURL(url, flash, {strings}) {
-    const link = fancifyURL(url, {strings});
-    return `<span class="nowrap">${
-        url.includes('homestuck.com') ? (isNaN(Number(flash.page))
-            ? strings('misc.external.flash.homestuck.secret', {link})
-            : strings('misc.external.flash.homestuck.page', {link, page: flash.page})) :
-        url.includes('bgreco.net') ? strings('misc.external.flash.bgreco', {link}) :
-        url.includes('youtu') ? strings('misc.external.flash.youtube', {link}) :
-        link
-    }</span>`;
-}
-
-export function iconifyURL(url, {strings, to}) {
-    const domain = new URL(url).hostname;
-    const [ id, msg ] = (
-        domain.includes('bandcamp.com') ? ['bandcamp', strings('misc.external.bandcamp')] :
-        (
-            domain.includes('music.solatrus.com')
-        ) ? ['bandcamp', strings('misc.external.bandcamp.domain', {domain})] :
-        (
-            domain.includes('types.pl')
-        ) ? ['mastodon', strings('misc.external.mastodon.domain', {domain})] :
-        domain.includes('youtu') ? ['youtube', strings('misc.external.youtube')] :
-        domain.includes('soundcloud') ? ['soundcloud', strings('misc.external.soundcloud')] :
-        domain.includes('tumblr.com') ? ['tumblr', strings('misc.external.tumblr')] :
-        domain.includes('twitter.com') ? ['twitter', strings('misc.external.twitter')] :
-        domain.includes('deviantart.com') ? ['deviantart', strings('misc.external.deviantart')] :
-        domain.includes('instagram.com') ? ['instagram', strings('misc.external.bandcamp')] :
-        ['globe', strings('misc.external.domain', {domain})]
-    );
-    return fixWS`<a href="${url}" class="icon"><svg><title>${msg}</title><use href="${to('shared.staticFile', `icons.svg#icon-${id}`)}"></use></svg></a>`;
-}
-
-// Grids
-
-export function getGridHTML({
-    getLinkThemeString,
-    img,
-    strings,
-
-    entries,
-    srcFn,
-    hrefFn,
-    altFn = () => '',
-    detailsFn = null,
-    lazy = true
-}) {
-    return entries.map(({ large, item }, i) => html.tag('a',
-        {
-            class: ['grid-item', 'box', large && 'large-grid-item'],
-            href: hrefFn(item),
-            style: getLinkThemeString(item.color)
-        },
-        fixWS`
-            ${img({
-                src: srcFn(item),
-                alt: altFn(item),
-                thumb: 'small',
-                lazy: (typeof lazy === 'number' ? i >= lazy : lazy),
-                square: true,
-                reveal: getRevealStringFromTags(item.artTags, {strings})
-            })}
-            <span>${item.name}</span>
-            ${detailsFn && `<span>${detailsFn(item)}</span>`}
-        `)).join('\n');
-}
-
-export function getAlbumGridHTML({
-    getAlbumCover, getGridHTML, strings, to,
-    details = false,
-    ...props
-}) {
-    return getGridHTML({
-        srcFn: getAlbumCover,
-        hrefFn: album => to('localized.album', album.directory),
-        detailsFn: details && (album => strings('misc.albumGridDetails', {
-            tracks: strings.count.tracks(album.tracks.length, {unit: true}),
-            time: strings.count.duration(getTotalDuration(album.tracks))
-        })),
-        ...props
-    });
-}
-
-export function getFlashGridHTML({
-    getFlashCover, getGridHTML, to,
-    ...props
-}) {
-    return getGridHTML({
-        srcFn: getFlashCover,
-        hrefFn: flash => to('localized.flash', flash.directory),
-        ...props
-    });
-}
-// Nav-bar links
-
-export function generateInfoGalleryLinks(currentThing, isGallery, {
-    link, strings,
-    linkKeyGallery,
-    linkKeyInfo
-}) {
-    return [
-        link[linkKeyInfo](currentThing, {
-            class: isGallery ? '' : 'current',
-            text: strings('misc.nav.info')
-        }),
-        link[linkKeyGallery](currentThing, {
-            class: isGallery ? 'current' : '',
-            text: strings('misc.nav.gallery')
-        })
-    ].join(', ');
-}
-
-export function generatePreviousNextLinks(current, {
-    data,
-    link,
-    linkKey,
-    strings
-}) {
-    const linkFn = link[linkKey];
-
-    const index = data.indexOf(current);
-    const previous = data[index - 1];
-    const next = data[index + 1];
-
-    return [
-        previous && linkFn(previous, {
-            attributes: {
-                id: 'previous-button',
-                title: previous.name
-            },
-            text: strings('misc.nav.previous'),
-            color: false
-        }),
-        next && linkFn(next, {
-            attributes: {
-                id: 'next-button',
-                title: next.name
-            },
-            text: strings('misc.nav.next'),
-            color: false
-        })
-    ].filter(Boolean).join(', ');
-}
diff --git a/src/node-utils.js b/src/node-utils.js
new file mode 100644
index 00000000..345d10aa
--- /dev/null
+++ b/src/node-utils.js
@@ -0,0 +1,102 @@
+// Utility functions which are only relevant to particular Node.js constructs.
+
+import {readdir, stat} from 'node:fs/promises';
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+import _commandExists from 'command-exists';
+
+// This package throws an error instead of returning false when the command
+// doesn't exist, for some reason. Yay for making logic more difficult!
+// Here's a straightforward workaround.
+export function commandExists(command) {
+  return _commandExists(command).then(
+    () => true,
+    () => false
+  );
+}
+
+// Very cool function origin8ting in... http-music pro8a8ly!
+// Sorry if we happen to 8e violating past-us's copyright, lmao.
+export function promisifyProcess(proc, showLogging = true) {
+  // Takes a process (from the child_process module) and returns a promise
+  // that resolves when the process exits (or rejects, if the exit code is
+  // non-zero).
+  //
+  // Ayy look, no alpha8etical second letter! Couldn't tell this was written
+  // like three years ago 8efore I was me. 8888)
+
+  return new Promise((resolve, reject) => {
+    if (showLogging) {
+      proc.stdout.pipe(process.stdout);
+      proc.stderr.pipe(process.stderr);
+    }
+
+    proc.on('exit', (code) => {
+      if (code === 0) {
+        resolve();
+      } else {
+        reject(code);
+      }
+    });
+  });
+}
+
+// Handy-dandy utility function for detecting whether the passed URL is the
+// running JavaScript file. This takes `import.meta.url` from ES6 modules, which
+// is great 'cuz (module === require.main) doesn't work without CommonJS
+// modules.
+export function isMain(importMetaURL) {
+  const metaPath = fileURLToPath(importMetaURL);
+  const relative = path.relative(process.argv[1], metaPath);
+  const isIndexJS = path.basename(metaPath) === 'index.js';
+  return [
+    '',
+    isIndexJS && 'index.js'
+  ].includes(relative);
+}
+
+// Like readdir... but it's recursive! This returns a flat list of file paths.
+// By default, the paths include the provided top/root path, but this can be
+// changed with prefixPath to prefix some other path, or to just return paths
+// relative to the root. Change pathStyle to specify posix or win32, or leave
+// it as the default device-correct style. Provide a filterDir function to
+// control which directory names are traversed at all, and filterFile to
+// select which filenames are included in the final list.
+export async function traverse(rootPath, {
+  pathStyle = 'device',
+  filterFile = () => true,
+  filterDir = () => true,
+  prefixPath = rootPath,
+} = {}) {
+  const pathJoinDevice = path.join;
+  const pathJoinStyle = {
+    'device': path.join,
+    'posix': path.posix.join,
+    'win32': path.win32.join,
+  }[pathStyle];
+
+  if (!pathJoinStyle) {
+    throw new Error(`Expected pathStyle to be device, posix, or win32`);
+  }
+
+  const recursive = (names, ...subdirectories) =>
+    Promise.all(names.map(async name => {
+      const devicePath = pathJoinDevice(rootPath, ...subdirectories, name);
+      const stats = await stat(devicePath);
+
+      if (stats.isDirectory() && !filterDir(name)) return [];
+      else if (stats.isFile() && !filterFile(name)) return [];
+      else if (!stats.isDirectory() && !stats.isFile()) return [];
+
+      if (stats.isDirectory()) {
+        return recursive(await readdir(devicePath), ...subdirectories, name);
+      } else {
+        return pathJoinStyle(prefixPath, ...subdirectories, name);
+      }
+    }));
+
+  const names = await readdir(rootPath);
+  const results = await recursive(names);
+  return results.flat(Infinity);
+}
diff --git a/src/page/album-commentary.js b/src/page/album-commentary.js
deleted file mode 100644
index c03ae3db..00000000
--- a/src/page/album-commentary.js
+++ /dev/null
@@ -1,143 +0,0 @@
-// Album commentary page and index specifications.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import {
-    filterAlbumsByCommentary
-} from '../util/wiki-data.js';
-
-// Page exports
-
-export function condition({wikiData}) {
-    return filterAlbumsByCommentary(wikiData.albumData).length;
-}
-
-export function targets({wikiData}) {
-    return filterAlbumsByCommentary(wikiData.albumData);
-}
-
-export function write(album, {wikiData}) {
-    const { wikiInfo } = wikiData;
-
-    const entries = [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary);
-    const words = entries.join(' ').split(' ').length;
-
-    const page = {
-        type: 'page',
-        path: ['albumCommentary', album.directory],
-        page: ({
-            getAlbumStylesheet,
-            getLinkThemeString,
-            getThemeString,
-            link,
-            strings,
-            to,
-            transformMultiline
-        }) => ({
-            title: strings('albumCommentaryPage.title', {album: album.name}),
-            stylesheet: getAlbumStylesheet(album),
-            theme: getThemeString(album.color),
-
-            main: {
-                content: fixWS`
-                    <div class="long-content">
-                        <h1>${strings('albumCommentaryPage.title', {
-                            album: link.album(album)
-                        })}</h1>
-                        <p>${strings('albumCommentaryPage.infoLine', {
-                            words: `<b>${strings.count.words(words, {unit: true})}</b>`,
-                            entries: `<b>${strings.count.commentaryEntries(entries.length, {unit: true})}</b>`
-                        })}</p>
-                        ${album.commentary && fixWS`
-                            <h3>${strings('albumCommentaryPage.entry.title.albumCommentary')}</h3>
-                            <blockquote>
-                                ${transformMultiline(album.commentary)}
-                            </blockquote>
-                        `}
-                        ${album.tracks.filter(t => t.commentary).map(track => fixWS`
-                            <h3 id="${track.directory}">${strings('albumCommentaryPage.entry.title.trackCommentary', {
-                                track: link.track(track)
-                            })}</h3>
-                            <blockquote style="${getLinkThemeString(track.color)}">
-                                ${transformMultiline(track.commentary)}
-                            </blockquote>
-                        `).join('\n')}
-                    </div>
-                `
-            },
-
-            nav: {
-                links: [
-                    {toHome: true},
-                    {
-                        path: ['localized.commentaryIndex'],
-                        title: strings('commentaryIndex.title')
-                    },
-                    {
-                        html: strings('albumCommentaryPage.nav.album', {
-                            album: link.albumCommentary(album, {class: 'current'})
-                        })
-                    }
-                ]
-            }
-        })
-    };
-
-    return [page];
-}
-
-export function writeTargetless({wikiData}) {
-    const data = filterAlbumsByCommentary(wikiData.albumData)
-        .map(album => ({
-            album,
-            entries: [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary)
-        }))
-        .map(({ album, entries }) => ({
-            album, entries,
-            words: entries.join(' ').split(' ').length
-        }));
-
-    const totalEntries = data.reduce((acc, {entries}) => acc + entries.length, 0);
-    const totalWords = data.reduce((acc, {words}) => acc + words, 0);
-
-    const page = {
-        type: 'page',
-        path: ['commentaryIndex'],
-        page: ({
-            link,
-            strings
-        }) => ({
-            title: strings('commentaryIndex.title'),
-
-            main: {
-                content: fixWS`
-                    <div class="long-content">
-                        <h1>${strings('commentaryIndex.title')}</h1>
-                        <p>${strings('commentaryIndex.infoLine', {
-                            words: `<b>${strings.count.words(totalWords, {unit: true})}</b>`,
-                            entries: `<b>${strings.count.commentaryEntries(totalEntries, {unit: true})}</b>`
-                        })}</p>
-                        <p>${strings('commentaryIndex.albumList.title')}</p>
-                        <ul>
-                            ${data
-                                .map(({ album, entries, words }) => fixWS`
-                                    <li>${strings('commentaryIndex.albumList.item', {
-                                        album: link.albumCommentary(album),
-                                        words: strings.count.words(words, {unit: true}),
-                                        entries: strings.count.commentaryEntries(entries.length, {unit: true})
-                                    })}</li>
-                                `)
-                                .join('\n')}
-                        </ul>
-                    </div>
-                `
-            },
-
-            nav: {simple: true}
-        })
-    };
-
-    return [page];
-}
diff --git a/src/page/album.js b/src/page/album.js
index 19efc701..696e2854 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -1,402 +1,142 @@
-// Album page specification.
+import {empty} from '#sugar';
 
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import * as html from '../util/html.js';
-
-import {
-    bindOpts
-} from '../util/sugar.js';
-
-import {
-    getAlbumCover,
-    getAlbumListTag,
-    getTotalDuration
-} from '../util/wiki-data.js';
-
-// Page exports
+export const description = `per-album info, artwork gallery & commentary pages`;
 
 export function targets({wikiData}) {
-    return wikiData.albumData;
+  return wikiData.albumData;
 }
 
-export function write(album, {wikiData}) {
-    const { wikiInfo } = wikiData;
-
-    const unbound_trackToListItem = (track, {
-        getArtistString,
-        getLinkThemeString,
-        link,
-        strings
-    }) => {
-        const itemOpts = {
-            duration: strings.count.duration(track.duration),
-            track: link.track(track)
-        };
-        return `<li style="${getLinkThemeString(track.color)}">${
-            (track.artists === album.artists
-                ? strings('trackList.item.withDuration', itemOpts)
-                : strings('trackList.item.withDuration.withArtists', {
-                    ...itemOpts,
-                    by: `<span class="by">${
-                        strings('trackList.item.withArtists.by', {
-                            artists: getArtistString(track.artists)
-                        })
-                    }</span>`
-                }))
-        }</li>`;
-    };
-
-    const commentaryEntries = [album, ...album.tracks].filter(x => x.commentary).length;
-    const albumDuration = getTotalDuration(album.tracks);
-
-    const listTag = getAlbumListTag(album);
-
-    const data = {
-        type: 'data',
-        path: ['album', album.directory],
-        data: ({
-            serializeContribs,
-            serializeCover,
-            serializeGroupsForAlbum,
-            serializeLink
-        }) => ({
-            name: album.name,
-            directory: album.directory,
-            dates: {
-                released: album.date,
-                trackArtAdded: album.trackArtDate,
-                coverArtAdded: album.coverArtDate,
-                addedToWiki: album.dateAdded
-            },
-            duration: albumDuration,
-            color: album.color,
-            cover: serializeCover(album, getAlbumCover),
-            artists: serializeContribs(album.artists || []),
-            coverArtists: serializeContribs(album.coverArtists || []),
-            wallpaperArtists: serializeContribs(album.wallpaperArtists || []),
-            bannerArtists: serializeContribs(album.bannerArtists || []),
-            groups: serializeGroupsForAlbum(album),
-            trackGroups: album.trackGroups?.map(trackGroup => ({
-                name: trackGroup.name,
-                color: trackGroup.color,
-                tracks: trackGroup.tracks.map(track => track.directory)
-            })),
-            tracks: album.tracks.map(track => ({
-                link: serializeLink(track),
-                duration: track.duration
-            }))
-        })
-    };
-
-    const page = {
-        type: 'page',
-        path: ['album', album.directory],
-        page: ({
-            fancifyURL,
-            generateChronologyLinks,
-            generateCoverLink,
-            getAlbumStylesheet,
-            getArtistString,
-            getLinkThemeString,
-            getThemeString,
-            link,
-            strings,
-            transformMultiline
-        }) => {
-            const trackToListItem = bindOpts(unbound_trackToListItem, {
-                getArtistString,
-                getLinkThemeString,
-                link,
-                strings
-            });
-
-            return {
-                title: strings('albumPage.title', {album: album.name}),
-                stylesheet: getAlbumStylesheet(album),
-                theme: getThemeString(album.color, [
-                    `--album-directory: ${album.directory}`
-                ]),
-
-                banner: album.bannerArtists && {
-                    dimensions: album.bannerDimensions,
-                    path: ['media.albumBanner', album.directory],
-                    alt: strings('misc.alt.albumBanner'),
-                    position: 'top'
-                },
-
-                main: {
-                    content: fixWS`
-                        ${generateCoverLink({
-                            path: ['media.albumCover', album.directory],
-                            alt: strings('misc.alt.albumCover'),
-                            tags: album.artTags
-                        })}
-                        <h1>${strings('albumPage.title', {album: album.name})}</h1>
-                        <p>
-                            ${[
-                                album.artists && strings('releaseInfo.by', {
-                                    artists: getArtistString(album.artists, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                album.coverArtists && strings('releaseInfo.coverArtBy', {
-                                    artists: getArtistString(album.coverArtists, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                album.wallpaperArtists && strings('releaseInfo.wallpaperArtBy', {
-                                    artists: getArtistString(album.wallpaperArtists, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                album.bannerArtists && strings('releaseInfo.bannerArtBy', {
-                                    artists: getArtistString(album.bannerArtists, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                strings('releaseInfo.released', {
-                                    date: strings.count.date(album.date)
-                                }),
-                                +album.coverArtDate !== +album.date && strings('releaseInfo.artReleased', {
-                                    date: strings.count.date(album.coverArtDate)
-                                }),
-                                strings('releaseInfo.duration', {
-                                    duration: strings.count.duration(albumDuration, {approximate: album.tracks.length > 1})
-                                })
-                            ].filter(Boolean).join('<br>\n')}
-                        </p>
-                        ${commentaryEntries && `<p>${
-                            strings('releaseInfo.viewCommentary', {
-                                link: link.albumCommentary(album, {
-                                    text: strings('releaseInfo.viewCommentary.link')
-                                })
-                            })
-                        }</p>`}
-                        ${album.urls.length && `<p>${
-                            strings('releaseInfo.listenOn', {
-                                links: strings.list.or(album.urls.map(url => fancifyURL(url, {album: true})))
-                            })
-                        }</p>`}
-                        ${album.trackGroups ? fixWS`
-                            <dl class="album-group-list">
-                                ${album.trackGroups.map(({ name, color, startIndex, tracks }) => fixWS`
-                                    <dt>${
-                                        strings('trackList.group', {
-                                            duration: strings.count.duration(getTotalDuration(tracks), {approximate: tracks.length > 1}),
-                                            group: name
-                                        })
-                                    }</dt>
-                                    <dd><${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}>
-                                        ${tracks.map(trackToListItem).join('\n')}
-                                    </${listTag}></dd>
-                                `).join('\n')}
-                            </dl>
-                        ` : fixWS`
-                            <${listTag}>
-                                ${album.tracks.map(trackToListItem).join('\n')}
-                            </${listTag}>
-                        `}
-                        <p>
-                            ${[
-                                strings('releaseInfo.addedToWiki', {
-                                    date: strings.count.date(album.dateAdded)
-                                })
-                            ].filter(Boolean).join('<br>\n')}
-                        </p>
-                        ${album.commentary && fixWS`
-                            <p>${strings('releaseInfo.artistCommentary')}</p>
-                            <blockquote>
-                                ${transformMultiline(album.commentary)}
-                            </blockquote>
-                        `}
-                    `
-                },
-
-                sidebarLeft: generateAlbumSidebar(album, null, {
-                    fancifyURL,
-                    getLinkThemeString,
-                    link,
-                    strings,
-                    transformMultiline,
-                    wikiData
-                }),
-
-                nav: {
-                    links: [
-                        {toHome: true},
-                        {
-                            html: strings('albumPage.nav.album', {
-                                album: link.album(album, {class: 'current'})
-                            })
-                        },
-                        album.tracks.length > 1 &&
-                        {
-                            divider: false,
-                            html: generateAlbumNavLinks(album, null, {strings})
-                        }
-                    ],
-                    content: html.tag('div', generateAlbumChronologyLinks(album, null, {generateChronologyLinks}))
-                }
-            };
-        }
-    };
-
-    return [page, data];
-}
-
-// Utility functions
-
-export function generateAlbumSidebar(album, currentTrack, {
-    fancifyURL,
-    getLinkThemeString,
-    link,
-    strings,
-    transformMultiline,
-    wikiData
-}) {
-    const listTag = getAlbumListTag(album);
-
-    const trackGroups = album.trackGroups || [{
-        name: strings('albumSidebar.trackList.fallbackGroupName'),
-        color: album.color,
-        startIndex: 0,
-        tracks: album.tracks
-    }];
-
-    const trackToListItem = track => html.tag('li',
-        {class: track === currentTrack && 'current'},
-        strings('albumSidebar.trackList.item', {
-            track: link.track(track)
-        }));
-
-    const trackListPart = fixWS`
-        <h1>${link.album(album)}</h1>
-        ${trackGroups.map(({ name, color, startIndex, tracks }) =>
-            html.tag('details', {
-                // Leave side8ar track groups collapsed on al8um homepage,
-                // since there's already a view of all the groups expanded
-                // in the main content area.
-                open: currentTrack && tracks.includes(currentTrack),
-                class: tracks.includes(currentTrack) && 'current'
-            }, [
-                html.tag('summary',
-                    {style: getLinkThemeString(color)},
-                    (listTag === 'ol'
-                        ? strings('albumSidebar.trackList.group.withRange', {
-                            group: `<span class="group-name">${name}</span>`,
-                            range: `${startIndex + 1}&ndash;${startIndex + tracks.length}`
-                        })
-                        : strings('albumSidebar.trackList.group', {
-                            group: `<span class="group-name">${name}</span>`
-                        }))
-                ),
-                fixWS`
-                    <${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}>
-                        ${tracks.map(trackToListItem).join('\n')}
-                    </${listTag}>
-                `
-            ])).join('\n')}
-    `;
-
-    const { groups } = album;
-
-    const groupParts = groups.map(group => {
-        const index = group.albums.indexOf(album);
-        const next = group.albums[index + 1];
-        const previous = group.albums[index - 1];
-        return {group, next, previous};
-    }).map(({group, next, previous}) => fixWS`
-        <h1>${
-            strings('albumSidebar.groupBox.title', {
-                group: link.groupInfo(group)
-            })
-        }</h1>
-        ${!currentTrack && transformMultiline(group.descriptionShort)}
-        ${group.urls.length && `<p>${
-            strings('releaseInfo.visitOn', {
-                links: strings.list.or(group.urls.map(url => fancifyURL(url)))
-            })
-        }</p>`}
-        ${!currentTrack && fixWS`
-            ${next && `<p class="group-chronology-link">${
-                strings('albumSidebar.groupBox.next', {
-                    album: link.album(next)
-                })
-            }</p>`}
-            ${previous && `<p class="group-chronology-link">${
-                strings('albumSidebar.groupBox.previous', {
-                    album: link.album(previous)
-                })
-            }</p>`}
-        `}
-    `);
-
-    if (groupParts.length) {
-        if (currentTrack) {
-            const combinedGroupPart = groupParts.join('\n<hr>\n');
-            return {
-                multiple: [
-                    trackListPart,
-                    combinedGroupPart
-                ]
-            };
-        } else {
-            return {
-                multiple: [
-                    ...groupParts,
-                    trackListPart
-                ]
-            };
-        }
-    } else {
-        return {
-            content: trackListPart
-        };
-    }
+export function pathsForTarget(album) {
+  return [
+    {
+      type: 'page',
+      path: ['album', album.directory],
+
+      contentFunction: {
+        name: 'generateAlbumInfoPage',
+        args: [album],
+      },
+    },
+
+    {
+      type: 'page',
+      path: ['albumGallery', album.directory],
+
+      contentFunction: {
+        name: 'generateAlbumGalleryPage',
+        args: [album],
+      },
+    },
+
+    {
+      type: 'page',
+      path: ['albumCommentary', album.directory],
+
+      contentFunction: {
+        name: 'generateAlbumCommentaryPage',
+        args: [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',
+      path: ['album', album.directory],
+
+      contentFunction: {
+        name: 'generateAlbumDataFile',
+        args: [album],
+      },
+    },
+    */
+  ];
 }
 
-export function generateAlbumNavLinks(album, currentTrack, {
-    generatePreviousNextLinks,
-    strings
-}) {
-    if (album.tracks.length <= 1) {
-        return '';
-    }
-
-    const previousNextLinks = currentTrack && generatePreviousNextLinks(currentTrack, {
-        data: album.tracks,
-        linkKey: 'track'
-    });
-    const randomLink = `<a href="#" data-random="track-in-album" id="random-button">${
-        (currentTrack
-            ? strings('trackPage.nav.random')
-            : strings('albumPage.nav.randomTrack'))
-    }</a>`;
-
-    return (previousNextLinks
-        ? `(${previousNextLinks}<span class="js-hide-until-data">, ${randomLink}</span>)`
-        : `<span class="js-hide-until-data">(${randomLink})</span>`);
+export function pathsTargetless({wikiData: {wikiInfo}}) {
+  return [
+    {
+      type: 'page',
+      path: ['commentaryIndex'],
+      contentFunction: {name: 'generateCommentaryIndexPage'},
+    },
+
+    {
+      type: 'redirect',
+      fromPath: ['page', 'list/all-commentary'],
+      toPath: ['commentaryIndex'],
+      title: 'Album Commentary',
+
+      condition: () =>
+        wikiInfo.canonicalBase === 'https://hsmusic.wiki/',
+    },
+  ];
 }
 
-export function generateAlbumChronologyLinks(album, currentTrack, {generateChronologyLinks}) {
-    return [
-        currentTrack && generateChronologyLinks(currentTrack, {
-            contribKey: 'artists',
-            getThings: artist => [...artist.tracks.asArtist, ...artist.tracks.asContributor],
-            headingString: 'misc.chronology.heading.track'
-        }),
-        generateChronologyLinks(currentTrack || album, {
-            contribKey: 'coverArtists',
-            dateKey: 'coverArtDate',
-            getThings: artist => [...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist],
-            headingString: 'misc.chronology.heading.coverArt'
-        })
-    ].filter(Boolean).join('\n');
+/*
+export function write(album, {wikiData}) {
+  const data = {
+    type: 'data',
+    path: ['album', album.directory],
+    data: ({
+      serializeContribs,
+      serializeCover,
+      serializeGroupsForAlbum,
+      serializeLink,
+    }) => ({
+      name: album.name,
+      directory: album.directory,
+      dates: {
+        released: album.date,
+        trackArtAdded: album.trackArtDate,
+        coverArtAdded: album.coverArtDate,
+        addedToWiki: album.dateAddedToWiki,
+      },
+      duration: albumDuration,
+      color: album.color,
+      cover: serializeCover(album, getAlbumCover),
+      artistContribs: serializeContribs(album.artistContribs),
+      coverArtistContribs: serializeContribs(album.coverArtistContribs),
+      wallpaperArtistContribs: serializeContribs(album.wallpaperArtistContribs),
+      bannerArtistContribs: serializeContribs(album.bannerArtistContribs),
+      groups: serializeGroupsForAlbum(album),
+      trackSections: album.trackSections?.map((section) => ({
+        name: section.name,
+        color: section.color,
+        tracks: section.tracks.map((track) => track.directory),
+      })),
+      tracks: album.tracks.map((track) => ({
+        link: serializeLink(track),
+        duration: track.duration,
+      })),
+    }),
+  };
 }
+*/
diff --git a/src/page/art-tag.js b/src/page/art-tag.js
new file mode 100644
index 00000000..5b61229d
--- /dev/null
+++ b/src/page/art-tag.js
@@ -0,0 +1,35 @@
+// Art tag page specification.
+
+export const description = `per-art-tag info & gallery pages`;
+
+export function condition({wikiData}) {
+  return wikiData.wikiInfo.enableArtTagUI;
+}
+
+export function targets({wikiData}) {
+  return wikiData.artTagData.filter((tag) => !tag.isContentWarning);
+}
+
+export function pathsForTarget(tag) {
+  return [
+    {
+      type: 'page',
+      path: ['artTagInfo', tag.directory],
+
+      contentFunction: {
+        name: 'generateArtTagInfoPage',
+        args: [tag],
+      },
+    },
+
+    {
+      type: 'page',
+      path: ['artTagGallery', tag.directory],
+
+      contentFunction: {
+        name: 'generateArtTagGalleryPage',
+        args: [tag],
+      },
+    },
+  ];
+}
diff --git a/src/page/artist-alias.js b/src/page/artist-alias.js
index d03510a8..6af5ef8a 100644
--- a/src/page/artist-alias.js
+++ b/src/page/artist-alias.js
@@ -1,22 +1,38 @@
-// Artist alias redirect pages.
-// (Makes old permalinks bring visitors to the up-to-date page.)
+export const description = `redirects for aliased artist names`;
 
 export function targets({wikiData}) {
-    return wikiData.artistAliasData;
-}
-
-export function write(aliasArtist, {wikiData}) {
-    // This function doesn't actually use wikiData, 8ut, um, consistency?
+  const normalArtistDirectories =
+    wikiData.artistData
+      .filter(artist => !artist.isAlias)
+      .map(artist => artist.directory);
 
-    const { alias: targetArtist } = aliasArtist;
+  return (
+    wikiData.artistData
+      .filter(artist => artist.isAlias)
 
-    const redirect = {
-        type: 'redirect',
-        fromPath: ['artist', aliasArtist.directory],
-        toPath: ['artist', targetArtist.directory],
-        title: () => aliasArtist.name
-    };
+      // 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)
 
-    return [redirect];
+      // 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;
+
+  return [
+    {
+      type: 'redirect',
+      fromPath: ['artist', aliasArtist.directory],
+      toPath: ['artist', aliasedArtist.directory],
+      title: aliasedArtist.name,
+    },
+  ];
+}
diff --git a/src/page/artist.js b/src/page/artist.js
index 2e87669d..257e060d 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -1,512 +1,103 @@
-// Artist page specification.
-//
-// NB: See artist-alias.js for artist alias redirect pages.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import * as html from '../util/html.js';
-
-import {
-    UNRELEASED_TRACKS_DIRECTORY
-} from '../util/magic-constants.js';
-
-import {
-    bindOpts,
-    unique
-} from '../util/sugar.js';
+import {empty} from '#sugar';
 
-import {
-    chunkByProperties,
-    getTotalDuration,
-    sortByDate
-} from '../util/wiki-data.js';
-
-// Page exports
+export const description = `per-artist info & artwork gallery pages`;
 
+// NB: See artist-alias.js for artist alias redirect pages.
 export function targets({wikiData}) {
-    return wikiData.artistData;
+  return wikiData.artistData.filter(artist => !artist.isAlias);
 }
 
-export function write(artist, {wikiData}) {
-    const { groupData, wikiInfo } = wikiData;
-
-    const {
-        name,
-        urls = [],
-        note = ''
-    } = artist;
-
-    const artThingsAll = sortByDate(unique([...artist.albums.asCoverArtist, ...artist.albums.asWallpaperArtist, ...artist.albums.asBannerArtist, ...artist.tracks.asCoverArtist]));
-    const artThingsGallery = sortByDate([...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist]);
-    const commentaryThings = sortByDate([...artist.albums.asCommentator, ...artist.tracks.asCommentator]);
-
-    const hasGallery = artThingsGallery.length > 0;
-
-    const getArtistsAndContrib = (thing, key) => ({
-        artists: thing[key]?.filter(({ who }) => who !== artist),
-        contrib: thing[key]?.find(({ who }) => who === artist),
-        thing,
-        key
-    });
-
-    const artListChunks = chunkByProperties(sortByDate(artThingsAll.flatMap(thing =>
-        (['coverArtists', 'wallpaperArtists', 'bannerArtists']
-            .map(key => getArtistsAndContrib(thing, key))
-            .filter(({ contrib }) => contrib)
-            .map(props => ({
-                album: thing.album || thing,
-                track: thing.album ? thing : null,
-                date: +(thing.coverArtDate || thing.date),
-                ...props
-            })))
-    )), ['date', 'album']);
-
-    const commentaryListChunks = chunkByProperties(commentaryThings.map(thing => ({
-        album: thing.album || thing,
-        track: thing.album ? thing : null
-    })), ['album']);
-
-    const allTracks = sortByDate(unique([...artist.tracks.asArtist, ...artist.tracks.asContributor]));
-    const unreleasedTracks = allTracks.filter(track => track.album.directory === UNRELEASED_TRACKS_DIRECTORY);
-    const releasedTracks = allTracks.filter(track => track.album.directory !== UNRELEASED_TRACKS_DIRECTORY);
-
-    const chunkTracks = tracks => (
-        chunkByProperties(tracks.map(track => ({
-            track,
-            date: +track.date,
-            album: track.album,
-            duration: track.duration,
-            artists: (track.artists.some(({ who }) => who === artist)
-                ? track.artists.filter(({ who }) => who !== artist)
-                : track.contributors.filter(({ who }) => who !== artist)),
-            contrib: {
-                who: artist,
-                what: [
-                    track.artists.find(({ who }) => who === artist)?.what,
-                    track.contributors.find(({ who }) => who === artist)?.what
-                ].filter(Boolean).join(', ')
-            }
-        })), ['date', 'album'])
-        .map(({date, album, chunk}) => ({
-            date, album, chunk,
-            duration: getTotalDuration(chunk),
-        })));
-
-    const unreleasedTrackListChunks = chunkTracks(unreleasedTracks);
-    const releasedTrackListChunks = chunkTracks(releasedTracks);
-
-    const totalReleasedDuration = getTotalDuration(releasedTracks);
-
-    const countGroups = things => {
-        const usedGroups = things.flatMap(thing => thing.groups || thing.album?.groups || []);
-        return groupData
-            .map(group => ({
-                group,
-                contributions: usedGroups.filter(g => g === group).length
-            }))
-            .filter(({ contributions }) => contributions > 0)
-            .sort((a, b) => b.contributions - a.contributions);
-    };
-
-    const musicGroups = countGroups(releasedTracks);
-    const artGroups = countGroups(artThingsAll);
-
-    let flashes, flashListChunks;
-    if (wikiInfo.features.flashesAndGames) {
-        flashes = sortByDate(artist.flashes.asContributor.slice());
-        flashListChunks = (
-            chunkByProperties(flashes.map(flash => ({
-                act: flash.act,
-                flash,
-                date: flash.date,
-                // Manual artists/contrib properties here, 8ecause we don't
-                // want to show the full list of other contri8utors inline.
-                // (It can often 8e very, very large!)
-                artists: [],
-                contrib: flash.contributors.find(({ who }) => who === artist)
-            })), ['act'])
-            .map(({ act, chunk }) => ({
-                act, chunk,
-                dateFirst: chunk[0].date,
-                dateLast: chunk[chunk.length - 1].date
-            })));
-    }
-
-    const generateEntryAccents = ({
-        getArtistString, strings,
-        aka, entry, artists, contrib
-    }) =>
-        (aka
-            ? strings('artistPage.creditList.entry.rerelease', {entry})
-            : (artists.length
-                ? (contrib.what
-                    ? strings('artistPage.creditList.entry.withArtists.withContribution', {
-                        entry,
-                        artists: getArtistString(artists),
-                        contribution: contrib.what
-                    })
-                    : strings('artistPage.creditList.entry.withArtists', {
-                        entry,
-                        artists: getArtistString(artists)
-                    }))
-                : (contrib.what
-                    ? strings('artistPage.creditList.entry.withContribution', {
-                        entry,
-                        contribution: contrib.what
-                    })
-                    : entry)));
-
-    const unbound_generateTrackList = (chunks, {
-        getArtistString, link, strings
-    }) => fixWS`
-        <dl>
-            ${chunks.map(({date, album, chunk, duration}) => fixWS`
-                <dt>${strings('artistPage.creditList.album.withDate.withDuration', {
-                    album: link.album(album),
-                    date: strings.count.date(date),
-                    duration: strings.count.duration(duration, {approximate: true})
-                })}</dt>
-                <dd><ul>
-                    ${(chunk
-                        .map(({track, ...props}) => ({
-                            aka: track.aka,
-                            entry: strings('artistPage.creditList.entry.track.withDuration', {
-                                track: link.track(track),
-                                duration: strings.count.duration(track.duration)
-                            }),
-                            ...props
-                        }))
-                        .map(({aka, ...opts}) => html.tag('li',
-                            {class: aka && 'rerelease'},
-                            generateEntryAccents({getArtistString, strings, aka, ...opts})))
-                        .join('\n'))}
-                </ul></dd>
-            `).join('\n')}
-        </dl>
-    `;
-
-    const unbound_serializeArtistsAndContrib = (key, {
-        serializeContribs,
-        serializeLink
-    }) => thing => {
-        const { artists, contrib } = getArtistsAndContrib(thing, key);
-        const ret = {};
-        ret.link = serializeLink(thing);
-        if (contrib.what) ret.contribution = contrib.what;
-        if (artists.length) ret.otherArtists = serializeContribs(artists);
-        return ret;
-    };
-
-    const unbound_serializeTrackListChunks = (chunks, {serializeLink}) =>
-        chunks.map(({date, album, chunk, duration}) => ({
-            album: serializeLink(album),
-            date,
-            duration,
-            tracks: chunk.map(({ track }) => ({
-                link: serializeLink(track),
-                duration: track.duration
-            }))
-        }));
-
-    const data = {
-        type: 'data',
-        path: ['artist', artist.directory],
-        data: ({
-            serializeContribs,
-            serializeLink
-        }) => {
-            const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, {
-                serializeContribs,
-                serializeLink
-            });
-
-            const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, {
-                serializeLink
-            });
-
-            return {
-                albums: {
-                    asCoverArtist: artist.albums.asCoverArtist.map(serializeArtistsAndContrib('coverArtists')),
-                    asWallpaperArtist: artist.albums.asWallpaperArtist.map(serializeArtistsAndContrib('wallpaperArtists')),
-                    asBannerArtist: artist.albums.asBannerArtist.map(serializeArtistsAndContrib('bannerArtists'))
-                },
-                flashes: wikiInfo.features.flashesAndGames ? {
-                    asContributor: artist.flashes.asContributor
-                        .map(flash => getArtistsAndContrib(flash, 'contributors'))
-                        .map(({ contrib, thing: flash }) => ({
-                            link: serializeLink(flash),
-                            contribution: contrib.what
-                        }))
-                } : null,
-                tracks: {
-                    asArtist: artist.tracks.asArtist.map(serializeArtistsAndContrib('artists')),
-                    asContributor: artist.tracks.asContributor.map(serializeArtistsAndContrib('contributors')),
-                    chunked: {
-                        released: serializeTrackListChunks(releasedTrackListChunks),
-                        unreleased: serializeTrackListChunks(unreleasedTrackListChunks)
-                    }
-                }
-            };
-        }
-    };
-
-    const infoPage = {
-        type: 'page',
-        path: ['artist', artist.directory],
-        page: ({
-            fancifyURL,
-            generateCoverLink,
-            generateInfoGalleryLinks,
-            getArtistString,
-            link,
-            strings,
-            to,
-            transformMultiline
-        }) => {
-            const generateTrackList = bindOpts(unbound_generateTrackList, {
-                getArtistString,
-                link,
-                strings
-            });
-
-            return {
-                title: strings('artistPage.title', {artist: name}),
-
-                main: {
-                    content: fixWS`
-                        ${artist.hasAvatar && generateCoverLink({
-                            path: ['localized.artistAvatar', artist.directory],
-                            alt: strings('misc.alt.artistAvatar')
-                        })}
-                        <h1>${strings('artistPage.title', {artist: name})}</h1>
-                        ${note && fixWS`
-                            <p>${strings('releaseInfo.note')}</p>
-                            <blockquote>
-                                ${transformMultiline(note)}
-                            </blockquote>
-                            <hr>
-                        `}
-                        ${urls.length && `<p>${strings('releaseInfo.visitOn', {
-                            links: strings.list.or(urls.map(url => fancifyURL(url, {strings})))
-                        })}</p>`}
-                        ${hasGallery && `<p>${strings('artistPage.viewArtGallery', {
-                            link: link.artistGallery(artist, {
-                                text: strings('artistPage.viewArtGallery.link')
-                            })
-                        })}</p>`}
-                        <p>${strings('misc.jumpTo.withLinks', {
-                            links: strings.list.unit([
-                                [
-                                    [...releasedTracks, ...unreleasedTracks].length && `<a href="#tracks">${strings('artistPage.trackList.title')}</a>`,
-                                    unreleasedTracks.length && `(<a href="#unreleased-tracks">${strings('artistPage.unreleasedTrackList.title')}</a>)`
-                                ].filter(Boolean).join(' '),
-                                artThingsAll.length && `<a href="#art">${strings('artistPage.artList.title')}</a>`,
-                                wikiInfo.features.flashesAndGames && flashes.length && `<a href="#flashes">${strings('artistPage.flashList.title')}</a>`,
-                                commentaryThings.length && `<a href="#commentary">${strings('artistPage.commentaryList.title')}</a>`
-                            ].filter(Boolean))
-                        })}</p>
-                        ${(releasedTracks.length || unreleasedTracks.length) && fixWS`
-                            <h2 id="tracks">${strings('artistPage.trackList.title')}</h2>
-                        `}
-                        ${releasedTracks.length && fixWS`
-                            <p>${strings('artistPage.contributedDurationLine', {
-                                artist: artist.name,
-                                duration: strings.count.duration(totalReleasedDuration, {approximate: true, unit: true})
-                            })}</p>
-                            <p>${strings('artistPage.musicGroupsLine', {
-                                groups: strings.list.unit(musicGroups
-                                    .map(({ group, contributions }) => strings('artistPage.groupsLine.item', {
-                                        group: link.groupInfo(group),
-                                        contributions: strings.count.contributions(contributions)
-                                    })))
-                            })}</p>
-                            ${generateTrackList(releasedTrackListChunks)}
-                        `}
-                        ${unreleasedTracks.length && fixWS`
-                            <h3 id="unreleased-tracks">${strings('artistPage.unreleasedTrackList.title')}</h3>
-                            ${generateTrackList(unreleasedTrackListChunks)}
-                        `}
-                        ${artThingsAll.length && fixWS`
-                            <h2 id="art">${strings('artistPage.artList.title')}</h2>
-                            ${hasGallery && `<p>${strings('artistPage.viewArtGallery.orBrowseList', {
-                                link: link.artistGallery(artist, {
-                                    text: strings('artistPage.viewArtGallery.link')
-                                })
-                            })}</p>`}
-                            <p>${strings('artistPage.artGroupsLine', {
-                                groups: strings.list.unit(artGroups
-                                    .map(({ group, contributions }) => strings('artistPage.groupsLine.item', {
-                                        group: link.groupInfo(group),
-                                        contributions: strings.count.contributions(contributions)
-                                    })))
-                            })}</p>
-                            <dl>
-                                ${artListChunks.map(({date, album, chunk}) => fixWS`
-                                    <dt>${strings('artistPage.creditList.album.withDate', {
-                                        album: link.album(album),
-                                        date: strings.count.date(date)
-                                    })}</dt>
-                                    <dd><ul>
-                                        ${(chunk
-                                            .map(({album, track, key, ...props}) => ({
-                                                entry: (track
-                                                    ? strings('artistPage.creditList.entry.track', {
-                                                        track: link.track(track)
-                                                    })
-                                                    : `<i>${strings('artistPage.creditList.entry.album.' + {
-                                                        wallpaperArtists: 'wallpaperArt',
-                                                        bannerArtists: 'bannerArt',
-                                                        coverArtists: 'coverArt'
-                                                    }[key])}</i>`),
-                                                ...props
-                                            }))
-                                            .map(opts => generateEntryAccents({getArtistString, strings, ...opts}))
-                                            .map(row => `<li>${row}</li>`)
-                                            .join('\n'))}
-                                    </ul></dd>
-                                `).join('\n')}
-                            </dl>
-                        `}
-                        ${wikiInfo.features.flashesAndGames && flashes.length && fixWS`
-                            <h2 id="flashes">${strings('artistPage.flashList.title')}</h2>
-                            <dl>
-                                ${flashListChunks.map(({act, chunk, dateFirst, dateLast}) => fixWS`
-                                    <dt>${strings('artistPage.creditList.flashAct.withDateRange', {
-                                        act: link.flash(chunk[0].flash, {text: act.name}),
-                                        dateRange: strings.count.dateRange([dateFirst, dateLast])
-                                    })}</dt>
-                                    <dd><ul>
-                                        ${(chunk
-                                            .map(({flash, ...props}) => ({
-                                                entry: strings('artistPage.creditList.entry.flash', {
-                                                    flash: link.flash(flash)
-                                                }),
-                                                ...props
-                                            }))
-                                            .map(opts => generateEntryAccents({getArtistString, strings, ...opts}))
-                                            .map(row => `<li>${row}</li>`)
-                                            .join('\n'))}
-                                    </ul></dd>
-                                `).join('\n')}
-                            </dl>
-                        `}
-                        ${commentaryThings.length && fixWS`
-                            <h2 id="commentary">${strings('artistPage.commentaryList.title')}</h2>
-                            <dl>
-                                ${commentaryListChunks.map(({album, chunk}) => fixWS`
-                                    <dt>${strings('artistPage.creditList.album', {
-                                        album: link.album(album)
-                                    })}</dt>
-                                    <dd><ul>
-                                        ${(chunk
-                                            .map(({album, track, ...props}) => track
-                                                ? strings('artistPage.creditList.entry.track', {
-                                                    track: link.track(track)
-                                                })
-                                                : `<i>${strings('artistPage.creditList.entry.album.commentary')}</i>`)
-                                            .map(row => `<li>${row}</li>`)
-                                            .join('\n'))}
-                                    </ul></dd>
-                                `).join('\n')}
-                            </dl>
-                        `}
-                    `
-                },
-
-                nav: generateNavForArtist(artist, false, hasGallery, {
-                    generateInfoGalleryLinks,
-                    link,
-                    strings,
-                    wikiData
-                })
-            };
-        }
-    };
-
-    const galleryPage = hasGallery && {
-        type: 'page',
-        path: ['artistGallery', artist.directory],
-        page: ({
-            generateInfoGalleryLinks,
-            getAlbumCover,
-            getGridHTML,
-            getTrackCover,
-            link,
-            strings,
-            to
-        }) => ({
-            title: strings('artistGalleryPage.title', {artist: name}),
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${strings('artistGalleryPage.title', {artist: name})}</h1>
-                    <p class="quick-info">${strings('artistGalleryPage.infoLine', {
-                        coverArts: strings.count.coverArts(artThingsGallery.length, {unit: true})
-                    })}</p>
-                    <div class="grid-listing">
-                        ${getGridHTML({
-                            entries: artThingsGallery.map(item => ({item})),
-                            srcFn: thing => (thing.album
-                                ? getTrackCover(thing)
-                                : getAlbumCover(thing)),
-                            hrefFn: thing => (thing.album
-                                ? to('localized.track', thing.directory)
-                                : to('localized.album', thing.directory))
-                        })}
-                    </div>
-                `
-            },
-
-            nav: generateNavForArtist(artist, true, hasGallery, {
-                generateInfoGalleryLinks,
-                link,
-                strings,
-                wikiData
-            })
-        })
-    };
-
-    return [data, infoPage, galleryPage].filter(Boolean);
+export function pathsForTarget(artist) {
+  return [
+    {
+      type: 'page',
+      path: ['artist', artist.directory],
+
+      contentFunction: {
+        name: 'generateArtistInfoPage',
+        args: [artist],
+      },
+    },
+
+    {
+      type: 'page',
+      path: ['artistGallery', artist.directory],
+
+      condition: () =>
+        !empty(artist.albumCoverArtistContributions) ||
+        !empty(artist.trackCoverArtistContributions),
+
+      contentFunction: {
+        name: 'generateArtistGalleryPage',
+        args: [artist],
+      },
+    },
+  ];
 }
 
-// Utility functions
-
-function generateNavForArtist(artist, isGallery, hasGallery, {
-    generateInfoGalleryLinks,
-    link,
-    strings,
-    wikiData
-}) {
-    const { wikiInfo } = wikiData;
+/*
+const unbound_serializeArtistsAndContrib =
+  (key, {serializeContribs, serializeLink}) =>
+  (thing) => {
+    const {artists, contrib} = getArtistsAndContrib(thing, key);
+    const ret = {};
+    ret.link = serializeLink(thing);
+    if (contrib.what) ret.contribution = contrib.what;
+    if (!empty(artists)) ret.otherArtists = serializeContribs(artists);
+    return ret;
+  };
+
+const unbound_serializeTrackListChunks = (chunks, {serializeLink}) =>
+  chunks.map(({date, album, chunk, duration}) => ({
+    album: serializeLink(album),
+    date,
+    duration,
+    tracks: chunk.map(({track}) => ({
+      link: serializeLink(track),
+      duration: track.duration,
+    })),
+  }));
+
+const data = {
+  type: 'data',
+  path: ['artist', artist.directory],
+  data: ({serializeContribs, serializeLink}) => {
+    const serializeArtistsAndContrib = bindOpts(unbound_serializeArtistsAndContrib, {
+      serializeContribs,
+      serializeLink,
+    });
 
-    const infoGalleryLinks = (hasGallery &&
-        generateInfoGalleryLinks(artist, isGallery, {
-            link, strings,
-            linkKeyGallery: 'artistGallery',
-            linkKeyInfo: 'artist'
-        }))
+    const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, {
+      serializeLink,
+    });
 
     return {
-        links: [
-            {toHome: true},
-            wikiInfo.features.listings &&
-            {
-                path: ['localized.listingIndex'],
-                title: strings('listingIndex.title')
-            },
-            {
-                html: strings('artistPage.nav.artist', {
-                    artist: link.artist(artist, {class: 'current'})
-                })
-            },
-            hasGallery &&
-            {
-                divider: false,
-                html: `(${infoGalleryLinks})`
-            }
-        ]
+      albums: {
+        asCoverArtist: artist.albumsAsCoverArtist
+          .map(serializeArtistsAndContrib('coverArtistContribs')),
+        asWallpaperArtist: artist.albumsAsWallpaperArtist
+          .map(serializeArtistsAndContrib('wallpaperArtistContribs')),
+        asBannerArtist: artist.albumsAsBannerArtis
+          .map(serializeArtistsAndContrib('bannerArtistContribs')),
+      },
+      flashes: wikiInfo.enableFlashesAndGames
+        ? {
+            asContributor: artist.flashesAsContributor
+              .map(flash => getArtistsAndContrib(flash, 'contributorContribs'))
+              .map(({contrib, thing: flash}) => ({
+                link: serializeLink(flash),
+                contribution: contrib.what,
+              })),
+          }
+        : null,
+      tracks: {
+        asArtist: artist.tracksAsArtist
+          .map(serializeArtistsAndContrib('artistContribs')),
+        asContributor: artist.tracksAsContributo
+          .map(serializeArtistsAndContrib('contributorContribs')),
+        chunked: serializeTrackListChunks(trackListChunks),
+      },
     };
-}
+  },
+};
+*/
diff --git a/src/page/flash-act.js b/src/page/flash-act.js
new file mode 100644
index 00000000..e54525ae
--- /dev/null
+++ b/src/page/flash-act.js
@@ -0,0 +1,23 @@
+export const description = `flash act gallery pages`;
+
+export function condition({wikiData}) {
+  return wikiData.wikiInfo.enableFlashesAndGames;
+}
+
+export function targets({wikiData}) {
+  return wikiData.flashActData;
+}
+
+export function pathsForTarget(flashAct) {
+  return [
+    {
+      type: 'page',
+      path: ['flashActGallery', flashAct.directory],
+
+      contentFunction: {
+        name: 'generateFlashActGalleryPage',
+        args: [flashAct],
+      },
+    },
+  ];
+}
diff --git a/src/page/flash.js b/src/page/flash.js
index 9c59016d..7df74158 100644
--- a/src/page/flash.js
+++ b/src/page/flash.js
@@ -1,264 +1,33 @@
-// Flash page and index specifications.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import * as html from '../util/html.js';
-
-import {
-    getFlashLink
-} from '../util/wiki-data.js';
-
-// Page exports
+export const description = `flash & game pages`;
 
 export function condition({wikiData}) {
-    return wikiData.wikiInfo.features.flashesAndGames;
+  return wikiData.wikiInfo.enableFlashesAndGames;
 }
 
 export function targets({wikiData}) {
-    return wikiData.flashData;
+  return wikiData.flashData;
 }
 
-export function write(flash, {wikiData}) {
-    const page = {
-        type: 'page',
-        path: ['flash', flash.directory],
-        page: ({
-            fancifyFlashURL,
-            generateChronologyLinks,
-            generateCoverLink,
-            generatePreviousNextLinks,
-            getArtistString,
-            getFlashCover,
-            getThemeString,
-            link,
-            strings,
-            transformInline
-        }) => ({
-            title: strings('flashPage.title', {flash: flash.name}),
-            theme: getThemeString(flash.color, [
-                `--flash-directory: ${flash.directory}`
-            ]),
-
-            main: {
-                content: fixWS`
-                    <h1>${strings('flashPage.title', {flash: flash.name})}</h1>
-                    ${generateCoverLink({
-                        src: getFlashCover(flash),
-                        alt: strings('misc.alt.flashArt')
-                    })}
-                    <p>${strings('releaseInfo.released', {date: strings.count.date(flash.date)})}</p>
-                    ${(flash.page || flash.urls.length) && `<p>${strings('releaseInfo.playOn', {
-                        links: strings.list.or([
-                            flash.page && getFlashLink(flash),
-                            ...flash.urls
-                        ].map(url => fancifyFlashURL(url, flash)))
-                    })}</p>`}
-                    ${flash.tracks.length && fixWS`
-                        <p>Tracks featured in <i>${flash.name.replace(/\.$/, '')}</i>:</p>
-                        <ul>
-                            ${(flash.tracks
-                                .map(track => strings('trackList.item.withArtists', {
-                                    track: link.track(track),
-                                    by: `<span class="by">${
-                                        strings('trackList.item.withArtists.by', {
-                                            artists: getArtistString(track.artists)
-                                        })
-                                    }</span>`
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul>
-                    `}
-                    ${flash.contributors.textContent && fixWS`
-                        <p>
-                            ${strings('releaseInfo.contributors')}
-                            <br>
-                            ${transformInline(flash.contributors.textContent)}
-                        </p>
-                    `}
-                    ${flash.contributors.length && fixWS`
-                        <p>${strings('releaseInfo.contributors')}</p>
-                        <ul>
-                            ${flash.contributors
-                                .map(contrib => `<li>${getArtistString([contrib], {
-                                    showContrib: true,
-                                    showIcons: true
-                                })}</li>`)
-                                .join('\n')}
-                        </ul>
-                    `}
-                `
-            },
-
-            sidebarLeft: generateSidebarForFlash(flash, {link, strings, wikiData}),
-            nav: generateNavForFlash(flash, {
-                generateChronologyLinks,
-                generatePreviousNextLinks,
-                link,
-                strings,
-                wikiData
-            })
-        })
-    };
-
-    return [page];
+export function pathsForTarget(flash) {
+  return [
+    {
+      type: 'page',
+      path: ['flash', flash.directory],
+
+      contentFunction: {
+        name: 'generateFlashInfoPage',
+        args: [flash],
+      },
+    },
+  ];
 }
 
-export function writeTargetless({wikiData}) {
-    const { flashActData } = wikiData;
-
-    const page = {
-        type: 'page',
-        path: ['flashIndex'],
-        page: ({
-            getFlashGridHTML,
-            getLinkThemeString,
-            link,
-            strings
-        }) => ({
-            title: strings('flashIndex.title'),
-
-            main: {
-                classes: ['flash-index'],
-                content: fixWS`
-                    <h1>${strings('flashIndex.title')}</h1>
-                    <div class="long-content">
-                        <p class="quick-info">${strings('misc.jumpTo')}</p>
-                        <ul class="quick-info">
-                            ${flashActData.filter(act => act.jump).map(({ anchor, jump, jumpColor }) => fixWS`
-                                <li><a href="#${anchor}" style="${getLinkThemeString(jumpColor)}">${jump}</a></li>
-                            `).join('\n')}
-                        </ul>
-                    </div>
-                    ${flashActData.map((act, i) => fixWS`
-                        <h2 id="${act.anchor}" style="${getLinkThemeString(act.color)}">${link.flash(act.flashes[0], {text: act.name})}</h2>
-                        <div class="grid-listing">
-                            ${getFlashGridHTML({
-                                entries: act.flashes.map(flash => ({item: flash})),
-                                lazy: i === 0 ? 4 : true
-                            })}
-                        </div>
-                    `).join('\n')}
-                `
-            },
-
-            nav: {simple: true}
-        })
-    };
-
-    return [page];
-}
-
-// Utility functions
-
-function generateNavForFlash(flash, {
-    generateChronologyLinks,
-    generatePreviousNextLinks,
-    link,
-    strings,
-    wikiData
-}) {
-    const { flashData, wikiInfo } = wikiData;
-
-    const previousNextLinks = generatePreviousNextLinks(flash, {
-        data: flashData,
-        linkKey: 'flash'
-    });
-
-    return {
-        links: [
-            {
-                path: ['localized.home'],
-                title: wikiInfo.shortName
-            },
-            {
-                path: ['localized.flashIndex'],
-                title: strings('flashIndex.title')
-            },
-            {
-                html: strings('flashPage.nav.flash', {
-                    flash: link.flash(flash, {class: 'current'})
-                })
-            },
-            previousNextLinks &&
-            {
-                divider: false,
-                html: `(${previousNextLinks})`
-            }
-        ],
-
-        content: fixWS`
-            <div>
-                ${generateChronologyLinks(flash, {
-                    headingString: 'misc.chronology.heading.flash',
-                    contribKey: 'contributors',
-                    getThings: artist => artist.flashes.asContributor
-                })}
-            </div>
-        `
-    };
-}
-
-function generateSidebarForFlash(flash, {link, strings, wikiData}) {
-    // all hard-coded, sorry :(
-    // this doesnt have a super portable implementation/design...yet!!
-
-    const { flashActData } = wikiData;
-
-    const act6 = flashActData.findIndex(act => act.name.startsWith('Act 6'));
-    const postCanon = flashActData.findIndex(act => act.name.includes('Post Canon'));
-    const outsideCanon = postCanon + flashActData.slice(postCanon).findIndex(act => !act.name.includes('Post Canon'));
-    const actIndex = flashActData.indexOf(flash.act);
-    const side = (
-        (actIndex < 0) ? 0 :
-        (actIndex < act6) ? 1 :
-        (actIndex <= outsideCanon) ? 2 :
-        3
-    );
-    const currentAct = flash && flash.act;
-
-    return {
-        content: fixWS`
-            <h1>${link.flashIndex('', {text: strings('flashIndex.title')})}</h1>
-            <dl>
-                ${flashActData.filter(act =>
-                    act.name.startsWith('Act 1') ||
-                    act.name.startsWith('Act 6 Act 1') ||
-                    act.name.startsWith('Hiveswap') ||
-                    // Sorry not sorry -Yiffy
-                    (({index = flashActData.indexOf(act)} = {}) => (
-                        index < act6 ? side === 1 :
-                        index < outsideCanon ? side === 2 :
-                        true
-                    ))()
-                ).flatMap(act => [
-                    act.name.startsWith('Act 1') && html.tag('dt',
-                        {class: ['side', side === 1 && 'current']},
-                        link.flash(act.flashes[0], {color: '#4ac925', text: `Side 1 (Acts 1-5)`}))
-                    || act.name.startsWith('Act 6 Act 1') && html.tag('dt',
-                        {class: ['side', side === 2 && 'current']},
-                        link.flash(act.flashes[0], {color: '#1076a2', text: `Side 2 (Acts 6-7)`}))
-                    || act.name.startsWith('Hiveswap Act 1') && html.tag('dt',
-                        {class: ['side', side === 3 && 'current']},
-                        link.flash(act.flashes[0], {color: '#008282', text: `Outside Canon (Misc. Games)`})),
-                    (({index = flashActData.indexOf(act)} = {}) => (
-                        index < act6 ? side === 1 :
-                        index < outsideCanon ? side === 2 :
-                        true
-                    ))() && html.tag('dt',
-                        {class: act === currentAct && 'current'},
-                        link.flash(act.flashes[0], {text: act.name})),
-                    act === currentAct && fixWS`
-                        <dd><ul>
-                            ${act.flashes.map(f => html.tag('li',
-                                {class: f === flash && 'current'},
-                                link.flash(f))).join('\n')}
-                        </ul></dd>
-                    `
-                ]).filter(Boolean).join('\n')}
-            </dl>
-        `
-    };
+export function pathsTargetless() {
+  return [
+    {
+      type: 'page',
+      path: ['flashIndex'],
+      contentFunction: {name: 'generateFlashIndexPage'},
+    },
+  ];
 }
diff --git a/src/page/group.js b/src/page/group.js
index 7282fc81..87590eaf 100644
--- a/src/page/group.js
+++ b/src/page/group.js
@@ -1,263 +1,58 @@
-// Group page specifications.
+import {empty} from '#sugar';
 
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import {
-    UNRELEASED_TRACKS_DIRECTORY
-} from '../util/magic-constants.js';
-
-import * as html from '../util/html.js';
-
-import {
-    getTotalDuration,
-    sortByDate
-} from '../util/wiki-data.js';
-
-// Page exports
+export const description = `per-group info & album gallery pages`;
 
 export function targets({wikiData}) {
-    return wikiData.groupData;
+  return wikiData.groupData;
 }
 
-export function write(group, {wikiData}) {
-    const { listingSpec, wikiInfo } = wikiData;
-
-    const releasedAlbums = group.albums.filter(album => album.directory !== UNRELEASED_TRACKS_DIRECTORY);
-    const releasedTracks = releasedAlbums.flatMap(album => album.tracks);
-    const totalDuration = getTotalDuration(releasedTracks);
-
-    const albumLines = group.albums.map(album => ({
-        album,
-        otherGroup: album.groups.find(g => g !== group)
-    }));
-
-    const infoPage = {
-        type: 'page',
-        path: ['groupInfo', group.directory],
-        page: ({
-            generateInfoGalleryLinks,
-            generatePreviousNextLinks,
-            getLinkThemeString,
-            getThemeString,
-            fancifyURL,
-            link,
-            strings,
-            transformMultiline
-        }) => ({
-            title: strings('groupInfoPage.title', {group: group.name}),
-            theme: getThemeString(group.color),
-
-            main: {
-                content: fixWS`
-                    <h1>${strings('groupInfoPage.title', {group: group.name})}</h1>
-                    ${group.urls.length && `<p>${
-                        strings('releaseInfo.visitOn', {
-                            links: strings.list.or(group.urls.map(url => fancifyURL(url, {strings})))
-                        })
-                    }</p>`}
-                    <blockquote>
-                        ${transformMultiline(group.description)}
-                    </blockquote>
-                    <h2>${strings('groupInfoPage.albumList.title')}</h2>
-                    <p>${
-                        strings('groupInfoPage.viewAlbumGallery', {
-                            link: link.groupGallery(group, {
-                                text: strings('groupInfoPage.viewAlbumGallery.link')
-                            })
-                        })
-                    }</p>
-                    <ul>
-                        ${albumLines.map(({ album, otherGroup }) => {
-                            const item = strings('groupInfoPage.albumList.item', {
-                                year: album.date.getFullYear(),
-                                album: link.album(album)
-                            });
-                            return html.tag('li', (otherGroup
-                                ? strings('groupInfoPage.albumList.item.withAccent', {
-                                    item,
-                                    accent: html.tag('span',
-                                        {class: 'other-group-accent'},
-                                        strings('groupInfoPage.albumList.item.otherGroupAccent', {
-                                            group: link.groupInfo(otherGroup, {color: false})
-                                        }))
-                                })
-                                : item));
-                        }).join('\n')}
-                    </ul>
-                `
-            },
-
-            sidebarLeft: generateGroupSidebar(group, false, {
-                getLinkThemeString,
-                link,
-                strings,
-                wikiData
-            }),
-
-            nav: generateGroupNav(group, false, {
-                generateInfoGalleryLinks,
-                generatePreviousNextLinks,
-                link,
-                strings,
-                wikiData
-            })
-        })
-    };
-
-    const galleryPage = {
-        type: 'page',
-        path: ['groupGallery', group.directory],
-        page: ({
-            generateInfoGalleryLinks,
-            generatePreviousNextLinks,
-            getAlbumGridHTML,
-            getLinkThemeString,
-            getThemeString,
-            link,
-            strings
-        }) => ({
-            title: strings('groupGalleryPage.title', {group: group.name}),
-            theme: getThemeString(group.color),
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${strings('groupGalleryPage.title', {group: group.name})}</h1>
-                    <p class="quick-info">${
-                        strings('groupGalleryPage.infoLine', {
-                            tracks: `<b>${strings.count.tracks(releasedTracks.length, {unit: true})}</b>`,
-                            albums: `<b>${strings.count.albums(releasedAlbums.length, {unit: true})}</b>`,
-                            time: `<b>${strings.count.duration(totalDuration, {unit: true})}</b>`
-                        })
-                    }</p>
-                    ${wikiInfo.features.groupUI && wikiInfo.features.listings && html.tag('p',
-                        {class: 'quick-info'},
-                        strings('groupGalleryPage.anotherGroupLine', {
-                            link: link.listing(listingSpec.find(l => l.directory === 'groups/by-category'), {
-                                text: strings('groupGalleryPage.anotherGroupLine.link')
-                            })
-                        })
-                    )}
-                    <div class="grid-listing">
-                        ${getAlbumGridHTML({
-                            entries: sortByDate(group.albums.map(item => ({item}))).reverse(),
-                            details: true
-                        })}
-                    </div>
-                `
-            },
-
-            sidebarLeft: generateGroupSidebar(group, true, {
-                getLinkThemeString,
-                link,
-                strings,
-                wikiData
-            }),
-
-            nav: generateGroupNav(group, true, {
-                generateInfoGalleryLinks,
-                generatePreviousNextLinks,
-                link,
-                strings,
-                wikiData
-            })
-        })
-    };
-
-    return [infoPage, galleryPage];
+export function pathsForTarget(group) {
+  return [
+    {
+      type: 'page',
+      path: ['groupInfo', group.directory],
+
+      contentFunction: {
+        name: 'generateGroupInfoPage',
+        args: [group],
+      },
+    },
+
+    {
+      type: 'page',
+      path: ['groupGallery', group.directory],
+
+      condition: () =>
+        !empty(group.albums),
+
+      contentFunction: {
+        name: 'generateGroupGalleryPage',
+        args: [group],
+      },
+    },
+  ];
 }
 
-// Utility functions
-
-function generateGroupSidebar(currentGroup, isGallery, {
-    getLinkThemeString,
-    link,
-    strings,
-    wikiData
-}) {
-    const { groupCategoryData, wikiInfo } = wikiData;
-
-    if (!wikiInfo.features.groupUI) {
-        return null;
-    }
-
-    const linkKey = isGallery ? 'groupGallery' : 'groupInfo';
-
-    return {
-        content: fixWS`
-            <h1>${strings('groupSidebar.title')}</h1>
-            ${groupCategoryData.map(category =>
-                html.tag('details', {
-                    open: category === currentGroup.category,
-                    class: category === currentGroup.category && 'current'
-                }, [
-                    html.tag('summary',
-                        {style: getLinkThemeString(category.color)},
-                        strings('groupSidebar.groupList.category', {
-                            category: `<span class="group-name">${category.name}</span>`
-                        })),
-                    html.tag('ul',
-                        category.groups.map(group => html.tag('li',
-                            {
-                                class: group === currentGroup && 'current',
-                                style: getLinkThemeString(group.color)
-                            },
-                            strings('groupSidebar.groupList.item', {
-                                group: link[linkKey](group)
-                            }))))
-                ])).join('\n')}
-            </dl>
-        `
-    };
-}
-
-function generateGroupNav(currentGroup, isGallery, {
-    generateInfoGalleryLinks,
-    generatePreviousNextLinks,
-    link,
-    strings,
-    wikiData
-}) {
-    const { groupData, wikiInfo } = wikiData;
-
-    if (!wikiInfo.features.groupUI) {
-        return {simple: true};
-    }
-
-    const urlKey = isGallery ? 'localized.groupGallery' : 'localized.groupInfo';
-    const linkKey = isGallery ? 'groupGallery' : 'groupInfo';
-
-    const infoGalleryLinks = generateInfoGalleryLinks(currentGroup, isGallery, {
-        linkKeyGallery: 'groupGallery',
-        linkKeyInfo: 'groupInfo'
-    });
-
-    const previousNextLinks = generatePreviousNextLinks(currentGroup, {
-        data: groupData,
-        linkKey
-    });
-
-    return {
-        links: [
-            {toHome: true},
-            wikiInfo.features.listings &&
-            {
-                path: ['localized.listingIndex'],
-                title: strings('listingIndex.title')
-            },
-            {
-                html: strings('groupPage.nav.group', {
-                    group: link[linkKey](currentGroup, {class: 'current'})
-                })
-            },
-            {
-                divider: false,
-                html: (previousNextLinks
-                    ? `(${infoGalleryLinks}; ${previousNextLinks})`
-                    : `(${previousNextLinks})`)
-            }
-        ]
-    };
+export function pathsTargetless({wikiData: {wikiInfo}}) {
+  return [
+    {
+      type: 'redirect',
+      fromPath: ['page', 'albums/fandom'],
+      toPath: ['groupGallery', 'fandom'],
+      title: 'Fandom - 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 37ec4426..cfcdd6e1 100644
--- a/src/page/homepage.js
+++ b/src/page/homepage.js
@@ -1,124 +1,15 @@
-// Homepage specification.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import find from '../util/find.js';
-
-import * as html from '../util/html.js';
-
-import {
-    getNewAdditions,
-    getNewReleases
-} from '../util/wiki-data.js';
-
-// Page exports
-
-export function writeTargetless({wikiData}) {
-    const { newsData, staticPageData, homepageInfo, wikiInfo } = wikiData;
-
-    const page = {
-        type: 'page',
-        path: ['home'],
-        page: ({
-            getAlbumGridHTML,
-            getLinkThemeString,
-            link,
-            strings,
-            to,
-            transformInline,
-            transformMultiline
-        }) => ({
-            title: wikiInfo.name,
-
-            meta: {
-                description: wikiInfo.description
-            },
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${wikiInfo.name}</h1>
-                    ${homepageInfo.rows.map((row, i) => fixWS`
-                        <section class="row" style="${getLinkThemeString(row.color)}">
-                            <h2>${row.name}</h2>
-                            ${row.type === 'albums' && fixWS`
-                                <div class="grid-listing">
-                                    ${getAlbumGridHTML({
-                                        entries: (
-                                            row.group === 'new-releases' ? getNewReleases(row.groupCount, {wikiData}) :
-                                            row.group === 'new-additions' ? getNewAdditions(row.groupCount, {wikiData}) :
-                                            ((find.group(row.group, {wikiData})?.albums || [])
-                                                .slice()
-                                                .reverse()
-                                                .slice(0, row.groupCount)
-                                                .map(album => ({item: album})))
-                                        ).concat(row.albums
-                                            .map(album => find.album(album, {wikiData}))
-                                            .map(album => ({item: album}))
-                                        ),
-                                        lazy: i > 0
-                                    })}
-                                    ${row.actions.length && fixWS`
-                                        <div class="grid-actions">
-                                            ${row.actions.map(action => transformInline(action)
-                                                .replace('<a', '<a class="box grid-item"')).join('\n')}
-                                        </div>
-                                    `}
-                                </div>
-                            `}
-                        </section>
-                    `).join('\n')}
-                `
-            },
-
-            sidebarLeft: homepageInfo.sidebar && {
-                wide: true,
-                collapse: false,
-                // This is a pretty filthy hack! 8ut otherwise, the [[news]] part
-                // gets treated like it's a reference to the track named "news",
-                // which o8viously isn't what we're going for. Gotta catch that
-                // 8efore we pass it to transformMultiline, 'cuz otherwise it'll
-                // get repl8ced with just the word "news" (or anything else that
-                // transformMultiline does with references it can't match) -- and
-                // we can't match that for replacing it with the news column!
-                //
-                // And no, I will not make [[news]] into part of transformMultiline
-                // (even though that would 8e hilarious).
-                content: (transformMultiline(homepageInfo.sidebar.replace('[[news]]', '__GENERATE_NEWS__'))
-                    .replace('<p>__GENERATE_NEWS__</p>', wikiInfo.features.news ? fixWS`
-                        <h1>${strings('homepage.news.title')}</h1>
-                        ${newsData.slice(0, 3).map((entry, i) => html.tag('article',
-                            {class: ['news-entry', i === 0 && 'first-news-entry']},
-                            fixWS`
-                                <h2><time>${strings.count.date(entry.date)}</time> ${link.newsEntry(entry)}</h2>
-                                ${transformMultiline(entry.bodyShort)}
-                                ${entry.bodyShort !== entry.body && link.newsEntry(entry, {
-                                    text: strings('homepage.news.entry.viewRest')
-                                })}
-                            `)).join('\n')}
-                    ` : `<p><i>News requested in content description but this feature isn't enabled</i></p>`))
-            },
-
-            nav: {
-                content: fixWS`
-                    <h2 class="dot-between-spans">
-                        ${[
-                            link.home('', {text: wikiInfo.shortName, class: 'current', to}),
-                            wikiInfo.features.listings &&
-                            link.listingIndex('', {text: strings('listingIndex.title'), to}),
-                            wikiInfo.features.news &&
-                            link.newsIndex('', {text: strings('newsIndex.title'), to}),
-                            wikiInfo.features.flashesAndGames &&
-                            link.flashIndex('', {text: strings('flashIndex.title'), to}),
-                            ...staticPageData.filter(page => page.listed).map(link.staticPage)
-                        ].filter(Boolean).map(link => `<span>${link}</span>`).join('\n')}
-                    </h2>
-                `
-            }
-        })
-    };
-
-    return [page];
+export const description = `main wiki homepage`;
+
+export function pathsTargetless({wikiData}) {
+  return [
+    {
+      type: 'page',
+      path: ['home'],
+
+      contentFunction: {
+        name: 'generateWikiHomepagePage',
+        args: [wikiData.homepageLayout],
+      },
+    },
+  ];
 }
diff --git a/src/page/index.js b/src/page/index.js
index f580cbea..ae480136 100644
--- a/src/page/index.js
+++ b/src/page/index.js
@@ -1,53 +1,12 @@
-// NB: This is the index for the page/ directory and contains exports for all
-// other modules here! It's not the page spec for the homepage - see
-// homepage.js for that.
-//
-// Each module published in this list should follow a particular format,
-// including any of the following exports:
-//
-// condition({wikiData})
-//     Returns a boolean indicating whether to process targets/writes (true) or
-//     skip this page spec altogether (false). This is usually used for
-//     selectively toggling pages according to site feature flags, though it may
-//     also be used to e.g. skip out if no targets would be found (preventing
-//     writeTargetless from generating an empty index page).
-//
-// targets({wikiData})
-//     Gets the objects which this page's write() function should be called on.
-//     Usually this will simply mean returning the appropriate thingData array,
-//     but it may also apply filter/map/etc if useful.
-//
-// write(thing, {wikiData})
-//     Provides descriptors for any page and data writes associated with the
-//     given thing (which will be a value from the targets() array). This
-//     includes page (HTML) writes, data (JSON) writes, etc. Notably, this
-//     function does not perform any file operations itself; it only describes
-//     the operations which will be processed elsewhere, once for each
-//     translation language.  The write function also immediately transforms
-//     any data which will be reused across writes of the same page, so that
-//     this data is effectively cached (rather than recalculated for each
-//     language/write).
-//
-// writeTargetless({wikiData})
-//     Provides descriptors for page/data/etc writes which will be used
-//     without concern for targets. This is usually used for writing index pages
-//     which should be generated just once (rather than corresponding to
-//     targets).
-//
-// As these modules are effectively the HTML templates for all site layout,
-// common patterns may also be exported alongside the special exports above.
-// These functions should be referenced only from adjacent modules, as they
-// pertain only to site page generation.
-
 export * as album from './album.js';
-export * as albumCommentary from './album-commentary.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';
 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/listing.js b/src/page/listing.js
index d3ab79e0..bb22c21f 100644
--- a/src/page/listing.js
+++ b/src/page/listing.js
@@ -1,207 +1,41 @@
-// Listing page specification.
-//
+export const description = `wiki-wide listing pages & index`;
+
 // The targets here are a bit different than for most pages: rather than data
 // objects loaded from text files in the wiki data directory, they're hard-
-// coded specifications, with various JS functions for processing wiki data
-// and turning it into user-readable HTML listings.
+// coded specifications, each directly identifying the hard-coded content
+// function used to generate that listing.
 //
 // Individual listing specs are described in src/listing-spec.js, but are
 // provided via wikiData like other (normal) data objects.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import * as html from '../util/html.js';
-
-import {
-    UNRELEASED_TRACKS_DIRECTORY
-} from '../util/magic-constants.js';
-
-import {
-    getTotalDuration
-} from '../util/wiki-data.js';
-
-// Page exports
-
-export function condition({wikiData}) {
-    return wikiData.wikiInfo.features.listings;
-}
-
+//
 export function targets({wikiData}) {
-    return wikiData.listingSpec;
+  return (
+    wikiData.listingSpec
+      .filter(listing => listing.contentFunction)
+      .filter(listing =>
+        !listing.featureFlag ||
+        wikiData.wikiInfo[listing.featureFlag]));
 }
 
-export function write(listing, {wikiData}) {
-    if (listing.condition && !listing.condition({wikiData})) {
-        return null;
-    }
-
-    const { wikiInfo } = wikiData;
-
-    const data = (listing.data
-        ? listing.data({wikiData})
-        : null);
-
-    const page = {
-        type: 'page',
-        path: ['listing', listing.directory],
-        page: opts => {
-            const { getLinkThemeString, link, strings } = opts;
-            const titleKey = `listingPage.${listing.stringsKey}.title`;
-
-            return {
-                title: strings(titleKey),
-
-                main: {
-                    content: fixWS`
-                        <h1>${strings(titleKey)}</h1>
-                        ${listing.html && (listing.data
-                            ? listing.html(data, opts)
-                            : listing.html(opts))}
-                        ${listing.row && fixWS`
-                            <ul>
-                                ${(data
-                                    .map(item => listing.row(item, opts))
-                                    .map(row => `<li>${row}</li>`)
-                                    .join('\n'))}
-                            </ul>
-                        `}
-                    `
-                },
-
-                sidebarLeft: {
-                    content: generateSidebarForListings(listing, {
-                        getLinkThemeString,
-                        link,
-                        strings,
-                        wikiData
-                    })
-                },
-
-                nav: {
-                    links: [
-                        {toHome: true},
-                        {
-                            path: ['localized.listingIndex'],
-                            title: strings('listingIndex.title')
-                        },
-                        {toCurrentPage: true}
-                    ]
-                }
-            };
-        }
-    };
-
-    return [page];
-}
-
-export function writeTargetless({wikiData}) {
-    const { albumData, trackData, wikiInfo } = wikiData;
-
-    const releasedTracks = trackData.filter(track => track.album.directory !== UNRELEASED_TRACKS_DIRECTORY);
-    const releasedAlbums = albumData.filter(album => album.directory !== UNRELEASED_TRACKS_DIRECTORY);
-    const duration = getTotalDuration(releasedTracks);
-
-    const page = {
-        type: 'page',
-        path: ['listingIndex'],
-        page: ({
-            getLinkThemeString,
-            strings,
-            link
-        }) => ({
-            title: strings('listingIndex.title'),
-
-            main: {
-                content: fixWS`
-                    <h1>${strings('listingIndex.title')}</h1>
-                    <p>${strings('listingIndex.infoLine', {
-                        wiki: wikiInfo.name,
-                        tracks: `<b>${strings.count.tracks(releasedTracks.length, {unit: true})}</b>`,
-                        albums: `<b>${strings.count.albums(releasedAlbums.length, {unit: true})}</b>`,
-                        duration: `<b>${strings.count.duration(duration, {approximate: true, unit: true})}</b>`
-                    })}</p>
-                    <hr>
-                    <p>${strings('listingIndex.exploreList')}</p>
-                    ${generateLinkIndexForListings(null, false, {link, strings, wikiData})}
-                `
-            },
-
-            sidebarLeft: {
-                content: generateSidebarForListings(null, {
-                    getLinkThemeString,
-                    link,
-                    strings,
-                    wikiData
-                })
-            },
-
-            nav: {simple: true}
-        })
-    };
-
-    return [page];
-};
-
-// Utility functions
-
-function generateSidebarForListings(currentListing, {
-    getLinkThemeString,
-    link,
-    strings,
-    wikiData
-}) {
-    return fixWS`
-        <h1>${link.listingIndex('', {text: strings('listingIndex.title')})}</h1>
-        ${generateLinkIndexForListings(currentListing, true, {
-            getLinkThemeString,
-            link,
-            strings,
-            wikiData
-        })}
-    `;
+export function pathsForTarget(listing) {
+  return [
+    {
+      type: 'page',
+      path: ['listing', listing.directory],
+      contentFunction: {
+        name: listing.contentFunction,
+        args: [listing],
+      },
+    },
+  ];
 }
 
-function generateLinkIndexForListings(currentListing, forSidebar, {
-    getLinkThemeString,
-    link,
-    strings,
-    wikiData
-}) {
-    const { listingTargetSpec, wikiInfo } = wikiData;
-
-    const filteredByCondition = listingTargetSpec
-        .map(({ listings, ...rest }) => ({
-            ...rest,
-            listings: listings.filter(({ condition: c }) => !c || c({wikiData}))
-        }))
-        .filter(({ listings }) => listings.length > 0);
-
-    const genUL = listings => html.tag('ul',
-        listings.map(listing => html.tag('li',
-            {class: [listing === currentListing && 'current']},
-            link.listing(listing, {text: strings(`listingPage.${listing.stringsKey}.title.short`)})
-        )));
-
-    if (forSidebar) {
-        return filteredByCondition.map(({ title, listings }) =>
-            html.tag('details', {
-                open: !forSidebar || listings.includes(currentListing),
-                class: listings.includes(currentListing) && 'current'
-            }, [
-                html.tag('summary',
-                    {style: getLinkThemeString(wikiInfo.color)},
-                    html.tag('span',
-                        {class: 'group-name'},
-                        title({strings}))),
-                genUL(listings)
-            ])).join('\n');
-    } else {
-        return html.tag('dl',
-            filteredByCondition.flatMap(({ title, listings }) => [
-                html.tag('dt', title({strings})),
-                html.tag('dd', genUL(listings))
-            ]));
-    }
+export function pathsTargetless() {
+  return [
+    {
+      type: 'page',
+      path: ['listingIndex'],
+      contentFunction: {name: 'generateListingsIndexPage'},
+    },
+  ];
 }
diff --git a/src/page/news.js b/src/page/news.js
index 99cbe8d5..194ffdcb 100644
--- a/src/page/news.js
+++ b/src/page/news.js
@@ -1,129 +1,32 @@
-// News entry & index page specifications.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-// Page exports
+export const description = `per-entry news pages & index`;
 
 export function condition({wikiData}) {
-    return wikiData.wikiInfo.features.news;
+  return wikiData.wikiInfo.enableNews;
 }
 
 export function targets({wikiData}) {
-    return wikiData.newsData;
+  return wikiData.newsData;
 }
 
-export function write(entry, {wikiData}) {
-    const page = {
-        type: 'page',
-        path: ['newsEntry', entry.directory],
-        page: ({
-            generatePreviousNextLinks,
-            link,
-            strings,
-            transformMultiline,
-        }) => ({
-            title: strings('newsEntryPage.title', {entry: entry.name}),
-
-            main: {
-                content: fixWS`
-                    <div class="long-content">
-                        <h1>${strings('newsEntryPage.title', {entry: entry.name})}</h1>
-                        <p>${strings('newsEntryPage.published', {date: strings.count.date(entry.date)})}</p>
-                        ${transformMultiline(entry.body)}
-                    </div>
-                `
-            },
-
-            nav: generateNewsEntryNav(entry, {
-                generatePreviousNextLinks,
-                link,
-                strings,
-                wikiData
-            })
-        })
-    };
-
-    return [page];
+export function pathsForTarget(newsEntry) {
+  return [
+    {
+      type: 'page',
+      path: ['newsEntry', newsEntry.directory],
+      contentFunction: {
+        name: 'generateNewsEntryPage',
+        args: [newsEntry],
+      },
+    },
+  ];
 }
 
-export function writeTargetless({wikiData}) {
-    const { newsData } = wikiData;
-
-    const page = {
-        type: 'page',
-        path: ['newsIndex'],
-        page: ({
-            link,
-            strings,
-            transformMultiline
-        }) => ({
-            title: strings('newsIndex.title'),
-
-            main: {
-                content: fixWS`
-                    <div class="long-content news-index">
-                        <h1>${strings('newsIndex.title')}</h1>
-                        ${newsData.map(entry => fixWS`
-                            <article id="${entry.directory}">
-                                <h2><time>${strings.count.date(entry.date)}</time> ${link.newsEntry(entry)}</h2>
-                                ${transformMultiline(entry.bodyShort)}
-                                ${entry.bodyShort !== entry.body && `<p>${link.newsEntry(entry, {
-                                    text: strings('newsIndex.entry.viewRest')
-                                })}</p>`}
-                            </article>
-                        `).join('\n')}
-                    </div>
-                `
-            },
-
-            nav: {simple: true}
-        })
-    };
-
-    return [page];
-}
-
-// Utility functions
-
-function generateNewsEntryNav(entry, {
-    generatePreviousNextLinks,
-    link,
-    strings,
-    wikiData
-}) {
-    const { wikiInfo, newsData } = wikiData;
-
-    // The newsData list is sorted reverse chronologically (newest ones first),
-    // so the way we find next/previous entries is flipped from normal.
-    const previousNextLinks = generatePreviousNextLinks(entry, {
-        link, strings,
-        data: newsData.slice().reverse(),
-        linkKey: 'newsEntry'
-    });
-
-    return {
-        links: [
-            {
-                path: ['localized.home'],
-                title: wikiInfo.shortName
-            },
-            {
-                path: ['localized.newsIndex'],
-                title: strings('newsEntryPage.nav.news')
-            },
-            {
-                html: strings('newsEntryPage.nav.entry', {
-                    date: strings.count.date(entry.date),
-                    entry: link.newsEntry(entry, {class: 'current'})
-                })
-            },
-            previousNextLinks &&
-            {
-                divider: false,
-                html: `(${previousNextLinks})`
-            }
-        ]
-    };
+export function pathsTargetless() {
+  return [
+    {
+      type: 'page',
+      path: ['newsIndex'],
+      contentFunction: {name: 'generateNewsIndexPage'},
+    },
+  ];
 }
diff --git a/src/page/static.js b/src/page/static.js
index ff57c4fb..733844de 100644
--- a/src/page/static.js
+++ b/src/page/static.js
@@ -1,40 +1,23 @@
-// Static content page specification. (These are static pages coded into the
-// wiki data folder, used for a variety of purposes, e.g. wiki info,
-// changelog, and so on.)
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-// Page exports
+export const description = `static wiki-wide content pages specified in data`;
 
+// Static pages are written in the wiki's data folder and contain content and
+// basic page metadata. They're used for a variety of purposes, such as an
+// "about" page, a changelog, links to places beyond the wiki, and so on.
 export function targets({wikiData}) {
-    return wikiData.staticPageData;
+  return wikiData.staticPageData;
 }
 
-export function write(staticPage, {wikiData}) {
-    const page = {
-        type: 'page',
-        path: ['staticPage', staticPage.directory],
-        page: ({
-            strings,
-            transformMultiline
-        }) => ({
-            title: staticPage.name,
-            stylesheet: staticPage.stylesheet,
-
-            main: {
-                content: fixWS`
-                    <div class="long-content">
-                        <h1>${staticPage.name}</h1>
-                        ${transformMultiline(staticPage.content)}
-                    </div>
-                `
-            },
-
-            nav: {simple: true}
-        })
-    };
-
-    return [page];
+export function pathsForTarget(staticPage) {
+  return [
+    {
+      type: 'page',
+      path: ['staticPage', staticPage.directory],
+      absoluteLinks: staticPage.absoluteLinks,
+
+      contentFunction: {
+        name: 'generateStaticPage',
+        args: [staticPage],
+      },
+    },
+  ];
 }
diff --git a/src/page/tag.js b/src/page/tag.js
deleted file mode 100644
index 4253120e..00000000
--- a/src/page/tag.js
+++ /dev/null
@@ -1,110 +0,0 @@
-// Art tag page specification.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-// Page exports
-
-export function condition({wikiData}) {
-    return wikiData.wikiInfo.features.artTagUI;
-}
-
-export function targets({wikiData}) {
-    return wikiData.tagData.filter(tag => !tag.isCW);
-}
-
-export function write(tag, {wikiData}) {
-    const { wikiInfo } = wikiData;
-    const { things } = tag;
-
-    // Display things featuring this art tag in reverse chronological order,
-    // sticking the most recent additions near the top!
-    const thingsReversed = things.slice().reverse();
-
-    const entries = thingsReversed.map(item => ({item}));
-
-    const page = {
-        type: 'page',
-        path: ['tag', tag.directory],
-        page: ({
-            generatePreviousNextLinks,
-            getAlbumCover,
-            getGridHTML,
-            getThemeString,
-            getTrackCover,
-            link,
-            strings,
-            to
-        }) => ({
-            title: strings('tagPage.title', {tag: tag.name}),
-            theme: getThemeString(tag.color),
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${strings('tagPage.title', {tag: tag.name})}</h1>
-                    <p class="quick-info">${strings('tagPage.infoLine', {
-                        coverArts: strings.count.coverArts(things.length, {unit: true})
-                    })}</p>
-                    <div class="grid-listing">
-                        ${getGridHTML({
-                            entries,
-                            srcFn: thing => (thing.album
-                                ? getTrackCover(thing)
-                                : getAlbumCover(thing)),
-                            hrefFn: thing => (thing.album
-                                ? to('localized.track', thing.directory)
-                                : to('localized.album', thing.directory))
-                        })}
-                    </div>
-                `
-            },
-
-            nav: generateTagNav(tag, {
-                generatePreviousNextLinks,
-                link,
-                strings,
-                wikiData
-            })
-        })
-    };
-
-    return [page];
-}
-
-// Utility functions
-
-function generateTagNav(tag, {
-    generatePreviousNextLinks,
-    link,
-    strings,
-    wikiData
-}) {
-    const previousNextLinks = generatePreviousNextLinks(tag, {
-        data: wikiData.tagData.filter(tag => !tag.isCW),
-        linkKey: 'tag'
-    });
-
-    return {
-        links: [
-            {toHome: true},
-            wikiData.wikiInfo.features.listings &&
-            {
-                path: ['localized.listingIndex'],
-                title: strings('listingIndex.title')
-            },
-            {
-                html: strings('tagPage.nav.tag', {
-                    tag: link.tag(tag, {class: 'current'})
-                })
-            },
-            /*
-            previousNextLinks && {
-                divider: false,
-                html: `(${previousNextLinks})`
-            }
-            */
-        ]
-    };
-}
diff --git a/src/page/track.js b/src/page/track.js
index 0941ee89..95647334 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -1,330 +1,51 @@
 // Track page specification.
 
-// Imports
+import {empty} from '#sugar';
 
-import fixWS from 'fix-whitespace';
-
-import {
-    generateAlbumChronologyLinks,
-    generateAlbumNavLinks,
-    generateAlbumSidebar
-} from './album.js';
-
-import * as html from '../util/html.js';
-
-import {
-    OFFICIAL_GROUP_DIRECTORY,
-    UNRELEASED_TRACKS_DIRECTORY
-} from '../util/magic-constants.js';
-
-import {
-    bindOpts
-} from '../util/sugar.js';
-
-import {
-    getTrackCover,
-    getAlbumListTag,
-    sortByDate
-} from '../util/wiki-data.js';
-
-// Page exports
+export const description = `per-track info pages`;
 
 export function targets({wikiData}) {
-    return wikiData.trackData;
+  return wikiData.trackData;
 }
 
-export function write(track, {wikiData}) {
-    const { groupData, wikiInfo } = wikiData;
-    const { album } = track;
-
-    const tracksThatReference = track.referencedBy;
-    const useDividedReferences = groupData.some(group => group.directory === OFFICIAL_GROUP_DIRECTORY);
-    const ttrFanon = (useDividedReferences &&
-        tracksThatReference.filter(t => t.album.groups.every(group => group.directory !== OFFICIAL_GROUP_DIRECTORY)));
-    const ttrOfficial = (useDividedReferences &&
-        tracksThatReference.filter(t => t.album.groups.some(group => group.directory === OFFICIAL_GROUP_DIRECTORY)));
-
-    const tracksReferenced = track.references;
-    const otherReleases = track.otherReleases;
-    const listTag = getAlbumListTag(album);
-
-    let flashesThatFeature;
-    if (wikiInfo.features.flashesAndGames) {
-        flashesThatFeature = sortByDate([track, ...otherReleases]
-            .flatMap(track => track.flashes.map(flash => ({flash, as: track}))));
-    }
-
-    const unbound_generateTrackList = (tracks, {getArtistString, link, strings}) => html.tag('ul',
-        tracks.map(track => {
-            const line = strings('trackList.item.withArtists', {
-                track: link.track(track),
-                by: `<span class="by">${strings('trackList.item.withArtists.by', {
-                    artists: getArtistString(track.artists)
-                })}</span>`
-            });
-            return (track.aka
-                ? `<li class="rerelease">${strings('trackList.item.rerelease', {track: line})}</li>`
-                : `<li>${line}</li>`);
-        })
-    );
-
-    const hasCommentary = track.commentary || otherReleases.some(t => t.commentary);
-    const generateCommentary = ({
-        link,
-        strings,
-        transformMultiline
-    }) => transformMultiline([
-        track.commentary,
-        ...otherReleases.map(track =>
-            (track.commentary?.split('\n')
-                .filter(line => line.replace(/<\/b>/g, '').includes(':</i>'))
-                .map(line => fixWS`
-                    ${line}
-                    ${strings('releaseInfo.artistCommentary.seeOriginalRelease', {
-                        original: link.track(track)
-                    })}
-                `)
-                .join('\n')))
-    ].filter(Boolean).join('\n'));
-
-    const data = {
-        type: 'data',
-        path: ['track', track.directory],
-        data: ({
-            serializeContribs,
-            serializeCover,
-            serializeGroupsForTrack,
-            serializeLink
-        }) => ({
-            name: track.name,
-            directory: track.directory,
-            dates: {
-                released: track.date,
-                originallyReleased: track.originalDate,
-                coverArtAdded: track.coverArtDate
-            },
-            duration: track.duration,
-            color: track.color,
-            cover: serializeCover(track, getTrackCover),
-            artists: serializeContribs(track.artists),
-            contributors: serializeContribs(track.contributors),
-            coverArtists: serializeContribs(track.coverArtists || []),
-            album: serializeLink(track.album),
-            groups: serializeGroupsForTrack(track),
-            references: track.references.map(serializeLink),
-            referencedBy: track.referencedBy.map(serializeLink),
-            alsoReleasedAs: otherReleases.map(track => ({
-                track: serializeLink(track),
-                album: serializeLink(track.album)
-            }))
-        })
-    };
-
-    const page = {
-        type: 'page',
-        path: ['track', track.directory],
-        page: ({
-            fancifyURL,
-            generateChronologyLinks,
-            generateCoverLink,
-            generatePreviousNextLinks,
-            getAlbumStylesheet,
-            getArtistString,
-            getLinkThemeString,
-            getThemeString,
-            getTrackCover,
-            link,
-            strings,
-            transformInline,
-            transformLyrics,
-            transformMultiline,
-            to
-        }) => {
-            const generateTrackList = bindOpts(unbound_generateTrackList, {getArtistString, link, strings});
-
-            return {
-                title: strings('trackPage.title', {track: track.name}),
-                stylesheet: getAlbumStylesheet(album, {to}),
-                theme: getThemeString(track.color, [
-                    `--album-directory: ${album.directory}`,
-                    `--track-directory: ${track.directory}`
-                ]),
-
-                // disabled for now! shifting banner position per height of page is disorienting
-                /*
-                banner: album.bannerArtists && {
-                    classes: ['dim'],
-                    dimensions: album.bannerDimensions,
-                    path: ['media.albumBanner', album.directory],
-                    alt: strings('misc.alt.albumBanner'),
-                    position: 'bottom'
-                },
-                */
-
-                main: {
-                    content: fixWS`
-                        ${generateCoverLink({
-                            src: getTrackCover(track),
-                            alt: strings('misc.alt.trackCover'),
-                            tags: track.artTags
-                        })}
-                        <h1>${strings('trackPage.title', {track: track.name})}</h1>
-                        <p>
-                            ${[
-                                strings('releaseInfo.by', {
-                                    artists: getArtistString(track.artists, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                track.coverArtists && strings('releaseInfo.coverArtBy', {
-                                    artists: getArtistString(track.coverArtists, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                album.directory !== UNRELEASED_TRACKS_DIRECTORY && strings('releaseInfo.released', {
-                                    date: strings.count.date(track.date)
-                                }),
-                                +track.coverArtDate !== +track.date && strings('releaseInfo.artReleased', {
-                                    date: strings.count.date(track.coverArtDate)
-                                }),
-                                track.duration && strings('releaseInfo.duration', {
-                                    duration: strings.count.duration(track.duration)
-                                })
-                            ].filter(Boolean).join('<br>\n')}
-                        </p>
-                        <p>${
-                            (track.urls.length
-                                ? strings('releaseInfo.listenOn', {
-                                    links: strings.list.or(track.urls.map(url => fancifyURL(url, {strings})))
-                                })
-                                : strings('releaseInfo.listenOn.noLinks'))
-                        }</p>
-                        ${otherReleases.length && fixWS`
-                            <p>${strings('releaseInfo.alsoReleasedAs')}</p>
-                            <ul>
-                                ${otherReleases.map(track => fixWS`
-                                    <li>${strings('releaseInfo.alsoReleasedAs.item', {
-                                        track: link.track(track),
-                                        album: link.album(track.album)
-                                    })}</li>
-                                `).join('\n')}
-                            </ul>
-                        `}
-                        ${track.contributors.textContent && fixWS`
-                            <p>
-                                ${strings('releaseInfo.contributors')}
-                                <br>
-                                ${transformInline(track.contributors.textContent)}
-                            </p>
-                        `}
-                        ${track.contributors.length && fixWS`
-                            <p>${strings('releaseInfo.contributors')}</p>
-                            <ul>
-                                ${(track.contributors
-                                    .map(contrib => `<li>${getArtistString([contrib], {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })}</li>`)
-                                    .join('\n'))}
-                            </ul>
-                        `}
-                        ${tracksReferenced.length && fixWS`
-                            <p>${strings('releaseInfo.tracksReferenced', {track: `<i>${track.name}</i>`})}</p>
-                            ${generateTrackList(tracksReferenced)}
-                        `}
-                        ${tracksThatReference.length && fixWS`
-                            <p>${strings('releaseInfo.tracksThatReference', {track: `<i>${track.name}</i>`})}</p>
-                            ${useDividedReferences && fixWS`
-                                <dl>
-                                    ${ttrOfficial.length && fixWS`
-                                        <dt>${strings('trackPage.referenceList.official')}</dt>
-                                        <dd>${generateTrackList(ttrOfficial)}</dd>
-                                    `}
-                                    ${ttrFanon.length && fixWS`
-                                        <dt>${strings('trackPage.referenceList.fandom')}</dt>
-                                        <dd>${generateTrackList(ttrFanon)}</dd>
-                                    `}
-                                </dl>
-                            `}
-                            ${!useDividedReferences && generateTrackList(tracksThatReference)}
-                        `}
-                        ${wikiInfo.features.flashesAndGames && flashesThatFeature.length && fixWS`
-                            <p>${strings('releaseInfo.flashesThatFeature', {track: `<i>${track.name}</i>`})}</p>
-                            <ul>
-                                ${flashesThatFeature.map(({ flash, as }) => html.tag('li',
-                                    {class: as !== track && 'rerelease'},
-                                    (as === track
-                                        ? strings('releaseInfo.flashesThatFeature.item', {
-                                            flash: link.flash(flash)
-                                        })
-                                        : strings('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
-                                            flash: link.flash(flash),
-                                            track: link.track(as)
-                                        })))).join('\n')}
-                            </ul>
-                        `}
-                        ${track.lyrics && fixWS`
-                            <p>${strings('releaseInfo.lyrics')}</p>
-                            <blockquote>
-                                ${transformLyrics(track.lyrics)}
-                            </blockquote>
-                        `}
-                        ${hasCommentary && fixWS`
-                            <p>${strings('releaseInfo.artistCommentary')}</p>
-                            <blockquote>
-                                ${generateCommentary({link, strings, transformMultiline})}
-                            </blockquote>
-                        `}
-                    `
-                },
-
-                sidebarLeft: generateAlbumSidebar(album, track, {
-                    fancifyURL,
-                    getLinkThemeString,
-                    link,
-                    strings,
-                    transformMultiline,
-                    wikiData
-                }),
-
-                nav: {
-                    links: [
-                        {toHome: true},
-                        {
-                            path: ['localized.album', album.directory],
-                            title: album.name
-                        },
-                        listTag === 'ol' ? {
-                            html: strings('trackPage.nav.track.withNumber', {
-                                number: album.tracks.indexOf(track) + 1,
-                                track: link.track(track, {class: 'current', to})
-                            })
-                        } : {
-                            html: strings('trackPage.nav.track', {
-                                track: link.track(track, {class: 'current', to})
-                            })
-                        },
-                        album.tracks.length > 1 &&
-                        {
-                            divider: false,
-                            html: generateAlbumNavLinks(album, track, {
-                                generatePreviousNextLinks,
-                                strings
-                            })
-                        }
-                    ].filter(Boolean),
-                    content: fixWS`
-                        <div>
-                            ${generateAlbumChronologyLinks(album, track, {generateChronologyLinks})}
-                        </div>
-                    `
-                }
-            };
-        }
-    };
-
-    return [data, page];
+export function pathsForTarget(track) {
+  return [
+    {
+      type: 'page',
+      path: ['track', track.directory],
+
+      contentFunction: {
+        name: 'generateTrackInfoPage',
+        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/replacer.js b/src/replacer.js
new file mode 100644
index 00000000..32657a5a
--- /dev/null
+++ b/src/replacer.js
@@ -0,0 +1,871 @@
+// Regex-based forward parser for wiki content, breaking up text input into
+// text and (possibly nested) tag nodes.
+//
+// The behavior here is quite tied into the `transformContent` content
+// function, which converts nodes parsed here into actual HTML, links, etc
+// for embedding in a wiki webpage.
+
+import * as marked from 'marked';
+
+import * as html from '#html';
+import {escapeRegex, typeAppearance} from '#sugar';
+
+export const replacerSpec = {
+  'album': {
+    find: 'album',
+    link: 'linkAlbumDynamically',
+  },
+
+  'album-commentary': {
+    find: 'album',
+    link: 'linkAlbumCommentary',
+  },
+
+  'album-gallery': {
+    find: 'album',
+    link: 'linkAlbumGallery',
+  },
+
+  'album-referenced-artworks': {
+    find: 'albumWithArtwork',
+    link: 'linkAlbumReferencedArtworks',
+  },
+
+  'album-referencing-artworks': {
+    find: 'albumWithArtwork',
+    link: 'linkAlbumReferencingArtworks',
+  },
+
+  'artist': {
+    find: 'artist',
+    link: 'linkArtist',
+  },
+
+  'artist-gallery': {
+    find: 'artist',
+    link: 'linkArtistGallery',
+  },
+
+  'commentary-index': {
+    find: null,
+    link: 'linkCommentaryIndex',
+  },
+
+  'date': {
+    find: null,
+    value: (ref) => new Date(ref),
+    html: (date, {html, language}) =>
+      html.tag('time',
+        {datetime: date.toUTCString()},
+        language.formatDate(date)),
+  },
+
+  'flash-index': {
+    find: null,
+    link: 'linkFlashIndex',
+  },
+
+  'flash': {
+    find: 'flash',
+    link: 'linkFlash',
+    transformName(name, node, input) {
+      const nextCharacter = input[node.iEnd];
+      const lastCharacter = name[name.length - 1];
+      if (![' ', '\n', '<'].includes(nextCharacter) && lastCharacter === '.') {
+        return name.slice(0, -1);
+      } else {
+        return name;
+      }
+    },
+  },
+
+  'flash-act': {
+    find: 'flashAct',
+    link: 'linkFlashAct',
+  },
+
+  'flash-side': {
+    find: 'flashSide',
+    link: 'linkFlashSide',
+  },
+
+  'group': {
+    find: 'group',
+    link: 'linkGroup',
+  },
+
+  'group-gallery': {
+    find: 'group',
+    link: 'linkGroupGallery',
+  },
+
+  'home': {
+    find: null,
+    link: 'linkWikiHomepage',
+  },
+
+  'listing-index': {
+    find: null,
+    link: 'linkListingIndex',
+  },
+
+  'listing': {
+    find: 'listing',
+    link: 'linkListing',
+  },
+
+  'media': {
+    find: null,
+    link: 'linkPathFromMedia',
+  },
+
+  'news-index': {
+    find: null,
+    link: 'linkNewsIndex',
+  },
+
+  'news-entry': {
+    find: 'newsEntry',
+    link: 'linkNewsEntry',
+  },
+
+  'root': {
+    find: null,
+    link: 'linkPathFromRoot',
+  },
+
+  'site': {
+    find: null,
+    link: 'linkPathFromSite',
+  },
+
+  'static': {
+    find: 'staticPage',
+    link: 'linkStaticPage',
+  },
+
+  'string': {
+    find: null,
+    value: (ref) => ref,
+    html: (ref, {language, args}) => language.$(ref, args),
+  },
+
+  'tag': {
+    find: 'artTag',
+    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.
+const tagBeginning = '[[';
+const tagEnding = ']]';
+const tagReplacerValue = ':';
+const tagHash = '#';
+const tagArgument = '*';
+const tagArgumentValue = '=';
+const tagLabel = '|';
+
+const noPrecedingWhitespace = '(?<!\\s)';
+
+const R_tagBeginning = escapeRegex(tagBeginning);
+
+const R_tagEnding = escapeRegex(tagEnding);
+
+const R_tagReplacerValue =
+  noPrecedingWhitespace + escapeRegex(tagReplacerValue);
+
+const R_tagHash = noPrecedingWhitespace + escapeRegex(tagHash);
+
+const R_tagArgument = escapeRegex(tagArgument);
+
+const R_tagArgumentValue = escapeRegex(tagArgumentValue);
+
+const R_tagLabel = escapeRegex(tagLabel);
+
+const regexpCache = {};
+
+const makeError = (i, message) => ({i, type: 'error', data: {message}});
+const endOfInput = (i, comment) =>
+  makeError(i, `Unexpected end of input (${comment}).`);
+
+// These are 8asically stored on the glo8al scope, which might seem odd
+// for a recursive function, 8ut the values are only ever used immediately
+// after they're set.
+let stopped, stop_iParse, stop_literal;
+
+function parseOneTextNode(input, i, stopAt) {
+  return parseNodes(input, i, stopAt, true)[0];
+}
+
+function parseNodes(input, i, stopAt, textOnly) {
+  let nodes = [];
+  let string = '';
+  let iString = 0;
+
+  stopped = false;
+
+  const pushTextNode = (isLast) => {
+    string = input.slice(iString, i);
+
+    // If this is the last text node 8efore stopping (at a stopAt match
+    // or the end of the input), trim off whitespace at the end.
+    if (isLast) {
+      string = string.trimEnd();
+    }
+
+    string = cleanRawText(string);
+
+    if (string.length) {
+      nodes.push({i: iString, iEnd: i, type: 'text', data: string});
+      string = '';
+    }
+  };
+
+  const literalsToMatch = stopAt
+    ? stopAt.concat([R_tagBeginning])
+    : [R_tagBeginning];
+
+  // The 8ackslash stuff here is to only match an even (or zero) num8er
+  // of sequential 'slashes. Even amounts always cancel out! Odd amounts
+  // don't, which would mean the following literal is 8eing escaped and
+  // should 8e counted only as part of the current string/text.
+  //
+  // Inspired 8y this: https://stackoverflow.com/a/41470813
+  const regexpSource = `(?<!\\\\)(?:\\\\{2})*(${literalsToMatch.join('|')})`;
+
+  // There are 8asically only a few regular expressions we'll ever use,
+  // 8ut it's a pain to hard-code them all, so we dynamically gener8te
+  // and cache them for reuse instead.
+  let regexp;
+  if (Object.hasOwn(regexpCache, regexpSource)) {
+    regexp = regexpCache[regexpSource];
+  } else {
+    regexp = new RegExp(regexpSource);
+    regexpCache[regexpSource] = regexp;
+  }
+
+  // Skip whitespace at the start of parsing. This is run every time
+  // parseNodes is called (and thus parseOneTextNode too), so spaces
+  // at the start of syntax elements will always 8e skipped. We don't
+  // skip whitespace that shows up inside content (i.e. once we start
+  // parsing below), though!
+  const whitespaceOffset = input.slice(i).search(/[^\s]/);
+
+  // If the string is all whitespace, that's just zero content, so
+  // return the empty nodes array.
+  if (whitespaceOffset === -1) {
+    return nodes;
+  }
+
+  i += whitespaceOffset;
+
+  while (i < input.length) {
+    const match = input.slice(i).match(regexp);
+
+    if (!match) {
+      iString = i;
+      i = input.length;
+      pushTextNode(true);
+      break;
+    }
+
+    const closestMatch = match[0];
+    const closestMatchIndex = i + match.index;
+
+    if (textOnly && closestMatch === tagBeginning)
+      throw makeError(i, `Unexpected [[tag]] - expected only text here.`);
+
+    const stopHere = closestMatch !== tagBeginning;
+
+    iString = i;
+    i = closestMatchIndex;
+    pushTextNode(stopHere);
+
+    i += closestMatch.length;
+
+    if (stopHere) {
+      stopped = true;
+      stop_iParse = i;
+      stop_literal = closestMatch;
+      break;
+    }
+
+    if (closestMatch === tagBeginning) {
+      const iTag = closestMatchIndex;
+
+      let N;
+
+      // Replacer key (or value)
+
+      N = parseOneTextNode(input, i, [
+        R_tagReplacerValue,
+        R_tagHash,
+        R_tagArgument,
+        R_tagLabel,
+        R_tagEnding,
+      ]);
+
+      if (!stopped) throw endOfInput(i, `reading replacer key`);
+
+      if (!N) {
+        switch (stop_literal) {
+          case tagReplacerValue:
+          case tagArgument:
+            throw makeError(i, `Expected text (replacer key).`);
+          case tagLabel:
+          case tagHash:
+          case tagEnding:
+            throw makeError(i, `Expected text (replacer key/value).`);
+        }
+      }
+
+      const replacerFirst = N;
+      i = stop_iParse;
+
+      // Replacer value (if explicit)
+
+      let replacerSecond;
+
+      if (stop_literal === tagReplacerValue) {
+        N = parseNodes(input, i, [
+          R_tagHash,
+          R_tagArgument,
+          R_tagLabel,
+          R_tagEnding,
+        ]);
+
+        if (!stopped) throw endOfInput(i, `reading replacer value`);
+        if (!N.length) throw makeError(i, `Expected content (replacer value).`);
+
+        replacerSecond = N;
+        i = stop_iParse;
+      }
+
+      // Assign first & second to replacer key/value
+
+      let replacerKey, replacerValue;
+
+      // Value is an array of nodes, 8ut key is just one (or null).
+      // So if we use replacerFirst as the value, we need to stick
+      // it in an array (on its own).
+      if (replacerSecond) {
+        replacerKey = replacerFirst;
+        replacerValue = replacerSecond;
+      } else {
+        replacerKey = null;
+        replacerValue = [replacerFirst];
+      }
+
+      // Hash
+
+      let hash;
+
+      if (stop_literal === tagHash) {
+        N = parseOneTextNode(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
+
+        if (!stopped) throw endOfInput(i, `reading hash`);
+        if (!N) throw makeError(i, `Expected text (hash).`);
+
+        hash = N;
+        i = stop_iParse;
+      }
+
+      // Arguments
+
+      const args = [];
+
+      while (stop_literal === tagArgument) {
+        N = parseOneTextNode(input, i, [
+          R_tagArgumentValue,
+          R_tagArgument,
+          R_tagLabel,
+          R_tagEnding,
+        ]);
+
+        if (!stopped) throw endOfInput(i, `reading argument key`);
+
+        if (stop_literal !== tagArgumentValue)
+          throw makeError(
+            i,
+            `Expected ${tagArgumentValue.literal} (tag argument).`
+          );
+
+        if (!N) throw makeError(i, `Expected text (argument key).`);
+
+        const key = N;
+        i = stop_iParse;
+
+        N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
+
+        if (!stopped) throw endOfInput(i, `reading argument value`);
+        if (!N.length) throw makeError(i, `Expected content (argument value).`);
+
+        const value = N;
+        i = stop_iParse;
+
+        args.push({key, value});
+      }
+
+      let label;
+
+      if (stop_literal === tagLabel) {
+        N = parseOneTextNode(input, i, [R_tagEnding]);
+
+        if (!stopped) throw endOfInput(i, `reading label`);
+        if (!N) throw makeError(i, `Expected text (label).`);
+
+        label = N;
+        i = stop_iParse;
+      }
+
+      nodes.push({
+        i: iTag,
+        iEnd: i,
+        type: 'tag',
+        data: {replacerKey, replacerValue, hash, args, label},
+      });
+
+      continue;
+    }
+  }
+
+  return nodes;
+}
+
+export function squashBackslashes(text) {
+  // Squash backslashes which aren't themselves escaped into
+  // the following character, unless that character is one of
+  // 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');
+}
+
+export function restoreRawHTMLTags(text) {
+  // Replace stuff like <html:a> with <a>; these signal that
+  // the tag shouldn't be processed by the replacer system,
+  // and should just be embedded into the content as raw HTML.
+  return text.replace(/<html:(.*?)(?=[ >])/g, '<$1');
+}
+
+export function cleanRawText(text) {
+  text = squashBackslashes(text);
+  text = restoreRawHTMLTags(text);
+  return text;
+}
+
+export function postprocessComments(inputNodes) {
+  const outputNodes = [];
+
+  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') {
+      let match = null, parseFrom = 0;
+      while (match = regexp.exec(node.data)) {
+        const previousText = node.data.slice(parseFrom, match.index);
+
+        outputNodes.push({
+          type: 'text',
+          data: previousText,
+          i: node.i + parseFrom,
+          iEnd: node.i + parseFrom + match.index,
+        });
+
+        parseFrom = match.index + match[0].length;
+
+        if (previousText.endsWith('\n')) {
+          atStartOfLine = true;
+        } else if (previousText.length) {
+          atStartOfLine = false;
+        }
+
+        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) {
+            return true;
+          }
+
+          // 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 (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 (!remainingTextInNode && node !== lastNode) {
+            return true;
+          }
+
+          // If no other condition matches, this tag is on its own line.
+          return false;
+        })();
+
+        outputNodes.push(
+          callback(attributes, {
+            inline,
+          }));
+
+        // 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;
+      }
+
+      if (parseFrom !== node.data.length) {
+        outputNodes.push({
+          type: 'text',
+          data: node.data.slice(parseFrom),
+          i: node.i + parseFrom,
+          iEnd: node.iEnd,
+        });
+      }
+
+      continue;
+    }
+
+    outputNodes.push(node);
+  }
+
+  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 = [];
+
+  for (const node of inputNodes) {
+    if (node.type !== 'text') {
+      outputNodes.push(node);
+      continue;
+    }
+
+    const headingRegexp = /<h2 (.*?)>/g;
+
+    let textContent = '';
+
+    let match = null, parseFrom = 0;
+    while (match = headingRegexp.exec(node.data)) {
+      textContent += node.data.slice(parseFrom, match.index);
+      parseFrom = match.index + match[0].length;
+
+      const attributes = html.parseAttributes(match[1]);
+      attributes.push('class', 'content-heading');
+
+      // We're only modifying the opening tag here. The remaining content,
+      // including the closing tag, will be pushed as-is.
+      textContent += `<h2 ${attributes}>`;
+    }
+
+    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 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 = [];
+
+  for (const node of inputNodes) {
+    if (node.type !== 'text') {
+      outputNodes.push(node);
+      continue;
+    }
+
+    const plausibleLinkRegexp = /\[.*?\)/g;
+
+    let textContent = '';
+
+    let plausibleMatch = null, parseFrom = 0;
+    while (plausibleMatch = plausibleLinkRegexp.exec(node.data)) {
+      textContent += node.data.slice(parseFrom, plausibleMatch.index);
+
+      // Pedantic rules use more particular parentheses detection in link
+      // destinations - they allow one level of balanced parentheses, and
+      // otherwise, parentheses must be escaped. This allows for entire links
+      // to be wrapped in parentheses, e.g below:
+      //
+      //   This is so cool. ([You know??](https://example.com))
+      //
+      const definiteMatch =
+        marked.Lexer.rules.inline.pedantic.link
+          .exec(node.data.slice(plausibleMatch.index));
+
+      if (definiteMatch) {
+        const {1: label, 2: href} = definiteMatch;
+
+        // Split the containing text node into two - the second of these will
+        // be added after iterating over matches, or by the next match.
+        if (textContent.length) {
+          outputNodes.push({type: 'text', data: textContent});
+          textContent = '';
+        }
+
+        const offset = plausibleMatch.index + definiteMatch.index;
+        const length = definiteMatch[0].length;
+
+        outputNodes.push({
+          i: node.i + offset,
+          iEnd: node.i + offset + length,
+          type: 'external-link',
+          data: {label, href},
+        });
+
+        parseFrom = offset + length;
+      } else {
+        parseFrom = plausibleMatch.index;
+      }
+    }
+
+    if (parseFrom !== node.data.length) {
+      textContent += node.data.slice(parseFrom);
+    }
+
+    if (textContent.length) {
+      outputNodes.push({type: 'text', data: textContent});
+    }
+  }
+
+  return outputNodes;
+}
+
+export function parseInput(input) {
+  if (typeof input !== 'string') {
+    throw new TypeError(`Expected input to be string, got ${typeAppearance(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) {
+    if (errorNode.type !== 'error') {
+      throw errorNode;
+    }
+
+    const {
+      i,
+      data: {message},
+    } = errorNode;
+
+    let lineStart = input.slice(0, i).lastIndexOf('\n');
+    if (lineStart >= 0) {
+      lineStart += 1;
+    } else {
+      lineStart = 0;
+    }
+
+    let lineEnd = input.slice(i).indexOf('\n');
+    if (lineEnd >= 0) {
+      lineEnd += i;
+    } else {
+      lineEnd = input.length;
+    }
+
+    const line = input.slice(lineStart, lineEnd);
+
+    const cursor = i - lineStart;
+
+    throw new SyntaxError([
+      `Parse error (at pos ${i}): ${message}`,
+      line,
+      '-'.repeat(cursor) + '^',
+    ].join('\n'));
+  }
+}
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/client.js b/src/static/client.js
deleted file mode 100644
index c12ff355..00000000
--- a/src/static/client.js
+++ /dev/null
@@ -1,415 +0,0 @@
-// 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.
-//
-// Upd8: As of 04/02/2021, it's now used for info cards too! Nice.
-
-import {
-    getColors
-} from '../util/colors.js';
-
-let albumData, artistData, flashData;
-let officialAlbumData, fandomAlbumData, artistNames;
-
-let ready = false;
-
-// 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, key) {
-    return getComputedStyle(el).getPropertyValue(key).trim();
-}
-
-function getRefDirectory(ref) {
-    return ref.split(':')[1];
-}
-
-function getAlbum(el) {
-    const directory = cssProp(el, '--album-directory');
-    return albumData.find(album => album.directory === directory);
-}
-
-function getFlash(el) {
-    const directory = cssProp(el, '--flash-directory');
-    return flashData.find(flash => flash.directory === directory);
-}
-
-// 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}`);
-const openFlash = d => rebase(`flash/${d}`);
-
-function getTrackListAndIndex() {
-    const album = getAlbum(document.body);
-    const directory = cssProp(document.body, '--track-directory');
-    if (!directory && !album) return {};
-    if (!directory) return {list: album.tracks};
-    const trackIndex = album.tracks.findIndex(track => track.directory === directory);
-    return {list: album.tracks, index: trackIndex};
-}
-
-function openRandomTrack() {
-    const { list } = getTrackListAndIndex();
-    if (!list) return;
-    return openTrack(pick(list));
-}
-
-function getFlashListAndIndex() {
-    const list = flashData.filter(flash => !flash.act8r8k)
-    const flash = getFlash(document.body);
-    if (!flash) return {list};
-    const flashIndex = list.indexOf(flash);
-    return {list, index: flashIndex};
-}
-
-// TODO: This should also use urlSpec.
-function fetchData(type, directory) {
-    return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData'))
-        .then(res => res.json());
-}
-
-// JS-based links -----------------------------------------
-
-for (const a of document.body.querySelectorAll('[data-random]')) {
-    a.addEventListener('click', evt => {
-        if (!ready) {
-            evt.preventDefault();
-            return;
-        }
-
-        setTimeout(() => {
-            a.href = rebase('js-disabled');
-        });
-        switch (a.dataset.random) {
-            case 'album': return a.href = openAlbum(pick(albumData).directory);
-            case 'album-in-fandom': return a.href = openAlbum(pick(fandomAlbumData).directory);
-            case 'album-in-official': return a.href = openAlbum(pick(officialAlbumData).directory);
-            case 'track': return a.href = openTrack(getRefDirectory(pick(albumData.map(a => a.tracks).reduce((a, b) => a.concat(b), []))));
-            case 'track-in-album': return a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks)));
-            case 'track-in-fandom': return a.href = openTrack(getRefDirectory(pick(fandomAlbumData.reduce((acc, album) => acc.concat(album.tracks), []))));
-            case 'track-in-official': return a.href = openTrack(getRefDirectory(pick(officialAlbumData.reduce((acc, album) => acc.concat(album.tracks), []))));
-            case 'artist': return a.href = openArtist(pick(artistData).directory);
-            case 'artist-more-than-one-contrib': return a.href = openArtist(pick(artistData.filter(artist => C.getArtistNumContributions(artist) > 1)).directory);
-        }
-    });
-}
-
-const next = document.getElementById('next-button');
-const previous = document.getElementById('previous-button');
-const random = document.getElementById('random-button');
-
-const prependTitle = (el, prepend) => {
-    const existing = el.getAttribute('title');
-    if (existing) {
-        el.setAttribute('title', prepend + ' ' + existing);
-    } else {
-        el.setAttribute('title', prepend);
-    }
-};
-
-if (next) prependTitle(next, '(Shift+N)');
-if (previous) prependTitle(previous, '(Shift+P)');
-if (random) prependTitle(random, '(Shift+R)');
-
-document.addEventListener('keypress', event => {
-    if (event.shiftKey) {
-        if (event.charCode === 'N'.charCodeAt(0)) {
-            if (next) next.click();
-        } else if (event.charCode === 'P'.charCodeAt(0)) {
-            if (previous) previous.click();
-        } else if (event.charCode === 'R'.charCodeAt(0)) {
-            if (random && ready) random.click();
-        }
-    }
-});
-
-for (const reveal of document.querySelectorAll('.reveal')) {
-    reveal.addEventListener('click', event => {
-        if (!reveal.classList.contains('revealed')) {
-            reveal.classList.add('revealed');
-            event.preventDefault();
-            event.stopPropagation();
-        }
-    });
-}
-
-const elements1 = document.getElementsByClassName('js-hide-once-data');
-const elements2 = document.getElementsByClassName('js-show-once-data');
-
-for (const element of elements1) element.style.display = 'block';
-
-fetch(rebase('data.json', 'rebaseShared')).then(data => data.json()).then(data => {
-    albumData = data.albumData;
-    artistData = data.artistData;
-    flashData = data.flashData;
-
-    officialAlbumData = albumData.filter(album => album.groups.includes('group:official'));
-    fandomAlbumData = albumData.filter(album => !album.groups.includes('group:official'));
-    artistNames = artistData.filter(artist => !artist.alias).map(artist => artist.name);
-
-    for (const element of elements1) element.style.display = 'none';
-    for (const element of elements2) element.style.display = 'block';
-
-    ready = true;
-});
-
-// Data & info card ---------------------------------------
-
-const NORMAL_HOVER_INFO_DELAY = 750;
-const FAST_HOVER_INFO_DELAY = 250;
-const END_FAST_HOVER_DELAY = 500;
-const HIDE_HOVER_DELAY = 250;
-
-let fastHover = false;
-let endFastHoverTimeout = null;
-
-function colorLink(a, color) {
-    if (color) {
-        const { primary, dim } = getColors(color);
-        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
-    };
-})();
-
-function makeInfoCardLinkHandlers(type) {
-    let hoverTimeout = null;
-
-    return {
-        mouseenter(evt) {
-            hoverTimeout = setTimeout(() => {
-                fastHover = true;
-                infoCard.show(type, evt.target);
-            }, fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY);
-
-            clearTimeout(endFastHoverTimeout);
-            endFastHoverTimeout = null;
-
-            infoCard.cancelHide();
-        },
-
-        mouseleave(evt) {
-            clearTimeout(hoverTimeout);
-
-            if (fastHover && !endFastHoverTimeout) {
-                endFastHoverTimeout = setTimeout(() => {
-                    endFastHoverTimeout = null;
-                    fastHover = false;
-                }, END_FAST_HOVER_DELAY);
-            }
-
-            infoCard.readyHide();
-        }
-    };
-}
-
-const infoCardLinkHandlers = {
-    track: makeInfoCardLinkHandlers('track')
-};
-
-function addInfoCardLinkHandlers(type) {
-    for (const a of document.querySelectorAll(`a[data-${type}]`)) {
-        for (const [ eventName, handler ] of Object.entries(infoCardLinkHandlers[type])) {
-            a.addEventListener(eventName, handler);
-        }
-    }
-}
-
-// 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/css/site-basic.css b/src/static/css/site-basic.css
new file mode 100644
index 00000000..586f37b5
--- /dev/null
+++ b/src/static/css/site-basic.css
@@ -0,0 +1,19 @@
+/**
+ * For redirects and stuff like that.
+ * Small file, not so much helped 8y this comment.
+ */
+
+html {
+  background-color: #222222;
+  color: white;
+}
+
+body {
+  padding: 15px;
+}
+
+main {
+  background-color: rgba(0, 0, 0, 0.6);
+  border: 1px dotted white;
+  padding: 20px;
+}
diff --git a/src/static/css/site.css b/src/static/css/site.css
new file mode 100644
index 00000000..25d9ce47
--- /dev/null
+++ b/src/static/css/site.css
@@ -0,0 +1,3640 @@
+/* A frontend file! Wow.
+ * This file is just loaded statically 8y <link>s in the HTML files, so there's
+ * no need to re-run upd8.js when tweaking values here. Handy!
+ */
+
+/* Squares */
+
+/* This styling is kind of awkwardly placed at the very top. Sorry!
+ * We need to rework what order sets of styles get applied in to be
+ * much more explicit (so that overriding isn't a headache), and
+ * hopefully that can be done through @imports, but it'll take some
+ * reworking and cleaning up.
+ */
+
+.square {
+  position: relative;
+  width: 100%;
+}
+
+.square::after {
+  content: "";
+  display: block;
+  padding-bottom: 100%;
+}
+
+.square-content {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+}
+
+/* Layout - Common */
+
+body {
+  position: relative;
+  margin: 0;
+  padding: 10px;
+  overflow-y: scroll;
+}
+
+body::before {
+  content: "";
+}
+
+body::before, .wallpaper-part {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100vw;
+  height: 100vh;
+  z-index: -1;
+
+  /* NB: these are 100 LVW, "largest view width", etc.
+   * Stabilizes background on viewports with modal dimensions,
+   * e.g. expanding/shrinking tab bar or collapsible find bar.
+   * 100% dimensions are kept above for browser compatibility.
+   */
+  width: 100lvw;
+  height: 100lvh;
+}
+
+#page-container {
+  max-width: 1100px;
+  margin: 0 auto 40px;
+  padding: 15px 0;
+}
+
+#page-container > * {
+  margin-left: 15px;
+  margin-right: 15px;
+}
+
+#skippers:focus-within {
+  position: static;
+  width: unset;
+  height: unset;
+}
+
+#banner {
+  margin: 10px 0;
+  width: 100%;
+  position: relative;
+}
+
+#banner::after {
+  content: "";
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+}
+
+#banner img {
+  display: block;
+  width: 100%;
+  height: auto;
+}
+
+#skippers {
+  position: absolute;
+  left: -10000px;
+  top: auto;
+  width: 1px;
+  height: 1px;
+}
+
+.layout-columns {
+  display: flex;
+  align-items: stretch;
+}
+
+#header,
+#secondary-nav,
+#skippers,
+#footer {
+  padding: 5px;
+}
+
+#header,
+#secondary-nav,
+#skippers {
+  margin-bottom: 10px;
+}
+
+#footer {
+  margin-top: 10px;
+}
+
+#header {
+  display: grid;
+}
+
+#header.nav-has-main-links.nav-has-content {
+  grid-template-columns: 2.5fr 3fr;
+  grid-template-rows: min-content 1fr;
+  grid-template-areas:
+    "main-links content"
+    "bottom-row content";
+}
+
+#header.nav-has-main-links:not(.nav-has-content) {
+  grid-template-columns: 1fr;
+  grid-template-areas:
+    "main-links"
+    "bottom-row";
+}
+
+.nav-main-links {
+  grid-area: main-links;
+  margin-right: 20px;
+}
+
+.nav-content {
+  grid-area: content;
+}
+
+.nav-bottom-row {
+  grid-area: bottom-row;
+  align-self: start;
+}
+
+.sidebar-column {
+  flex: 1 1 20%;
+  min-width: 150px;
+  max-width: 250px;
+  flex-basis: 250px;
+  align-self: flex-start;
+}
+
+.sidebar-column.wide {
+  max-width: 350px;
+  flex-basis: 300px;
+  flex-shrink: 0;
+  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;
+}
+
+.sidebar-multiple .sidebar:not(:first-child) {
+  margin-top: 15px;
+}
+
+.sidebar {
+  --content-padding: 5px;
+  padding: var(--content-padding);
+}
+
+#sidebar-left {
+  margin-right: 10px;
+}
+
+#sidebar-right {
+  margin-left: 10px;
+}
+
+#content {
+  position: relative;
+  --content-padding: 20px;
+  box-sizing: border-box;
+  padding: var(--content-padding);
+  flex-grow: 1;
+  flex-shrink: 3;
+}
+
+.footer-content {
+  margin: 5px 12%;
+}
+
+.footer-content > :first-child {
+  margin-top: 0;
+}
+
+.footer-content > :last-child {
+  margin-bottom: 0;
+}
+
+.footer-localization-links {
+  margin: 5px 12%;
+}
+
+/* Design & Appearance - Layout elements */
+
+:root {
+  color-scheme: dark;
+}
+
+body {
+  background: black;
+}
+
+body::before {
+  /* 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;
+}
+
+#page-container {
+  background-color: var(--bg-color, rgba(35, 35, 35, 0.8));
+  color: #ffffff;
+  box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
+}
+
+#skippers > * {
+  display: inline-block;
+}
+
+#skippers > .skipper-list:not(:last-child)::after {
+  display: inline-block;
+  content: "\00a0";
+  margin-left: 2px;
+  margin-right: -2px;
+  border-left: 1px dotted;
+}
+
+#skippers .skipper-list > .skipper:not(:last-child)::after {
+  content: " \00b7 ";
+  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);
+  border-bottom: 1px solid var(--primary-color);
+}
+
+#banner::after {
+  box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.35);
+  pointer-events: none;
+}
+
+#banner.dim img {
+  opacity: 0.8;
+}
+
+#header,
+#secondary-nav,
+#skippers,
+#footer,
+.sidebar {
+  font-size: 0.85em;
+}
+
+.sidebar,
+#content,
+#header,
+#secondary-nav,
+#skippers,
+#footer {
+  background-color: rgba(0, 0, 0, 0.6);
+  border: 1px dotted var(--primary-color);
+  border-radius: 3px;
+  transition: background-color 0.2s;
+}
+
+/*
+.sidebar:focus-within,
+#content:focus-within,
+#header:focus-within,
+#secondary-nav:focus-within,
+#skippers:focus-within,
+#footer:focus-within {
+  background-color: rgba(0, 0, 0, 0.85);
+  border-style: solid;
+}
+*/
+
+.sidebar > h1,
+.sidebar > h2,
+.sidebar > h3,
+.sidebar > p {
+  text-align: center;
+  padding-left: 4px;
+  padding-right: 4px;
+}
+
+.sidebar h1 {
+  font-size: 1.25em;
+}
+
+.sidebar h2 {
+  font-size: 1.1em;
+  margin: 0;
+}
+
+.sidebar h2:first-child {
+  margin-top: 0.5em;
+  margin-bottom: 0.5em;
+}
+
+.sidebar h3 {
+  font-size: 1.1em;
+  font-style: oblique;
+  font-variant: small-caps;
+  margin-top: 0.3em;
+  margin-bottom: 0em;
+}
+
+.sidebar > p {
+  margin: 0.5em 0;
+  padding: 0 5px;
+}
+
+.sidebar hr {
+  color: #555;
+  margin: 10px 5px;
+}
+
+.sidebar > ol,
+.sidebar > ul {
+  padding-left: 30px;
+  padding-right: 15px;
+}
+
+.sidebar > dl {
+  padding-right: 15px;
+  padding-left: 0;
+}
+
+.sidebar > dl dt {
+  padding-left: 10px;
+  margin-top: 0.5em;
+}
+
+.sidebar > dl dt.current {
+  font-weight: 800;
+}
+
+.sidebar > dl dd {
+  margin-left: 0;
+}
+
+.sidebar > dl dd ul {
+  padding-left: 30px;
+  margin-left: 0;
+}
+
+.sidebar > dl .side {
+  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;
+}
+
+.sidebar li {
+  overflow-wrap: break-word;
+}
+
+.sidebar > details.current summary {
+  font-weight: 800;
+}
+
+.sidebar > details summary {
+  margin-top: 0.5em;
+  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 > 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,
+.sidebar > details ol {
+  margin-top: 0;
+  margin-bottom: 0;
+}
+
+.sidebar > details:last-child {
+  margin-bottom: 10px;
+}
+
+.sidebar > details[open] {
+  margin-bottom: 1em;
+}
+
+.sidebar article {
+  text-align: left;
+  margin: 5px 5px 15px 5px;
+}
+
+.sidebar article:last-child {
+  margin-bottom: 5px;
+}
+
+.sidebar article h2,
+.news-index h2 {
+  border-bottom: 1px dotted;
+}
+
+.sidebar article h2 time,
+.news-index time {
+  float: right;
+  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;
+}
+
+footer {
+  text-align: center;
+  font-style: oblique;
+}
+
+.footer-localization-links > span:not(:last-child)::after {
+  content: " \00b7 ";
+  font-weight: 800;
+}
+
+/* Design & Appearance - Content elements */
+
+a {
+  color: var(--primary-color);
+  text-decoration: none;
+}
+
+a:hover {
+  text-decoration: underline;
+  text-decoration-style: solid !important;
+}
+
+a.current {
+  font-weight: 800;
+}
+
+a.series {
+  font-style: oblique;
+}
+
+a:not([href]) {
+  cursor: default;
+}
+
+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: url(/static-4p1/misc/image.svg);
+  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;
+}
+
+.nav-main-links .nav-link-accent {
+  display: inline-block;
+}
+
+.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 + .nav-link::before,
+.nav-links-hierarchical .nav-link + .blockwrap .nav-link::before {
+  content: "\0020/\0020";
+}
+
+.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 {
+  white-space: nowrap;
+}
+
+.blockwrap, .chunkwrap {
+  display: inline-block;
+}
+
+.text-with-tooltip {
+  position: relative;
+}
+
+.text-with-tooltip .text-with-tooltip-interaction-cue {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+.text-with-tooltip > .hoverable:hover .text-with-tooltip-interaction-cue,
+.text-with-tooltip > .hoverable.has-visible-tooltip .text-with-tooltip-interaction-cue {
+  text-decoration-style: wavy !important;
+}
+
+.text-with-tooltip.datetimestamp .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;
+}
+
+.text-with-tooltip.missing-duration > .hoverable {
+  opacity: 0.5;
+}
+
+.text-with-tooltip.missing-duration > .hoverable:hover,
+.text-with-tooltip.missing-duration > .hoverable.has-visible-tooltip {
+  opacity: 1;
+}
+
+.text-with-tooltip.missing-duration .text-with-tooltip-interaction-cue {
+  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;
+  top: calc(1em + 1px);
+  display: none;
+}
+
+.cover-artwork .tooltip,
+#sidebar .tooltip {
+  font-size: 0.9rem;
+}
+
+li:not(:first-child:last-child) .tooltip,
+.offset-tooltips > :not(:first-child:last-child) .tooltip {
+  left: 14px;
+}
+
+.tooltip-content {
+  display: block;
+
+  background: var(--bg-black-color);
+  border: 1px dotted var(--primary-color);
+  border-radius: 6px;
+
+  -webkit-backdrop-filter:
+    brightness(1.5) saturate(1.4) blur(4px);
+
+          backdrop-filter:
+    brightness(1.5) saturate(1.4) blur(4px);
+
+  box-shadow:
+    0 3px 4px 4px #000000aa,
+    0 -2px 4px -2px var(--primary-color) inset;
+
+  text-indent: 0;
+}
+
+.contribution-tooltip {
+  padding: 3px 6px 6px 6px;
+  left: -34px;
+}
+
+.datetimestamp-tooltip,
+.missing-duration-tooltip,
+.commentary-date-tooltip,
+.rerelease-tooltip,
+.first-release-tooltip {
+  padding: 3px 4px 2px 2px;
+  left: -10px;
+}
+
+.thing-name-tooltip,
+.wiki-edits-tooltip {
+  padding: 3px 4px 2px 2px;
+  left: -6px !important;
+}
+
+.thing-name-tooltip .tooltip-content,
+.wiki-edits-tooltip .tooltip-content {
+  font-size: 0.85em;
+}
+
+/* Terrifying?
+ * https://stackoverflow.com/a/64424759/4633828
+ */
+.thing-name-tooltip { margin-right: -120px; }
+.wiki-edits-tooltip { margin-right: -200px; }
+
+.contribution-tooltip .tooltip-content {
+  padding: 6px 2px 2px 2px;
+
+  -webkit-user-select: none;
+          user-select: none;
+
+  cursor: default;
+
+  display: grid;
+
+  grid-template-columns:
+    [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;
+}
+
+.contribution-tooltip .external-icon,
+.contribution-tooltip .chronology-symbol {
+  grid-column-start: icon-start;
+  grid-column-end: icon-end;
+}
+
+.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: platform-start;
+  grid-column-end: platform-end;
+
+  --external-platform-opacity: 0.8;
+  opacity: 0.8;
+  padding-right: 4px;
+
+  white-space: nowrap;
+}
+
+.contribution-tooltip.show-info .external-platform,
+.contribution-tooltip.show-info .chronology-info {
+  display: inline;
+  animation: external-platform 0.2s forwards linear;
+}
+
+@keyframes external-platform {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: var(--external-platform-opacity);
+  }
+}
+
+.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,
+.commentary-date-tooltip .tooltip-content {
+  padding: 5px 6px;
+  white-space: nowrap;
+  font-size: 0.9em;
+}
+
+.thing-name-tooltip .tooltip-content,
+.wiki-edits-tooltip .tooltip-content {
+  padding: 3px 4.5px;
+}
+
+.rerelease-tooltip .tooltip-content,
+.first-release-tooltip .tooltip-content {
+  padding: 3px 4.5px;
+  width: 260px;
+  font-size: 0.9em;
+}
+
+.external-icon {
+  display: inline-block;
+  padding: 0 3px;
+  width: 24px;
+  height: 1em;
+  position: relative;
+}
+
+.external-icon svg {
+  width: 24px;
+  height: 24px;
+  top: -0.25em;
+  position: absolute;
+  fill: var(--primary-color);
+}
+
+.other-group-accent,
+.rerelease-line {
+  opacity: 0.7;
+  font-style: oblique;
+}
+
+.other-group-accent {
+  white-space: nowrap;
+}
+
+.other-group-accent a {
+  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;
+}
+
+.content-columns .column {
+  break-inside: avoid;
+}
+
+.content-columns .column h2 {
+  margin-top: 0;
+  font-size: 1em;
+}
+
+p .current {
+  font-weight: 800;
+}
+
+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);
+
+  border-radius: 0 0 4px 4px;
+  background: var(--bg-black-color);
+
+  -webkit-backdrop-filter: blur(3px);
+          backdrop-filter: blur(3px);
+}
+
+.cover-artwork:has(.image-details),
+.cover-artwork.has-image-details {
+  border-radius: 0 0 6px 6px;
+}
+
+.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 the .cover-artwork needs to cut off its child .image-container
+   * (which has a background that otherwise causes sharp corners).
+   */
+  overflow: hidden;
+}
+
+#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 !important;
+}
+
+.cover-artwork .image-details {
+  border-top-color: var(--deep-color);
+}
+
+.cover-artwork .image-details + .image-details {
+  border-top-color: var(--primary-color);
+}
+
+.cover-artwork .image {
+  display: block;
+  width: 100%;
+  height: 100%;
+}
+
+.image-details {
+  display: block;
+
+  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);
+}
+
+.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;
+}
+
+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 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%;
+  max-width: 250px;
+  margin: 15px 0 10px 20px;
+
+  /* This !important is unfortunate, but it's necessary
+   * even if the rule itself is placed lower, because this
+   * is a relatively low-priority selector compared to
+   * others that alter image shadows.
+   */
+  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-video-container {
+  margin-top: 1em;
+  margin-bottom: 1em;
+}
+
+.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;
+}
+
+.image-link {
+  display: block;
+  overflow: hidden;
+}
+
+.image-link:focus {
+  outline: 3px double white;
+}
+
+.image-link:focus:not(:focus-visible) {
+  outline: none;
+}
+
+.image-link .image {
+  display: block;
+  max-width: 100%;
+  height: auto;
+}
+
+.square .image-link {
+  width: 100%;
+  height: 100%;
+}
+
+.square .image {
+  width: 100%;
+  height: 100%;
+}
+
+h1 {
+  font-size: 1.5em;
+}
+
+#content li {
+  margin-bottom: 4px;
+}
+
+#content li i {
+  white-space: nowrap;
+}
+
+#content.top-index h1,
+#content.flash-index h1 {
+  text-align: center;
+  font-size: 2em;
+}
+
+#content.flash-index h2 {
+  text-align: center;
+  font-size: 2.5em;
+  font-variant: small-caps;
+  font-style: oblique;
+  margin-bottom: 0;
+  text-align: center;
+  width: 100%;
+}
+
+#content.top-index h2 {
+  text-align: center;
+  font-size: 2em;
+  font-weight: normal;
+  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 {
+  display: inline-block;
+}
+
+ul.quick-info li:not(:last-child)::after {
+  content: " \00b7 ";
+  font-weight: 800;
+}
+
+.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;
+  background-color: #222222;
+  text-align: center;
+  border: 1px dotted var(--primary-color);
+  border-radius: 2px;
+}
+
+#intro-menu p {
+  margin: 12px 0;
+}
+
+#intro-menu a {
+  margin: 0 6px;
+}
+
+li .by {
+  font-style: oblique;
+  max-width: 600px;
+}
+
+li .by a {
+  display: inline-block;
+}
+
+p code {
+  font-size: 0.95em;
+  font-family: "courier new", monospace;
+  font-weight: 800;
+  line-height: 1.1;
+}
+
+#content blockquote {
+  margin-left: 40px;
+  max-width: 600px;
+  margin-right: 0;
+}
+
+#content blockquote blockquote {
+  margin-left: 10px;
+  padding-left: 10px;
+  margin-right: 20px;
+  border-left: dotted 1px;
+  padding-top: 6px;
+  padding-bottom: 6px;
+}
+
+#content blockquote blockquote > :first-child {
+  margin-top: 0;
+}
+
+#content blockquote blockquote > :last-child {
+  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: var(--responsive-padding-ratio);
+}
+
+main.long-content .main-content-container,
+main.long-content > h1 {
+  padding-left: calc(var(--long-content-padding-ratio) * 100%);
+  padding-right: calc(var(--long-content-padding-ratio) * 100%);
+}
+
+dl dt {
+  padding-left: 40px;
+  max-width: 600px;
+}
+
+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;
+}
+
+dl ul,
+dl ol {
+  margin-top: 0;
+  margin-bottom: 0;
+}
+
+ul > li.has-details {
+  list-style-type: none;
+  margin-left: -17px;
+}
+
+.album-group-list dt,
+.group-series-list dt {
+  font-style: oblique;
+  padding-left: 0;
+}
+
+.album-group-list dd,
+.group-series-list dd {
+  margin-left: 0;
+}
+
+.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%;
+}
+
+#content hr.split::before {
+  content: "(split)";
+  color: #808080;
+}
+
+#content hr.split {
+  position: relative;
+  overflow: hidden;
+  border: none;
+}
+
+#content hr.split::after {
+  display: inline-block;
+  content: "";
+  border: 1px inset #808080;
+  width: 100%;
+  position: absolute;
+  top: 50%;
+  margin-top: -2px;
+  margin-left: 10px;
+}
+
+li > ul {
+  margin-top: 5px;
+}
+
+.additional-files-list {
+  padding-left: 0;
+}
+
+.additional-files-list > li {
+  list-style-type: none;
+}
+
+.additional-files-list summary {
+  /* Sorry, Safari!
+   * https://bugs.webkit.org/show_bug.cgi?id=157323
+   */
+  list-style-position: outside;
+  margin-left: 40px;
+}
+
+.additional-files-list details ul {
+  margin-left: 40px;
+  margin-top: 2px;
+  margin-bottom: 10px;
+}
+
+.additional-files-list .entry-description {
+  list-style-type: none;
+  max-width: 540px;
+
+  /* This should be margin-bottom, but cascading rules
+   * cause some awkwardness - `#content li` takes precedence.
+   */
+  padding-bottom: 3px;
+}
+
+.group-contributions-table {
+  display: inline-block;
+}
+
+.group-contributions-table .group-contributions-row {
+  display: flex;
+  justify-content: space-between;
+}
+
+.group-contributions-table .group-contributions-metrics {
+  margin-left: 1.5ch;
+  white-space: nowrap;
+}
+
+.group-contributions-sorted-by-count:not(.visible),
+.group-contributions-sorted-by-duration:not(.visible) {
+  display: none;
+}
+
+.group-contributions-sort-button {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+html[data-url-key="localized.albumCommentary"] li.no-commentary {
+  opacity: 0.7;
+}
+
+html[data-url-key="localized.albumCommentary"] .content-heading-main-title {
+  margin-right: 0.25em;
+}
+
+html[data-url-key="localized.albumCommentary"] .content-heading-accent {
+  font-weight: normal;
+  font-style: oblique;
+  font-size: 0.9rem;
+  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 {
+  display: none;
+}
+
+html[data-url-key="localized.listing"][data-url-value0="random"] #content a:not([href]) {
+  opacity: 0.7;
+}
+
+html[data-url-key="localized.newsEntry"] .read-another-links {
+  font-style: oblique;
+  font-size: 0.9em;
+}
+
+/* Additional names (heading and box) */
+
+h1 a[href="#additional-names-box"] {
+  color: inherit;
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+h1 a[href="#additional-names-box"]:hover {
+  text-decoration-style: solid;
+}
+
+#additional-names-box {
+  --custom-scroll-offset: calc(0.5em - 2px);
+
+  margin: 1em 0 1em -10px;
+  max-width: min(60vw, 600px);
+
+  padding: 15px 20px 10px 20px;
+
+  display: none;
+}
+
+#additional-names-box > :first-child { margin-top: 0; }
+#additional-names-box > :last-child { margin-bottom: 0; }
+
+#additional-names-box p {
+  padding-left: 10px;
+  padding-right: 10px;
+  margin-bottom: 0;
+  font-style: oblique;
+}
+
+#additional-names-box ul {
+  padding-left: 10px;
+  margin-top: 0.5em;
+}
+
+#additional-names-box li .additional-name {
+  margin-right: 0.25em;
+}
+
+#additional-names-box li .additional-name .content-image {
+  margin-bottom: 0.25em;
+  margin-top: 0.5em;
+}
+
+#additional-names-box li .accent {
+  opacity: 0.8;
+}
+
+#additional-names-box li .additional-name > img {
+  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 {
+  display: block;
+  box-sizing: border-box;
+  position: relative;
+  height: 100%;
+  overflow: hidden;
+
+  background-color: var(--dim-color);
+  border: 2px solid var(--primary-color);
+  border-radius: 0;
+  box-shadow: 0 2px 4px -2px var(--bg-black-color) inset;
+
+  text-align: left;
+  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;
+  left: 0;
+  bottom: 0;
+  right: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+  padding: 5px 15px;
+
+  background: rgba(0, 0, 0, 0.65);
+  box-shadow: 0 0 5px rgba(0, 0, 0, 0.5) inset;
+
+  line-height: 1.35em;
+  color: var(--primary-color);
+  font-style: oblique;
+  text-shadow: 0 2px 5px rgba(0, 0, 0, 0.75);
+}
+
+.image-outer-area {
+  width: 100%;
+  height: 100%;
+  padding: 5px;
+  box-sizing: border-box;
+}
+
+.image-link {
+  border-bottom: 1px solid #ffffff03;
+  border-radius: 2.5px 2.5px 3px 3px;
+  box-shadow:
+    0 1px 8px -3px var(--bg-black-color);
+}
+
+.image-inner-area {
+  position: relative;
+  width: 100%;
+  height: 100%;
+}
+
+.image-link .image-inner-area {
+  /* Jankily fix a rendering issue with border-radius on Safari.
+   * The `-webkit-` prefix is only to keep this from applying on
+   * other browsers (well, Firefox), where it doesn't *break*
+   * anything, but also isn't necessary.
+   */
+  -webkit-transform: translateZ(0);
+}
+
+img {
+  object-fit: cover;
+}
+
+.image-inner-area::after {
+  content: "";
+  display: block;
+  position: absolute;
+  pointer-events: none;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  border-bottom-left-radius: 0.5px;
+  opacity: 0.035;
+  box-shadow:
+    6px -6px 2px -4px white inset;
+}
+
+img.pixelate, .pixelate img,
+video.pixelate, .pixelate video {
+  image-rendering: crisp-edges;
+}
+
+.reveal-text-container {
+  position: absolute;
+  top: 15px;
+  left: 10px;
+  right: 10px;
+  bottom: 10px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+}
+
+.grid-item .reveal-text {
+  font-size: 0.9em;
+}
+
+.reveal-text {
+  color: white;
+  text-align: center;
+  font-weight: bold;
+  padding-bottom: 0.5em;
+  font-size: 0.8rem;
+}
+
+.reveal-symbol {
+  display: inline-block;
+  width: 1em;
+  height: 1em;
+  margin-bottom: 0.1em;
+
+  font-size: 1.6em;
+  opacity: 0.8;
+}
+
+.reveal-interaction {
+  opacity: 0.8;
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+.reveal .image {
+  opacity: 0.7;
+  filter: blur(20px) brightness(0.7);
+}
+
+.reveal .image.reveal-thumbnail {
+  position: absolute;
+  top: 0;
+  left: 0;
+  image-rendering: pixelated;
+}
+
+.reveal.has-reveal-thumbnail:not(.revealed) .image:not(.reveal-thumbnail) {
+  /* Keep the main image as part of the box model.
+   * It's what actually defines the dimensions of the
+   * image-container, so those dimensions never shift
+   * once the image is actually revealed.
+   */
+  visibility: hidden;
+}
+
+.reveal.revealed.has-reveal-thumbnail .image.reveal-thumbnail {
+  display: none !important;
+}
+
+.reveal.revealed .image {
+  filter: none;
+  opacity: 1;
+}
+
+.reveal.revealed .reveal-text-container {
+  display: none;
+}
+
+.reveal:not(.revealed) .image-outer-area > * {
+  --reveal-border-radius: 6px;
+  position: relative;
+  overflow: hidden;
+  border-radius: var(--reveal-border-radius);
+}
+
+.reveal:not(.revealed) .image-outer-area > *::after {
+  content: "";
+  position: absolute;
+  box-sizing: border-box;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  right: 0;
+  border: 1px dotted var(--primary-color);
+  border-radius: var(--reveal-border-radius);
+  pointer-events: none;
+
+  /* By an awkward DOM intersection, this element might be
+   * .image-inner-area::after, which is already styled with
+   * a slight visual effect. Guarantee that the properties
+   * set to that end are overwritten, and fully co-opt it
+   * to serve as the interaction cue instead.
+   */
+  box-shadow: none;
+  opacity: 1;
+}
+
+.reveal:not(.revealed) .image-inner-area {
+  background: var(--deep-color);
+}
+
+.reveal:not(.revealed) .image-outer-area > *:hover::after {
+  border-style: solid;
+  box-shadow: 0 0 0 1.5px #00000099 inset;
+}
+
+.reveal:not(.revealed) .image-outer-area > *:hover .image {
+  filter: blur(20px) brightness(0.6);
+  opacity: 0.6;
+}
+
+.reveal:not(.revealed) .image-outer-area > *:hover .reveal-interaction {
+  text-decoration-style: solid;
+}
+
+.image-container.has-link:not(.no-image-preview) {
+  background: var(--deep-color);
+  box-shadow: none;
+  border-radius: 0 0 4px 4px;
+}
+
+.sidebar .image-container {
+  max-width: 350px;
+}
+
+/* Grid listings */
+
+.grid-listing {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  align-items: flex-start;
+  padding: 5px 15px;
+}
+
+.grid-item {
+  font-size: 0.9em;
+}
+
+.grid-item {
+  display: inline-block;
+  text-align: center;
+  background-color: #111111;
+  border: 1px dotted var(--primary-color);
+  border-radius: 2px;
+  padding: 5px;
+  margin: 10px;
+}
+
+.grid-item .image-container {
+  width: 100%;
+}
+
+.grid-item .image-inner-area {
+  border-radius: 0;
+  box-shadow: none;
+}
+
+.grid-item .image-inner-area::after {
+  box-shadow: none;
+}
+
+.grid-item .image {
+  width: 100%;
+  height: 100% !important;
+  margin-top: auto;
+  margin-bottom: auto;
+}
+
+.grid-item:hover {
+  text-decoration: none;
+}
+
+.grid-actions .grid-item:hover {
+  text-decoration: underline;
+}
+
+.grid-item > span {
+  display: block;
+  overflow-wrap: break-word;
+  hyphens: auto;
+}
+
+.grid-item > span:not(:first-child) {
+  margin-top: 2px;
+}
+
+.grid-item > span:first-of-type {
+  margin-top: 6px;
+}
+
+.grid-item > span:not(:first-of-type) {
+  font-size: 0.9em;
+  opacity: 0.8;
+}
+
+.grid-item:hover > span:first-of-type {
+  text-decoration: underline;
+}
+
+.grid-listing > .grid-item {
+  flex: 1 25%;
+  max-width: 200px;
+}
+
+.grid-actions {
+  display: flex;
+  flex-direction: row;
+  margin: 15px;
+  align-self: center;
+  flex-wrap: wrap;
+  justify-content: center;
+}
+
+.grid-actions > .grid-item {
+  flex-basis: unset !important;
+  margin: 5px;
+  width: 120px;
+  --primary-color: inherit !important;
+  --dim-color: inherit !important;
+}
+
+/* Carousel */
+
+.carousel-container {
+  --carousel-tile-min-width: 120px;
+  --carousel-row-count: 3;
+  --carousel-column-count: 6;
+
+  position: relative;
+  overflow: hidden;
+  margin: 20px 0 5px 0;
+  padding: 8px 0;
+}
+
+.carousel-container::before {
+  content: "";
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: -20;
+  background-color: var(--dim-color);
+  filter: brightness(0.6);
+}
+
+.carousel-container::after {
+  content: "";
+  pointer-events: none;
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  border: 1px solid var(--primary-color);
+  border-radius: 4px;
+  z-index: 40;
+  box-shadow:
+    inset 20px 2px 40px var(--shadow-color),
+    inset -20px -2px 40px var(--shadow-color);
+}
+
+.carousel-container:hover .carousel-grid {
+  animation-play-state: running;
+}
+
+html[data-url-key="localized.home"] .carousel-container {
+  --carousel-tile-size: 140px;
+}
+
+.carousel-container[data-carousel-rows="1"] { --carousel-row-count: 1; }
+.carousel-container[data-carousel-rows="2"] { --carousel-row-count: 2; }
+.carousel-container[data-carousel-rows="3"] { --carousel-row-count: 3; }
+.carousel-container[data-carousel-columns="4"] { --carousel-column-count: 4; }
+.carousel-container[data-carousel-columns="5"] { --carousel-column-count: 5; }
+.carousel-container[data-carousel-columns="6"] { --carousel-column-count: 6; }
+
+.carousel-grid:nth-child(2),
+.carousel-grid:nth-child(3) {
+  position: absolute;
+  top: 8px;
+  left: 0;
+  right: 0;
+}
+
+.carousel-grid:nth-child(2) {
+  animation-name: carousel-marquee2;
+}
+
+.carousel-grid:nth-child(3) {
+  animation-name: carousel-marquee3;
+}
+
+@keyframes carousel-marquee1 {
+  0% {
+    transform: translateX(-100%) translateX(70px);
+  }
+
+  100% {
+    transform: translateX(-200%) translateX(70px);
+  }
+}
+
+@keyframes carousel-marquee2 {
+  0% {
+    transform: translateX(0%) translateX(70px);
+  }
+
+  100% {
+    transform: translateX(-100%) translateX(70px);
+  }
+}
+
+@keyframes carousel-marquee3 {
+  0% {
+    transform: translateX(100%) translateX(70px);
+  }
+
+  100% {
+    transform: translateX(0%) translateX(70px);
+  }
+}
+
+.carousel-grid {
+  /* Thanks to: https://css-tricks.com/an-auto-filling-css-grid-with-max-columns/ */
+  --carousel-gap-count: calc(var(--carousel-column-count) - 1);
+  --carousel-total-gap-width: calc(var(--carousel-gap-count) * 10px);
+  --carousel-calculated-tile-max-width: calc((100% - var(--carousel-total-gap-width)) / var(--carousel-column-count));
+
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(max(var(--carousel-tile-min-width), var(--carousel-calculated-tile-max-width)), 1fr));
+  grid-template-rows: repeat(var(--carousel-row-count), auto);
+  grid-auto-flow: dense;
+  grid-auto-rows: 0;
+  overflow: hidden;
+  margin: auto;
+
+  transform: translateX(0);
+  animation: carousel-marquee1 40s linear infinite;
+  animation-play-state: paused;
+  z-index: 5;
+}
+
+.carousel-item {
+  display: inline-block;
+  margin: 0;
+  flex: 1 1 150px;
+  padding: 3px;
+  border-radius: 10px;
+  filter: brightness(0.8);
+}
+
+.carousel-item .image-container {
+  border: none;
+  border-radius: 5px;
+}
+
+.carousel-item .image-outer-area {
+  padding: 0;
+}
+
+.carousel-item .image-inner-area::after {
+  box-shadow: none;
+}
+
+.carousel-item .image {
+  width: 100%;
+  height: 100%;
+  margin-top: auto;
+  margin-bottom: auto;
+}
+
+.carousel-item:hover {
+  filter: brightness(1);
+  background: var(--dim-color);
+}
+
+/* Info card */
+
+#info-card-container {
+  position: absolute;
+
+  left: 0;
+  right: 10px;
+
+  pointer-events: none; /* Padding area shouldn't 8e interactive. */
+  display: none;
+}
+
+#info-card-container.show,
+#info-card-container.hide {
+  display: flex;
+}
+
+#info-card-container > * {
+  flex-basis: 400px;
+}
+
+#info-card-container.show {
+  animation: 0.2s linear forwards info-card-show;
+  transition: top 0.1s, left 0.1s;
+}
+
+#info-card-container.hide {
+  animation: 0.2s linear forwards info-card-hide;
+}
+
+@keyframes info-card-show {
+  0% {
+    opacity: 0;
+    margin-top: -5px;
+  }
+
+  100% {
+    opacity: 1;
+    margin-top: 0;
+  }
+}
+
+@keyframes info-card-hide {
+  0% {
+    opacity: 1;
+    margin-top: 0;
+  }
+
+  100% {
+    opacity: 0;
+    margin-top: 5px;
+    display: none !important;
+  }
+}
+
+.info-card-decor {
+  padding-left: 3ch;
+  border-top: 1px solid white;
+}
+
+.info-card {
+  background-color: black;
+  color: white;
+
+  border: 1px dotted var(--primary-color);
+  border-radius: 3px;
+  box-shadow: 0 5px 5px black;
+
+  padding: 5px;
+  font-size: 0.9em;
+
+  pointer-events: none;
+}
+
+.info-card::after {
+  content: "";
+  display: block;
+  clear: both;
+}
+
+#info-card-container.show .info-card {
+  animation: 0.01s linear 0.2s forwards info-card-become-interactive;
+}
+
+@keyframes info-card-become-interactive {
+  to {
+    pointer-events: auto;
+  }
+}
+
+.info-card-art-container {
+  float: right;
+
+  width: 40%;
+  margin: 5px;
+  font-size: 0.8em;
+
+  /* Dynamically shown. */
+  display: none;
+}
+
+.info-card-art-container .image-container {
+  padding: 2px;
+}
+
+.info-card-art {
+  display: block;
+  width: 100%;
+  height: 100%;
+}
+
+.info-card-name {
+  font-size: 1em;
+  border-bottom: 1px dotted;
+  margin: 0;
+}
+
+.info-card p {
+  margin-top: 0.25em;
+  margin-bottom: 0.25em;
+}
+
+.info-card p:last-child {
+  margin-bottom: 0;
+}
+
+/* Custom hash links */
+
+.content-heading {
+  border-bottom: 3px double transparent;
+  margin-bottom: -3px;
+}
+
+.content-heading.highlight-hash-link {
+  animation: highlight-hash-link 4s;
+  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;
+}
+
+/* This animation's name is referenced in JavaScript */
+@keyframes highlight-hash-link {
+  0% {
+    border-bottom-color: transparent;
+  }
+
+  10% {
+    border-bottom-color: white;
+  }
+
+  25% {
+    border-bottom-color: white;
+  }
+
+  100% {
+    border-bottom-color: transparent;
+  }
+}
+
+/* Sticky heading */
+
+[id] {
+  --custom-scroll-offset: 0px;
+}
+
+#content [id] {
+  /* Adjust scroll margin. */
+  scroll-margin-top: calc(
+      74px /* Sticky heading */
+    + 33px /* Sticky subheading */
+    - 1em  /* One line of text (align bottom) */
+    - 12px /* Padding for hanging letters & focus ring */
+    + var(--custom-scroll-offset) /* Customizable offset */
+  );
+}
+
+.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;
+}
+
+.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;
+}
+
+.content-sticky-heading-root[inert] {
+  visibility: hidden;
+}
+
+main.long-content .content-sticky-heading-container {
+  padding-left: 0;
+  padding-right: 0;
+}
+
+main.long-content .content-sticky-heading-container .content-sticky-heading-row,
+main.long-content .content-sticky-heading-container .content-sticky-subheading-row {
+  padding-left: calc(var(--long-content-padding-ratio) * (100% - 2 * var(--content-padding)) + var(--content-padding));
+  padding-right: calc(var(--long-content-padding-ratio) * (100% - 2 * var(--content-padding)) + var(--content-padding));
+}
+
+.content-sticky-heading-row {
+  box-sizing: border-box;
+  padding:
+    calc(1.25 * var(--content-padding) + 5px)
+    20px
+    calc(0.75 * var(--content-padding))
+    20px;
+
+  width: 100%;
+  margin: 0;
+
+  background: var(--bg-black-color);
+  border-bottom: 1px dotted rgba(220, 220, 220, 0.4);
+
+  -webkit-backdrop-filter: blur(6px);
+          backdrop-filter: blur(6px);
+}
+
+.content-sticky-heading-container.has-cover .content-sticky-heading-row,
+.content-sticky-heading-container.has-cover .content-sticky-subheading-row {
+  display: grid;
+  grid-template-areas:
+    "title cover";
+  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 {
+  position: relative;
+  height: 0;
+  margin: -15px 0px -5px -5px;
+}
+
+.content-sticky-heading-cover-needs-reveal {
+  display: none;
+}
+
+.content-sticky-heading-cover {
+  position: absolute;
+  top: 0;
+  width: 80px;
+  right: 10px;
+  box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.25);
+  transition: transform 0.35s, opacity 0.25s;
+}
+
+.content-sticky-heading-cover-container:not(.visible) .content-sticky-heading-cover {
+  opacity: 0;
+  transform: translateY(15px);
+  transition: transform 0.35s, opacity 0.30s;
+}
+
+.content-sticky-heading-cover .cover-artwork {
+  border-width: 1px;
+  border-radius: 1.25px;
+  box-shadow: none;
+}
+
+.content-sticky-heading-container .image-outer-area {
+  padding: 3px;
+}
+
+.content-sticky-heading-container .image-inner-area {
+  border-radius: 1.75px;
+  overflow: hidden;
+}
+
+.content-sticky-heading-cover .image {
+  display: block;
+  width: 100%;
+  height: 100%;
+}
+
+.content-sticky-subheading-row {
+  position: absolute;
+  width: 100%;
+  box-sizing: border-box;
+  padding: 10px 20px 5px 20px;
+  margin-top: 0;
+  z-index: -1;
+
+  background: var(--bg-black-color);
+  border-bottom: 1px dotted rgba(220, 220, 220, 0.4);
+  box-shadow:
+    0 2px 2px -1px #00000060,
+    0 4px 12px -4px #00000090;
+
+  -webkit-backdrop-filter: blur(4px);
+          backdrop-filter: blur(4px);
+
+  transition: margin-top 0.35s, opacity 0.25s;
+}
+
+.content-sticky-subheading-row h2 {
+  margin: 0;
+
+  font-size: 0.9em !important;
+  font-weight: normal;
+  font-style: oblique;
+  color: #eee;
+}
+
+.content-sticky-subheading-row:not(.visible) {
+  margin-top: -20px;
+  opacity: 0;
+}
+
+.content-sticky-subheading {
+  padding-right: 20px;
+}
+
+.content-sticky-heading-container h2.visible {
+  margin-top: 0;
+  opacity: 1;
+}
+
+.content-sticky-heading-row {
+  box-shadow:
+    inset 0 10px 10px -5px var(--shadow-color),
+    0 4px 8px -4px #000000b0;
+}
+
+#content, .sidebar {
+  contain: paint;
+}
+
+/* Sticky sidebar */
+
+.sidebar-column:not(.sticky-column) {
+  align-self: stretch;
+}
+
+.sidebar-column.sticky-column {
+  position: sticky;
+  top: 10px;
+  align-self: flex-start;
+  max-height: calc(100vh - 20px);
+  display: flex;
+  flex-direction: column;
+}
+
+.sidebar-multiple.sticky-column .sidebar:last-child {
+  flex-shrink: 1;
+  overflow-y: scroll;
+  scrollbar-width: thin;
+  scrollbar-color: var(--dim-color) var(--dark-color);
+}
+
+.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.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;
+  border-radius: 10px;
+  background-clip: content-box;
+}
+
+.sidebar-column.sidebar.sticky-column > h1 {
+  position: sticky;
+  top: 0;
+  z-index: 2;
+
+  margin: 0 calc(-1 * var(--content-padding));
+  margin-bottom: 10px;
+
+  border-bottom: 1px dotted rgba(220, 220, 220, 0.4);
+  padding: 10px 5px;
+
+  background: var(--bg-black-color);
+  -webkit-backdrop-filter: blur(4px);
+  backdrop-filter: blur(4px);
+
+  box-shadow:
+    0 2px 3px -1px #0006,
+    0 4px 8px -2px #0009;
+}
+
+/* Image overlay */
+
+#image-overlay-container {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 4000;
+
+  background: rgba(0, 0, 0, 0.8);
+  color: white;
+  padding: 20px 40px;
+  box-sizing: border-box;
+
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 0.4s;
+
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+
+#image-overlay-container::before {
+  content: '';
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+
+  background: var(--deep-color);
+  opacity: 0.20;
+}
+
+#image-overlay-container.visible {
+  opacity: 1;
+  pointer-events: auto;
+}
+
+#image-overlay-content-container {
+  border-radius: 0 0 8px 8px;
+  border: 2px solid var(--primary-color);
+  background: var(--deep-ghost-color);
+  overflow: hidden;
+
+  box-shadow:
+    0 0 90px 30px #00000060,
+    0 0 20px 10px #00000040,
+    0 0 10px 3px #00000080;
+
+  -webkit-backdrop-filter: blur(3px);
+          backdrop-filter: blur(3px);
+}
+
+#image-overlay-image-area {
+  display: block;
+  overflow: hidden;
+  width: 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: block;
+  width: 100%;
+  height: auto;
+}
+
+#image-overlay-image {
+  position: absolute;
+}
+
+#image-overlay-container.no-thumb #image-overlay-image {
+  position: static;
+}
+
+#image-overlay-image-thumb {
+  filter: blur(16px);
+  transform: scale(1.5);
+}
+
+#image-overlay-container.loaded #image-overlay-image-thumb {
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 0.25s;
+}
+
+#image-overlay-image-area::after {
+  content: "";
+  display: block;
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  height: 4px;
+  width: var(--download-progress);
+  background: var(--primary-color);
+  box-shadow: 0 -3px 12px 4px var(--primary-color);
+  transition: 0.25s;
+}
+
+#image-overlay-container.loaded #image-overlay-image-area::after {
+  width: 100%;
+  background: white;
+  opacity: 0;
+}
+
+#image-overlay-container.errored #image-overlay-image-area::after {
+  width: 100%;
+  background: red;
+}
+
+#image-overlay-container:not(.visible) #image-overlay-image-area::after {
+  width: 0 !important;
+}
+
+#image-overlay-action-container {
+  padding: 7px 4px 7px 4px;
+  border-radius: 0 0 5px 5px;
+  background: var(--bg-black-color);
+  color: white;
+  font-style: oblique;
+  text-align: center;
+  box-shadow:
+    0 3px 8px -5px var(--primary-color) inset;
+}
+
+#image-overlay-container #image-overlay-action-content-without-size:not(.visible),
+#image-overlay-container #image-overlay-action-content-with-size:not(.visible),
+#image-overlay-container #image-overlay-file-size-warning:not(.visible),
+#image-overlay-container #image-overlay-file-size-kilobytes:not(.visible),
+#image-overlay-container #image-overlay-file-size-megabytes:not(.visible) {
+  display: none;
+}
+
+#image-overlay-file-size-warning {
+  opacity: 0.8;
+  font-size: 0.9em;
+}
+
+/* Layout - Wide (most computers) */
+
+@media (min-width: 900px) {
+  #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;
+  }
+}
+
+/* Layout - Medium (tablets, some landscape mobiles)
+ *
+ * Note: Rules defined here are exclusive to "medium" width, i.e. they don't
+ * additionally apply to "thin". Use the later section which applies to both
+ * if so desired.
+ */
+
+@media (min-width: 600px) and (max-width: 899.98px) {
+  /* Medium layout is mainly defined (to the user) by hiding the sidebar, so
+   * 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.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-root {
+    /* Safari doesn't always play nicely with position: sticky,
+     * this seems to fix images sometimes displaying above the
+     * position: absolute subheading (h2) child
+     *
+     * See also: https://stackoverflow.com/questions/50224855/
+     */
+    transform: translate3d(0, 0, 0);
+    z-index: 1;
+  }
+
+  /* Cover art floats to the right. It's positioned in HTML beneath the
+   * heading, so pull it up a little to "float" on top.
+   */
+  #artwork-column {
+    float: right;
+    width: 40%;
+    max-width: 400px;
+    margin: -60px 0 10px 20px;
+
+    position: relative;
+    z-index: 2;
+  }
+
+  /* ...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.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+7) {
+    flex-basis: 18%;
+    margin: 10px;
+  }
+}
+
+/* Layout - Medium or Thin */
+
+@media (max-width: 899.98px) {
+  .sidebar.collapsible,
+  .sidebar-box-joiner.collapsible,
+  .sidebar-column.all-boxes-collapsible {
+    display: none;
+  }
+
+  /* Duplicated for "sidebars in content column" */
+
+  .layout-columns {
+    flex-direction: column;
+  }
+
+  .layout-columns > *:not(:last-child) {
+    margin-bottom: 10px;
+  }
+
+  .sidebar-column {
+    position: static !important;
+    max-width: unset !important;
+    flex-basis: unset !important;
+    margin-right: 0 !important;
+    margin-left: 0 !important;
+    width: 100%;
+  }
+
+  .sidebar .news-entry:not(.first-news-entry) {
+    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) {
+  .content-columns {
+    columns: 1;
+  }
+
+  main {
+    --responsive-padding-ratio: 0.02;
+  }
+
+  #artwork-column {
+    margin: 25px 0 5px 0;
+    width: 100%;
+    max-width: unset;
+  }
+
+  #additional-names-box {
+    width: unset;
+    max-width: unset;
+  }
+
+  .nav-has-content .nav-main-links .nav-link-accent {
+    display: block;
+  }
+
+  /* Show sticky heading above cover art */
+
+  .content-sticky-heading-root {
+    z-index: 2;
+  }
+
+  .content-sticky-heading-row h1 {
+    padding-right: 10px;
+  }
+
+  /* Let sticky heading text span past lower-index cover art */
+
+  .content-sticky-heading-container.has-cover .content-sticky-heading-row,
+  .content-sticky-heading-container.has-cover .content-sticky-subheading-row {
+    grid-template-columns: 1fr 90px;
+  }
+
+  /* Disable grid features, just line header children up vertically */
+
+  #header {
+    display: block;
+  }
+
+  #header > div:not(:first-child) {
+    margin-top: 0.5em;
+  }
+}
diff --git a/src/static/icons.svg b/src/static/icons.svg
deleted file mode 100644
index 1e4351bf..00000000
--- a/src/static/icons.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 40 40" display="none" width="0" height="0">
-	<symbol id="icon-globe" viewBox="0 0 40 40"><path d="M20,3C10.6,3,3,10.6,3,20s7.6,17,17,17s17-7.6,17-17S29.4,3,20,3z M34.8,18.9h-6.2c-0.1-2.2-0.3-4.2-0.8-6.1 c1.5-0.4,3-0.9,4.2-1.5c0.6,0.9,1.2,1.8,1.6,2.9C34.3,15.7,34.7,17.3,34.8,18.9z M25.7,26.7c-1.5-0.3-3.1-0.4-4.6-0.5v-5.1h5.4 c-0.1,1.8-0.3,3.5-0.6,5.1C25.8,26.3,25.8,26.5,25.7,26.7z M14.2,26.2c-0.3-1.6-0.6-3.3-0.6-5.1h5.4v5.1c-1.6,0-3.2,0.2-4.6,0.5 C14.2,26.5,14.2,26.3,14.2,26.2z M14.3,13.3c1.5,0.3,3.1,0.4,4.6,0.5v5.1h-5.4c0.1-1.8,0.3-3.5,0.6-5.1 C14.2,13.7,14.2,13.5,14.3,13.3z M21.1,5.4C21.4,5.6,21.7,5.7,22,6c0.8,0.7,1.6,1.7,2.2,3c0.4,0.7,0.7,1.5,0.9,2.3 c-1.3,0.2-2.7,0.4-4,0.4V5.4z M18,6c0.3-0.3,0.6-0.4,0.9-0.6v6.2c-1.4,0-2.8-0.2-4-0.4c0.3-0.8,0.6-1.6,0.9-2.3 C16.5,7.7,17.2,6.7,18,6z M18.9,28.4v6.2c-0.3-0.1-0.6-0.3-0.9-0.6c-0.8-0.7-1.6-1.7-2.2-3c-0.4-0.7-0.7-1.5-0.9-2.3 C16.2,28.6,17.5,28.4,18.9,28.4z M22,34c-0.3,0.3-0.6,0.4-0.9,0.6v-6.2c1.4,0,2.8,0.2,4,0.4c-0.3,0.8-0.6,1.6-0.9,2.3 C23.5,32.3,22.8,33.3,22,34z M21.1,18.9v-5.1c1.6,0,3.2-0.2,4.6-0.5c0,0.2,0.1,0.4,0.1,0.5c0.3,1.6,0.6,3.3,0.6,5.1H21.1z M30.5,9.5 c0,0,0.1,0.1,0.1,0.1c-1,0.4-2.2,0.8-3.4,1.1c-0.6-1.9-1.4-3.5-2.4-4.8c0.3,0.1,0.6,0.2,0.9,0.3C27.5,7.1,29.1,8.1,30.5,9.5z M14.2,6.3c0.3-0.1,0.6-0.2,0.9-0.3c-0.9,1.3-1.7,2.9-2.4,4.8c-1.2-0.3-2.3-0.7-3.4-1.1c0,0,0.1-0.1,0.1-0.1 C10.9,8.1,12.5,7.1,14.2,6.3z M7.9,11.4c1.3,0.6,2.7,1.1,4.2,1.5c-0.4,1.9-0.7,3.9-0.8,6.1H5.2c0.1-1.6,0.5-3.2,1.1-4.7 C6.8,13.2,7.3,12.3,7.9,11.4z M5.2,21.1h6.2c0.1,2.2,0.3,4.2,0.8,6.1c-1.5,0.4-3,0.9-4.2,1.5c-0.6-0.9-1.2-1.8-1.6-2.9 C5.7,24.3,5.3,22.7,5.2,21.1z M9.5,30.5c0,0-0.1-0.1-0.1-0.1c1-0.4,2.2-0.8,3.4-1.1c0.6,1.9,1.4,3.5,2.4,4.8 c-0.3-0.1-0.6-0.2-0.9-0.3C12.5,32.9,10.9,31.9,9.5,30.5z M25.8,33.7c-0.3,0.1-0.6,0.2-0.9,0.3c0.9-1.3,1.7-2.9,2.4-4.8 c1.2,0.3,2.3,0.7,3.4,1.1c0,0-0.1,0.1-0.1,0.1C29.1,31.9,27.5,32.9,25.8,33.7z M32.1,28.6c-1.3-0.6-2.7-1.1-4.2-1.5 c0.4-1.9,0.7-3.9,0.8-6.1h6.2c-0.1,1.6-0.5,3.2-1.1,4.7C33.2,26.8,32.7,27.7,32.1,28.6z"/></symbol>
-	<symbol id="icon-bandcamp" viewBox="0 0 40 40"><path d="M7.1,13.3c5.6,0,11.1,0,16.7,0c0,1.5,0,3.1,0,4.6c0.7-0.7,1.5-1.5,3.2-1.3c2.6,0.3,3.8,3,3.6,5.6c-0.1,1.1-0.5,2.4-1.3,3.1 c-0.9,0.9-2.9,1.4-4.6,0.5c-0.4-0.2-0.7-0.6-1-1.1c0,0.4,0,0.8,0,1.3c-0.6,0-1.3,0-1.9,0c0-4.2,0-8.3,0-12.5 c-2.3,3.9-4.6,8.4-6.9,12.5c-4.9,0-9.8,0-14.7,0C2.5,21.7,4.8,17.5,7.1,13.3L7.1,13.3z M24.3,19c-1.4,1.9-0.4,6.7,2.8,5.5 c2.4-0.9,2-6.6-1.2-6.3C25.2,18.3,24.7,18.5,24.3,19L24.3,19z"/> <path d="M39.7,19.9c-0.6,0-1.3,0-2,0c0-1.6-1.9-2-3.1-1.5c-2.3,1.1-1.8,7.1,1.6,6.2c0.8-0.2,1.2-0.9,1.4-2c0.6,0,1.3,0,2,0 c-0.1,2.4-2.1,3.9-4.4,3.7c-2.1-0.1-3.8-1.8-4-4.2c-0.2-2.9,1.3-5.9,5-5.5C38.3,16.8,39.6,17.9,39.7,19.9z"/></symbol>
-	<symbol id="icon-deviantart" viewBox="0 0 40 40"><path d="M30,9.2L24,20.9l0.5,0.6H30v8.3H19.9L19,30.5l-2.8,5.5l-0.6,0.6h-6v-6.1l6.1-11.7l-0.5-0.6H9.5V9.8h10.2l0.9-0.6l2.8-5.5 L24,3.2h6C30,3.2,30,9.2,30,9.2z"/></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-tumblr" viewBox="0 0 40 40"><path d="M26.8,29.7l1.6,4.6c-0.3,0.5-1,0.9-2.2,1.3s-2.3,0.6-3.4,0.6c-1.4,0-2.6-0.1-3.7-0.5s-2.1-0.8-2.8-1.4 c-0.7-0.6-1.3-1.3-1.9-2.1c-0.5-0.8-0.9-1.6-1.1-2.3c-0.2-0.8-0.3-1.5-0.3-2.3V16.9H9.7v-4.2c0.9-0.3,1.8-0.8,2.5-1.4 s1.3-1.1,1.8-1.8s0.8-1.3,1.1-2c0.3-0.7,0.5-1.4,0.7-1.9S16,4.6,16.1,4c0-0.1,0-0.1,0.1-0.2s0.1-0.1,0.1-0.1h4.8V12h6.5v4.9h-6.5V27 c0,0.4,0,0.8,0.1,1.1c0.1,0.3,0.2,0.7,0.4,1s0.5,0.6,1,0.8c0.4,0.2,1,0.3,1.6,0.3C25.2,30.2,26.1,30,26.8,29.7L26.8,29.7z"/></symbol>
-	<symbol id="icon-twitter" viewBox="0 0 40 40"><path d="M36.3,10.2c-1,1.3-2.1,2.5-3.4,3.5c0,0.2,0,0.4,0,1c0,1.7-0.2,3.6-0.9,5.3c-0.6,1.7-1.2,3.5-2.4,5.1 c-1.1,1.5-2.3,3.1-3.7,4.3c-1.4,1.2-3.3,2.3-5.3,3c-2.1,0.8-4.2,1.2-6.6,1.2c-3.6,0-7-1-10.2-3c0.4,0,1.1,0.1,1.5,0.1 c3.1,0,5.9-1,8.2-2.9c-1.4,0-2.7-0.4-3.8-1.3c-1.2-1-1.9-2-2.2-3.3c0.4,0.1,1,0.1,1.2,0.1c0.6,0,1.2-0.1,1.7-0.2 c-1.4-0.3-2.7-1.1-3.7-2.3s-1.4-2.6-1.4-4.2v-0.1c1,0.6,2,0.9,3,0.9c-1-0.6-1.5-1.3-2.2-2.4c-0.6-1-0.9-2.1-0.9-3.3s0.3-2.3,1-3.4 c1.5,2.1,3.6,3.6,6,4.9s4.9,2,7.6,2.1c-0.1-0.6-0.1-1.1-0.1-1.4c0-1.8,0.8-3.5,2-4.7c1.2-1.2,2.9-2,4.7-2c2,0,3.6,0.8,4.8,2.1 c1.4-0.3,2.9-0.9,4.2-1.5c-0.4,1.5-1.4,2.7-2.9,3.6C33.8,11.2,35.1,10.9,36.3,10.2L36.3,10.2z"/></symbol>
-	<symbol id="icon-youtube" viewBox="0 0 40 40"><path d="M24.3,27v4.2c0,0.9-0.3,1.3-0.8,1.3c-0.3,0-0.6-0.1-0.9-0.4v-6c0.3-0.3,0.6-0.4,0.9-0.4C24,25.6,24.3,26.1,24.3,27L24.3,27z M31.1,27v0.9h-1.8V27c0-0.9,0.3-1.4,0.9-1.4C30.8,25.6,31.1,26.1,31.1,27L31.1,27z M11.7,22.6h2.1v-1.9H7.6v1.9h2.1v11.4h2 L11.7,22.6L11.7,22.6z M17.5,34.1h1.8v-9.9h-1.8v7.6c-0.4,0.6-0.8,0.8-1.1,0.8c-0.2,0-0.4-0.1-0.4-0.4c0,0,0-0.3,0-0.7v-7.3h-1.8V32 c0,0.7,0.1,1.1,0.2,1.5c0.2,0.5,0.5,0.7,1.2,0.7c0.6,0,1.3-0.4,2-1.2L17.5,34.1L17.5,34.1z M26.1,31.1v-4c0-1-0.1-1.6-0.2-2 c-0.2-0.7-0.7-1.1-1.4-1.1c-0.7,0-1.3,0.4-1.9,1.1v-4.4h-1.8v13.3h1.8v-1c0.6,0.7,1.2,1.1,1.9,1.1c0.7,0,1.2-0.4,1.4-1.1 C26,32.7,26.1,32.1,26.1,31.1L26.1,31.1z M32.9,30.9v-0.3H31c0,0.7,0,1.1,0,1.2c-0.1,0.5-0.4,0.7-0.8,0.7c-0.6,0-0.9-0.5-0.9-1.4 v-1.7h3.6v-2.1c0-1.1-0.2-1.8-0.5-2.3c-0.5-0.7-1.2-1-2.1-1c-0.9,0-1.6,0.3-2.1,1c-0.4,0.5-0.6,1.3-0.6,2.3v3.5 c0,1.1,0.2,1.8,0.6,2.3c0.5,0.7,1.2,1,2.2,1c1,0,1.7-0.4,2.2-1.1c0.2-0.4,0.4-0.7,0.4-1.1C32.9,31.9,32.9,31.5,32.9,30.9L32.9,30.9z M20.7,12.5V8.3c0-0.9-0.3-1.4-0.9-1.4c-0.6,0-0.9,0.5-0.9,1.4v4.2c0,0.9,0.3,1.4,0.9,1.4C20.4,14,20.7,13.5,20.7,12.5z M35.1,27.6 c0,3.1-0.2,5.5-0.5,7c-0.2,0.8-0.6,1.5-1.2,2c-0.6,0.5-1.3,0.8-2,0.9c-2.5,0.3-6.2,0.4-11.1,0.4s-8.7-0.1-11.1-0.4 c-0.8-0.1-1.5-0.4-2.1-0.9c-0.6-0.5-1-1.2-1.2-2c-0.3-1.5-0.5-3.8-0.5-7c0-3.1,0.2-5.5,0.5-7c0.2-0.8,0.6-1.5,1.2-2 c0.6-0.5,1.3-0.9,2.1-0.9c2.5-0.3,6.2-0.4,11.1-0.4s8.7,0.1,11.1,0.4c0.8,0.1,1.5,0.4,2.1,0.9c0.6,0.5,1,1.2,1.2,2 C34.9,22.1,35.1,24.4,35.1,27.6z M15.1,2h2l-2.4,8v5.4h-2V10c-0.2-1-0.6-2.4-1.2-4.3c-0.5-1.4-0.9-2.6-1.3-3.8h2.1l1.4,5.3L15.1,2z M22.5,8.7v3.5c0,1.1-0.2,1.9-0.6,2.4c-0.5,0.7-1.2,1-2.1,1c-0.9,0-1.6-0.3-2.1-1c-0.4-0.5-0.6-1.3-0.6-2.4V8.7 c0-1.1,0.2-1.9,0.6-2.3c0.5-0.7,1.2-1,2.1-1c0.9,0,1.6,0.3,2.1,1C22.3,6.8,22.5,7.6,22.5,8.7z M29.2,5.4v10h-1.8v-1.1 c-0.7,0.8-1.4,1.2-2.1,1.2c-0.6,0-1-0.2-1.2-0.7C24,14.5,24,14,24,13.4V5.4h1.8v7.4c0,0.4,0,0.7,0,0.7c0,0.3,0.2,0.4,0.4,0.4 c0.4,0,0.7-0.3,1.1-0.9V5.4C27.4,5.4,29.2,5.4,29.2,5.4z"/></symbol>
-    <symbol id="icon-instagram" viewBox="0 0 40 40"><path d="M20,7c4.2,0,4.7,0,6.3,0.1c1.5,0.1,2.3,0.3,3,0.5C30,8,30.5,8.3,31.1,8.9c0.5,0.5,0.9,1.1,1.2,1.8c0.2,0.5,0.5,1.4,0.5,3 C33,15.3,33,15.8,33,20s0,4.7-0.1,6.3c-0.1,1.5-0.3,2.3-0.5,3c-0.3,0.7-0.6,1.2-1.2,1.8c-0.5,0.5-1.1,0.9-1.8,1.2 c-0.5,0.2-1.4,0.5-3,0.5C24.7,33,24.2,33,20,33s-4.7,0-6.3-0.1c-1.5-0.1-2.3-0.3-3-0.5C10,32,9.5,31.7,8.9,31.1 C8.4,30.6,8,30,7.7,29.3c-0.2-0.5-0.5-1.4-0.5-3C7,24.7,7,24.2,7,20s0-4.7,0.1-6.3c0.1-1.5,0.3-2.3,0.5-3C8,10,8.3,9.5,8.9,8.9 C9.4,8.4,10,8,10.7,7.7c0.5-0.2,1.4-0.5,3-0.5C15.3,7.1,15.8,7,20,7z M20,4.3c-4.3,0-4.8,0-6.5,0.1c-1.6,0-2.8,0.3-3.8,0.7 C8.7,5.5,7.8,6,6.9,6.9C6,7.8,5.5,8.7,5.1,9.7c-0.4,1-0.6,2.1-0.7,3.8c-0.1,1.7-0.1,2.2-0.1,6.5s0,4.8,0.1,6.5 c0,1.6,0.3,2.8,0.7,3.8c0.4,1,0.9,1.9,1.8,2.8c0.9,0.9,1.7,1.4,2.8,1.8c1,0.4,2.1,0.6,3.8,0.7c1.6,0.1,2.2,0.1,6.5,0.1 s4.8,0,6.5-0.1c1.6-0.1,2.9-0.3,3.8-0.7c1-0.4,1.9-0.9,2.8-1.8c0.9-0.9,1.4-1.7,1.8-2.8c0.4-1,0.6-2.1,0.7-3.8 c0.1-1.6,0.1-2.2,0.1-6.5s0-4.8-0.1-6.5c-0.1-1.6-0.3-2.9-0.7-3.8c-0.4-1-0.9-1.9-1.8-2.8c-0.9-0.9-1.7-1.4-2.8-1.8 c-1-0.4-2.1-0.6-3.8-0.7C24.8,4.3,24.3,4.3,20,4.3L20,4.3L20,4.3z"/><path d="M20,11.9c-4.5,0-8.1,3.7-8.1,8.1s3.7,8.1,8.1,8.1s8.1-3.7,8.1-8.1S24.5,11.9,20,11.9z M20,25.2c-2.9,0-5.2-2.3-5.2-5.2 s2.3-5.2,5.2-5.2s5.2,2.3,5.2,5.2S22.9,25.2,20,25.2z"/><path d="M30.6,11.6c0,1-0.8,1.9-1.9,1.9c-1,0-1.9-0.8-1.9-1.9s0.8-1.9,1.9-1.9C29.8,9.7,30.6,10.5,30.6,11.6z"/></symbol>
-    <symbol id="icon-mastodon" viewBox="-20 -20 237 255"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z"/></symbol>
-</svg>
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..b2343f07
--- /dev/null
+++ b/src/static/js/client/index.js
@@ -0,0 +1,237 @@
+/* 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 lyricsSwitcherModule from './lyrics-switcher.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,
+  lyricsSwitcherModule,
+  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/lyrics-switcher.js b/src/static/js/client/lyrics-switcher.js
new file mode 100644
index 00000000..b350ea50
--- /dev/null
+++ b/src/static/js/client/lyrics-switcher.js
@@ -0,0 +1,70 @@
+/* eslint-env browser */
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+import {cssProp} from '../client-util.js';
+
+export const info = {
+  id: 'lyricsSwitcherInfo',
+
+  entries: null,
+  switchLinks: null,
+  currentLinks: null,
+};
+
+export function getPageReferences() {
+  const content = document.getElementById('content');
+
+  if (!content) return;
+
+  const switcher = content.querySelector('.lyrics-switcher');
+
+  if (!switcher) return;
+
+  info.entries =
+    Array.from(content.querySelectorAll('.lyrics-entry'));
+
+  info.currentLinks =
+    Array.from(switcher.querySelectorAll('a.current'));
+
+  info.switchLinks =
+    Array.from(switcher.querySelectorAll('a:not(.current)'));
+}
+
+export function addPageListeners() {
+  if (!info.switchLinks) return;
+
+  for (const {switchLink, entry} of stitchArrays({
+    switchLink: info.switchLinks,
+    entry: info.entries,
+  })) {
+    switchLink.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+      showLyricsEntry(entry);
+    });
+  }
+}
+
+function showLyricsEntry(entry) {
+  const entryToShow = entry;
+
+  stitchArrays({
+    entry: info.entries,
+    currentLink: info.currentLinks,
+    switchLink: info.switchLinks,
+  }).forEach(({
+      entry,
+      currentLink,
+      switchLink,
+    }) => {
+      if (entry === entryToShow) {
+        cssProp(entry, 'display', null);
+        cssProp(currentLink, 'display', null);
+        cssProp(switchLink, 'display', 'none');
+      } else {
+        cssProp(entry, 'display', 'none');
+        cssProp(currentLink, 'display', 'none');
+        cssProp(switchLink, 'display', null);
+      }
+    });
+}
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/js/lazy-loading.js b/src/static/js/lazy-loading.js
new file mode 100644
index 00000000..1df56f08
--- /dev/null
+++ b/src/static/js/lazy-loading.js
@@ -0,0 +1,54 @@
+/* eslint-env browser */
+
+// Lazy loading! Roll your own. Woot.
+// This file includes a 8unch of fall8acks and stuff like that, and is written
+// with fairly Olden JavaScript(TM), so as to work on pretty much any 8rowser
+// with JS ena8led. (If it's disa8led, there are gener8ted <noscript> tags to
+// work there.)
+
+var observer;
+
+function loadImage(image) {
+  image.src = image.dataset.original;
+}
+
+function lazyLoad(elements) {
+  for (var i = 0; i < elements.length; i++) {
+    var item = elements[i];
+    if (item.intersectionRatio > 0) {
+      observer.unobserve(item.target);
+      loadImage(item.target);
+    }
+  }
+}
+
+function lazyLoadMain() {
+  // This is a live HTMLCollection! We can't iter8te over it normally 'cuz
+  // we'd 8e mutating its value just 8y interacting with the DOM elements it
+  // contains. A while loop works just fine, even though you'd think reading
+  // over this code that this would 8e an infinitely hanging loop. It isn't!
+  var elements = document.getElementsByClassName('js-hide');
+  while (elements.length) {
+    elements[0].classList.remove('js-hide');
+  }
+
+  var lazyElements = document.getElementsByClassName('lazy');
+  var i;
+  if (window.IntersectionObserver) {
+    observer = new IntersectionObserver(lazyLoad, {
+      rootMargin: '200px',
+      threshold: 0,
+    });
+    for (i = 0; i < lazyElements.length; i++) {
+      observer.observe(lazyElements[i]);
+    }
+  } else {
+    for (i = 0; i < lazyElements.length; i++) {
+      var element = lazyElements[i];
+      var original = element.getAttribute('data-original');
+      element.setAttribute('src', original);
+    }
+  }
+}
+
+document.addEventListener('DOMContentLoaded', lazyLoadMain);
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..cdab2cb8
--- /dev/null
+++ b/src/static/js/rectangles.js
@@ -0,0 +1,513 @@
+/* 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,
+    });
+  }
+}
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/lazy-loading.js b/src/static/lazy-loading.js
deleted file mode 100644
index a403d7ca..00000000
--- a/src/static/lazy-loading.js
+++ /dev/null
@@ -1,51 +0,0 @@
-// Lazy loading! Roll your own. Woot.
-// This file includes a 8unch of fall8acks and stuff like that, and is written
-// with fairly Olden JavaScript(TM), so as to work on pretty much any 8rowser
-// with JS ena8led. (If it's disa8led, there are gener8ted <noscript> tags to
-// work there.)
-
-var observer;
-
-function loadImage(image) {
-    image.src = image.dataset.original;
-}
-
-function lazyLoad(elements) {
-    for (var i = 0; i < elements.length; i++) {
-        var item = elements[i];
-        if (item.intersectionRatio > 0) {
-            observer.unobserve(item.target);
-            loadImage(item.target);
-        }
-    }
-}
-
-function lazyLoadMain() {
-    // This is a live HTMLCollection! We can't iter8te over it normally 'cuz
-    // we'd 8e mutating its value just 8y interacting with the DOM elements it
-    // contains. A while loop works just fine, even though you'd think reading
-    // over this code that this would 8e an infinitely hanging loop. It isn't!
-    var elements = document.getElementsByClassName('js-hide');
-    while (elements.length) {
-        elements[0].classList.remove('js-hide');
-    }
-
-    var lazyElements = document.getElementsByClassName('lazy');
-    if (window.IntersectionObserver) {
-        observer = new IntersectionObserver(lazyLoad, {
-            rootMargin: '200px',
-            threshold: 1.0
-        });
-        for (var i = 0; i < lazyElements.length; i++) {
-            observer.observe(lazyElements[i]);
-        }
-    } else {
-        for (var i = 0; i < lazyElements.length; i++) {
-            var element = lazyElements[i];
-            var original = element.getAttribute('data-original');
-            element.setAttribute('src', original);
-        }
-    }
-}
-
-document.addEventListener('DOMContentLoaded', lazyLoadMain);
diff --git a/src/static/misc/icons.svg b/src/static/misc/icons.svg
new file mode 100644
index 00000000..87cb0169
--- /dev/null
+++ b/src/static/misc/icons.svg
@@ -0,0 +1,45 @@
+<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 40 40" display="none" width="0" height="0">
+  <symbol id="icon-globe" viewBox="0 0 40 40"><path d="M20,3C10.6,3,3,10.6,3,20s7.6,17,17,17s17-7.6,17-17S29.4,3,20,3z M34.8,18.9h-6.2c-0.1-2.2-0.3-4.2-0.8-6.1 c1.5-0.4,3-0.9,4.2-1.5c0.6,0.9,1.2,1.8,1.6,2.9C34.3,15.7,34.7,17.3,34.8,18.9z M25.7,26.7c-1.5-0.3-3.1-0.4-4.6-0.5v-5.1h5.4 c-0.1,1.8-0.3,3.5-0.6,5.1C25.8,26.3,25.8,26.5,25.7,26.7z M14.2,26.2c-0.3-1.6-0.6-3.3-0.6-5.1h5.4v5.1c-1.6,0-3.2,0.2-4.6,0.5 C14.2,26.5,14.2,26.3,14.2,26.2z M14.3,13.3c1.5,0.3,3.1,0.4,4.6,0.5v5.1h-5.4c0.1-1.8,0.3-3.5,0.6-5.1 C14.2,13.7,14.2,13.5,14.3,13.3z M21.1,5.4C21.4,5.6,21.7,5.7,22,6c0.8,0.7,1.6,1.7,2.2,3c0.4,0.7,0.7,1.5,0.9,2.3 c-1.3,0.2-2.7,0.4-4,0.4V5.4z M18,6c0.3-0.3,0.6-0.4,0.9-0.6v6.2c-1.4,0-2.8-0.2-4-0.4c0.3-0.8,0.6-1.6,0.9-2.3 C16.5,7.7,17.2,6.7,18,6z M18.9,28.4v6.2c-0.3-0.1-0.6-0.3-0.9-0.6c-0.8-0.7-1.6-1.7-2.2-3c-0.4-0.7-0.7-1.5-0.9-2.3 C16.2,28.6,17.5,28.4,18.9,28.4z M22,34c-0.3,0.3-0.6,0.4-0.9,0.6v-6.2c1.4,0,2.8,0.2,4,0.4c-0.3,0.8-0.6,1.6-0.9,2.3 C23.5,32.3,22.8,33.3,22,34z M21.1,18.9v-5.1c1.6,0,3.2-0.2,4.6-0.5c0,0.2,0.1,0.4,0.1,0.5c0.3,1.6,0.6,3.3,0.6,5.1H21.1z M30.5,9.5 c0,0,0.1,0.1,0.1,0.1c-1,0.4-2.2,0.8-3.4,1.1c-0.6-1.9-1.4-3.5-2.4-4.8c0.3,0.1,0.6,0.2,0.9,0.3C27.5,7.1,29.1,8.1,30.5,9.5z M14.2,6.3c0.3-0.1,0.6-0.2,0.9-0.3c-0.9,1.3-1.7,2.9-2.4,4.8c-1.2-0.3-2.3-0.7-3.4-1.1c0,0,0.1-0.1,0.1-0.1 C10.9,8.1,12.5,7.1,14.2,6.3z M7.9,11.4c1.3,0.6,2.7,1.1,4.2,1.5c-0.4,1.9-0.7,3.9-0.8,6.1H5.2c0.1-1.6,0.5-3.2,1.1-4.7 C6.8,13.2,7.3,12.3,7.9,11.4z M5.2,21.1h6.2c0.1,2.2,0.3,4.2,0.8,6.1c-1.5,0.4-3,0.9-4.2,1.5c-0.6-0.9-1.2-1.8-1.6-2.9 C5.7,24.3,5.3,22.7,5.2,21.1z M9.5,30.5c0,0-0.1-0.1-0.1-0.1c1-0.4,2.2-0.8,3.4-1.1c0.6,1.9,1.4,3.5,2.4,4.8 c-0.3-0.1-0.6-0.2-0.9-0.3C12.5,32.9,10.9,31.9,9.5,30.5z M25.8,33.7c-0.3,0.1-0.6,0.2-0.9,0.3c0.9-1.3,1.7-2.9,2.4-4.8 c1.2,0.3,2.3,0.7,3.4,1.1c0,0-0.1,0.1-0.1,0.1C29.1,31.9,27.5,32.9,25.8,33.7z M32.1,28.6c-1.3-0.6-2.7-1.1-4.2-1.5 c0.4-1.9,0.7-3.9,0.8-6.1h6.2c-0.1,1.6-0.5,3.2-1.1,4.7C33.2,26.8,32.7,27.7,32.1,28.6z"/></symbol>
+
+  <symbol id="icon-appleMusic" viewBox="-20 -20 401 401"><path d="M 112.60938 0 C 108.30938 0 104.01093 -0.00046873 99.710938 0.01953125 C 96.090941 0.03953123 92.469606 0.0796876 88.849609 0.1796875 C 80.959617 0.39968728 72.999211 0.85953266 65.199219 2.2695312 C 57.279227 3.6895298 49.920462 6.0196912 42.730469 9.6796875 C 35.660476 13.279684 29.190073 17.979849 23.580078 23.589844 C 17.970084 29.199838 13.269918 35.660476 9.6699219 42.730469 C 6.0099255 49.930462 3.6797642 57.300711 2.2597656 65.220703 C 0.85976703 73.020695 0.38968729 80.979383 0.1796875 88.859375 C 0.0796876 92.479371 0.03953123 96.100707 0.01953125 99.720703 C -0.00046873 104.0107 0 108.30938 0 112.60938 L 0 247.38086 C 0 251.68086 -0.00046873 255.9793 0.01953125 260.2793 C 0.03953123 263.89929 0.0796876 267.52063 0.1796875 271.14062 C 0.38968729 279.03062 0.85976702 286.9793 2.2597656 294.7793 C 3.6797642 302.69929 6.0099255 310.06954 9.6699219 317.26953 C 13.269918 324.33952 17.970084 330.80016 23.580078 336.41016 C 29.190073 342.02015 35.660476 346.72032 42.730469 350.32031 C 49.920462 353.98031 57.289227 356.30047 65.199219 357.73047 C 72.999211 359.13047 80.959617 359.60055 88.849609 359.81055 C 92.469606 359.91055 96.090941 359.9507 99.710938 359.9707 C 104.01093 360.0007 108.30938 359.99023 112.60938 359.99023 L 247.38086 359.99023 C 251.68086 359.99023 255.9793 359.9907 260.2793 359.9707 C 263.89929 359.9507 267.52063 359.91055 271.14062 359.81055 C 279.03062 359.60055 286.98907 359.13047 294.78906 357.73047 C 302.70905 356.31047 310.06977 353.98031 317.25977 350.32031 C 324.32976 346.72032 330.80016 342.02015 336.41016 336.41016 C 342.02015 330.80016 346.72032 324.33952 350.32031 317.26953 C 353.98031 310.06954 356.31047 302.69929 357.73047 294.7793 C 359.13047 286.9793 359.60055 279.02062 359.81055 271.14062 C 359.91055 267.52063 359.9507 263.89929 359.9707 260.2793 C 360.0007 255.9793 359.99023 251.68086 359.99023 247.38086 L 359.99023 112.60938 L 360 112.60938 C 360 108.30938 360.00047 104.01093 359.98047 99.710938 C 359.96047 96.090941 359.92031 92.469606 359.82031 88.849609 C 359.61031 80.959617 359.14023 73.01093 357.74023 65.210938 C 356.32024 57.290945 353.99007 49.920696 350.33008 42.720703 C 346.73008 35.65071 342.02992 29.190073 336.41992 23.580078 C 330.80993 17.970084 324.33952 13.269918 317.26953 9.6699219 C 310.07954 6.0099255 302.71077 3.6897642 294.80078 2.2597656 C 287.00079 0.85976703 279.04038 0.38968729 271.15039 0.1796875 C 267.53039 0.0796876 263.90906 0.03953123 260.28906 0.01953125 C 255.98907 -0.00046873 251.69062 0 247.39062 0 L 112.60938 0 z M 254.5 55 C 260.28999 54.500001 263.53953 58.300944 263.51953 64.460938 L 263.51953 234.26953 C 263.51953 238.82953 263.47953 242.9593 262.51953 247.5293 C 261.58953 251.95929 259.89929 256.13086 257.2793 259.88086 C 254.6693 263.62086 251.32968 266.69024 247.42969 268.99023 C 243.48969 271.32023 239.34968 272.64906 234.92969 273.53906 C 226.6297 275.20906 220.94914 275.58953 215.61914 274.51953 C 210.47915 273.47953 206.12086 271.11992 202.63086 267.91992 C 197.46086 263.18993 194.23906 256.7896 193.53906 250.09961 C 192.71906 242.25962 195.32094 233.8907 201.21094 227.7207 C 204.18093 224.60071 207.91063 222.14094 212.89062 220.21094 C 218.10062 218.19094 223.84946 216.97922 232.68945 215.19922 L 239.67969 213.78906 C 242.74968 213.16906 245.37024 212.39078 247.49023 209.80078 C 249.63023 207.20078 249.66016 204.02086 249.66016 200.88086 L 249.66016 121.58984 C 249.66016 115.51985 246.93062 113.87047 241.14062 114.98047 C 236.99063 115.79047 148.05078 133.73047 148.05078 133.73047 C 143.03079 134.95047 141.26953 136.59055 141.26953 142.81055 L 141.26953 258.96094 C 141.26953 263.52093 141.04008 267.65071 140.08008 272.2207 C 139.15008 276.6507 137.45984 280.82032 134.83984 284.57031 C 132.22985 288.31031 128.89023 291.37969 124.99023 293.67969 C 121.05024 296.00969 116.91023 297.39906 112.49023 298.28906 C 104.19024 299.96906 98.509682 300.33953 93.179688 299.26953 C 88.039693 298.23953 83.67945 295.80937 80.189453 292.60938 C 75.019458 287.87938 72.010546 281.47906 71.310547 274.78906 C 70.490548 266.94907 72.879537 258.58015 78.769531 252.41016 C 81.739528 249.29016 85.469224 246.83039 90.449219 244.90039 C 95.659214 242.88039 101.41001 241.67062 110.25 239.89062 L 117.24023 238.48047 C 120.31023 237.86047 122.93078 237.08023 125.05078 234.49023 C 127.17078 231.90024 127.41992 228.86047 127.41992 225.73047 L 127.41992 91.810547 C 127.41992 90.010549 127.57016 88.789453 127.66016 88.189453 C 128.09016 85.369456 129.21977 82.950233 131.25977 81.240234 C 132.94976 79.820236 135.13969 78.830234 137.92969 78.240234 L 137.9707 78.230469 L 244.9707 56.640625 C 245.9007 56.450625 253.63 55.08 254.5 55 z " /></symbol>
+  <symbol id="icon-artstation" viewBox="45 35 118.8 118.8"><path d="M 92.900391 51.5 L 146.59961 144.5 L 155.09961 129.80078 C 156.69961 127.00078 157.19922 125.80039 157.19922 123.40039 C 157.19922 121.30039 156.6 119.29961 155.5 117.59961 L 120.69922 57.199219 C 118.89922 53.799222 115.40078 51.5 111.30078 51.5 L 92.900391 51.5 z M 84.199219 66.599609 L 60.199219 108.09961 L 108.09961 108.09961 L 84.199219 66.599609 z M 51.400391 123.30078 L 60.300781 138.69922 C 62.100779 142.19922 65.700785 144.59961 69.800781 144.59961 L 129.09961 144.59961 L 116.80078 123.30078 L 51.400391 123.30078 z " /></symbol>
+  <symbol id="icon-bandcamp" viewBox="-55 -145 440 440"><path d="M 87.222998,512.00002 H 274.006 l -87.225,-161.01 H 0 Z" transform="matrix(1.3333333,0,0,-1.3333333,0,682.66667)" /></symbol>
+
+  <symbol id="icon-carrd" viewBox="20 20 600 600">
+    <!-- Carrd Brand Asset: Symbol (Dark) | (c) Carrd Inc. | "Carrd" is a registered trademark of Carrd Inc. -->
+    <path d="M529.2,465.1L269.1,590c-1.6,0.8-3.4,1.2-5.2,1.2c-2.2,0-4.4-0.6-6.4-1.8c-3.5-2.2-5.6-6-5.6-10.2V455.5 l-140.5-58.8c-4.5-1.9-7.4-6.2-7.4-11.1V60.8c0-4.1,2.1-8,5.6-10.2c3.5-2.2,7.9-2.4,11.6-0.7l270.4,129.8l127.3-61.1 c3.7-1.8,8.1-1.5,11.6,0.7c3.5,2.2,5.6,6,5.6,10.2v324.8C536,458.9,533.4,463.1,529.2,465.1z M128,79.9v297.7l123.9,51.9v-59.8 l-77.9-31.4c-6.1-2.5-9.1-9.5-6.7-15.6c2.5-6.2,9.5-9.1,15.6-6.7l69,27.8v-49.1l-77.9-31.4c-6.1-2.5-9.1-9.5-6.7-15.6 c2.5-6.2,9.5-9.1,15.6-6.7l69,27.8v-14.3c0-4.6,2.6-8.8,6.8-10.8l17.8-8.6l-103.1-46.9c-6-2.7-8.7-9.9-6-15.9c2.7-6,9.9-8.7,15.9-6 l121.3,55.3l59.1-28.4L128,79.9z M512,148.6L275.9,262v24.3c0,0,0,0,0,0.1v74.9c0,0,0,0,0,0.1v198.8L512,446.7V148.6z M321,303.8 l135.3-65.4c6-2.9,13.1-0.4,16,5.6c2.9,6,0.4,13.2-5.6,16l-135.3,65.4c-1.7,0.8-3.5,1.2-5.2,1.2c-4.5,0-8.7-2.5-10.8-6.8 C312.6,313.9,315.1,306.7,321,303.8z M321,378.8l135.3-65.4c6-2.9,13.1-0.4,16,5.6c2.9,6,0.4,13.2-5.6,16l-135.3,65.4 c-1.7,0.8-3.5,1.2-5.2,1.2c-4.5,0-8.7-2.5-10.8-6.8C312.6,388.8,315.1,381.7,321,378.8z M321,453.7l135.3-65.4 c6-2.9,13.1-0.4,16,5.6c2.9,6,0.4,13.2-5.6,16l-135.3,65.4c-1.7,0.8-3.5,1.2-5.2,1.2c-4.5,0-8.7-2.5-10.8-6.8 C312.6,463.8,315.1,456.6,321,453.7z"/>
+  </symbol>
+
+  <symbol id="icon-bluesky" viewBox="0 0 600 530"><path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/></symbol>
+  <symbol id="icon-cohost" viewBox="0 -40 180 180"><path fill-rule="evenodd" clip-rule="evenodd" d="M142.814 106.403C131.705 113.206 118.897 118.552 104.39 122.439C88.2779 126.756 73.0919 128.487 58.8317 127.631C44.5716 126.775 32.4222 123.055 22.3834 116.471C12.3446 109.887 5.57634 100.068 2.07868 87.0142C-1.43905 73.886 -0.492012 61.9799 4.9198 51.2958C10.3316 40.6118 19.0083 31.3714 30.95 23.5747C42.8917 15.7779 56.9185 9.72092 73.0304 5.40379C89.0677 1.1066 104.193 -0.627685 118.406 0.200922C127.955 0.757684 136.568 2.6028 144.246 5.73626C147.995 7.26657 151.521 9.10414 154.824 11.249C164.89 17.7858 171.672 27.581 175.17 40.6346C178.667 53.6882 177.697 65.5807 172.258 76.312C171.498 77.8112 170.675 79.2823 169.789 80.7261C169.163 77.9074 167.906 75.4497 166.018 73.353C165.091 72.3236 164.061 71.3784 162.926 70.5172C160.603 68.7538 157.845 67.3429 154.652 66.2845C149.898 64.7092 144.602 63.9216 138.763 63.9216C132.896 63.9216 127.58 64.7024 122.813 66.2641C118.046 67.8259 114.257 70.1752 111.446 73.3122C108.635 76.4492 107.23 80.4078 107.23 85.188C107.23 89.9411 108.635 93.8928 111.446 97.0434C114.257 100.194 118.046 102.564 122.813 104.153C127.58 105.741 132.896 106.536 138.763 106.536C140.143 106.536 141.493 106.492 142.814 106.403ZM91.9944 97.9397C90.8808 99.1348 88.9185 100.404 86.1074 101.749C83.2963 103.093 79.9081 104.227 75.9427 105.151C71.9773 106.074 67.7132 106.536 63.1502 106.536C59.1577 106.536 55.2466 106.149 51.417 105.375C47.5875 104.601 44.1245 103.372 41.0283 101.688C37.932 100.004 35.4672 97.8039 33.6339 95.0879C31.8006 92.3719 30.8839 89.0719 30.8839 85.188C30.8839 81.2498 31.8006 77.9227 33.6339 75.2066C35.4672 72.4906 37.932 70.3042 41.0283 68.6475C44.1245 66.9907 47.5875 65.7888 51.417 65.0419C55.2466 64.295 59.1577 63.9216 63.1502 63.9216C67.7403 63.9216 71.9773 64.329 75.8612 65.1438C79.7451 65.9586 83.079 67.0246 85.8629 68.3419C88.6469 69.6592 90.6635 71.0647 91.9129 72.5585L81.4834 79.4028C79.9624 77.7461 77.6538 76.4221 74.5575 75.4307C71.4613 74.4394 67.6996 73.9437 63.2725 73.9437C61.0997 73.9437 58.9065 74.1135 56.6929 74.4529C54.4793 74.7925 52.4491 75.3696 50.6022 76.1844C48.7554 76.9992 47.2683 78.1399 46.1412 79.6066C45.014 81.0732 44.4504 82.9337 44.4504 85.188C44.4504 87.4151 45.014 89.2553 46.1412 90.7083C47.2683 92.1614 48.7554 93.3157 50.6022 94.1712C52.4491 95.0268 54.4793 95.6311 56.6929 95.9842C58.9065 96.3373 61.0997 96.5138 63.2725 96.5138C67.6181 96.5138 71.3866 95.9706 74.5779 94.8842C77.7692 93.7978 80.0167 92.5484 81.3204 91.1361L91.9944 97.9397ZM138.763 96.3508C144.439 96.3508 148.839 95.3323 151.963 93.2953C155.086 91.2583 156.648 88.5559 156.648 85.188C156.648 81.7386 155.079 79.0294 151.942 77.0603C148.805 75.0912 144.412 74.1066 138.763 74.1066C133.086 74.1066 128.666 75.0912 125.502 77.0603C122.338 79.0294 120.756 81.7386 120.756 85.188C120.756 88.6102 122.338 91.3262 125.502 93.3361C128.666 95.3459 133.086 96.3508 138.763 96.3508Z"/></symbol>
+  <symbol id="icon-deviantart" viewBox="0 0 40 40"><path d="M30,9.2L24,20.9l0.5,0.6H30v8.3H19.9L19,30.5l-2.8,5.5l-0.6,0.6h-6v-6.1l6.1-11.7l-0.5-0.6H9.5V9.8h10.2l0.9-0.6l2.8-5.5 L24,3.2h6C30,3.2,30,9.2,30,9.2z"/></symbol>
+  <symbol id="icon-facebook" viewBox="100 100 733 733"><path d="m 0,0 c 0,138.071 -111.929,250 -250,250 -138.071,0 -250,-111.929 -250,-250 0,-117.245 80.715,-215.622 189.606,-242.638 v 166.242 h -51.552 V 0 h 51.552 v 32.919 c 0,85.092 38.508,124.532 122.048,124.532 15.838,0 43.167,-3.105 54.347,-6.211 V 81.986 c -5.901,0.621 -16.149,0.932 -28.882,0.932 -40.993,0 -56.832,-15.528 -56.832,-55.9 V 0 h 81.659 l -14.028,-76.396 h -67.631 V -248.169 C -95.927,-233.218 0,-127.818 0,0" transform="matrix(1.3333333,0,0,-1.3333333,799.99998,466.66668)"/></symbol>
+  <symbol id="icon-kofi" viewBox="100 60 700 760"><path d="M 207.0293 281.82031 C 181.39936 281.82031 167.74023 305.44029 167.74023 322.49023 L 167.74023 327.68945 C 167.73023 329.70945 166.47977 530.46002 169.00977 638.17969 C 169.00977 638.38969 169.01906 638.61031 169.03906 638.82031 C 171.56906 679.76019 194.721 699.32909 213.71094 708.53906 C 233.32088 718.04903 252.52008 718.16969 253.33008 718.17969 L 253.41016 718.17969 C 261.93013 718.17969 463.3196 718.16992 562.0293 716.91992 C 567.75927 716.91992 573.18042 716.91969 580.40039 715.17969 L 580.65039 715.11914 C 610.6803 707.43917 632.08003 689.87056 644.25 662.89062 C 649.99997 650.14068 653.47987 635.77072 654.83984 619.05078 C 682.38978 618.35078 708.30022 612.90958 731.91016 602.84961 C 756.78007 592.24964 777.84029 577.07064 794.49023 557.7207 C 827.19014 519.72082 839.42919 469.40891 828.94922 416.03906 L 828.93945 416.03906 L 828.93945 416.01953 L 828.91992 415.91992 L 828.90039 415.80078 C 817.58042 359.06096 786.439 328.33075 762.28906 312.55078 C 733.99915 292.75084 698.67996 281.83984 662.83008 281.83984 L 207.0293 281.82031 z M 656.9707 392.17969 L 670.67969 392.17969 C 686.12963 392.17969 699.87089 398.51003 709.38086 410 L 709.64062 410.31055 C 717.58062 419.44052 721.43945 431.00925 721.43945 445.69922 C 721.43945 479.18913 709.16007 497.62956 680.41016 507.26953 C 672.92016 508.04953 665.0507 508.48984 656.9707 508.58984 L 656.9707 392.17969 z M 476.60742 393.72461 C 485.18645 393.7029 494.3015 395.30658 503.74023 399.28906 C 552.80015 420.42902 551.42025 477.44944 522.32031 511.85938 C 522.31746 511.86266 522.3134 511.86584 522.31055 511.86914 C 520.24235 514.26239 518.02021 516.7657 515.66992 519.35352 C 514.72786 520.39083 513.68242 521.49514 512.70117 522.55859 C 511.23104 524.15182 509.80205 525.71777 508.25391 527.36133 C 508.2503 527.36515 508.24774 527.36922 508.24414 527.37305 C 506.12619 529.6214 503.7259 532.03298 501.48828 534.35352 C 498.27088 537.69012 495.15981 540.96685 491.77148 544.38867 C 486.2458 549.96915 480.54963 555.60713 474.88477 561.15039 C 474.66348 561.36692 474.44579 561.58848 474.22461 561.80469 C 474.22132 561.8079 474.21813 561.81124 474.21484 561.81445 C 468.33146 567.56551 462.48624 573.2008 456.90039 578.53711 C 456.89719 578.54017 456.89383 578.54381 456.89062 578.54688 C 445.71718 589.22101 435.57811 598.69928 428.23242 605.50781 C 428.22984 605.5102 428.22523 605.51519 428.22266 605.51758 C 420.88542 612.31814 416.33008 616.46094 416.33008 616.46094 C 416.33008 616.46094 416.31111 616.476 416.31055 616.47656 C 416.25736 616.52936 413.7392 619.01347 410.82031 618.42969 L 410.81055 618.42969 C 409.53055 618.22969 408.34914 617.54016 407.36914 616.66016 C 407.14434 616.45713 405.99082 615.40947 405.69727 615.14453 C 405.69644 615.16685 405.68945 615.17525 405.68945 615.19922 C 393.78949 604.57924 316.9109 535.74932 299.71094 511.85938 C 281.70924 486.23263 272.47566 443.08094 294.81055 416.36328 C 295.52774 415.5049 296.27745 414.6632 297.06055 413.83984 C 297.06308 413.83718 297.06583 413.83469 297.06836 413.83203 L 297.07031 413.83008 C 297.85371 413.00684 298.66556 412.20625 299.50391 411.42969 C 299.50727 411.42658 299.51031 411.42303 299.51367 411.41992 C 300.35226 410.64352 301.21872 409.89266 302.10938 409.16406 C 328.93957 387.21554 378.74326 387.20684 412.30078 424.44922 C 412.30862 424.44038 412.94176 423.72533 414.1582 422.50391 C 415.37822 421.27829 417.17743 419.55137 419.49805 417.54102 C 419.50123 417.53826 419.50463 417.53401 419.50781 417.53125 C 420.66455 416.52948 421.9529 415.45886 423.35938 414.3457 C 434.64596 405.41287 453.72216 393.78252 476.60742 393.72461 z " /></symbol>
+  <symbol id="icon-instagram" viewBox="-50 -50 1100 1100"><path class="cls-1" d="M295.42,6c-53.2,2.51-89.53,11-121.29,23.48-32.87,12.81-60.73,30-88.45,57.82S40.89,143,28.17,175.92c-12.31,31.83-20.65,68.19-23,121.42S2.3,367.68,2.56,503.46,3.42,656.26,6,709.6c2.54,53.19,11,89.51,23.48,121.28,12.83,32.87,30,60.72,57.83,88.45S143,964.09,176,976.83c31.8,12.29,68.17,20.67,121.39,23s70.35,2.87,206.09,2.61,152.83-.86,206.16-3.39S799.1,988,830.88,975.58c32.87-12.86,60.74-30,88.45-57.84S964.1,862,976.81,829.06c12.32-31.8,20.69-68.17,23-121.35,2.33-53.37,2.88-70.41,2.62-206.17s-.87-152.78-3.4-206.1-11-89.53-23.47-121.32c-12.85-32.87-30-60.7-57.82-88.45S862,40.87,829.07,28.19c-31.82-12.31-68.17-20.7-121.39-23S637.33,2.3,501.54,2.56,348.75,3.4,295.42,6m5.84,903.88c-48.75-2.12-75.22-10.22-92.86-17-23.36-9-40-19.88-57.58-37.29s-28.38-34.11-37.5-57.42c-6.85-17.64-15.1-44.08-17.38-92.83-2.48-52.69-3-68.51-3.29-202s.22-149.29,2.53-202c2.08-48.71,10.23-75.21,17-92.84,9-23.39,19.84-40,37.29-57.57s34.1-28.39,57.43-37.51c17.62-6.88,44.06-15.06,92.79-17.38,52.73-2.5,68.53-3,202-3.29s149.31.21,202.06,2.53c48.71,2.12,75.22,10.19,92.83,17,23.37,9,40,19.81,57.57,37.29s28.4,34.07,37.52,57.45c6.89,17.57,15.07,44,17.37,92.76,2.51,52.73,3.08,68.54,3.32,202s-.23,149.31-2.54,202c-2.13,48.75-10.21,75.23-17,92.89-9,23.35-19.85,40-37.31,57.56s-34.09,28.38-57.43,37.5c-17.6,6.87-44.07,15.07-92.76,17.39-52.73,2.48-68.53,3-202.05,3.29s-149.27-.25-202-2.53m407.6-674.61a60,60,0,1,0,59.88-60.1,60,60,0,0,0-59.88,60.1M245.77,503c.28,141.8,115.44,256.49,257.21,256.22S759.52,643.8,759.25,502,643.79,245.48,502,245.76,245.5,361.22,245.77,503m90.06-.18a166.67,166.67,0,1,1,167,166.34,166.65,166.65,0,0,1-167-166.34" transform="translate(-2.5 -2.5)"/></symbol>
+  <symbol id="icon-itch" viewBox="-20 -20 265 241"><path d="M31.99 1.365C21.287 7.72.2 31.945 0 38.298v10.516C0 62.144 12.46 73.86 23.773 73.86c13.584 0 24.902-11.258 24.903-24.62 0 13.362 10.93 24.62 24.515 24.62 13.586 0 24.165-11.258 24.165-24.62 0 13.362 11.622 24.62 25.207 24.62h.246c13.586 0 25.208-11.258 25.208-24.62 0 13.362 10.58 24.62 24.164 24.62 13.585 0 24.515-11.258 24.515-24.62 0 13.362 11.32 24.62 24.903 24.62 11.313 0 23.773-11.714 23.773-25.046V38.298c-.2-6.354-21.287-30.58-31.988-36.933C180.118.197 157.056-.005 122.685 0c-34.37.003-81.228.54-90.697 1.365zm65.194 66.217a28.025 28.025 0 0 1-4.78 6.155c-5.128 5.014-12.157 8.122-19.906 8.122a28.482 28.482 0 0 1-19.948-8.126c-1.858-1.82-3.27-3.766-4.563-6.032l-.006.004c-1.292 2.27-3.092 4.215-4.954 6.037a28.5 28.5 0 0 1-19.948 8.12c-.934 0-1.906-.258-2.692-.528-1.092 11.372-1.553 22.24-1.716 30.164l-.002.045c-.02 4.024-.04 7.333-.06 11.93.21 23.86-2.363 77.334 10.52 90.473 19.964 4.655 56.7 6.775 93.555 6.788h.006c36.854-.013 73.59-2.133 93.554-6.788 12.883-13.14 10.31-66.614 10.52-90.474-.022-4.596-.04-7.905-.06-11.93l-.003-.045c-.162-7.926-.623-18.793-1.715-30.165-.786.27-1.757.528-2.692.528a28.5 28.5 0 0 1-19.948-8.12c-1.862-1.822-3.662-3.766-4.955-6.037l-.006-.004c-1.294 2.266-2.705 4.213-4.563 6.032a28.48 28.48 0 0 1-19.947 8.125c-7.748 0-14.778-3.11-19.906-8.123a28.025 28.025 0 0 1-4.78-6.155 27.99 27.99 0 0 1-4.736 6.155 28.49 28.49 0 0 1-19.95 8.124c-.27 0-.54-.012-.81-.02h-.007c-.27.008-.54.02-.813.02a28.49 28.49 0 0 1-19.95-8.123 27.992 27.992 0 0 1-4.736-6.155zm-20.486 26.49l-.002.01h.015c8.113.017 15.32 0 24.25 9.746 7.028-.737 14.372-1.105 21.722-1.094h.006c7.35-.01 14.694.357 21.723 1.094 8.93-9.747 16.137-9.73 24.25-9.746h.014l-.002-.01c3.833 0 19.166 0 29.85 30.007L210 165.244c8.504 30.624-2.723 31.373-16.727 31.4-20.768-.773-32.267-15.855-32.267-30.935-11.496 1.884-24.907 2.826-38.318 2.827h-.006c-13.412 0-26.823-.943-38.318-2.827 0 15.08-11.5 30.162-32.267 30.935-14.004-.027-25.23-.775-16.726-31.4L46.85 124.08C57.534 94.073 72.867 94.073 76.7 94.073zm45.985 23.582v.006c-.02.02-21.863 20.08-25.79 27.215l14.304-.573v12.474c0 .584 5.74.346 11.486.08h.006c5.744.266 11.485.504 11.485-.08v-12.474l14.304.573c-3.928-7.135-25.79-27.215-25.79-27.215v-.006l-.003.002z" color="#000"/></symbol>
+  <symbol id="icon-linktree" viewBox="0 0 24 24"><path d="m13.511 5.853 4.005-4.117 2.325 2.381-4.201 4.005h5.909v3.305h-5.937l4.229 4.108-2.325 2.334-5.741-5.769-5.741 5.769-2.325-2.325 4.229-4.108H2V8.122h5.909L3.708 4.117l2.325-2.381 4.005 4.117V0h3.473v5.853zM10.038 16.16h3.473v7.842h-3.473V16.16z"/></symbol>
+  <symbol id="icon-mastodon" viewBox="-20 -20 237 255"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z"/></symbol>
+  <symbol id="icon-patreon" viewBox="-80 -80 1160 1160"><path d="M1033.05,324.45c-0.19-137.9-107.59-250.92-233.6-291.7c-156.48-50.64-362.86-43.3-512.28,27.2 C106.07,145.41,49.18,332.61,47.06,519.31c-1.74,153.5,13.58,557.79,241.62,560.67c169.44,2.15,194.67-216.18,273.07-321.33 c55.78-74.81,127.6-95.94,216.01-117.82C929.71,603.22,1033.27,483.3,1033.05,324.45z"/></symbol>
+  <symbol id="icon-spotify" viewBox="0 0 41 40"><path d="m 40.738,21.322 c -0.015,0.221 -0.029,0.441 -0.051,0.659 -0.013,0.13 -0.032,0.258 -0.047,0.386 -0.024,0.197 -0.047,0.393 -0.076,0.587 -0.021,0.136 -0.046,0.271 -0.07,0.406 -0.032,0.185 -0.063,0.37 -0.1,0.553 -0.028,0.138 -0.06,0.274 -0.091,0.411 -0.04,0.178 -0.08,0.355 -0.125,0.531 -0.035,0.138 -0.074,0.274 -0.112,0.411 -0.048,0.172 -0.096,0.344 -0.149,0.515 a 19.186,19.186 0 0 1 -0.304,0.907 20.859,20.859 0 0 1 -0.515,1.278 26.182,26.182 0 0 1 -0.404,0.857 c -0.08,0.159 -0.163,0.315 -0.246,0.471 -0.066,0.122 -0.131,0.243 -0.199,0.364 a 21.5,21.5 0 0 1 -0.271,0.463 c -0.07,0.116 -0.139,0.232 -0.211,0.347 -0.097,0.155 -0.198,0.307 -0.3,0.459 -0.073,0.109 -0.144,0.219 -0.219,0.326 -0.108,0.156 -0.221,0.308 -0.334,0.461 -0.074,0.1 -0.145,0.2 -0.221,0.299 -0.124,0.162 -0.253,0.319 -0.383,0.477 -0.069,0.085 -0.136,0.171 -0.206,0.255 -0.159,0.188 -0.324,0.371 -0.489,0.554 -0.046,0.049 -0.088,0.101 -0.134,0.15 -0.215,0.231 -0.434,0.457 -0.66,0.678 -0.04,0.04 -0.084,0.078 -0.125,0.118 a 19.93,19.93 0 0 1 -0.562,0.525 c -0.081,0.073 -0.165,0.141 -0.246,0.212 -0.156,0.135 -0.311,0.27 -0.471,0.4 -0.097,0.079 -0.196,0.154 -0.294,0.231 -0.15,0.117 -0.299,0.234 -0.452,0.348 -0.107,0.079 -0.216,0.154 -0.324,0.231 l -0.339,0.237 c 0.032,-0.023 0.067,-0.042 0.1,-0.064 -0.038,0.026 -0.077,0.048 -0.115,0.074 -0.032,0.022 -0.063,0.045 -0.095,0.066 -0.115,0.077 -0.231,0.151 -0.347,0.225 a 19.368,19.368 0 0 1 -0.599,0.371 l -0.07,0.04 c 0.015,-0.008 0.031,-0.016 0.045,-0.025 l -0.209,0.121 c -0.148,0.086 -0.296,0.17 -0.446,0.252 l -0.197,0.104 c -0.115,0.061 -0.232,0.121 -0.349,0.18 l -0.061,0.03 c -0.09,0.045 -0.178,0.091 -0.269,0.135 -0.122,0.058 -0.244,0.116 -0.368,0.173 l -0.1,0.044 c -0.11,0.05 -0.22,0.099 -0.331,0.147 l -0.048,0.021 c -0.097,0.042 -0.196,0.082 -0.294,0.123 a 15.8,15.8 0 0 1 -0.516,0.203 c -0.117,0.044 -0.233,0.087 -0.35,0.129 l -0.151,0.055 c -0.129,0.045 -0.26,0.088 -0.391,0.13 -0.107,0.035 -0.214,0.07 -0.322,0.103 l -0.043,0.013 c -0.116,0.036 -0.232,0.07 -0.349,0.103 l -0.334,0.094 c -0.143,0.038 -0.285,0.077 -0.429,0.113 l -0.073,0.016 -0.18,0.044 c 0.032,-0.007 0.062,-0.017 0.094,-0.024 l -0.208,0.047 c 0.037,-0.009 0.076,-0.014 0.114,-0.023 -0.088,0.021 -0.177,0.037 -0.265,0.057 l -0.013,0.003 c -0.143,0.031 -0.285,0.064 -0.429,0.092 -0.149,0.029 -0.299,0.054 -0.449,0.079 -0.088,0.015 -0.175,0.034 -0.263,0.048 0.041,-0.007 0.081,-0.017 0.122,-0.024 l -0.217,0.036 c 0.032,-0.005 0.064,-0.007 0.095,-0.012 l -0.147,0.021 -0.118,0.02 c -0.199,0.029 -0.4,0.051 -0.601,0.075 -0.13,0.015 -0.26,0.034 -0.392,0.046 l -0.03,0.004 h -0.019 c -0.054,0.005 -0.11,0.007 -0.164,0.012 -0.124,0.011 -0.248,0.018 -0.372,0.027 -0.421,0.03 -0.845,0.049 -1.273,0.053 -0.038,0 -0.074,0.005 -0.111,0.005 h -0.153 c -0.036,0 -0.07,-0.004 -0.106,-0.005 A 21.083,21.083 0 0 1 18.9,39.931 C 18.781,39.922 18.661,39.915 18.542,39.905 18.494,39.9 18.445,39.899 18.396,39.894 h -0.018 a 19.603,19.603 0 0 1 -1.065,-0.138 c 0.051,0.008 0.103,0.012 0.154,0.019 -0.127,-0.019 -0.257,-0.03 -0.383,-0.05 l -0.18,-0.032 c 0.048,0.008 0.095,0.021 0.143,0.029 -0.086,-0.014 -0.17,-0.034 -0.256,-0.049 l -0.03,-0.005 c -0.053,-0.009 -0.104,-0.022 -0.156,-0.031 a 21.03,21.03 0 0 1 -1.011,-0.206 l 0.096,0.019 -0.158,-0.036 0.062,0.017 c -0.145,-0.034 -0.286,-0.077 -0.43,-0.115 l 0.023,0.006 C 15.172,39.319 15.158,39.314 15.143,39.31 l 0.021,0.006 c -0.291,-0.075 -0.582,-0.15 -0.868,-0.237 L 14.26,39.069 C 14.221,39.057 14.183,39.043 14.144,39.03 l -0.018,-0.006 c -0.161,-0.051 -0.319,-0.11 -0.479,-0.165 l 0.208,0.071 c -0.11,-0.037 -0.219,-0.076 -0.328,-0.115 l 0.12,0.044 c -0.245,-0.084 -0.49,-0.168 -0.73,-0.261 l -0.121,-0.047 c -0.017,-0.007 -0.033,-0.015 -0.05,-0.021 a 21.151,21.151 0 0 1 -0.491,-0.205 c 0.107,0.046 0.215,0.089 0.322,0.133 -0.159,-0.065 -0.317,-0.134 -0.474,-0.202 0.051,0.022 0.101,0.047 0.152,0.069 -0.168,-0.072 -0.338,-0.142 -0.504,-0.219 l -0.036,-0.015 c -0.022,-0.01 -0.045,-0.019 -0.067,-0.03 -0.079,-0.036 -0.154,-0.08 -0.232,-0.117 -0.16,-0.078 -0.32,-0.154 -0.478,-0.235 0.135,0.069 0.273,0.133 0.41,0.2 a 21.302,21.302 0 0 1 -0.584,-0.296 c 0.059,0.031 0.115,0.065 0.174,0.096 -0.133,-0.069 -0.265,-0.137 -0.396,-0.208 L 10.494,37.476 C 10.445,37.45 10.397,37.421 10.349,37.394 10.286,37.36 10.227,37.321 10.165,37.285 l -0.053,-0.03 0.027,0.015 C 9.922,37.146 9.708,37.02 9.497,36.889 9.56,36.928 9.62,36.972 9.684,37.011 9.581,36.949 9.481,36.883 9.38,36.82 L 9.371,36.814 9.217,36.717 C 9.139,36.667 9.065,36.612 8.988,36.561 8.823,36.452 8.654,36.349 8.492,36.235 8.613,36.32 8.742,36.395 8.865,36.477 8.649,36.333 8.431,36.192 8.221,36.039 l 0.044,0.033 C 8.25,36.062 8.236,36.05 8.221,36.039 8.025,35.895 7.834,35.745 7.643,35.595 L 7.634,35.588 7.632,35.587 C 7.548,35.52 7.462,35.456 7.378,35.388 L 7.377,35.387 7.344,35.359 C 7.112,35.169 6.882,34.975 6.659,34.775 L 6.641,34.76 6.627,34.747 C 6.545,34.674 6.469,34.597 6.389,34.522 6.231,34.376 6.073,34.23 5.92,34.079 L 5.901,34.061 5.864,34.026 5.901,34.061 C 5.838,33.999 5.778,33.934 5.716,33.871 5.766,33.922 5.813,33.976 5.864,34.026 5.813,33.975 5.765,33.921 5.714,33.869 L 5.632,33.783 C 5.493,33.639 5.353,33.497 5.218,33.349 L 5.216,33.346 C 5.139,33.262 5.064,33.176 4.989,33.091 L 4.931,33.024 A 21.99,21.99 0 0 1 4.335,32.314 C 4.315,32.29 4.296,32.264 4.277,32.239 A 19.844,19.844 0 0 1 3.729,31.519 C 3.707,31.488 3.687,31.456 3.665,31.426 A 22.618,22.618 0 0 1 3.334,30.947 C 3.279,30.865 3.222,30.783 3.168,30.699 3.143,30.661 3.121,30.622 3.097,30.584 A 18.36,18.36 0 0 1 2.799,30.102 C 2.787,30.08 2.773,30.059 2.76,30.038 2.723,29.977 2.685,29.917 2.649,29.855 2.622,29.809 2.598,29.761 2.572,29.714 A 20.391,20.391 0 0 1 2.298,29.212 C 2.255,29.133 2.21,29.056 2.169,28.976 2.143,28.926 2.12,28.875 2.095,28.825 A 16.057,16.057 0 0 1 1.839,28.289 C 1.806,28.22 1.771,28.151 1.739,28.081 L 1.687,27.96 C 1.594,27.752 1.507,27.541 1.422,27.329 L 1.352,27.16 1.323,27.081 A 18.694,18.694 0 0 1 1.044,26.308 L 1.01,26.212 1,26.181 A 19.533,19.533 0 0 1 0.394,23.914 L 0.386,23.874 C 0.37,23.795 0.359,23.715 0.344,23.637 A 19.434,19.434 0 0 1 0.224,22.955 L 0.209,22.867 C 0.193,22.755 0.183,22.641 0.169,22.528 A 17.848,17.848 0 0 1 0.096,21.912 C 0.094,21.892 0.09,21.872 0.089,21.851 0.086,21.824 0.087,21.796 0.085,21.768 A 19.6,19.6 0 0 1 0,19.995 c 0,-0.257 0.01,-0.513 0.02,-0.768 -0.004,0.116 -0.014,0.231 -0.016,0.348 0.002,-0.117 0.012,-0.232 0.016,-0.348 0.006,-0.145 0.012,-0.29 0.02,-0.435 0.005,-0.075 0.005,-0.151 0.011,-0.226 -0.006,0.075 -0.006,0.151 -0.011,0.226 0.012,-0.193 0.028,-0.384 0.045,-0.575 -0.011,0.116 -0.026,0.232 -0.034,0.349 0.008,-0.118 0.024,-0.234 0.034,-0.352 0.013,-0.144 0.026,-0.287 0.042,-0.43 0.022,-0.19 0.047,-0.38 0.074,-0.569 0.02,-0.142 0.04,-0.283 0.063,-0.423 V 16.791 C 0.295,16.604 0.33,16.418 0.366,16.233 V 16.231 C 0.393,16.092 0.42,15.953 0.45,15.815 v -10e-4 a 19.77,19.77 0 0 1 0.13,-0.55 C 0.613,15.128 0.647,14.991 0.684,14.856 V 14.854 C 0.733,14.674 0.785,14.497 0.839,14.32 L 0.84,14.316 c 0.04,-0.134 0.08,-0.269 0.123,-0.401 l 0.001,-0.003 c 0.057,-0.175 0.118,-0.348 0.18,-0.521 L 1.146,13.386 C 1.193,13.255 1.239,13.124 1.289,12.994 L 1.29,12.991 C 1.355,12.82 1.424,12.652 1.493,12.484 L 1.496,12.477 C 1.549,12.349 1.602,12.22 1.657,12.093 L 1.659,12.09 a 21.895,21.895 0 0 1 0.231,-0.5 c 0.059,-0.125 0.117,-0.251 0.179,-0.374 l 0.002,-0.004 c 0.08,-0.161 0.164,-0.319 0.249,-0.477 l 0.005,-0.01 C 2.39,10.604 2.454,10.481 2.522,10.361 L 2.524,10.357 C 2.611,10.202 2.703,10.049 2.794,9.896 L 2.801,9.885 C 2.872,9.767 2.942,9.648 3.015,9.531 L 3.018,9.527 C 3.112,9.377 3.21,9.23 3.308,9.083 L 3.316,9.07 C 3.393,8.956 3.468,8.841 3.547,8.728 L 3.55,8.723 C 3.65,8.579 3.755,8.438 3.859,8.296 L 3.87,8.282 C 3.952,8.171 4.032,8.06 4.116,7.951 L 4.12,7.946 C 4.226,7.808 4.337,7.673 4.448,7.537 L 4.46,7.522 C 4.547,7.415 4.633,7.308 4.722,7.203 A 0.018,0.018 0 0 1 4.726,7.198 C 4.838,7.066 4.955,6.936 5.071,6.807 L 5.086,6.791 C 5.178,6.688 5.268,6.585 5.362,6.484 L 5.367,6.48 C 5.485,6.353 5.608,6.23 5.729,6.107 L 5.746,6.09 C 5.843,5.992 5.938,5.893 6.037,5.797 A 0.018,0.018 0 0 0 6.041,5.792 L 6.077,5.756 6.043,5.791 C 6.15,5.687 6.261,5.587 6.37,5.485 6.28,5.569 6.187,5.65 6.098,5.735 6.203,5.634 6.313,5.537 6.42,5.438 L 6.439,5.421 C 6.541,5.327 6.641,5.232 6.744,5.141 L 6.749,5.137 6.802,5.088 6.749,5.137 C 6.86,5.038 6.976,4.944 7.09,4.847 6.994,4.928 6.896,5.005 6.802,5.088 6.914,4.99 7.03,4.897 7.143,4.802 A 0.425,0.425 0 0 1 7.165,4.784 C 7.27,4.696 7.374,4.606 7.481,4.52 L 7.486,4.516 7.538,4.473 7.487,4.515 C 7.603,4.422 7.722,4.333 7.84,4.243 7.74,4.32 7.637,4.394 7.538,4.473 7.655,4.379 7.777,4.29 7.897,4.199 L 7.92,4.181 C 8.03,4.098 8.137,4.014 8.248,3.933 L 8.252,3.93 8.303,3.892 8.255,3.928 C 8.376,3.84 8.499,3.757 8.621,3.672 8.516,3.746 8.407,3.816 8.303,3.892 8.426,3.802 8.554,3.718 8.68,3.632 L 8.706,3.614 C 8.818,3.536 8.929,3.457 9.043,3.382 L 9.047,3.379 9.096,3.346 9.052,3.376 C 9.177,3.294 9.306,3.217 9.432,3.137 9.321,3.207 9.206,3.274 9.096,3.346 9.226,3.261 9.359,3.182 9.491,3.1 L 9.52,3.083 C 9.635,3.011 9.749,2.937 9.866,2.868 L 9.87,2.866 A 0.739,0.739 0 0 1 9.917,2.837 l -0.04,0.025 c 0.13,-0.077 0.263,-0.149 0.395,-0.223 -0.118,0.066 -0.239,0.129 -0.355,0.198 0.135,-0.08 0.275,-0.154 0.412,-0.23 L 10.361,2.589 C 10.479,2.524 10.595,2.456 10.714,2.392 A 0.008,0.008 0 0 0 10.718,2.39 l 0.045,-0.024 -0.036,0.02 C 10.863,2.314 11.003,2.247 11.14,2.178 11.015,2.241 10.887,2.3 10.763,2.366 10.905,2.291 11.05,2.223 11.193,2.151 a 0.378,0.378 0 0 0 0.035,-0.017 c 0.12,-0.059 0.238,-0.121 0.36,-0.178 l 0.003,-0.002 0.043,-0.021 -0.031,0.016 c 0.143,-0.067 0.289,-0.129 0.434,-0.193 -0.134,0.06 -0.27,0.115 -0.403,0.177 0.148,-0.069 0.299,-0.131 0.449,-0.197 L 12.121,1.719 C 12.242,1.666 12.362,1.61 12.485,1.56 l 0.003,-0.002 a 0.684,0.684 0 0 0 0.04,-0.017 L 12.502,1.553 C 12.654,1.49 12.809,1.434 12.963,1.375 12.818,1.43 12.672,1.483 12.528,1.541 12.682,1.478 12.84,1.422 12.996,1.362 L 13.039,1.346 C 13.161,1.299 13.281,1.25 13.405,1.205 h 0.002 A 0.426,0.426 0 0 1 13.445,1.191 L 13.423,1.199 C 13.588,1.14 13.757,1.088 13.924,1.033 13.764,1.086 13.603,1.135 13.445,1.191 13.606,1.134 13.77,1.084 13.932,1.03 l 0.05,-0.016 c 0.122,-0.04 0.241,-0.083 0.363,-0.12 h 0.002 L 14.382,0.883 14.365,0.889 C 14.539,0.836 14.716,0.79 14.891,0.742 l -0.027,0.008 0.07,-0.02 0.061,-0.017 c 0.104,-0.028 0.206,-0.059 0.311,-0.086 h 0.001 l 0.031,-0.008 -0.01,0.003 c 0.18,-0.045 0.363,-0.083 0.545,-0.123 L 15.809,0.513 C 15.901,0.492 15.992,0.47 16.085,0.451 l 0.088,-0.02 c 0.038,-0.008 0.075,-0.018 0.112,-0.025 l 0.001,-0.001 0.027,-0.004 -0.004,0.001 c 0.188,-0.038 0.379,-0.068 0.57,-0.1 L 16.777,0.319 C 16.947,0.29 17.115,0.256 17.285,0.231 L 17.286,0.23 17.303,0.228 h 0.004 C 17.459,0.205 17.614,0.19 17.767,0.17 17.664,0.183 17.559,0.195 17.456,0.209 17.608,0.188 17.762,0.17 17.915,0.152 L 17.767,0.17 C 17.944,0.148 18.12,0.122 18.299,0.104 H 18.3 l 0.021,-0.002 h 10e-4 c 0.151,-0.015 0.304,-0.023 0.456,-0.034 l -0.149,0.011 c 0.126,-0.01 0.251,-0.02 0.377,-0.028 l -0.228,0.017 c 0.183,-0.014 0.365,-0.032 0.549,-0.041 h 10e-4 L 19.351,0.026 C 19.503,0.018 19.657,0.018 19.809,0.014 L 19.724,0.016 C 19.947,0.009 20.169,0 20.394,0 c 11.264,0 20.394,8.951 20.394,19.995 0,0.335 -0.01,0.668 -0.026,0.999 -0.006,0.11 -0.017,0.219 -0.024,0.328 z M 8.562,26.391 c 0.155,0.67 0.838,1.09 1.522,0.936 7.098,-1.59 13.126,-0.942 17.914,1.928 0.6,0.358 1.383,0.175 1.749,-0.416 A 1.231,1.231 0 0 0 29.325,27.127 C 23.949,23.905 17.286,23.156 9.517,24.898 A 1.242,1.242 0 0 0 8.562,26.391 Z M 7.748,20.54 c 0.256,0.823 1.144,1.287 1.986,1.038 6.484,-1.929 14.841,-0.973 20.321,2.331 0.747,0.449 1.727,0.22 2.187,-0.512 A 1.543,1.543 0 0 0 31.72,21.252 C 25.424,17.461 16.217,16.392 8.809,18.596 A 1.557,1.557 0 0 0 7.748,20.54 Z M 34.404,14.509 C 26.836,10.104 14.861,9.69 7.657,11.835 a 1.864,1.864 0 0 0 -1.271,2.332 c 0.305,0.989 1.37,1.546 2.379,1.246 6.275,-1.867 17.118,-1.514 23.693,2.313 a 1.927,1.927 0 0 0 2.613,-0.655 1.848,1.848 0 0 0 -0.667,-2.562 z M 20.34,0 h 0.054 C 20.198,0 20.004,0.009 19.809,0.014 19.986,0.009 20.162,0 20.34,0 Z"/></symbol>
+
+  <symbol id="icon-toyhouse" viewBox="0 0 197 190">
+    <!-- Via: https://logos.fandom.com/wiki/File:Toyhouse.svg -->
+    <path d="M 92.377573 54.858845 L -6.2923729 154.22229 L 25.027679 154.05951 L 25.027679 245.16092 L 159.06549 245.16092 L 159.06549 153.9484 L 191.43458 153.9484 L 152.83642 115.35024 L 152.83642 69.076582 L 121.69051 69.076582 L 121.69051 84.093236 L 92.377573 54.858845 z M 108.84479 170.6156 L 108.62206 196.7153 L 135.0499 196.7153 L 135.0499 170.99543 L 144.0168 170.99543 L 144.0168 232.6604 L 134.73519 232.6604 L 134.73519 203.89264 L 108.70061 203.89264 L 108.70061 233.01438 L 99.73423 233.01438 L 99.73423 181.1023 C 99.73423 178.74267 99.733714 178.97633 99.733714 178.97633 L 81.812844 178.97633 L 81.812844 232.98079 L 70.800596 232.98079 L 70.800596 179.03214 L 54.338035 179.03214 L 54.338035 170.63369 L 108.84479 170.6156 z " transform="translate(6.2923729,-54.858845)"/>
+  </symbol>
+
+  <symbol id="icon-internetArchive" viewBox="0 0 27 30"><path d="M 26.666667,28.604651 V 30 H 0 l 2.8368794e-4,-1.395349 z m -1.052632,-2.093023 v 1.744186 H 1.0526316 V 26.511628 Z M 3.624692,7.6744186 3.9174691,7.8215328 4.0639977,10.173954 4.2105263,13.996393 v 3.676169 L 4.0639977,22.255044 4.039623,25.342193 3.624692,25.465116 H 2.1602464 L 1.7209407,25.342193 1.5503175,22.255044 1.4035088,17.697034 V 14.021147 L 1.5503175,10.173954 1.6842385,7.8088748 1.9896232,7.6744186 Z m 21.052795,0 0.293116,0.1471142 0.146277,2.3524212 0.146278,3.822439 v 3.676169 l -0.146278,4.582482 -0.0241,3.087149 -0.415294,0.122923 h -1.464458 l -0.439393,-0.122923 -0.171218,-3.087149 -0.146278,-4.55801 v -3.675887 l 0.146278,-3.847193 0.134508,-2.3650792 0.305166,-0.1344562 z m -14.737064,0 0.292806,0.1471142 0.146544,2.3524212 0.146543,3.822439 v 3.676169 l -0.146543,4.582482 -0.0241,3.087149 -0.415253,0.122923 H 8.4758312 L 8.0362015,25.342193 7.8655613,22.255044 7.7192982,17.697034 V 14.021147 L 7.8655613,10.173954 8.000056,7.8088748 8.3049108,7.6744186 Z m 8.070175,0 0.292807,0.1471142 0.146543,2.3524212 0.146543,3.822439 v 3.676169 l -0.146543,4.582482 -0.0241,3.087149 -0.415253,0.122923 h -1.464591 l -0.43935,-0.122923 -0.17092,-3.087149 -0.146263,-4.55801 v -3.675887 l 0.146263,-3.847193 0.134494,-2.3650792 0.305135,-0.1344562 z M 25.614035,4.5348837 V 6.9767442 H 1.0526316 V 4.5348837 Z M 13.080676,0 25.964912,2.9333134 25.448414,3.8372093 H 0.77192525 L 0,3.1041615 Z"/></symbol>
+
+  <symbol id="icon-newgrounds" viewbox="0 0 40">
+    <!-- Thanks for clicking a convert button for the greater good, @PeterShaggyNoble! https://github.com/simple-icons/simple-icons/issues/1974#issuecomment-637606360 -->
+    <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>
+  <symbol id="icon-twitter" viewBox="0 0 40 40"><path d="M36.3,10.2c-1,1.3-2.1,2.5-3.4,3.5c0,0.2,0,0.4,0,1c0,1.7-0.2,3.6-0.9,5.3c-0.6,1.7-1.2,3.5-2.4,5.1 c-1.1,1.5-2.3,3.1-3.7,4.3c-1.4,1.2-3.3,2.3-5.3,3c-2.1,0.8-4.2,1.2-6.6,1.2c-3.6,0-7-1-10.2-3c0.4,0,1.1,0.1,1.5,0.1 c3.1,0,5.9-1,8.2-2.9c-1.4,0-2.7-0.4-3.8-1.3c-1.2-1-1.9-2-2.2-3.3c0.4,0.1,1,0.1,1.2,0.1c0.6,0,1.2-0.1,1.7-0.2 c-1.4-0.3-2.7-1.1-3.7-2.3s-1.4-2.6-1.4-4.2v-0.1c1,0.6,2,0.9,3,0.9c-1-0.6-1.5-1.3-2.2-2.4c-0.6-1-0.9-2.1-0.9-3.3s0.3-2.3,1-3.4 c1.5,2.1,3.6,3.6,6,4.9s4.9,2,7.6,2.1c-0.1-0.6-0.1-1.1-0.1-1.4c0-1.8,0.8-3.5,2-4.7c1.2-1.2,2.9-2,4.7-2c2,0,3.6,0.8,4.8,2.1 c1.4-0.3,2.9-0.9,4.2-1.5c-0.4,1.5-1.4,2.7-2.9,3.6C33.8,11.2,35.1,10.9,36.3,10.2L36.3,10.2z"/></symbol>
+  <symbol id="icon-youtube" viewBox="0 -30 256 256"><g transform="matrix(1.3333333,0,0,-1.3333333,0,256)"><path d="M 0,0 V 52.338 L 46,26.168 Z M 102.322,68.806 C 100.298,76.428 94.334,82.43 86.762,84.467 73.037,88.169 18,88.169 18,88.169 c 0,0 -55.037,0 -68.762,-3.702 C -58.334,82.43 -64.298,76.428 -66.322,68.806 -70,54.992 -70,26.169 -70,26.169 c 0,0 0,-28.822 3.678,-42.637 2.024,-7.622 7.988,-13.624 15.56,-15.662 13.725,-3.701 68.762,-3.701 68.762,-3.701 0,0 55.037,0 68.762,3.701 7.572,2.038 13.536,8.04 15.56,15.662 C 106,-2.653 106,26.169 106,26.169 c 0,0 0,28.823 -3.678,42.637" transform="translate(78,69.8311)"/></g></symbol>
+</svg>
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/misc/warning.svg b/src/static/misc/warning.svg
new file mode 100644
index 00000000..92e55778
--- /dev/null
+++ b/src/static/misc/warning.svg
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   width="64mm"
+   height="64mm"
+   viewBox="0 0 64 64"
+   version="1.1"
+   id="svg5"
+   inkscape:version="1.2.2 (b0a84865, 2022-12-01)"
+   sodipodi:docname="warning.svg"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <sodipodi:namedview
+     id="namedview7"
+     pagecolor="#505050"
+     bordercolor="#eeeeee"
+     borderopacity="1"
+     inkscape:showpageshadow="0"
+     inkscape:pageopacity="0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#505050"
+     inkscape:document-units="mm"
+     showgrid="false"
+     inkscape:zoom="2.2162213"
+     inkscape:cx="164.24353"
+     inkscape:cy="147.77405"
+     inkscape:window-width="1309"
+     inkscape:window-height="865"
+     inkscape:window-x="172"
+     inkscape:window-y="117"
+     inkscape:window-maximized="0"
+     inkscape:current-layer="layer1" />
+  <defs
+     id="defs2">
+    <inkscape:path-effect
+       effect="bool_op"
+       operand-path="#path1050"
+       id="path-effect1430"
+       is_visible="true"
+       lpeversion="1"
+       operation="diff"
+       swap-operands="false"
+       filltype-this="positive"
+       filter=""
+       filltype-operand="nonzero" />
+    <filter
+       id="selectable_hidder_filter"
+       width="1"
+       height="1"
+       x="0"
+       y="0"
+       style="color-interpolation-filters:sRGB;"
+       inkscape:label="LPE boolean visibility">
+      <feComposite
+         id="boolops_hidder_primitive"
+         result="composite1"
+         operator="arithmetic"
+         in2="SourceGraphic"
+         in="BackgroundImage" />
+    </filter>
+    <inkscape:path-effect
+       effect="bool_op"
+       operand-path=""
+       id="path-effect1410"
+       is_visible="true"
+       lpeversion="1"
+       operation="diff"
+       swap-operands="false"
+       filltype-this="from-curve"
+       filter=""
+       filltype-operand="from-curve" />
+    <linearGradient
+       id="linearGradient1106"
+       inkscape:swatch="solid">
+      <stop
+         style="stop-color:#000000;stop-opacity:1;"
+         offset="0"
+         id="stop1104" />
+    </linearGradient>
+  </defs>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1">
+    <path
+       id="path1458"
+       style="color:#000000;fill:#ffffff;stroke-width:11;stroke-linejoin:round;-inkscape-stroke:none;paint-order:fill markers stroke"
+       d="M 32.000114 8.4041382 A 5.50055 5.50055 0 0 0 27.320296 11.013798 L 4.9805745 47.209005 A 5.50055 5.50055 0 0 0 9.6619425 55.59764 L 54.337769 55.59764 A 5.50055 5.50055 0 0 0 59.019653 47.209005 L 36.679932 11.013798 A 5.50055 5.50055 0 0 0 32.000114 8.4041382 z M 29.123287 18.81849 L 34.876941 18.81849 A 1.069827 1.069827 0 0 1 35.945093 19.935734 L 35.052641 39.585697 A 1.069827 1.069827 0 0 1 33.808789 40.593905 C 33.210998 40.494454 32.606121 40.443789 32.000114 40.443526 C 31.395269 40.445711 30.791425 40.496549 30.195056 40.597522 A 1.069827 1.069827 0 0 1 28.94707 39.591899 L 28.054618 19.935734 A 1.069827 1.069827 0 0 1 29.123287 18.81849 z M 32.000114 42.910042 A 3.5582614 3.5582614 0 0 1 35.558036 46.468481 A 3.5582614 3.5582614 0 0 1 32.000114 50.026404 A 3.5582614 3.5582614 0 0 1 28.441675 46.468481 A 3.5582614 3.5582614 0 0 1 32.000114 42.910042 z " />
+  </g>
+</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/static/site-basic.css b/src/static/site-basic.css
deleted file mode 100644
index d26584ae..00000000
--- a/src/static/site-basic.css
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * For redirects and stuff like that.
- * Small file, not so much helped 8y this comment.
- */
-
-html {
-    background-color: #222222;
-    color: white;
-}
-
-body {
-    padding: 15px;
-}
-
-main {
-    background-color: rgba(0, 0, 0, 0.6);
-    border: 1px dotted white;
-    padding: 20px;
-}
diff --git a/src/static/site.css b/src/static/site.css
deleted file mode 100644
index 65d4d343..00000000
--- a/src/static/site.css
+++ /dev/null
@@ -1,928 +0,0 @@
-/* A frontend file! Wow.
- * This file is just loaded statically 8y <link>s in the HTML files, so there's
- * no need to re-run upd8.js when tweaking values here. Handy!
- */
-
-:root {
-    --primary-color: #0088ff;
-}
-
-body {
-    background: black;
-    margin: 10px;
-    overflow-y: scroll;
-}
-
-body::before {
-    content: "";
-    position: fixed;
-    top: 0;
-    left: 0;
-    width: 100%;
-    height: 100%;
-    z-index: -1;
-
-    background-image: url("../media/bg.jpg");
-    background-position: center;
-    background-size: cover;
-    opacity: 0.5;
-}
-
-#page-container {
-    background-color: var(--bg-color, rgba(35, 35, 35, 0.80));
-    color: #ffffff;
-
-    max-width: 1100px;
-    margin: 10px auto 50px;
-    padding: 15px 0;
-
-    box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
-}
-
-#page-container > * {
-    margin-left: 15px;
-    margin-right: 15px;
-}
-
-#banner {
-    margin: 10px 0;
-    width: 100%;
-    background: black;
-    background-color: var(--dim-color);
-    border-bottom: 1px solid var(--primary-color);
-    position: relative;
-}
-
-#banner::after {
-    content: "";
-    box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.35);
-    position: absolute;
-    top: 0;
-    left: 0;
-    right: 0;
-    bottom: 0;
-    pointer-events: none;
-}
-
-#banner.dim img {
-    opacity: 0.8;
-}
-
-#banner img {
-    display: block;
-    width: 100%;
-    height: auto;
-}
-
-a {
-    color: var(--primary-color);
-    text-decoration: none;
-}
-
-a:hover {
-    text-decoration: underline;
-}
-
-#skippers {
-    position: absolute;
-    left: -10000px;
-    top: auto;
-    width: 1px;
-    height: 1px;
-}
-
-#skippers:focus-within {
-    position: static;
-    width: unset;
-    height: unset;
-}
-
-#skippers > .skipper:not(:last-child)::after {
-    content: " \00b7 ";
-    font-weight: 800;
-}
-
-.layout-columns {
-    display: flex;
-}
-
-#header, #skippers, #footer {
-    padding: 5px;
-    font-size: 0.85em;
-}
-
-#header, #skippers {
-    margin-bottom: 10px;
-}
-
-#footer {
-    margin-top: 10px;
-}
-
-#header {
-    display: flex;
-}
-
-#header > h2 {
-    font-size: 1em;
-    margin: 0 20px 0 0;
-    font-weight: normal;
-}
-
-#header > h2 a.current {
-    font-weight: 800;
-}
-
-#header > h2.dot-between-spans > span:not(:last-child)::after {
-    content: " \00b7 ";
-    font-weight: 800;
-}
-
-#header > h2 > span {
-    white-space: nowrap;
-}
-
-#header > div {
-    flex-grow: 1;
-}
-
-#header > div > *:not(:last-child)::after {
-    content: " \00b7 ";
-    font-weight: 800;
-}
-
-#header .chronology {
-    display: inline-block;
-}
-
-#header .chronology .heading,
-#header .chronology .buttons {
-    display: inline-block;
-}
-
-footer {
-    text-align: center;
-    font-style: oblique;
-}
-
-footer > :first-child {
-    margin-top: 0;
-}
-
-footer > :last-child {
-    margin-bottom: 0;
-}
-
-.nowrap {
-    white-space: nowrap;
-}
-
-.icons {
-    font-style: normal;
-    white-space: nowrap;
-}
-
-.icon {
-    display: inline-block;
-    width: 24px;
-    height: 1em;
-    position: relative;
-}
-
-.icon > svg {
-    width: 24px;
-    height: 24px;
-    top: -0.25em;
-    position: absolute;
-    fill: var(--primary-color);
-}
-
-.rerelease,
-.other-group-accent {
-    opacity: 0.7;
-    font-style: oblique;
-}
-
-.other-group-accent {
-    white-space: nowrap;
-}
-
-.content-columns {
-    columns: 2;
-}
-
-.content-columns .column {
-    break-inside: avoid;
-}
-
-.content-columns .column h2 {
-    margin-top: 0;
-    font-size: 1em;
-}
-
-.sidebar, #content, #header, #skippers, #footer {
-    background-color: rgba(0, 0, 0, 0.6);
-    border: 1px dotted var(--primary-color);
-    border-radius: 3px;
-}
-
-.sidebar-column {
-    flex: 1 1 20%;
-    min-width: 150px;
-    max-width: 250px;
-    flex-basis: 250px;
-    height: 100%;
-}
-
-.sidebar-multiple {
-    display: flex;
-    flex-direction: column;
-}
-
-.sidebar-multiple .sidebar:not(:first-child) {
-    margin-top: 10px;
-}
-
-.sidebar {
-    padding: 5px;
-    font-size: 0.85em;
-}
-
-#sidebar-left {
-    margin-right: 10px;
-}
-
-#sidebar-right {
-    margin-left: 10px;
-}
-
-.sidebar.wide {
-    max-width: 350px;
-    flex-basis: 300px;
-    flex-shrink: 0;
-    flex-grow: 1;
-}
-
-#content {
-    box-sizing: border-box;
-    padding: 20px;
-    flex-grow: 1;
-    flex-shrink: 3;
-    overflow-wrap: break-word;
-}
-
-.sidebar > h1,
-.sidebar > h2,
-.sidebar > h3,
-.sidebar > p {
-    text-align: center;
-}
-
-.sidebar h1 {
-    font-size: 1.25em;
-}
-
-.sidebar h2 {
-    font-size: 1.1em;
-    margin: 0;
-}
-
-.sidebar h3 {
-    font-size: 1.1em;
-    font-style: oblique;
-    font-variant: small-caps;
-    margin-top: 0.3em;
-    margin-bottom: 0em;
-}
-
-.sidebar > p {
-    margin: 0.5em 0;
-    padding: 0 5px;
-}
-
-.sidebar hr {
-    color: #555;
-    margin: 10px 5px;
-}
-
-.sidebar > ol, .sidebar > ul {
-    padding-left: 30px;
-    padding-right: 15px;
-}
-
-.sidebar > dl {
-    padding-right: 15px;
-    padding-left: 0;
-}
-
-.sidebar > dl dt {
-    padding-left: 10px;
-    margin-top: 0.5em;
-}
-
-.sidebar > dl dt.current {
-    font-weight: 800;
-}
-
-.sidebar > dl dd {
-    margin-left: 0;
-}
-
-.sidebar > dl dd ul {
-    padding-left: 30px;
-    margin-left: 0;
-}
-
-.sidebar > dl .side {
-    padding-left: 10px;
-}
-
-.sidebar li.current {
-    font-weight: 800;
-}
-
-.sidebar li {
-    overflow-wrap: break-word;
-}
-
-.sidebar > details.current summary {
-    font-weight: 800;
-}
-
-.sidebar > details summary {
-    margin-top: 0.5em;
-    padding-left: 5px;
-    user-select: none;
-}
-
-.sidebar > details summary .group-name {
-    color: var(--primary-color);
-}
-
-.sidebar > details summary:hover {
-    cursor: pointer;
-    text-decoration: underline;
-    text-decoration-color: var(--primary-color);
-}
-
-.sidebar > details ul,
-.sidebar > details ol {
-    margin-top: 0;
-    margin-bottom: 0;
-}
-
-.sidebar > details:last-child {
-    margin-bottom: 10px;
-}
-
-.sidebar > details[open] {
-    margin-bottom: 1em;
-}
-
-.sidebar article {
-    text-align: left;
-    margin: 5px 5px 15px 5px;
-}
-
-.sidebar article:last-child {
-    margin-bottom: 5px;
-}
-
-.sidebar article h2,
-.news-index h2 {
-    border-bottom: 1px dotted;
-}
-
-.sidebar article h2 time,
-.news-index time {
-    float: right;
-    font-weight: normal;
-}
-
-#cover-art-container {
-    float: right;
-    width: 40%;
-    max-width: 400px;
-    margin: 0 0 10px 10px;
-    font-size: 0.8em;
-}
-
-#cover-art img {
-    display: block;
-    width: 100%;
-    height: 100%;
-}
-
-#cover-art-container p {
-    margin-top: 5px;
-}
-
-.image-container {
-    border: 2px solid var(--primary-color);
-    box-sizing: border-box;
-    position: relative;
-    padding: 5px;
-    text-align: left;
-    background-color: var(--dim-color);
-    color: white;
-    display: inline-block;
-    width: 100%;
-    height: 100%;
-}
-
-.image-inner-area {
-    overflow: hidden;
-    width: 100%;
-    height: 100%;
-}
-
-img {
-    object-fit: cover;
-    /* these unfortunately dont take effect while loading, so...
-    text-align: center;
-    line-height: 2em;
-    text-shadow: 0 0 5px black;
-    font-style: oblique;
-    */
-}
-
-.js-hide,
-.js-show-once-data,
-.js-hide-once-data {
-    display: none;
-}
-
-a.box:focus {
-    outline: 3px double var(--primary-color);
-}
-
-a.box:focus:not(:focus-visible) {
-    outline: none;
-}
-
-a.box img {
-    display: block;
-    width: 100%;
-    height: 100%;
-}
-
-h1 {
-    font-size: 1.5em;
-}
-
-#content li {
-    margin-bottom: 4px;
-}
-
-#content li i {
-    white-space: nowrap;
-}
-
-.grid-listing {
-    display: flex;
-    flex-wrap: wrap;
-    justify-content: center;
-    align-items: center;
-}
-
-.grid-item {
-    display: inline-block;
-    margin: 15px;
-    text-align: center;
-    background-color: #111111;
-    border: 1px dotted var(--primary-color);
-    border-radius: 2px;
-    padding: 5px;
-}
-
-.grid-item img {
-    width: 100%;
-    height: 100%;
-    margin-top: auto;
-    margin-bottom: auto;
-}
-
-.grid-item span {
-    overflow-wrap: break-word;
-    hyphens: auto;
-}
-
-.grid-item:hover {
-    text-decoration: none;
-}
-
-.grid-actions .grid-item:hover {
-    text-decoration: underline;
-}
-
-.grid-item > span:first-of-type {
-    margin-top: 0.45em;
-    display: block;
-}
-
-.grid-item:hover > span:first-of-type {
-    text-decoration: underline;
-}
-
-.grid-listing > .grid-item {
-    flex: 1 1 26%;
-}
-
-.grid-actions {
-    display: flex;
-    flex-direction: column;
-    margin: 15px;
-}
-
-.grid-actions > .grid-item {
-    flex-basis: unset !important;
-    margin: 5px;
-    --primary-color: inherit !important;
-    --dim-color: inherit !important;
-}
-
-.grid-item {
-    flex-basis: 240px;
-    min-width: 200px;
-    max-width: 240px;
-}
-
-.grid-item:not(.large-grid-item) {
-    flex-basis: 180px;
-    min-width: 120px;
-    max-width: 180px;
-    font-size: 0.9em;
-}
-
-.square {
-    position: relative;
-    width: 100%;
-}
-
-.square::after {
-    content: "";
-    display: block;
-    padding-bottom: 100%;
-}
-
-.square-content {
-    position: absolute;
-    width: 100%;
-    height: 100%;
-}
-
-.vertical-square {
-    position: relative;
-    height: 100%;
-}
-
-.vertical-square::after {
-    content: "";
-    display: block;
-    padding-right: 100%;
-}
-
-.reveal {
-    position: relative;
-    width: 100%;
-    height: 100%;
-}
-
-.reveal img {
-    filter: blur(20px);
-    opacity: 0.4;
-}
-
-.reveal-text {
-    color: white;
-    position: absolute;
-    top: 15px;
-    left: 10px;
-    right: 10px;
-    text-align: center;
-    font-weight: bold;
-}
-
-.reveal-interaction {
-    opacity: 0.8;
-}
-
-.reveal.revealed img {
-    filter: none;
-    opacity: 1;
-}
-
-.reveal.revealed .reveal-text {
-    display: none;
-}
-
-#content.top-index h1,
-#content.flash-index h1 {
-    text-align: center;
-    font-size: 2em;
-}
-
-#content.flash-index h2 {
-    text-align: center;
-    font-size: 2.5em;
-    font-variant: small-caps;
-    font-style: oblique;
-    margin-bottom: 0;
-    text-align: center;
-    width: 100%;
-}
-
-#content.top-index h2 {
-    text-align: center;
-    font-size: 2em;
-    font-weight: normal;
-    margin-bottom: 0.25em;
-}
-
-.quick-info {
-    text-align: center;
-}
-
-ul.quick-info {
-    list-style: none;
-    padding-left: 0;
-}
-
-ul.quick-info li {
-    display: inline-block;
-}
-
-ul.quick-info li:not(:last-child)::after {
-    content: " \00b7 ";
-    font-weight: 800;
-}
-
-#intro-menu {
-    margin: 24px 0;
-    padding: 10px;
-    background-color: #222222;
-    text-align: center;
-    border: 1px dotted var(--primary-color);
-    border-radius: 2px;
-}
-
-#intro-menu p {
-    margin: 12px 0;
-}
-
-#intro-menu a {
-    margin: 0 6px;
-}
-
-li .by {
-    white-space: nowrap;
-    font-style: oblique;
-}
-
-p code {
-    font-size: 1em;
-    font-family: 'courier new';
-    font-weight: 800;
-}
-
-blockquote {
-    max-width: 600px;
-    margin-right: 0;
-}
-
-.long-content {
-    margin-left: 12%;
-    margin-right: 12%;
-}
-
-p img {
-    max-width: 100%;
-    height: auto;
-}
-
-dl dt {
-    padding-left: 2em;
-}
-
-dl dt {
-    margin-bottom: 2px;
-}
-
-dl dd {
-    margin-bottom: 1em;
-}
-
-dl ul, dl ol {
-    margin-top: 0;
-    margin-bottom: 0;
-}
-
-.album-group-list dt {
-    font-style: oblique;
-    padding-left: 0;
-}
-
-.album-group-list dd {
-    margin-left: 0;
-}
-
-.group-chronology-link {
-    font-style: oblique;
-}
-
-hr.split::before {
-    content: "(split)";
-    color: #808080;
-}
-
-hr.split {
-    position: relative;
-    overflow: hidden;
-    border: none;
-}
-
-hr.split::after {
-    display: inline-block;
-    content: "";
-    border: 1px inset #808080;
-    width: 100%;
-    position: absolute;
-    top: 50%;
-    margin-top: -2px;
-    margin-left: 10px;
-}
-
-li > ul {
-    margin-top: 5px;
-}
-
-#info-card-container {
-    position: absolute;
-
-    left: 0;
-    right: 10px;
-
-    pointer-events: none; /* Padding area shouldn't 8e interactive. */
-    display: none;
-}
-
-#info-card-container.show,
-#info-card-container.hide {
-    display: flex;
-}
-
-#info-card-container > * {
-    flex-basis: 400px;
-}
-
-#info-card-container.show {
-    animation: 0.2s linear forwards info-card-show;
-    transition: top 0.1s, left 0.1s;
-}
-
-#info-card-container.hide {
-    animation: 0.2s linear forwards info-card-hide;
-}
-
-@keyframes info-card-show {
-    0% {
-        opacity: 0;
-        margin-top: -5px;
-    }
-
-    100% {
-        opacity: 1;
-        margin-top: 0;
-    }
-}
-
-@keyframes info-card-hide {
-    0% {
-        opacity: 1;
-        margin-top: 0;
-    }
-
-    100% {
-        opacity: 0;
-        margin-top: 5px;
-        display: none !important;
-    }
-}
-
-.info-card-decor {
-    padding-left: 3ch;
-    border-top: 1px solid white;
-}
-
-.info-card {
-    background-color: black;
-    color: white;
-
-    border: 1px dotted var(--primary-color);
-    border-radius: 3px;
-    box-shadow: 0 5px 5px black;
-
-    padding: 5px;
-    font-size: 0.9em;
-
-    pointer-events: none;
-}
-
-.info-card::after {
-    content: "";
-    display: block;
-    clear: both;
-}
-
-#info-card-container.show .info-card {
-    animation: 0.01s linear 0.2s forwards info-card-become-interactive;
-}
-
-@keyframes info-card-become-interactive {
-    to {
-        pointer-events: auto;
-    }
-}
-
-.info-card-art-container {
-    float: right;
-
-    width: 40%;
-    margin: 5px;
-    font-size: 0.8em;
-
-    /* Dynamically shown. */
-    display: none;
-}
-
-.info-card-art-container .image-container {
-    padding: 2px;
-}
-
-.info-card-art {
-    display: block;
-    width: 100%;
-    height: 100%;
-}
-
-.info-card-name {
-    font-size: 1em;
-    border-bottom: 1px dotted;
-    margin: 0;
-}
-
-.info-card p {
-    margin-top: 0.25em;
-    margin-bottom: 0.25em;
-}
-
-.info-card p:last-child {
-    margin-bottom: 0;
-}
-
-@media (max-width: 900px) {
-    .sidebar-column:not(.no-hide) {
-        display: none;
-    }
-
-    .layout-columns.vertical-when-thin {
-        flex-direction: column;
-    }
-
-    .layout-columns.vertical-when-thin > *:not(:last-child) {
-        margin-bottom: 10px;
-    }
-
-    .sidebar-column.no-hide {
-        max-width: unset !important;
-        flex-basis: unset !important;
-        margin-right: 0 !important;
-        margin-left: 0 !important;
-    }
-
-    .sidebar .news-entry:not(.first-news-entry) {
-        display: none;
-    }
-}
-
-@media (max-width: 600px) {
-    .content-columns {
-        columns: 1;
-    }
-
-    #cover-art-container {
-        float: none;
-        margin: 0 10px 10px 10px;
-        margin: 0;
-        width: 100%;
-        max-width: unset;
-    }
-
-    #header {
-        display: block;
-    }
-
-    #header > div {
-        margin-top: 0.5em;
-    }
-}
diff --git a/src/strings-default.json b/src/strings-default.json
deleted file mode 100644
index b80c99f6..00000000
--- a/src/strings-default.json
+++ /dev/null
@@ -1,346 +0,0 @@
-{
-    "meta.languageCode": "en",
-    "count.tracks": "{TRACKS}",
-    "count.tracks.withUnit.zero": "",
-    "count.tracks.withUnit.one": "{TRACKS} track",
-    "count.tracks.withUnit.two": "",
-    "count.tracks.withUnit.few": "",
-    "count.tracks.withUnit.many": "",
-    "count.tracks.withUnit.other": "{TRACKS} tracks",
-    "count.albums": "{ALBUMS}",
-    "count.albums.withUnit.zero": "",
-    "count.albums.withUnit.one": "{ALBUMS} album",
-    "count.albums.withUnit.two": "",
-    "count.albums.withUnit.two": "",
-    "count.albums.withUnit.few": "",
-    "count.albums.withUnit.many": "",
-    "count.albums.withUnit.other": "{ALBUMS} albums",
-    "count.commentaryEntries": "{ENTRIES}",
-    "count.commentaryEntries.withUnit.zero": "",
-    "count.commentaryEntries.withUnit.one": "{ENTRIES} entry",
-    "count.commentaryEntries.withUnit.two": "",
-    "count.commentaryEntries.withUnit.few": "",
-    "count.commentaryEntries.withUnit.many": "",
-    "count.commentaryEntries.withUnit.other": "{ENTRIES} entries",
-    "count.contributions": "{CONTRIBUTIONS}",
-    "count.contributions.withUnit.zero": "",
-    "count.contributions.withUnit.one": "{CONTRIBUTIONS} contribution",
-    "count.contributions.withUnit.two": "",
-    "count.contributions.withUnit.few": "",
-    "count.contributions.withUnit.many": "",
-    "count.contributions.withUnit.other": "{CONTRIBUTIONS} contributions",
-    "count.coverArts": "{COVER_ARTS}",
-    "count.coverArts.withUnit.zero": "",
-    "count.coverArts.withUnit.one": "{COVER_ARTS} cover art",
-    "count.coverArts.withUnit.two": "",
-    "count.coverArts.withUnit.few": "",
-    "count.coverArts.withUnit.many": "",
-    "count.coverArts.withUnit.other": "{COVER_ARTS} cover arts",
-    "count.timesReferenced": "{TIMES_REFERENCED}",
-    "count.timesReferenced.withUnit.zero": "",
-    "count.timesReferenced.withUnit.one": "{TIMES_REFERENCED} time referenced",
-    "count.timesReferenced.withUnit.two": "",
-    "count.timesReferenced.withUnit.few": "",
-    "count.timesReferenced.withUnit.many": "",
-    "count.timesReferenced.withUnit.other": "{TIMES_REFERENCED} times referenced",
-    "count.words": "{WORDS}",
-    "count.words.thousand": "{WORDS}k",
-    "count.words.withUnit.zero": "",
-    "count.words.withUnit.one": "{WORDS} word",
-    "count.words.withUnit.two": "",
-    "count.words.withUnit.few": "",
-    "count.words.withUnit.many": "",
-    "count.words.withUnit.other": "{WORDS} words",
-    "count.timesUsed": "{TIMES_USED}",
-    "count.timesUsed.withUnit.zero": "",
-    "count.timesUsed.withUnit.one": "used {TIMES_USED} time",
-    "count.timesUsed.withUnit.two": "",
-    "count.timesUsed.withUnit.few": "",
-    "count.timesUsed.withUnit.many": "",
-    "count.timesUsed.withUnit.other": "used {TIMES_USED} times",
-    "count.index.zero": "",
-    "count.index.one": "{INDEX}st",
-    "count.index.two": "{INDEX}nd",
-    "count.index.few": "{INDEX}rd",
-    "count.index.many": "",
-    "count.index.other": "{INDEX}th",
-    "count.duration.hours": "{HOURS}:{MINUTES}:{SECONDS}",
-    "count.duration.hours.withUnit": "{HOURS}:{MINUTES}:{SECONDS} hours",
-    "count.duration.minutes": "{MINUTES}:{SECONDS}",
-    "count.duration.minutes.withUnit": "{MINUTES}:{SECONDS} minutes",
-    "count.duration.approximate": "~{DURATION}",
-    "count.duration.missing": "_:__",
-    "releaseInfo.by": "By {ARTISTS}.",
-    "releaseInfo.from": "From {ALBUM}.",
-    "releaseInfo.coverArtBy": "Cover art by {ARTISTS}.",
-    "releaseInfo.wallpaperArtBy": "Wallpaper art by {ARTISTS}.",
-    "releaseInfo.bannerArtBy": "Banner art by {ARTISTS}.",
-    "releaseInfo.released": "Released {DATE}.",
-    "releaseInfo.artReleased": "Art released {DATE}.",
-    "releaseInfo.addedToWiki": "Added to wiki {DATE}.",
-    "releaseInfo.duration": "Duration: {DURATION}.",
-    "releaseInfo.viewCommentary": "View {LINK}!",
-    "releaseInfo.viewCommentary.link": "commentary page",
-    "releaseInfo.listenOn": "Listen on {LINKS}.",
-    "releaseInfo.listenOn.noLinks": "This track has no URLs at which it can be listened.",
-    "releaseInfo.visitOn": "Visit on {LINKS}.",
-    "releaseInfo.playOn": "Play on {LINKS}.",
-    "releaseInfo.alsoReleasedAs": "Also released as:",
-    "releaseInfo.alsoReleasedAs.item": "{TRACK} (on {ALBUM})",
-    "releaseInfo.contributors": "Contributors:",
-    "releaseInfo.tracksReferenced": "Tracks that {TRACK} references:",
-    "releaseInfo.tracksThatReference": "Tracks that reference {TRACK}:",
-    "releaseInfo.flashesThatFeature": "Flashes & games that feature {TRACK}:",
-    "releaseInfo.flashesThatFeature.item": "{FLASH}",
-    "releaseInfo.flashesThatFeature.item.asDifferentRelease": "{FLASH} (as {TRACK})",
-    "releaseInfo.lyrics": "Lyrics:",
-    "releaseInfo.artistCommentary": "Artist commentary:",
-    "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!",
-    "releaseInfo.artTags": "Tags:",
-    "releaseInfo.note": "Note:",
-    "trackList.group": "{GROUP} ({DURATION}):",
-    "trackList.item.withDuration": "({DURATION}) {TRACK}",
-    "trackList.item.withDuration.withArtists": "({DURATION}) {TRACK} {BY}",
-    "trackList.item.withArtists": "{TRACK} {BY}",
-    "trackList.item.withArtists.by": "by {ARTISTS}",
-    "trackList.item.rerelease": "{TRACK} (re-release)",
-    "misc.alt.albumCover": "album cover",
-    "misc.alt.albumBanner": "album banner",
-    "misc.alt.trackCover": "track cover",
-    "misc.alt.artistAvatar": "artist avatar",
-    "misc.alt.flashArt": "flash art",
-    "misc.chronology.seeArtistPages": "(See artist pages for chronology info!)",
-    "misc.chronology.heading.coverArt": "{INDEX} cover art by {ARTIST}",
-    "misc.chronology.heading.flash": "{INDEX} flash/game by {ARTIST}",
-    "misc.chronology.heading.track": "{INDEX} track by {ARTIST}",
-    "misc.external.domain": "External ({DOMAIN})",
-    "misc.external.local": "Wiki Archive (local upload)",
-    "misc.external.bandcamp": "Bandcamp",
-    "misc.external.bandcamp.domain": "Bandcamp ({DOMAIN})",
-    "misc.external.deviantart": "DeviantArt",
-    "misc.external.instagram": "Instagram",
-    "misc.external.mastodon": "Mastodon",
-    "misc.external.mastodon.domain": "Mastodon ({DOMAIN})",
-    "misc.external.patreon": "Patreon",
-    "misc.external.poetryFoundation": "Poetry Foundation",
-    "misc.external.soundcloud": "SoundCloud",
-    "misc.external.tumblr": "Tumblr",
-    "misc.external.twitter": "Twitter",
-    "misc.external.wikipedia": "Wikipedia",
-    "misc.external.youtube": "YouTube",
-    "misc.external.youtube.playlist": "YouTube (playlist)",
-    "misc.external.youtube.fullAlbum": "YouTube (full album)",
-    "misc.external.flash.bgreco": "{LINK} (HQ Audio)",
-    "misc.external.flash.homestuck.page": "{LINK} (page {PAGE})",
-    "misc.external.flash.homestuck.secret": "{LINK} (secret page)",
-    "misc.external.flash.youtube": "{LINK} (on any device)",
-    "misc.nav.previous": "Previous",
-    "misc.nav.next": "Next",
-    "misc.nav.info": "Info",
-    "misc.nav.gallery": "Gallery",
-    "misc.skippers.skipToContent": "Skip to content",
-    "misc.skippers.skipToSidebar": "Skip to sidebar",
-    "misc.skippers.skipToSidebar.left": "Skip to sidebar (left)",
-    "misc.skippers.skipToSidebar.right": "Skip to sidebar (right)",
-    "misc.skippers.skipToFooter": "Skip to footer",
-    "misc.jumpTo": "Jump to:",
-    "misc.jumpTo.withLinks": "Jump to: {LINKS}.",
-    "misc.contentWarnings": "cw: {WARNINGS}",
-    "misc.contentWarnings.reveal": "click to show",
-    "misc.albumGridDetails": "({TRACKS}, {TIME})",
-    "homepage.title": "{TITLE}",
-    "homepage.news.title": "News",
-    "homepage.news.entry.viewRest": "(View rest of entry!)",
-    "albumSidebar.trackList.fallbackGroupName": "Track list",
-    "albumSidebar.trackList.group": "{GROUP}",
-    "albumSidebar.trackList.group.withRange": "{GROUP} ({RANGE})",
-    "albumSidebar.trackList.item": "{TRACK}",
-    "albumSidebar.groupBox.title": "{GROUP}",
-    "albumSidebar.groupBox.next": "Next: {ALBUM}",
-    "albumSidebar.groupBox.previous": "Previous: {ALBUM}",
-    "albumPage.title": "{ALBUM}",
-    "albumPage.nav.album": "{ALBUM}",
-    "albumPage.nav.randomTrack": "Random Track",
-    "albumCommentaryPage.title": "{ALBUM} - Commentary",
-    "albumCommentaryPage.infoLine": "{WORDS} across {ENTRIES}.",
-    "albumCommentaryPage.nav.album": "Album: {ALBUM}",
-    "albumCommentaryPage.entry.title.albumCommentary": "Album commentary",
-    "albumCommentaryPage.entry.title.trackCommentary": "{TRACK}",
-    "artistPage.title": "{ARTIST}",
-    "artistPage.creditList.album": "{ALBUM}",
-    "artistPage.creditList.album.withDate": "{ALBUM} ({DATE})",
-    "artistPage.creditList.album.withDate.withDuration": "{ALBUM} ({DATE}; {DURATION})",
-    "artistPage.creditList.flashAct": "{ACT}",
-    "artistPage.creditList.flashAct.withDateRange": "{ACT} ({DATE_RANGE})",
-    "artistPage.creditList.entry.track": "{TRACK}",
-    "artistPage.creditList.entry.track.withDuration": "({DURATION}) {TRACK}",
-    "artistPage.creditList.entry.album.coverArt": "(cover art)",
-    "artistPage.creditList.entry.album.wallpaperArt": "(wallpaper art)",
-    "artistPage.creditList.entry.album.bannerArt": "(banner art)",
-    "artistPage.creditList.entry.album.commentary": "(album commentary)",
-    "artistPage.creditList.entry.flash": "{FLASH}",
-    "artistPage.creditList.entry.rerelease": "{ENTRY} (re-release)",
-    "artistPage.creditList.entry.withContribution": "{ENTRY} ({CONTRIBUTION})",
-    "artistPage.creditList.entry.withArtists": "{ENTRY} (with {ARTISTS})",
-    "artistPage.creditList.entry.withArtists.withContribution": "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})",
-    "artistPage.contributedDurationLine": "{ARTIST} has contributed {DURATION} of music shared on this wiki.",
-    "artistPage.musicGroupsLine": "Contributed music to groups: {GROUPS}",
-    "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}",
-    "artistPage.groupsLine.item": "{GROUP} ({CONTRIBUTIONS})",
-    "artistPage.trackList.title": "Tracks",
-    "artistPage.unreleasedTrackList.title": "Unreleased Tracks",
-    "artistPage.artList.title": "Art",
-    "artistPage.flashList.title": "Flashes & Games",
-    "artistPage.commentaryList.title": "Commentary",
-    "artistPage.viewArtGallery": "View {LINK}!",
-    "artistPage.viewArtGallery.orBrowseList": "View {LINK}! Or browse the list:",
-    "artistPage.viewArtGallery.link": "art gallery",
-    "artistPage.nav.artist": "Artist: {ARTIST}",
-    "artistGalleryPage.title": "{ARTIST} - Gallery",
-    "artistGalleryPage.infoLine": "Contributed to {COVER_ARTS}.",
-    "commentaryIndex.title": "Commentary",
-    "commentaryIndex.infoLine": "{WORDS} across {ENTRIES}, in all.",
-    "commentaryIndex.albumList.title": "Choose an album:",
-    "commentaryIndex.albumList.item": "{ALBUM} ({WORDS} across {ENTRIES})",
-    "flashIndex.title": "Flashes & Games",
-    "flashPage.title": "{FLASH}",
-    "flashPage.nav.flash": "{FLASH}",
-    "groupSidebar.title": "Groups",
-    "groupSidebar.groupList.category": "{CATEGORY}",
-    "groupSidebar.groupList.item": "{GROUP}",
-    "groupPage.nav.group": "Group: {GROUP}",
-    "groupInfoPage.title": "{GROUP}",
-    "groupInfoPage.viewAlbumGallery": "View {LINK}! Or browse the list:",
-    "groupInfoPage.viewAlbumGallery.link": "album gallery",
-    "groupInfoPage.albumList.title": "Albums",
-    "groupInfoPage.albumList.item": "({YEAR}) {ALBUM}",
-    "groupInfoPage.albumList.item.withAccent": "{ITEM} {ACCENT}",
-    "groupInfoPage.albumList.item.otherGroupAccent": "(from {GROUP})",
-    "groupGalleryPage.title": "{GROUP} - Gallery",
-    "groupGalleryPage.infoLine": "{TRACKS} across {ALBUMS}, totaling {TIME}.",
-    "groupGalleryPage.anotherGroupLine": "({LINK})",
-    "groupGalleryPage.anotherGroupLine.link": "Choose another group to filter by!",
-    "listingIndex.title": "Listings",
-    "listingIndex.infoLine": "{WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}.",
-    "listingIndex.exploreList": "Feel free to explore any of the listings linked below and in the sidebar!",
-    "listingPage.target.album": "Albums",
-    "listingPage.target.artist": "Artists",
-    "listingPage.target.group": "Groups",
-    "listingPage.target.track": "Tracks",
-    "listingPage.target.tag": "Tags",
-    "listingPage.target.other": "Other",
-    "listingPage.listAlbums.byName.title": "Albums - by Name",
-    "listingPage.listAlbums.byName.title.short": "...by Name",
-    "listingPage.listAlbums.byName.item": "{ALBUM} ({TRACKS})",
-    "listingPage.listAlbums.byTracks.title": "Albums - by Tracks",
-    "listingPage.listAlbums.byTracks.title.short": "...by Tracks",
-    "listingPage.listAlbums.byTracks.item": "{ALBUM} ({TRACKS})",
-    "listingPage.listAlbums.byDuration.title": "Albums - by Duration",
-    "listingPage.listAlbums.byDuration.title.short": "...by Duration",
-    "listingPage.listAlbums.byDuration.item": "{ALBUM} ({DURATION})",
-    "listingPage.listAlbums.byDate.title": "Albums - by Date",
-    "listingPage.listAlbums.byDate.title.short": "...by Date",
-    "listingPage.listAlbums.byDate.item": "{ALBUM} ({DATE})",
-    "listingPage.listAlbums.byDateAdded.title.short": "...by Date Added to Wiki",
-    "listingPage.listAlbums.byDateAdded.title": "Albums - by Date Added to Wiki",
-    "listingPage.listAlbums.byDateAdded.date": "{DATE}",
-    "listingPage.listAlbums.byDateAdded.album": "{ALBUM}",
-    "listingPage.listArtists.byName.title": "Artists - by Name",
-    "listingPage.listArtists.byName.title.short": "...by Name",
-    "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})",
-    "listingPage.listArtists.byContribs.title": "Artists - by Contributions",
-    "listingPage.listArtists.byContribs.title.short": "...by Contributions",
-    "listingPage.listArtists.byContribs.item": "{ARTIST} ({CONTRIBUTIONS})",
-    "listingPage.listArtists.byCommentary.title": "Artists - by Commentary Entries",
-    "listingPage.listArtists.byCommentary.title.short": "...by Commentary Entries",
-    "listingPage.listArtists.byCommentary.item": "{ARTIST} ({ENTRIES})",
-    "listingPage.listArtists.byDuration.title": "Artists - by Duration",
-    "listingPage.listArtists.byDuration.title.short": "...by Duration",
-    "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})",
-    "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution",
-    "listingPage.listArtists.byLatest.title.short": "...by Latest Contribution",
-    "listingPage.listArtists.byLatest.item": "{ARTIST} ({DATE})",
-    "listingPage.listGroups.byName.title": "Groups - by Name",
-    "listingPage.listGroups.byName.title.short": "...by Name",
-    "listingPage.listGroups.byName.item": "{GROUP} ({GALLERY})",
-    "listingPage.listGroups.byName.item.gallery": "Gallery",
-    "listingPage.listGroups.byCategory.title": "Groups - by Category",
-    "listingPage.listGroups.byCategory.title.short": "...by Category",
-    "listingPage.listGroups.byCategory.category": "{CATEGORY}",
-    "listingPage.listGroups.byCategory.group": "{GROUP} ({GALLERY})",
-    "listingPage.listGroups.byCategory.group.gallery": "Gallery",
-    "listingPage.listGroups.byAlbums.title": "Groups - by Albums",
-    "listingPage.listGroups.byAlbums.title.short": "...by Albums",
-    "listingPage.listGroups.byAlbums.item": "{GROUP} ({ALBUMS})",
-    "listingPage.listGroups.byTracks.title": "Groups - by Tracks",
-    "listingPage.listGroups.byTracks.title.short": "...by Tracks",
-    "listingPage.listGroups.byTracks.item": "{GROUP} ({TRACKS})",
-    "listingPage.listGroups.byDuration.title": "Groups - by Duration",
-    "listingPage.listGroups.byDuration.title.short": "...by Duration",
-    "listingPage.listGroups.byDuration.item": "{GROUP} ({DURATION})",
-    "listingPage.listGroups.byLatest.title": "Groups - by Latest Album",
-    "listingPage.listGroups.byLatest.title.short": "...by Latest Album",
-    "listingPage.listGroups.byLatest.item": "{GROUP} ({DATE})",
-    "listingPage.listTracks.byName.title": "Tracks - by Name",
-    "listingPage.listTracks.byName.title.short": "...by Name",
-    "listingPage.listTracks.byName.item": "{TRACK}",
-    "listingPage.listTracks.byAlbum.title": "Tracks - by Album",
-    "listingPage.listTracks.byAlbum.title.short": "...by Album",
-    "listingPage.listTracks.byAlbum.album": "{ALBUM}",
-    "listingPage.listTracks.byAlbum.track": "{TRACK}",
-    "listingPage.listTracks.byDate.title": "Tracks - by Date",
-    "listingPage.listTracks.byDate.title.short": "...by Date",
-    "listingPage.listTracks.byDate.album": "{ALBUM} ({DATE})",
-    "listingPage.listTracks.byDate.track": "{TRACK}",
-    "listingPage.listTracks.byDate.track.rerelease": "{TRACK} (re-release)",
-    "listingPage.listTracks.byDuration.title": "Tracks - by Duration",
-    "listingPage.listTracks.byDuration.title.short": "...by Duration",
-    "listingPage.listTracks.byDuration.item": "{TRACK} ({DURATION})",
-    "listingPage.listTracks.byDurationInAlbum.title": "Tracks - by Duration (in Album)",
-    "listingPage.listTracks.byDurationInAlbum.title.short": "...by Duration (in Album)",
-    "listingPage.listTracks.byDurationInAlbum.album": "{ALBUM}",
-    "listingPage.listTracks.byDurationInAlbum.track": "{TRACK} ({DURATION})",
-    "listingPage.listTracks.byTimesReferenced.title": "Tracks - by Times Referenced",
-    "listingPage.listTracks.byTimesReferenced.title.short": "...by Times Referenced",
-    "listingPage.listTracks.byTimesReferenced.item": "{TRACK} ({TIMES_REFERENCED})",
-    "listingPage.listTracks.inFlashes.byAlbum.title": "Tracks - in Flashes & Games (by Album)",
-    "listingPage.listTracks.inFlashes.byAlbum.title.short": "...in Flashes & Games (by Album)",
-    "listingPage.listTracks.inFlashes.byAlbum.album": "{ALBUM} ({DATE})",
-    "listingPage.listTracks.inFlashes.byAlbum.track": "{TRACK} (in {FLASHES})",
-    "listingPage.listTracks.inFlashes.byFlash.title": "Tracks - in Flashes & Games (by Flash)",
-    "listingPage.listTracks.inFlashes.byFlash.title.short": "...in Flashes & Games (by Flash)",
-    "listingPage.listTracks.inFlashes.byFlash.flash": "{FLASH} ({DATE})",
-    "listingPage.listTracks.inFlashes.byFlash.track": "{TRACK} (from {ALBUM})",
-    "listingPage.listTracks.withLyrics.title": "Tracks - with Lyrics",
-    "listingPage.listTracks.withLyrics.title.short": "...with Lyrics",
-    "listingPage.listTracks.withLyrics.album": "{ALBUM} ({DATE})",
-    "listingPage.listTracks.withLyrics.track": "{TRACK}",
-    "listingPage.listTags.byName.title": "Tags - by Name",
-    "listingPage.listTags.byName.title.short": "...by Name",
-    "listingPage.listTags.byName.item": "{TAG} ({TIMES_USED})",
-    "listingPage.listTags.byUses.title": "Tags - by Uses",
-    "listingPage.listTags.byUses.title.short": "...by Uses",
-    "listingPage.listTags.byUses.item": "{TAG} ({TIMES_USED})",
-    "listingPage.other.randomPages.title": "Random Pages",
-    "listingPage.other.randomPages.title.short": "Random Pages",
-    "listingPage.misc.trackContributors": "Track Contributors",
-    "listingPage.misc.artContributors": "Art Contributors",
-    "listingPage.misc.artAndFlashContributors": "Art & Flash Contributors",
-    "newsIndex.title": "News",
-    "newsIndex.entry.viewRest": "(View rest of entry!)",
-    "newsEntryPage.title": "{ENTRY}",
-    "newsEntryPage.published": "(Published {DATE}.)",
-    "newsEntryPage.nav.news": "News",
-    "newsEntryPage.nav.entry": "{DATE}: {ENTRY}",
-    "redirectPage.title": "Moved to {TITLE}",
-    "redirectPage.infoLine": "This page has been moved to {TARGET}.",
-    "tagPage.title": "{TAG}",
-    "tagPage.infoLine": "Appears in {COVER_ARTS}.",
-    "tagPage.nav.tag": "Tag: {TAG}",
-    "trackPage.title": "{TRACK}",
-    "trackPage.referenceList.fandom": "Fandom:",
-    "trackPage.referenceList.official": "Official:",
-    "trackPage.nav.track": "{TRACK}",
-    "trackPage.nav.track.withNumber": "{NUMBER}. {TRACK}",
-    "trackPage.nav.random": "Random"
-}
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
new file mode 100644
index 00000000..7a40bd0d
--- /dev/null
+++ b/src/strings-default.yaml
@@ -0,0 +1,2379 @@
+meta.languageCode: en
+meta.languageName: English
+
+#
+# count:
+#
+#   This covers pretty much any time that a specific number of things
+#   is represented! It's sectioned... like an alignment chart meme...
+#
+#   First counting specific wiki objects, then more abstract stuff,
+#   and finally numerical representations of kinds of quantities that
+#   aren't really "counting", per se.
+#
+#   These must be filled out according to the Unicode Common Locale
+#   Data Repository (Unicode CLDR). Check out info on their site:
+#   https://cldr.unicode.org
+#
+#   Specifically, you'll want to look into the Plural Rules for your
+#   language. Here's a summary on what those even are:
+#   https://cldr.unicode.org/index/cldr-spec/plural-rules
+#
+#   CLDR's charts are available online! This should bring you to the
+#   most recent table of plural rules:
+#   https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html
+#
+#   Counting is generally done with the "Type: cardinal" section on
+#   that chart - for example, if the chart lists "one", "many", and
+#   "other" under the cardinal plural rules for your language, then
+#   your job is to fill in the correct pluralizations of the specific
+#   term for each of those.
+#
+#   If you adore technical details or want to better understand the
+#   "Rules" column, you'll want to check out the syntax outline here:
+#   https://unicode.org/reports/tr35/tr35-numbers.html#Language_Plural_Rules
+#
+count:
+
+  # Count things and objects
+
+  additionalFiles:
+    _: "{FILES}"
+    withUnit:
+      zero: ""
+      one: "{FILES} file"
+      two: ""
+      few: ""
+      many: ""
+      other: "{FILES} files"
+
+  albums:
+    _: "{ALBUMS}"
+    withUnit:
+      zero: ""
+      one: "{ALBUMS} album"
+      two: ""
+      few: ""
+      many: ""
+      other: "{ALBUMS} albums"
+
+  artTags:
+    _: "{TAGS}"
+    withUnit:
+      zero: ""
+      one: "{TAGS} tag"
+      two: ""
+      few: ""
+      many: ""
+      other: "{TAGS} tags"
+
+  artworks:
+    _: "{ARTWORKS}"
+    withUnit:
+      zero: ""
+      one: "{ARTWORKS} artwork"
+      two: ""
+      few: ""
+      many: ""
+      other: "{ARTWORKS} artworks"
+
+  commentaryEntries:
+    _: "{ENTRIES}"
+    withUnit:
+      zero: ""
+      one: "{ENTRIES} entry"
+      two: ""
+      few: ""
+      many: ""
+      other: "{ENTRIES} entries"
+
+  contributions:
+    _: "{CONTRIBUTIONS}"
+    withUnit:
+      zero: ""
+      one: "{CONTRIBUTIONS} contribution"
+      two: ""
+      few: ""
+      many: ""
+      other: "{CONTRIBUTIONS} contributions"
+
+  coverArts:
+    _: "{COVER_ARTS}"
+    withUnit:
+      zero: ""
+      one: "{COVER_ARTS} cover art"
+      two: ""
+      few: ""
+      many: ""
+      other: "{COVER_ARTS} cover arts"
+
+  flashes:
+    _: "{FLASHES}"
+    withUnit:
+      zero: ""
+      one: "{FLASHES} flashes & games"
+      two: ""
+      few: ""
+      many: ""
+      other: "{FLASHES} flashes & games"
+
+  tracks:
+    _: "{TRACKS}"
+    withUnit:
+      zero: ""
+      one: "{TRACKS} track"
+      two: ""
+      few: ""
+      many: ""
+      other: "{TRACKS} tracks"
+
+  # Count more abstract stuff
+
+  days:
+    _: "{DAYS}"
+    withUnit:
+      zero: ""
+      one: "{DAYS} day"
+      two: ""
+      few: ""
+      many: ""
+      other: "{DAYS} days"
+
+  months:
+    _: "{MONTHS}"
+    withUnit:
+      zero: ""
+      one: "{MONTHS} month"
+      two: ""
+      few: ""
+      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:
+      zero: ""
+      one: "{TIMES_REFERENCED} time referenced"
+      two: ""
+      few: ""
+      many: ""
+      other: "{TIMES_REFERENCED} times referenced"
+
+  timesUsed:
+    _: "{TIMES_USED}"
+    withUnit:
+      zero: ""
+      one: "used {TIMES_USED} time"
+      two: ""
+      few: ""
+      many: ""
+      other: "used {TIMES_USED} times"
+
+  weeks:
+    _: "{WEEKS}"
+    withUnit:
+      zero: ""
+      one: "{WEEKS} week"
+      two: ""
+      few: ""
+      many: ""
+      other: "{WEEKS} weeks"
+
+  words:
+    _: "{WORDS}"
+    thousand: "{WORDS}k"
+    withUnit:
+      zero: ""
+      one: "{WORDS} word"
+      two: ""
+      few: ""
+      many: ""
+      other: "{WORDS} words"
+
+  years:
+    _: "{YEARS}"
+    withUnit:
+      zero: ""
+      one: "{YEARS} year"
+      two: ""
+      few: ""
+      many: ""
+      other: "{YEARS} years"
+
+  # Numerical things that aren't exactly counting, per se
+
+  duration:
+    missing: "_:__"
+    approximate: "~{DURATION}"
+    hours:
+      _:        "{HOURS}:{MINUTES}:{SECONDS}"
+      withUnit: "{HOURS}:{MINUTES}:{SECONDS} hours"
+    minutes:
+      _:        "{MINUTES}:{SECONDS}"
+      withUnit: "{MINUTES}:{SECONDS} minutes"
+
+  dateDuration:
+    earlier: "{DURATION} earlier"
+    later: "{DURATION} later"
+    same: "on the same date"
+    zero: "at most one day"
+    approximate: "about {DURATION}"
+    approximateEarlier: "about {DURATION} earlier"
+    approximateLater: "about {DURATION} later"
+    relativeAbsolute: "{ABSOLUTE}; {RELATIVE}"
+
+    years: "{YEARS}"
+    months: "{MONTHS}"
+    days: "{DAYS}"
+    yearsMonthsDays: "{YEARS}, {MONTHS}, {DAYS}"
+    yearsMonths: "{YEARS}, {MONTHS}"
+    yearsDays: "{YEARS}, {DAYS}"
+    monthsDays: "{MONTHS}, {DAYS}"
+
+  fileSize:
+    terabytes: "{TERABYTES} TB"
+    gigabytes: "{GIGABYTES} GB"
+    megabytes: "{MEGABYTES} MB"
+    kilobytes: "{KILOBYTES} kB"
+    bytes: "{BYTES} bytes"
+
+  # Indexes in a list
+  # These use "Type: ordinal" on CLDR's chart of plural rules.
+
+  index:
+    zero: ""
+    one: "{INDEX}st"
+    two: "{INDEX}nd"
+    few: "{INDEX}rd"
+    many: ""
+    other: "{INDEX}th"
+
+#
+# releaseInfo:
+#
+#   This covers a lot of generic strings - they're used in a variety
+#   of contexts. They're sorted below with descriptions first, then
+#   actions further down.
+#
+releaseInfo:
+
+  # Descriptions
+
+  by:
+    _: "By {ARTISTS}."
+    featuring: "By {ARTISTS}, featuring {FEATURING}."
+
+  from: "From {ALBUM}."
+
+  wallpaperArtBy: "Wallpaper by {ARTISTS}"
+  bannerArtBy: "Banner by {ARTISTS}"
+
+  released: "Released {DATE}."
+  albumReleased: "Album released {DATE}."
+  trackReleased: "Track released {DATE}."
+  addedToWiki: "Added to wiki {DATE}."
+
+  duration: "Duration: {DURATION}."
+
+  contributors: "Contributors:"
+
+  lyrics:
+    _: "Lyrics:"
+
+    switcher: "({ENTRIES})"
+
+  note: "Context notes:"
+
+  alsoReleasedOn: "Also released on {ALBUMS}."
+
+  tracksReferenced:
+    _: "Tracks that {TRACK} references:"
+    sticky: "Tracks that this one references:"
+
+  tracksSampled:
+    _: "Tracks that {TRACK} samples:"
+    sticky: "Tracks that this one samples:"
+
+  tracksThatReference:
+    _: "Tracks that reference {TRACK}:"
+
+    sticky:
+      _: "Tracks that reference this one:"
+      fromGroup: "Tracks 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
+
+  viewCommentary:
+    _: "View {LINK}!"
+    link: "commentary page"
+
+  viewGallery:
+    _: "View {LINK}!"
+    link: "gallery page"
+
+  viewGalleryOrCommentary:
+    _: "View {GALLERY} or {COMMENTARY}!"
+    gallery: "gallery page"
+    commentary: "commentary page"
+
+  viewOriginalFile:
+    _: "View {LINK}."
+    withSize: "View {LINK} ({SIZE})."
+    link: "original file"
+    sizeWarning: >-
+      (Heads up! If you're on a mobile plan, this is a large download.)
+
+  listenOn:
+    _: "Listen on {LINKS}."
+    noLinks: >-
+      This wiki doesn't have any listening links for {NAME}.
+
+  visitOn: "Visit on {LINKS}."
+  playOn: "Play on {LINKS}."
+
+  readCommentary:
+    _: "Read {LINK}."
+    link: "artist commentary"
+
+  readCreditSources:
+    _: "Read {LINK}."
+    link: "crediting sources"
+
+  additionalFiles:
+    heading: "View or download additional files:"
+
+    entry:
+      _: "{TITLE}"
+
+      noFilesAvailable: >-
+        There are no files available or listed for this entry.
+
+    file:
+      _: "{FILE}"
+      withSize: "{FILE} ({SIZE})"
+
+    shortcut:
+      _: "View {LINK}."
+      link: "additional files"
+
+  sheetMusicFiles:
+    heading: "Print or download sheet music files:"
+
+    shortcut:
+      _: "Download {LINK}."
+      link: "sheet music files"
+
+  midiProjectFiles:
+    heading: "Download MIDI/project files:"
+
+    shortcut:
+      _: "Download {LINK}."
+      link: "MIDI/project files"
+
+#
+# trackList:
+#
+#   A list of tracks! These are used pretty much across the wiki.
+#   Track lists can be split into sections, groups, or not split at
+#   all. "Track sections" are divisions in the list which suit the
+#   album as a whole, like if it has multiple discs or bonus tracks.
+#   "Groups" are actual group objects (see ex. groupInfoPage).
+#
+trackList:
+  section:
+    _: "{SECTION}:"
+    withDuration: "{SECTION}: ({DURATION})"
+    sticky: "{SECTION}:"
+
+  fromGroup: "From {GROUP}:"
+  fromOther: "From somewhere else:"
+
+  item:
+    _: "{TRACK}"
+
+    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}"
+
+    withDuration.withArtists: >-
+      {DURATION} {TRACK} {BY}
+
+    rerelease: >-
+      {TRACK} (rerelease)
+
+#
+# misc:
+#
+#   These cover a whole host of general things across the wiki, and
+#   aren't specially organized. Sorry! See each entry for details.
+#
+misc:
+
+  # additionalNames:
+  #   "Drop"-styled box that catalogues a variety of additional or
+  #   alternate names for the current thing; toggled by clicking on the
+  #   thing's title, which is styled interactively and gets a tooltip
+  #   (hover text), since it isn't usually an interactive element.
+
+  additionalNames:
+    title: "Additional or alternate names:"
+    tooltip: "Click to view additional or alternate names"
+
+    item:
+      _: "{NAME}"
+      withAccent: "{NAME} {ACCENT}"
+
+      accent:
+        withAnnotation: "({ANNOTATION})"
+
+  # alt:
+  #   Fallback text for the alt text of images and artworks - these
+  #   are read aloud by screen readers.
+
+  alt:
+    albumCover: "album cover"
+    albumBanner: "album banner"
+    trackCover: "track cover"
+    artistAvatar: "artist avatar"
+    flashArt: "flash art"
+
+  # artistCommentary:
+
+  artistCommentary:
+    _: "Artist commentary:"
+
+    entry:
+      title:
+        _: "{ARTISTS}:"
+
+        noArtists: "Unknown artist"
+
+        withAccent: "{ARTISTS}: {ACCENT}"
+
+        accent:
+          withAnnotation: "({ANNOTATION})"
+
+        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
+  #   present in a variety of places across the wiki.
+
+  artistLink:
+    _: "{ARTIST}"
+
+    # Contribution to a track, artwork, or other thing.
+    withContribution: "{ARTIST} ({CONTRIB})"
+
+    # 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
+  #   most things with individual contributors across the wiki! These
+  #   allow for quick navigation between older and newer releases of
+  #   a given artist, or seeing at a glance how many contributions an
+  #   artist made before the one you're currently viewing.
+  #
+  #   Chronology information is described for each artist and shows
+  #   the kind of thing which is being contributed to, since all of
+  #   the entries are displayed together in one list.
+  #
+
+  chronology:
+
+    # seeArtistPages:
+    #   If the thing you're viewing has a lot of contributors, their
+    #   chronology info will be exempt from the nav bar, which'll
+    #   show this message instead.
+
+    seeArtistPages: "(See artist pages for chronology info!)"
+
+    # withNavigation:
+    #   Navigation refers to previous/next links.
+
+    withNavigation: "{HEADING} ({NAVIGATION})"
+
+    heading:
+      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.
+  #   The list of sites is hard-coded into the wiki software, so it
+  #   may be out of date or missing ones that are relevant to another
+  #   wiki - sorry!
+
+  external:
+    external: "External"
+
+    withDomain:
+      "{PLATFORM} ({DOMAIN})"
+
+    withHandle:
+      "{PLATFORM} ({HANDLE})"
+
+    opensInNewTab:
+      _: "{LINK} ({ANNOTATION})"
+      annotation: "opens in new tab"
+
+    invalidURL:
+      _: "{LINK} ({ANNOTATION})"
+      annotation: "invalid URL"
+
+    amazonMusic: "Amazon Music"
+    appleMusic: "Apple Music"
+    artstation: "ArtStation"
+    bandcamp: "Bandcamp"
+
+    bgreco:
+      _: "bgreco.net"
+      flash: "bgreco.net (high quality audio)"
+
+    bluesky: "Bluesky"
+    carrd: "Carrd"
+    cohost: "Cohost"
+
+    deconreconstruction:
+      _: "Deconreconstruction"
+      music: "MUSIC@DCRC"
+
+    deviantart: "DeviantArt"
+    facebook: "Facebook"
+
+    fandom:
+      _: "Fandom"
+
+      mspaintadventures:
+        _: "MSPA Wiki"
+        page: "MSPA Wiki ({PAGE})"
+
+    gamebanana: "GameBanana"
+
+    homestuck:
+      _: "Homestuck"
+      page: "Homestuck (page {PAGE})"
+      secretPage: "Homestuck (secret page)"
+
+    hsmusic:
+      _: "HSMusic"
+      archive: "HSMusic (wiki archive)"
+
+    instagram: "Instagram"
+    internetArchive: "Internet Archive"
+    itch: "itch.io"
+    kofi: "Ko-fi"
+    linktree: "Linktree"
+    mastodon: "Mastodon"
+    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"
+    twitch: "Twitch"
+    twitter: "Twitter"
+    waybackMachine: "Wayback Machine"
+    wikipedia: "Wikipedia"
+
+    youtube:
+      _: "YouTube"
+      flash: "YouTube (on any device)"
+      playlist: "YouTube (playlist)"
+      fullAlbum: "YouTube (full album)"
+
+  # missingImage:
+  #   Fallback text displayed in an image when it's sourced to a file
+  #   that isn't available under the wiki's media directory. While it
+  #   shouldn't display on a correct build of the site, it may be
+  #   displayed when working on data locally (for example adding a
+  #   track before you've brought in its cover art).
+
+  missingImage: "(This image file is missing)"
+
+  # misingLinkContent:
+  #   Generic fallback when a link is completely missing its content.
+  #   This is only to make those links visible in the first place -
+  #   it should never appear on the website and is only intended for
+  #   debugging.
+
+  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.
+
+  nav:
+    previous: "Previous"
+    next: "Next"
+    info: "Info"
+    gallery: "Gallery"
+
+  # pageTitle:
+  #   Title set under the page's <title> HTML element, which is
+  #   displayed in the browser tab bar, bookmarks list, etc.
+
+  pageTitle:
+    _: >-
+      {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:
+  #
+  #   These are navigational links that only show up when you're
+  #   navigating the wiki using the Tab key (or some other method of
+  #   "tabbing" between links and interactive elements). They move
+  #   the browser's nav focus to the selected element when pressed.
+  #
+  #   There are a lot of definitions here, and they're mostly shown
+  #   conditionally, based on the elements that are actually apparent
+  #   on the current page.
+  #
+
+  skippers:
+    skipTo: "Skip to:"
+
+    content: "Content"
+    header: "Header"
+    footer: "Footer"
+
+    sidebar:
+      _: "Sidebar"
+      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.
+
+    contributors: "Contributors"
+
+    # Displayed on track info page.
+
+    references: "References..."
+    referencedBy: "Referenced by..."
+    samples: "Samples..."
+    sampledBy: "Sampled by..."
+    features: "Features..."
+    featuredIn: "Featured in..."
+
+    lyrics: "Lyrics"
+
+    sheetMusicFiles: "Sheet music files"
+    midiProjectFiles: "MIDI/project files"
+
+    # Displayed on track and album info pages.
+
+    additionalFiles: "Additional files"
+
+  # socialEmbed:
+  #   Social embeds describe how the page should be represented on
+  #   social platforms, chat messaging apps, and so on.
+
+  socialEmbed:
+    heading: "{WIKI_NAME} | {HEADING}"
+
+  # jumpTo:
+  #   Generic action displayed at the top of some longer pages, for
+  #   quickly scrolling down to a particular section.
+
+  jumpTo:
+    _: "Jump to:"
+    withLinks: "Jump to: {LINKS}."
+
+  # contentWarnings:
+  #   Displayed for some artworks, informing of possibly sensitive
+  #   content and giving the viewer a chance to consider before
+  #   clicking through.
+
+  contentWarnings:
+    warnings: "{WARNINGS}"
+    reveal: "click to show"
+
+  # 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.
+
+  coverGrid:
+    noCoverArt: "{ALBUM}"
+
+    details:
+      accent: "({DETAILS})"
+
+      albumLength: "{TRACKS}, {TIME}"
+      coverArtists: "Artwork by {ARTISTS}"
+      otherCoverArtists: "With {ARTISTS}"
+
+  albumGalleryGrid:
+    noCoverArt: "{NAME}"
+
+  # uiLanguage:
+  #   Displayed in the footer, for switching between languages.
+
+  uiLanguage: "UI Language: {LANGUAGES}"
+
+#
+# homepage:
+#   This is the main index and home for the whole wiki! There isn't
+#   much for strings here as the layout is very customizable and
+#   includes mostly wiki-provided content.
+#
+homepage:
+  title: "{TITLE}"
+
+  # news:
+  #   If the wiki has news entries enabled, then there's a box in the
+  #   homepage's sidebar (beneath custom sidebar content, if any)
+  #   which displays the bodies the latest few entries up to a split.
+
+  news:
+    title: "News"
+
+    entry:
+      viewRest: "(View rest of entry!)"
+
+#
+# albumSidebar:
+#   This sidebar is displayed on both the album and track info pages!
+#   It displays the groups that the album is from (each getting its
+#   own box on the album page, all conjoined in one box on the track
+#   page) and the list of tracks in the album, which can be sectioned
+#   similarly to normal track lists, but displays the range of tracks
+#   in each section rather than the section's duration.
+#
+albumSidebar:
+  trackList:
+    item: "{TRACK}"
+
+    # fallbackSectionName:
+    #   If an album's track list isn't sectioned, the track list here
+    #   will still have all the tracks grouped under a list that can
+    #   be toggled open and closed. This controls how that list gets
+    #   titled.
+
+    fallbackSectionName: "Track list"
+
+    # group:
+    #   "Group" is a misnomer - these are track sections. Some albums
+    #   don't use track numbers at all, and for these, the default
+    #   string will be used instead of group.withRange.
+
+    group:
+      _: "{GROUP}"
+
+      withRange: "{GROUP} {RANGE_PART}"
+      withRange.rangePart: "({RANGE})"
+
+  # groupBox:
+  #   This is the box for groups. Apart from the next and previous
+  #   links, it also gets "visit on" and the group's descripton
+  #   (up to a split).
+
+  groupBox:
+    title: "{GROUP}"
+    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:
+#
+#   Albums group together tracks and provide quick access to each of
+#   their pages, have release data (and sometimes credits) that are
+#   generally inherited by the album's tracks plus commentary and
+#   other goodies of their own, and are generally the main object on
+#   the wiki!
+#
+#   Most of the strings on the album info page are tracked under
+#   releaseInfo, so there isn't a lot here.
+#
+albumPage:
+  title: "{ALBUM}"
+
+  nav:
+    album: "{ALBUM}"
+
+    backToAlbum: "Return to album page"
+
+    randomTrack: "Random Track"
+    gallery: "Gallery"
+    commentary: "Commentary"
+
+  socialEmbed:
+    heading: "{GROUP}"
+    title: "{ALBUM}"
+
+    # body:
+    #   These permutations are a bit awkward. "Tracks" is a counted
+    #   string, ex. "63 tracks".
+
+    body:
+      withDuration: "{DURATION}."
+      withTracks: "{TRACKS}."
+      withReleaseDate: Released {DATE}.
+      withDuration.withTracks: "{DURATION}, {TRACKS}."
+      withDuration.withReleaseDate: "{DURATION}. Released {DATE}."
+      withTracks.withReleaseDate: "{TRACKS}. Released {DATE}."
+      withDuration.withTracks.withReleaseDate: "{DURATION}, {TRACKS}. Released {DATE}."
+
+#
+# albumGalleryPage:
+#   Album galleries provide an alternative way to navigate the album,
+#   and put all its artwork - including for each track - into the
+#   spotlight. Apart from the main gallery grid (which usually lists
+#   each artwork's illustrators), this page also has a quick stats
+#   line about the album, and may display a message about all of the
+#   artworks if one applies.
+#
+albumGalleryPage:
+  title: "{ALBUM} - Gallery"
+
+  # statsLine:
+  #   Most albums have release dates, but not all. These strings
+  #   react accordingly.
+
+  statsLine: >-
+    {TRACKS} totaling {DURATION}.
+
+  statsLine.withDate: >-
+    {TRACKS} totaling {DURATION}. Released {DATE}.
+
+  # coverArtistsLine:
+  #   This is displayed if every track (which has artwork at all)
+  #   has the same illustration credits.
+
+  coverArtistsLine: >-
+    All track artwork by {ARTISTS}.
+
+  # noTrackArtworksLine:
+  #   This is displayed if none of the tracks on the album have any
+  #   artwork at all. Generally, this means the album gallery won't
+  #   be linked from the album's other pages, but it is possible to
+  #   end up on "stub galleries" using nav links on another gallery.
+
+  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
+#   the commentary for the album, and each of its tracks, to the
+#   front. It's basically inspired by reading in a library, or by
+#   following along with an album's booklet or liner notes while
+#   playing it back on a treasured dinky CD player late at night.
+#
+albumCommentaryPage:
+  title: "{ALBUM} - Commentary"
+
+  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:
+        _: "{TRACK}"
+        accent: "Listen on: {LISTENING_LINKS}"
+
+#
+# artistInfoPage:
+#   The artist info page is an artist's main home on the wiki, and
+#   automatically includes a full list of all the things they've
+#   contributed to and been credited on. It's split into a section
+#   for each of the kinds of things the artist is credited for,
+#   including tracks, artworks, flashes/games, and commentary.
+#
+artistPage:
+  title: "{ARTIST}"
+
+  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:
+    #   Tracks are chunked by albums, as long as the tracks are all
+    #   of the same date (if applicable).
+
+    album:
+      _: "{ALBUM}"
+      withDate: "{ALBUM} ({DATE})"
+      withDuration: "{ALBUM} ({DURATION})"
+      withDate.withDuration: "{ALBUM} ({DATE}; {DURATION})"
+
+    # flashAct:
+    #   Flashes are chunked by flash act, though a single flash act
+    #   might be split into multiple chunks if it spans a long range
+    #   and the artist contributed to a flash from some other act
+    #   between. A date range will be shown if an act has at least
+    #   two differently dated flashes.
+
+    flashAct:
+      _: "{ACT}"
+      withDate: "{ACT} ({DATE})"
+      withDateRange: "{ACT} ({DATE_RANGE})"
+
+    # entry:
+    #   This section covers strings for all kinds of individual
+    #   things which an artist has contributed to, and refers to the
+    #   items in each of the chunks described above.
+
+    entry:
+
+      # withAnnotation:
+      #   The specific contribution that an artist made to a given
+      #   thing may be described with a word or two, and that's shown
+      #   in the list.
+
+      withAnnotation: "{ENTRY} ({ANNOTATION})"
+
+      # withArtists:
+      #   This lists co-artists or co-contributors, depending on how
+      #   the artist themselves was credited.
+
+      withArtists: "{ENTRY} (with {ARTISTS})"
+
+      withArtists.withAnnotation: "{ENTRY} ({ANNOTATION}; with {ARTISTS})"
+
+      # rerelease:
+      #   Tracks which aren't the original release don't display co-
+      #   artists or contributors, and get dimmed a little compared
+      #   to original release track entries.
+
+      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
+      #   track credits list as well as their commentary list.
+
+      track:
+        _: "{TRACK}"
+        withDuration: "({DURATION}) {TRACK}"
+
+      # album:
+      #   The artist info page doesn't display if the artist is
+      #   musically credited outright for the album as a whole,
+      #   opting to show each of the tracks from that album instead.
+      #   But other parts belonging specifically to the album have
+      #   credits too, and those entreis get the strings below.
+
+      album:
+        coverArt: "(cover art)"
+        wallpaperArt: "(wallpaper art)"
+        bannerArt: "(banner art)"
+        commentary: "(album commentary)"
+
+      flash:
+        _: "{FLASH}"
+
+  # contributedDurationLine:
+  #   This is shown at the top of the artist's track list, provided
+  #   any of their tracks have durations at all.
+
+  contributedDurationLine: >-
+    {ARTIST} has contributed {DURATION} of music shared on this wiki.
+
+  # groupContributions:
+  #   This is a special "chunk" shown at the top of an artist's
+  #   track and artwork lists. It lists which groups an artist has
+  #   contributed the most (and least) to, and is interactive -
+  #   it can be sorted by count or, for tracks, by duration.
+
+  groupContributions:
+    title:
+      music: "Contributed music to groups:"
+      artworks: "Contributed artworks to groups:"
+      withSortButton: "{TITLE} ({SORT})"
+
+      sorting:
+        count: "Sorting by count."
+        duration: "Sorting by duration."
+
+    item:
+      countAccent: "({COUNT})"
+      durationAccent: "({DURATION})"
+      countDurationAccent: "({COUNT} — {DURATION})"
+      durationCountAccent: "({DURATION} — {COUNT})"
+
+  trackList:
+    title: "Tracks"
+
+  artList:
+    title: "Artworks"
+
+  flashList:
+    title: "Flashes & Games"
+
+  commentaryList:
+    title: "Commentary"
+
+  # viewArtGallery:
+  #   This is shown twice on the page - once at almost the very top
+  #   of the page, just beneath visiting links, and once above the
+  #   list of credited artworks, where it gets the longer
+  #   orBrowseList form.
+
+  viewArtGallery:
+    _: "View {LINK}!"
+    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
+#   track artworks an artist has contributed to! Co-illustrators are
+#   also displayed when applicable.
+#
+artistGalleryPage:
+  title: "{ARTIST} - Gallery"
+
+  infoLine: >-
+    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
+#   dedicated commentary page.
+#
+commentaryIndex:
+  title: "Commentary"
+
+  infoLine: >-
+    {WORDS} across {ENTRIES}, in all.
+
+  albumList:
+    title: "Choose an album:"
+    item: "{ALBUM} ({WORDS} across {ENTRIES})"
+
+#
+# flashIndex:
+#   The flash index page shows a very long grid including every flash
+#   on the wiki, sectioned with big headings for each act. It's also
+#   got jump links at the top to skip to a specific overarching
+#   section ("side") of flash acts.
+#
+flashIndex:
+  title: "Flashes & Games"
+
+#
+# flashSidebar:
+#   The flash sidebar is used on both the flash info and flash act
+#   gallery pages, and has two boxes - one showing all the flashes in
+#   the current flash act, and one showing all the flash acts on the
+#   wiki, sectioned by "side".
+#
+flashSidebar:
+  flashList:
+
+    # This is the default string used when neither a flash act nor its side
+    # specifies a custom phrasing.
+    entriesInThisSection: "Entries in this section"
+
+#
+# flashPage:
+#   The flash info page shows release information, links to check the
+#   flash out, and lists of contributors and featured tracks. Most of
+#   those strings are under releaseInfo, so there aren't a lot of
+#   strings here.
+#
+flashPage:
+  title: "{FLASH}"
+
+  nav:
+    flash: "{FLASH}"
+
+#
+# groupSidebar:
+#   The group sidebar is used on both the group info and group
+#   gallery pages, and is formed of just one box, showing all the
+#   groups on the wiki, sectioned by "category".
+#
+groupSidebar:
+  title: "Groups"
+
+  groupList:
+    category: "{CATEGORY}"
+    item: "{GROUP}"
+
+#
+# groupPage:
+#   This section represents strings common to multiple group pages.
+#
+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
+#   description, and a list of albums from the group.
+#
+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:
+
+    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
+  #   list of groups.
+
+  albumList:
+    title: "Albums"
+
+    series:
+      _: >-
+        {SERIES}:
+
+    item:
+      _: >-
+        {ALBUM}
+
+      withYear:
+        _: >-
+          {YEAR_ACCENT} {ALBUM}
+
+        accent: "({YEAR})"
+
+      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}
+
+      withYear.withArtists: >-
+        {YEAR_ACCENT} {ALBUM} {BY}
+
+#
+# groupGalleryPage:
+#   The group gallery page shows a grid of all the albums from that
+#   group, each including the number of tracks and duration, as well
+#   as a stats line for the group as a whole, and a neat carousel, if
+#   pre-configured!
+#
+groupGalleryPage:
+  title: "{GROUP} - Gallery"
+
+  infoLine: >-
+    {TRACKS} across {ALBUMS}, totaling {TIME}.
+
+#
+# listingIndex:
+#   The listing index page shows all available listings on the wiki,
+#   and a very exciting stats line for the wiki as a whole.
+#
+listingIndex:
+  title: "Listings"
+
+  infoLine: >-
+    {WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}.
+
+  exploreList: >-
+    Feel free to explore any of the listings linked below and in the sidebar!
+
+#
+# listingPage:
+#
+#   There are a lot of listings! Each is automatically generated and
+#   sorts or organizes the data on the wiki in some way that provides
+#   useful or interesting information. Most listings work primarily
+#   with one kind of data and are sectioned accordingly, for example
+#   "listAlbums.byDuration" or "listTracks.byDate".
+#
+#   There are also some miscellaneous strings here, most of which are
+#   common to a variety of listings, and are often navigational in
+#   nature.
+#
+listingPage:
+
+  # target:
+  #   Just the names for each of the sections - each chunk on the
+  #   listing index (and in the sidebar) gets is titled with one of
+  #   these.
+
+  target:
+    album: "Albums"
+    artist: "Artists"
+    group: "Groups"
+    track: "Tracks"
+    tag: "Tags"
+    other: "Other"
+
+  # misc:
+  #   Common, generic terminology across multiple listings.
+
+  misc:
+    trackContributors: "Track Contributors"
+    artContributors: "Art Contributors"
+    flashContributors: "Flash & Game Contributors"
+    artAndFlashContributors: "Art & Flash Contributors"
+
+  # listingFor:
+  #   Displays quick links to navigate to other listings for the
+  #   current target.
+
+  listingsFor: "Listings for {TARGET}: {LISTINGS}"
+
+  # seeAlso:
+  #   Displays directly related listings, which might be from other
+  #   targets besides the current one.
+
+  seeAlso: "Also check out: {LISTINGS}"
+
+  # skipToSection:
+  #   Some listings which use a chunked-list layout also show links
+  #   to scroll down to each of these sections - this is the title
+  #   for the list of those links.
+
+  skipToSection: "Skip to a section:"
+
+  listAlbums:
+
+    # listAlbums.byName:
+    #   Lists albums alphabetically without sorting or chunking by
+    #   any other criteria. Also displays the number of tracks for
+    #   each album.
+
+    byName:
+      title: "Albums - by Name"
+      title.short: "...by Name"
+      item: "{ALBUM} ({TRACKS})"
+
+    # listAlbums.byTracks:
+    #   Lists albums by number of tracks, most to least, or by name
+    #   alphabetically, if two albums have the same track count.
+    #   Albums without any tracks are totally excluded.
+
+    byTracks:
+      title: "Albums - by Tracks"
+      title.short: "...by Tracks"
+      item: "{ALBUM} ({TRACKS})"
+
+    # listAlbums.byDuration:
+    #   Lists albums by total duration of all tracks, longest to
+    #   shortest, falling back to an alphabetical sort if two albums
+    #   are the same duration. Albums with zero duration are totally
+    #   excluded.
+
+    byDuration:
+      title: "Albums - by Duration"
+      title.short: "...by Duration"
+      item: "{ALBUM} ({DURATION})"
+
+    # listAlbums.byDate:
+    #   Lists albums by release date, oldest to newest, falling back
+    #   to an alphabetical sort if two albums were released on the
+    #   same date. Dateless albums are totally excluded.
+
+    byDate:
+      title: "Albums - by Date"
+      title.short: "...by Date"
+      item: "{ALBUM} ({DATE})"
+
+    # listAlbums.byDateAdded:
+    #   Lists albums by the date they were added to the wiki, oldest
+    #   to newest, and chunks these by date, since albums are usually
+    #   added in bunches at a time. The albums in each chunk are
+    #   sorted alphabetically, and albums which are missing the
+    #   "Date Added" field are totally excluded.
+
+    byDateAdded:
+      title: "Albums - by Date Added to Wiki"
+      title.short: "...by Date Added to Wiki"
+      chunk:
+        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:
+    #   Lists artists alphabetically without sorting or chunking by
+    #   any other criteria. Also displays the number of contributions
+    #   from each artist.
+
+    byName:
+      title: "Artists - by Name"
+      title.short: "...by Name"
+      item: "{ARTIST} ({CONTRIBUTIONS})"
+
+    # listArtists.byContribs:
+    #   Lists artists by number of contributions, most to least,
+    #   with separate lists for contributions to tracks, artworks,
+    #   and flashes. Falls back alphabetically if two artists have
+    #   the same number of contributions. Artists who aren't credited
+    #   for any contributions to each of these categories are
+    #   excluded from the respective list.
+
+    byContribs:
+      title: "Artists - by Contributions"
+      title.short: "...by Contributions"
+      chunk:
+        item: "{ARTIST} ({CONTRIBUTIONS})"
+        title:
+          trackContributors: "Contributed tracks:"
+          artContributors: "Contributed artworks:"
+          flashContributors: "Contributed to flashes & games:"
+
+    # listArtists.byCommentary:
+    #   Lists artists by number of commentary entries, most to least,
+    #   falling back to an alphabetical sort if two artists have the
+    #   same count. Artists who don't have any commentary entries are
+    #   totally excluded.
+
+    byCommentary:
+      title: "Artists - by Commentary Entries"
+      title.short: "...by Commentary Entries"
+      item: "{ARTIST} ({ENTRIES})"
+
+    # listArtists.byDuration:
+    #   Lists artists by total duration of the tracks which they're
+    #   credited on (as either artist or contributor), longest sum to
+    #   shortest, falling back alphabetically if two artists have
+    #   the same duration. Artists who haven't contributed any music,
+    #   or whose tracks all lack durations, are totally excluded.
+
+    byDuration:
+      title: "Artists - by Duration"
+      title.short: "...by Duration"
+      item: "{ARTIST} ({DURATION})"
+
+    # listArtists.byGroup:
+    #   Lists artists who have contributed to each of the main groups
+    #   of a wiki (its "Divide Track Lists By Groups" field), sorted
+    #   alphabetically. Artists who aren't credited for contributions
+    #   under each of the groups are exlcuded from the respective
+    #   list.
+
+    byGroup:
+      title: "Artists - by Group"
+      title.short: "...by Group"
+      item: "{ARTIST} ({CONTRIBUTIONS})"
+      chunk:
+        title: "Contributed to {GROUP}:"
+        item: "{ARTIST} ({CONTRIBUTIONS})"
+
+    # listArtists.byLatest:
+    #   Lists artists by the date of their latest contribution
+    #   overall, and chunks artists together by the album or flash
+    #   which that contribution belongs to. Within albums, each
+    #   artist is accented with the kind of contribution they made -
+    #   tracks, artworks, or both - and sorted so those of the same
+    #   sort of contribution are bunched together, then by name.
+    #   Artists who aren't credited for any dated contributions are
+    #   included at the bottom under a separate chunk.
+
+    byLatest:
+      title: "Artists - by Latest Contribution"
+      title.short: "...by Latest Contribution"
+      chunk:
+        title:
+          album: "{ALBUM} ({DATE})"
+          flash: "{FLASH} ({DATE})"
+          dateless: "These artists' contributions aren't dated:"
+        item:
+          _: "{ARTIST}"
+          tracks: "{ARTIST} (tracks)"
+          tracksAndArt: "{ARTIST} (tracks, art)"
+          art: "{ARTIST} (art)"
+
+  listGroups:
+
+    # listGroups.byName:
+    #   Lists groups alphabetically without sorting or chunking by
+    #   any other criteria. Also displays a link to each group's
+    #   gallery page.
+
+    byName:
+      title: "Groups - by Name"
+      title.short: "...by Name"
+      item: "{GROUP} ({GALLERY})"
+      item.gallery: "Gallery"
+
+    # listGroups.byCategory:
+    #   Lists groups directly reflecting the way they're sorted in
+    #   the wiki's groups.yaml data file, with no automatic sorting,
+    #   chunked (as sectioned in groups.yaml) by category. Also shows
+    #   a link to each group's gallery page.
+
+    byCategory:
+      title: "Groups - by Category"
+      title.short: "...by Category"
+
+      chunk:
+        title: "{CATEGORY}"
+        item: "{GROUP} ({GALLERY})"
+        item.gallery: "Gallery"
+
+    # listGroups.byAlbums:
+    #   Lists groups by number of belonging albums, most to least,
+    #   falling back alphabetically if two groups have the same
+    #   number of albums. Groups without any albums are totally
+    #   excluded.
+
+    byAlbums:
+      title: "Groups - by Albums"
+      title.short: "...by Albums"
+      item: "{GROUP} ({ALBUMS})"
+
+    # listGroups.byTracks:
+    #   Lists groups by number of tracks under each group's albums,
+    #   most to least, falling back to an alphabetical sort if two
+    #   groups have the same track counts. Groups without any tracks
+    #   are totally excluded.
+
+    byTracks:
+      title: "Groups - by Tracks"
+      title.short: "...by Tracks"
+      item: "{GROUP} ({TRACKS})"
+
+    # listGroups.byDuration:
+    #   Lists groups by sum of durations of all the tracks under each
+    #   of the group's albums, longest to shortest, falling back to
+    #   an alphabetical sort if two groups have the same duration.
+    #   Groups whose total duration is zero are totally excluded.
+
+    byDuration:
+      title: "Groups - by Duration"
+      title.short: "...by Duration"
+      item: "{GROUP} ({DURATION})"
+
+    # listGroups.byLatest:
+    #   List groups by release date of each group's most recent
+    #   album, most recent to longest ago, falling back to sorting
+    #   alphabetically if two groups' latest albums were released
+    #   on the same date. Groups which don't have any albums, or
+    #   whose albums are all dateless, are totally excluded.
+
+    byLatest:
+      title: "Groups - by Latest Album"
+      title.short: "...by Latest Album"
+      item: "{GROUP} ({DATE})"
+
+  listTracks:
+
+    # listTracks.byName:
+    #   List tracks alphabetically without sorting or chunking by
+    #   any other criteria.
+
+    byName:
+      title: "Tracks - by Name"
+      title.short: "...by Name"
+      item: "{TRACK}"
+
+    # listTracks.byAlbum:
+    #   List tracks chunked by the album they're from, retaining the
+    #   position each track occupies in its album, and sorting albums
+    #   from oldest to newest (or alphabetically, if two albums were
+    #   released on the same date). Dateless albums are included at
+    #   the bottom of the list. Custom "Date First Released" fields
+    #   on individual tracks are totally ignored.
+
+    byAlbum:
+      title: "Tracks - by Album"
+      title.short: "...by Album"
+
+      chunk:
+        title: "{ALBUM}"
+        item: "{TRACK}"
+
+    # listTracks.byDate:
+    #   List tracks according to their own release dates, which may
+    #   differ from that of the album via the "Date First Released"
+    #   field, oldest to newest, and chunked by album when multiple
+    #   tracks from one album were released on the same date. Track
+    #   order within a given album is preserved where possible.
+    #   Dateless albums are excluded, except for contained tracks
+    #   which have custom "Date First Released" fields.
+
+    byDate:
+      title: "Tracks - by Date"
+      title.short: "...by Date"
+
+      chunk:
+        title: "{ALBUM} ({DATE})"
+        item: "{TRACK}"
+        item.rerelease: "{TRACK} (rerelease)"
+
+    # listTracks.byDuration:
+    #   List tracks by duration, longest to shortest, falling back to
+    #   an alphabetical sort if two tracks have the same duration.
+    #   Tracks which don't have any duration are totally excluded.
+
+    byDuration:
+      title: "Tracks - by Duration"
+      title.short: "...by Duration"
+      item: "{TRACK} ({DURATION})"
+
+    # listTracks.byDurationInAlbum:
+    #   List tracks chunked by the album they're from, then sorted
+    #   by duration, longest to shortest; albums are sorted by date,
+    #   oldest to newest, and both sorts fall back alphabetically.
+    #   Dateless albums are included at the bottom of the list.
+
+    byDurationInAlbum:
+      title: "Tracks - by Duration (in Album)"
+      title.short: "...by Duration (in Album)"
+
+      chunk:
+        title: "{ALBUM}"
+        item: "{TRACK} ({DURATION})"
+
+    # listTracks.byTimesReferenced:
+    #   List tracks by how many other tracks' reference lists each
+    #   appears in, most times referenced to fewest, falling back
+    #   alphabetically if two tracks have been referenced the same
+    #   number of times. Tracks that aren't referenced by any other
+    #   tracks are totally excluded from the list.
+
+    byTimesReferenced:
+      title: "Tracks - by Times Referenced"
+      title.short: "...by Times Referenced"
+      item: "{TRACK} ({TIMES_REFERENCED})"
+
+    # listTracks.inFlashes.byAlbum:
+    #   List tracks, chunked by album (which are sorted by date,
+    #   falling back alphabetically) and in their usual track order,
+    #   and display the list of flashes that eack track is featured
+    #   in. Tracks which aren't featured in any flashes are totally
+    #   excluded from the list.
+
+    inFlashes.byAlbum:
+      title: "Tracks - in Flashes & Games (by Album)"
+      title.short: "...in Flashes & Games (by Album)"
+
+      chunk:
+        title: "{ALBUM}"
+        item: "{TRACK} (in {FLASHES})"
+
+    # listTracks.inFlashes.byFlash:
+    #   List tracks, chunked by flash (which are sorted by date,
+    #   retaining their positions in a common act where applicable,
+    #   or else by the two acts' names) and sorted according to the
+    #   featured list of the flash, and display a link to the album
+    #   each track is contained in. Tracks which aren't featured in
+    #   any flashes are totally excluded from the list.
+
+    inFlashes.byFlash:
+      title: "Tracks - in Flashes & Games (by Flash)"
+      title.short: "...in Flashes & Games (by Flash)"
+
+      chunk:
+        title: "{FLASH}"
+        item: "{TRACK} (from {ALBUM})"
+
+    # listTracks.withLyrics:
+    #   List tracks, chunked by album (which are sorted by date,
+    #   falling back alphabetically) and in their usual track order,
+    #   displaying only tracks which have lyrics. The chunk titles
+    #   also display the date each album was released, and tracks'
+    #   own custom "Date First Released" fields are totally ignored.
+
+    withLyrics:
+      title: "Tracks - with Lyrics"
+      title.short: "...with Lyrics"
+
+      chunk:
+        title: "{ALBUM}"
+        title.withDate: "{ALBUM} ({DATE})"
+        item: "{TRACK}"
+
+    # listTracks.withSheetMusicFiles:
+    #   List tracks, chunked by album (which are sorted by date,
+    #   falling back alphabetically) and in their usual track order,
+    #   displaying only tracks which have sheet music files. The
+    #   chunk titles also display the date each album was released,
+    #   and tracks' own custom "Date First Released" fields are
+    #   totally ignored.
+
+    withSheetMusicFiles:
+      title: "Tracks - with Sheet Music Files"
+      title.short: "...with Sheet Music Files"
+
+      chunk:
+        title: "{ALBUM}"
+        title.withDate: "{ALBUM} ({DATE})"
+        item: "{TRACK}"
+
+    # listTracks.withMidiProjectFiles:
+    #   List tracks, chunked by album (which are sorted by date,
+    #   falling back alphabetically) and in their usual track order,
+    #   displaying only tracks which have MIDI & project files. The
+    #   chunk titles also display the date each album was released,
+    #   and tracks' own custom "Date First Released" fields are
+    #   totally ignored.
+
+    withMidiProjectFiles:
+      title: "Tracks - with MIDI & Project Files"
+      title.short: "...with MIDI & Project Files"
+
+      chunk:
+        title: "{ALBUM}"
+        title.withDate: "{ALBUM} ({DATE})"
+        item: "{TRACK}"
+
+  other:
+
+    # other.allSheetMusic:
+    #   List all sheet music files, sectioned by album (which are
+    #   sorted by date, falling back alphabetically) and then by
+    #   track (which retain album ordering). If one "file" entry
+    #   contains multiple files, then it's displayed as an expandable
+    #   list, collapsed by default, accented with the number of
+    #   downloadable files.
+
+    allSheetMusic:
+      title: "All Sheet Music"
+      title.short: "All Sheet Music"
+      albumFiles: "Album sheet music:"
+
+      file:
+        _: "{TITLE}"
+        withMultipleFiles: "{TITLE} ({FILES})"
+        withNoFiles: "{TITLE} (no files)"
+
+    # other.midiProjectFiles:
+    #   Same as other.allSheetMusic, but for MIDI & project files.
+
+    allMidiProjectFiles:
+      title: "All MIDI/Project Files"
+      title.short: "All MIDI/Project Files"
+      albumFiles: "Album MIDI/project files:"
+
+      file:
+        _: "{TITLE}"
+        withMultipleFiles: "{TITLE} ({FILES})"
+        withNoFiles: "{TITLE} (no files)"
+
+    # other.additionalFiles:
+    #   Same as other.allSheetMusic, but for additional files.
+
+    allAdditionalFiles:
+      title: "All Additional Files"
+      title.short: "All Additional Files"
+      albumFiles: "Album additional files:"
+
+      file:
+        _: "{TITLE}"
+        withMultipleFiles: "{TITLE} ({FILES})"
+        withNoFiles: "{TITLE} (no files available)"
+
+    # other.randomPages:
+    #   Special listing which shows a bunch of buttons that each
+    #   link to a random page on the wiki under a particular scope.
+
+    randomPages:
+      title: "Random Pages"
+      title.short: "Random Pages"
+
+      # chooseLinkLine:
+      #   Introductory line explaining the links on this listing.
+
+      chooseLinkLine:
+        _: "{FROM_PART} {BROWSER_SUPPORT_PART}"
+
+        fromPart:
+          dividedByGroups: >-
+            Choose a link to go to a random page in that group or album!
+          notDividedByGroups: >-
+            Choose a link to go to a random page in that album!
+
+        browserSupportPart: >-
+          If your browser doesn't support relatively modern JavaScript
+          or you've disabled it, these links won't work - sorry.
+
+      # dataLoadingLine, dataLoadedLine, dataErrorLine:
+      #   Since the links on this page depend on access to a fairly
+      #   large data file that is downloaded separately and in the
+      #   background, these messages indicate the status of that
+      #   download and whether or not the links will work yet.
+
+      dataLoadingLine: >-
+        (Data files are downloading in the background! Please wait for data to load.)
+
+      dataLoadedLine: >-
+        (Data files have finished being downloaded. The links should work!)
+
+      dataErrorLine: >-
+        (Data files failed to download. Sorry, some of these links won't work right now!)
+
+      chunk:
+
+        title:
+          misc: "Miscellaneous:"
+
+          # fromAlbum:
+          #   If the wiki hasn't got "Divide Track Lists By Groups"
+          #   set, all albums across the wiki are grouped in one
+          #   long chunk.
+
+          fromAlbum: "From an album:"
+
+          # fromGroup:
+          #   If the wiki does have "Divide Track Lists By Groups"
+          #   set, there's one chunk past Miscellaneous for each of
+          #   those groups, listing all the albums from that group,
+          #   each of which links to a random track from that album.
+
+          fromGroup:
+            _: "From {GROUP}:"
+
+            accent:
+              _: "({RANDOM_ALBUM}, {RANDOM_TRACK})"
+              randomAlbum: "Random Album"
+              randomTrack: "Random Track"
+
+        item:
+          album: "{ALBUM}"
+
+          randomArtist:
+            _: "{MAIN_LINK} ({AT_LEAST_TWO_CONTRIBUTIONS})"
+            mainLink: "Random Artist"
+            atLeastTwoContributions: "at least 2 contributions"
+
+          randomAlbumWholeSite: "Random Album (whole site)"
+          randomTrackWholeSite: "Random Track (whole site)"
+
+#
+# newsIndex:
+#   The news index page shows a list of every news entry on the wiki!
+#   (If it's got news entries enabled.) Each entry gets a stylized
+#   heading with its name of and date, sorted newest to oldest, as
+#   well as its body (up to a split) and a link to view the rest of
+#   the entry on its dedicated news entry page.
+#
+newsIndex:
+  title: "News"
+
+  entry:
+    viewRest: "(View rest of entry!)"
+
+#
+# newsEntryPage:
+#   The news entry page displays all the content of a news entry,
+#   as well as its date published, in one big list, and has nav links
+#   to go to the previous and next news entry.
+#
+newsEntryPage:
+  title: "{ENTRY}"
+  published: "(Published {DATE}.)"
+
+  readAnother:
+    previous:
+      _: "(← {ENTRY})"
+      withDate: "(← {DATE} {ENTRY})"
+
+    next:
+      _: "(→ {ENTRY})"
+      withDate: "(→ {DATE} {ENTRY})"
+
+#
+# redirectPage:
+#   Static "placeholder" pages when redirecting a visitor from one
+#   page to another - this generally happens automatically, before
+#   you have a chance to read the page, so content is concise.
+#
+redirectPage:
+  title: "Moved to {TITLE}"
+
+  infoLine: >-
+    This page has been moved to {TARGET}.
+
+#
+# referencedArtworksPage:
+#   The "referenced artworks" page shows a gallery of all the artworks
+#   which some artwork references.
+#
+referencedArtworksPage:
+  subtitle: "Referenced Artworks"
+
+  statsLine: >-
+    References {ARTWORKS}.
+
+#
+# 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:
+#
+#   The track info page is pretty much the most discrete and common
+#   chunk of information across the whole site, displaying info about
+#   the track like its release date, artists, cover illustrators,
+#   commentary, and more, as well as relational info, like the tracks
+#   it references and tracks which reference it, and flashes which
+#   it's been featured in. Tracks can also have extra related files,
+#   like sheet music and MIDI/project files.
+#
+#   Most of the details about tracks use strings that are defined
+#   under releaseInfo, so this section is a little sparse.
+#
+trackPage:
+  title: "{TRACK}"
+
+  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}"
+
+    body:
+      withArtists.withCoverArtists: "By {ARTISTS}; art by {COVER_ARTISTS}."
+      withArtists: "By {ARTISTS}."
+      withCoverArtists: "Art by {COVER_ARTISTS}."
diff --git a/src/thing/album.js b/src/thing/album.js
deleted file mode 100644
index e99cfc36..00000000
--- a/src/thing/album.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import Thing from './thing.js';
-
-import {
-    validateDirectory,
-    validateReference
-} from './structures.js';
-
-import {
-    showAggregate,
-    withAggregate
-} from '../util/sugar.js';
-
-export default class Album extends Thing {
-    #directory = null;
-    #tracks = [];
-
-    static updateError = {
-        directory: Thing.extendPropertyError('directory'),
-        tracks: Thing.extendPropertyError('tracks')
-    };
-
-    update(source) {
-        const err = this.constructor.updateError;
-
-        withAggregate(({ nest, filter, throws }) => {
-
-            if (source.directory) {
-                nest(throws(err.directory), ({ call }) => {
-                    if (call(validateDirectory, source.directory)) {
-                        this.#directory = source.directory;
-                    }
-                });
-            }
-
-            if (source.tracks)
-                this.#tracks = filter(source.tracks, validateReference('track'), throws(err.tracks));
-        });
-    }
-
-    get directory() { return this.#directory; }
-    get tracks() { return this.#tracks; }
-}
-
-const album = new Album();
-
-console.log('tracks (before):', album.tracks);
-
-try {
-    album.update({
-        directory: 'oh yes',
-        tracks: [
-            'lol',
-            123,
-            'track:oh-yeah',
-            'group:what-am-i-doing-here'
-        ]
-    });
-} catch (error) {
-    showAggregate(error);
-}
-
-console.log('tracks (after):', album.tracks);
diff --git a/src/thing/structures.js b/src/thing/structures.js
deleted file mode 100644
index 89c9bd39..00000000
--- a/src/thing/structures.js
+++ /dev/null
@@ -1,32 +0,0 @@
-// Generic structure utilities common across various Thing types.
-
-export function validateDirectory(directory) {
-    if (typeof directory !== 'string')
-        throw new TypeError(`Expected a string, got ${directory}`);
-
-    if (directory.length === 0)
-        throw new TypeError(`Expected directory to be non-zero length`);
-
-    if (directory.match(/[^a-zA-Z0-9\-]/))
-        throw new TypeError(`Expected only letters, numbers, and dash, got "${directory}"`);
-
-    return true;
-}
-
-export function validateReference(type = '') {
-    return ref => {
-        if (typeof ref !== 'string')
-            throw new TypeError(`Expected a string, got ${ref}`);
-
-        if (type) {
-            if (!ref.includes(':'))
-                throw new TypeError(`Expected ref to begin with "${type}:", but no type specified (ref: ${ref})`);
-
-            const typePart = ref.split(':')[0];
-            if (typePart !== type)
-                throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:" (ref: ${ref})`);
-        }
-
-        return true;
-    };
-}
diff --git a/src/thing/thing.js b/src/thing/thing.js
deleted file mode 100644
index c2465e32..00000000
--- a/src/thing/thing.js
+++ /dev/null
@@ -1,66 +0,0 @@
-// Base class for Things. No, we will not come up with a better name.
-// Sorry not sorry! :)
-//
-// NB: Since these methods all involve processing a variety of input data, some
-// of which will pass and some of which may fail, any failures should be thrown
-// together as an AggregateError. See util/sugar.js for utility functions to
-// make writing code around this easier!
-
-export default class Thing {
-    constructor(source, {
-        wikiData
-    } = {}) {
-        if (source) {
-            this.update(source);
-        }
-
-        if (wikiData && this.checkComplete()) {
-            this.postprocess({wikiData});
-        }
-    }
-
-    static PropertyError = class extends AggregateError {
-        #key = this.constructor.key;
-        get key() { return this.#key; }
-
-        constructor(errors) {
-            super(errors, '');
-            this.message = `${errors.length} error(s) in property "${this.#key}"`;
-        }
-    };
-
-    static extendPropertyError(key) {
-        const cls = class extends this.PropertyError {
-            static #key = key;
-            static get key() { return this.#key; }
-        };
-
-        Object.defineProperty(cls, 'name', {value: `PropertyError:${key}`});
-        return cls;
-    }
-
-    // Called when instantiating a thing, and when its data is updated for any
-    // reason. (Which currently includes no reasons, but hey, future-proofing!)
-    //
-    // Don't expect source to be a complete object, even on the first call - the
-    // method checkComplete() will prevent incomplete resources from being mixed
-    // with the rest.
-    update(source) {}
-
-    // Called when collecting the full list of available things of that type
-    // for wiki data; this method determine whether or not to include it.
-    //
-    // This should return whether or not the object is complete enough to be
-    // used across the wiki - not whether every optional attribute is provided!
-    // (That is, attributes required for postprocessing & basic page generation
-    // are all present.)
-    checkComplete() {}
-
-    // Called when adding the thing to the wiki data list, and when its source
-    // data is updated (provided checkComplete() passes).
-    //
-    // This should generate any cached object references, across other wiki
-    // data; for example, building an array of actual track objects
-    // corresponding to an album's track list ('track:cool-track' strings).
-    postprocess({wikiData}) {}
-}
diff --git a/src/upd8.js b/src/upd8.js
index 2319c13a..86ecab69 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -17,6 +17,9 @@
 //      going to 8e in. May8e JSON, 8ut more likely some weird custom format
 //      which will 8e a lot easier to edit.
 //
+//      Like three years later oh god: SURPISE! We went with the latter, but
+//      they're YAML now. Probably. Assuming that hasn't changed, yet.
+//
 //   3. Generate the page files! They're just static index.html files, and are
 //      what gh-pages (or wherever this is hosted) will show to clients.
 //      Hopefully pretty minimalistic HTML, 8ut like, shrug. They'll reference
@@ -28,3102 +31,3451 @@
 // Oh yeah, like. Just run this through some relatively recent version of
 // node.js and you'll 8e fine. ...Within the project root. O8viously.
 
-// HEY FUTURE ME!!!!!!!! Don't forget to implement artist pages! Those are,
-// like, the coolest idea you've had yet, so DO NOT FORGET. (Remem8er, link
-// from track listings, etc!) --- Thanks, past me. To futurerer me: an al8um
-// listing page (a list of all the al8ums)! Make sure to sort these 8y date -
-// we'll need a new field for al8ums.
-
-// ^^^^^^^^ DID THAT! 8ut also, artist images. Pro8a8ly stolen from the fandom
-// wiki (I found half those images anywayz).
-
-// TRACK ART CREDITS. This is a must.
-
-// 2020-08-23
-// ATTENTION ALL 8*TCHES AND OTHER GENDER TRUCKERS: AS IT TURNS OUT, THIS CODE
-// ****SUCKS****. I DON'T THINK ANYTHING WILL EVER REDEEM IT, 8UT THAT DOESN'T
-// MEAN WE CAN'T TAKE SOME ACTION TO MAKE WRITING IT A LITTLE LESS TERRI8LE.
-// We're gonna start defining STRUCTURES to make things suck less!!!!!!!!
-// No classes 8ecause those are a huge pain and like, pro8a8ly 8ad performance
-// or whatever -- just some standard structures that should 8e followed
-// wherever reasona8le. Only one I need today is the contri8 one 8ut let's put
-// any new general-purpose structures here too, ok?
-//
-// Contri8ution: {who, what, date, thing}. D8 and thing are the new fields.
-//
-// Use these wisely, which is to say all the time and instead of whatever
-// terri8le new pseudo structure you're trying to invent!!!!!!!!
-//
-// Upd8 2021-01-03: Soooooooo we didn't actually really end up using these,
-// lol? Well there's still only one anyway. Kinda ended up doing a 8ig refactor
-// of all the o8ject structures today. It's not *especially* relevant 8ut feels
-// worth mentioning? I'd get rid of this comment 8lock 8ut I like it too much!
-// Even though I haven't actually reread it, lol. 8ut yeah, hopefully in the
-// spirit of this "make things more consistent" attitude I 8rought up 8ack in
-// August, stuff's lookin' 8etter than ever now. W00t!
-
-import * as path from 'path';
-import { promisify } from 'util';
-import { fileURLToPath } from 'url';
-
-// I made this dependency myself! A long, long time ago. It is pro8a8ly my
-// most useful li8rary ever. I'm not sure 8esides me actually uses it, though.
-import fixWS from 'fix-whitespace';
-// Wait nevermind, I forgot a8out why-do-kids-love-the-taste-of-cinnamon-toast-
-// crunch. THAT is my 8est li8rary.
-
-// It stands for "HTML Entities", apparently. Cursed.
-import he from 'he';
+import '#import-heck';
+
+import {execSync} from 'node:child_process';
+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';
+
+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 thingConstructors from '#things';
+import {identifyAllWebRoutes} from '#web-routes';
 
 import {
-    // This is the dum8est name for a function possi8le. Like, SURE, fine, may8e
-    // the UNIX people had some valid reason to go with the weird truncated
-    // lowercased convention they did. 8ut Node didn't have to ALSO use that
-    // convention! Would it have 8een so hard to just name the function
-    // something like fs.readDirectory???????? No, it wouldn't have 8een.
-    readdir,
-    // ~~ 8ut okay, like, look at me. DOING THE SAME THING. See, *I* could have
-    // named my promisified function differently, and yet I did not. I literally
-    // cannot explain why. We are all used to following in the 8ad decisions of
-    // our ancestors, and never never never never never never never consider
-    // that hey, may8e we don't need to make the exact same decisions they did.
-    // Even when we're perfectly aware th8t's exactly what we're doing! ~~
-    //
-    // 2021 ADDENDUM: Ok, a year and a half later the a8ove is still true,
-    //                except for the part a8out promisifying, since fs/promises
-    //                already does that for us. 8ut I could STILL import it
-    //                using my own name (`readdir as readDirectory`), and yet
-    //                here I am, defin8tely not doing that.
-    //                SOME THINGS NEVER CHANGE.
-    //
-    // Programmers, including me, are all pretty stupid.
-
-    // 8ut I mean, come on. Look. Node decided to use readFile, instead of like,
-    // what, cat? Why couldn't they rename readdir too???????? As Johannes
-    // Kepler once so elegantly put it: "Shrug."
-    readFile,
-    writeFile,
-    access,
-    mkdir,
-    symlink,
-    unlink
-} from 'fs/promises';
-
-import genThumbs from './gen-thumbs.js';
-import { listingSpec, listingTargetSpec } from './listing-spec.js';
-import urlSpec from './url-spec.js';
-import * as pageSpecs from './page/index.js';
-
-import find from './util/find.js';
-import * as html from './util/html.js';
-import unbound_link, {getLinkThemeString} from './util/link.js';
+  colors,
+  decorateTime,
+  fileIssue,
+  logWarn,
+  logInfo,
+  logError,
+  parseOptions,
+  progressCallAll,
+  showHelpForOptions as unboundShowHelpForOptions,
+} from '#cli';
 
 import {
-    fancifyFlashURL,
-    fancifyURL,
-    generateChronologyLinks,
-    generateCoverLink,
-    generateInfoGalleryLinks,
-    generatePreviousNextLinks,
-    getAlbumGridHTML,
-    getAlbumStylesheet,
-    getArtistString,
-    getFlashGridHTML,
-    getGridHTML,
-    getRevealStringFromTags,
-    getRevealStringFromWarnings,
-    getThemeString,
-    iconifyURL
-} from './misc-templates.js';
+  filterReferenceErrors,
+  reportContentTextErrors,
+  reportDirectoryErrors,
+  reportOrphanedArtworks,
+} from '#data-checks';
 
 import {
-    decorateTime,
-    logWarn,
-    logInfo,
-    logError,
-    parseOptions,
-    progressPromiseAll
-} from './util/cli.js';
+  bindOpts,
+  empty,
+  filterMultipleArrays,
+  indentWrap as unboundIndentWrap,
+  withEntries,
+} from '#sugar';
+
+import genThumbs, {
+  CACHE_FILE as thumbsCacheFile,
+  defaultMagickThreads,
+  determineMediaCachePath,
+  isThumb,
+  migrateThumbsIntoDedicatedCacheDirectory,
+  verifyImagePaths,
+} from '#thumbs';
 
 import {
-    validateReplacerSpec,
-    transformInline
-} from './util/replacer.js';
+  applyLocalizedWithBaseDirectory,
+  applyURLSpecOverriding,
+  generateURLs,
+  getOrigin,
+  internalDefaultURLSpecFile,
+  processURLSpecFromFile,
+} from '#urls';
 
 import {
-    genStrings,
-    count,
-    list
-} from './util/strings.js';
+  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';
 
-import {
-    chunkByConditions,
-    chunkByProperties,
-    getAlbumCover,
-    getAlbumListTag,
-    getAllTracks,
-    getArtistCommentary,
-    getArtistNumContributions,
-    getFlashCover,
-    getKebabCase,
-    getTotalDuration,
-    getTrackCover,
-    sortByArtDate,
-    sortByDate,
-    sortByName
-} from './util/wiki-data.js';
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
-import {
-    serializeContribs,
-    serializeCover,
-    serializeGroupsForAlbum,
-    serializeGroupsForTrack,
-    serializeImagePaths,
-    serializeLink
-} from './util/serialize.js';
+let COMMIT;
+try {
+  COMMIT = execSync('git log --format="%h %B" -n 1 HEAD', {cwd: __dirname}).toString().trim();
+} catch (error) {
+  COMMIT = '(failed to detect)';
+}
 
-import {
-    bindOpts,
-    call,
-    filterEmptyLines,
-    mapInPlace,
-    queue,
-    splitArray,
-    unique,
-    withEntries
-} from './util/sugar.js';
+const BUILD_TIME = new Date();
 
-import {
-    generateURLs,
-    thumb
-} from './util/urls.js';
+const STATUS_NOT_STARTED       = `not started`;
+const STATUS_NOT_APPLICABLE    = `not applicable`;
+const STATUS_STARTED_NOT_DONE  = `started but not yet done`;
+const STATUS_DONE_CLEAN        = `done without warnings`;
+const STATUS_FATAL_ERROR       = `fatal error`;
+const STATUS_HAS_WARNINGS      = `has warnings`;
 
-// Pensive emoji!
-import {
-    FANDOM_GROUP_DIRECTORY,
-    OFFICIAL_GROUP_DIRECTORY,
-    UNRELEASED_TRACKS_DIRECTORY
-} from './util/magic-constants.js';
+const defaultStepStatus = {status: STATUS_NOT_STARTED, annotation: null};
 
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
+// 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 shouldShowStepStatusSummary = false;
+let shouldShowStepMemoryInSummary = false;
 
-const CACHEBUST = 7;
-
-const WIKI_INFO_FILE = 'wiki-info.txt';
-const HOMEPAGE_INFO_FILE = 'homepage.txt';
-const ARTIST_DATA_FILE = 'artists.txt';
-const FLASH_DATA_FILE = 'flashes.txt';
-const NEWS_DATA_FILE = 'news.txt';
-const TAG_DATA_FILE = 'tags.txt';
-const GROUP_DATA_FILE = 'groups.txt';
-const STATIC_PAGE_DATA_FILE = 'static-pages.txt';
-const DEFAULT_STRINGS_FILE = 'strings-default.json';
-
-// Code that's common 8etween the 8uild code (i.e. upd8.js) and gener8ted
-// site code should 8e put here. Which, uh, ~~only really means this one
-// file~~ is now a variety of useful utilities!
-//
-// Rather than hard code it, anything in this directory can 8e shared across
-// 8oth ends of the code8ase.
-// (This gets symlinked into the --data-path directory.)
-const UTILITY_DIRECTORY = 'util';
-
-// Code that's used only in the static site! CSS, cilent JS, etc.
-// (This gets symlinked into the --data-path directory.)
-const STATIC_DIRECTORY = 'static';
-
-// Su8directory under provided --data-path directory for al8um files, which are
-// read from and processed to compose the majority of album and track data.
-const DATA_ALBUM_DIRECTORY = 'album';
-
-// Shared varia8les! These are more efficient to access than a shared varia8le
-// (or at least I h8pe so), and are easier to pass across functions than a
-// 8unch of specific arguments.
-//
-// Upd8: Okay yeah these aren't actually any different. Still cleaner than
-// passing around a data object containing all this, though.
-let dataPath;
-let mediaPath;
-let langPath;
-let outputPath;
-
-// Glo8al data o8ject shared 8etween 8uild functions and all that. This keeps
-// everything encapsul8ted in one place, so it's easy to pass and share across
-// modules!
-let wikiData = {};
-
-let queueSize;
-
-let languages;
-
-const urls = generateURLs(urlSpec);
-
-// Note there isn't a 'find track data files' function. I plan on including the
-// data for all tracks within an al8um collected in the single metadata file
-// for that al8um. Otherwise there'll just 8e way too many files, and I'd also
-// have to worry a8out linking track files to al8um files (which would contain
-// only the track listing, not track data itself), and dealing with errors of
-// missing track files (or track files which are not linked to al8ums). All a
-// 8unch of stuff that's a pain to deal with for no apparent 8enefit.
-async function findFiles(dataPath, filter = f => true) {
-    return (await readdir(dataPath))
-        .map(file => path.join(dataPath, file))
-        .filter(file => filter(file));
-}
+async function main() {
+  Error.stackTraceLimit = Infinity;
 
-function* getSections(lines) {
-    // ::::)
-    const isSeparatorLine = line => /^-{8,}$/.test(line);
-    yield* splitArray(lines, isSeparatorLine);
-}
+  let paragraph = true;
 
-function getBasicField(lines, name) {
-    const line = lines.find(line => line.startsWith(name + ':'));
-    return line && line.slice(name.length + 1).trim();
-}
+  stepStatusSummary = {
+    determineMediaCachePath:
+      {...defaultStepStatus, name: `determine media cache path`,
+        for: ['thumbs', 'build']},
 
-function getDimensionsField(lines, name) {
-    const string = getBasicField(lines, name);
-    if (!string) return string;
-    const parts = string.split(/[x,* ]+/g);
-    if (parts.length !== 2) throw new Error(`Invalid dimensions: ${string} (expected width & height)`);
-    const nums = parts.map(part => Number(part.trim()));
-    if (nums.includes(NaN)) throw new Error(`Invalid dimensions: ${string} (couldn't parse as numbers)`);
-    return nums;
-}
+    migrateThumbnails:
+      {...defaultStepStatus, name: `migrate thumbnails`,
+        for: ['thumbs']},
 
-function getBooleanField(lines, name) {
-    // The ?? oper8tor (which is just, hilariously named, lol) can 8e used to
-    // specify a default!
-    const value = getBasicField(lines, name);
-    switch (value) {
-        case 'yes':
-        case 'true':
-            return true;
-        case 'no':
-        case 'false':
-            return false;
-        default:
-            return null;
-    }
-}
+    loadOfflineThumbnailCache:
+      {...defaultStepStatus, name: `load offline thumbnail cache file`,
+        for: ['thumbs', 'build']},
 
-function getListField(lines, name) {
-    let startIndex = lines.findIndex(line => line.startsWith(name + ':'));
-    // If callers want to default to an empty array, they should stick
-    // "|| []" after the call.
-    if (startIndex === -1) {
-        return null;
-    }
-    // We increment startIndex 8ecause we don't want to include the
-    // "heading" line (e.g. "URLs:") in the actual data.
-    startIndex++;
-    let endIndex = lines.findIndex((line, index) => index >= startIndex && !line.startsWith('- '));
-    if (endIndex === -1) {
-        endIndex = lines.length;
-    }
-    if (endIndex === startIndex) {
-        // If there is no list that comes after the heading line, treat the
-        // heading line itself as the comma-separ8ted array value, using
-        // the 8asic field function to do that. (It's l8 and my 8rain is
-        // sleepy. Please excuse any unhelpful comments I may write, or may
-        // have already written, in this st8. Thanks!)
-        const value = getBasicField(lines, name);
-        return value && value.split(',').map(val => val.trim());
-    }
-    const listLines = lines.slice(startIndex, endIndex);
-    return listLines.map(line => line.slice(2));
-};
-
-function getContributionField(section, name) {
-    let contributors = getListField(section, name);
-
-    if (!contributors) {
-        return null;
-    }
-
-    if (contributors.length === 1 && contributors[0].startsWith('<i>')) {
-        const arr = [];
-        arr.textContent = contributors[0];
-        return arr;
-    }
-
-    contributors = contributors.map(contrib => {
-        // 8asically, the format is "Who (What)", or just "Who". 8e sure to
-        // keep in mind that "what" doesn't necessarily have a value!
-        const match = contrib.match(/^(.*?)( \((.*)\))?$/);
-        if (!match) {
-            return contrib;
-        }
-        const who = match[1];
-        const what = match[3] || null;
-        return {who, what};
+    generateThumbnails:
+      {...defaultStepStatus, name: `generate thumbnails`,
+        for: ['thumbs']},
+
+    loadDataFiles:
+      {...defaultStepStatus, name: `load and process data files`,
+        for: ['build']},
+
+    linkWikiDataArrays:
+      {...defaultStepStatus, name: `link wiki data arrays`,
+        for: ['build']},
+
+    precacheCommonData:
+      {...defaultStepStatus, name: `precache common data`,
+        for: ['build']},
+
+    reportDirectoryErrors:
+      {...defaultStepStatus, name: `report directory errors`,
+        for: ['verify']},
+
+    reportOrphanedArtworks:
+      {...defaultStepStatus, name: `report orphaned artworks`,
+        for: ['verify']},
+
+    filterReferenceErrors:
+      {...defaultStepStatus, name: `filter reference errors`,
+        for: ['verify']},
+
+    reportContentTextErrors:
+      {...defaultStepStatus, name: `report content text errors`,
+        for: ['verify']},
+
+    sortWikiDataArrays:
+      {...defaultStepStatus, name: `sort wiki data arrays`,
+        for: ['build']},
+
+    precacheAllData:
+      {...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`,
+        for: ['build']},
+
+    loadLanguageFiles:
+      {...defaultStepStatus, name: `statically load custom language files`,
+        for: ['build']},
+
+    watchLanguageFiles:
+      {...defaultStepStatus, name: `watch custom language files`,
+        for: ['build']},
+
+    initializeDefaultLanguage:
+      {...defaultStepStatus, name: `initialize default language`,
+        for: ['build']},
+
+    verifyImagePaths:
+      {...defaultStepStatus, name: `verify missing/misplaced image paths`,
+        for: ['verify']},
+
+    preloadFileSizes:
+      {...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`,
+        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 = (
+    withEntries(buildModes, entries =>
+      entries.map(([key, mode]) => [key, {
+        help: mode.description,
+        type: 'flag',
+      }])));
+
+  const selectedBuildModeFlags = Object.keys(
+    await parseOptions(process.argv.slice(2), {
+      [parseOptions.handleUnknown]: () => {},
+      ...buildModeFlagOptions,
+    }));
+
+  let selectedBuildModeFlag;
+  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`,
     });
 
-    const badContributor = contributors.find(val => typeof val === 'string');
-    if (badContributor) {
-        return {error: `An entry has an incorrectly formatted contributor, "${badContributor}".`};
-    }
+    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`,
+    });
 
-    if (contributors.length === 1 && contributors[0].who === 'none') {
-        return null;
-    }
+    Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, {
+      status: STATUS_NOT_STARTED,
+      annotation: `--sort not provided, dry run applicable`,
+    });
+  }
+
+  if (empty(selectedBuildModeFlags)) {
+    // 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 one build mode.`;
+    return false;
+  } else {
+    selectedBuildModeFlag = selectedBuildModeFlags[0];
+  }
+
+  const selectedBuildMode =
+    (selectedBuildModeFlag
+      ? buildModes[selectedBuildModeFlag]
+      : null);
+
+  // This is about to get a whole lot more stuff put in it.
+  const wikiData = {
+    listingSpec,
+    listingTargetSpec,
+  };
+
+  const buildOptions =
+    (selectedBuildMode
+      ? selectedBuildMode.getCLIOptions()
+      : {});
+
+  const commonOptions = {
+    'help': {
+      help: `Display usage info and basic information for the \`hsmusic\` command`,
+      type: 'flag',
+    },
 
-    return contributors;
-};
+    // Data files for the site, including flash, artist, and al8um data,
+    // 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; may be provided via the HSMUSIC_DATA environment variable`,
+      type: 'value',
+    },
 
-function getMultilineField(lines, name) {
-    // All this code is 8asically the same as the getListText - just with a
-    // different line prefix (four spaces instead of a dash and a space).
-    let startIndex = lines.findIndex(line => line.startsWith(name + ':'));
-    if (startIndex === -1) {
-        return null;
-    }
-    startIndex++;
-    let endIndex = lines.findIndex((line, index) => index >= startIndex && line.length && !line.startsWith('    '));
-    if (endIndex === -1) {
-        endIndex = lines.length;
-    }
-    // If there aren't any content lines, don't return anything!
-    if (endIndex === startIndex) {
-        return null;
-    }
-    // We also join the lines instead of returning an array.
-    const listLines = lines.slice(startIndex, endIndex);
-    return listLines.map(line => line.slice(4)).join('\n');
-};
+    // Static media will 8e referenced in the site here! The contents are
+    // 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; may be provided via the HSMUSIC_MEDIA environment variable`,
+      type: 'value',
+    },
 
-const replacerSpec = {
-    'album': {
-        find: 'album',
-        link: 'album'
+    'media-cache-path': {
+      help: `Specify path to media cache directory, including automatically generated thumbnails\n\nThis usually doesn't need to be provided, and will be inferred either by loading "media-cache" from --cache-path, or by adding "-cache" to the end of the media directory\n\nMay be provided via the HSMUSIC_MEDIA_CACHE environment variable`,
+      type: 'value',
     },
-    'album-commentary': {
-        find: 'album',
-        link: 'albumCommentary'
+
+    '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',
     },
-    'artist': {
-        find: 'artist',
-        link: 'artist'
+
+    // String files! For the most part, this is used for translating the
+    // site to different languages, though you can also customize strings
+    // for your own 8uild of the site if you'd like. Files here should all
+    // match the format in strings-default.json in this repository. (If a
+    // language file is missing any strings, the site code will fall 8ack
+    // to what's specified in strings-default.json.)
+    //
+    // Unlike the other options here, this one's optional - the site will
+    // 8uild with the default (English) strings if this path is left
+    // unspecified.
+    'lang-path': {
+      help: `Specify path to language directory, including JSON files that mapping internal string keys to localized language content, and various language metadata\n\nOptional for wiki building, unless the wiki's default language is not English; may be provided via the HSMUSIC_LANG environment variable instead`,
+      type: 'value',
     },
-    'artist-gallery': {
-        find: 'artist',
-        link: 'artistGallery'
+
+    'urls': {
+      help: `Specify which optional URL specs to use for this build, customizing where pages are generated or resources are accessed from`,
+      type: 'value',
     },
-    'commentary-index': {
-        find: null,
-        link: 'commentaryIndex'
+
+    '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',
     },
-    'date': {
-        find: null,
-        value: ref => new Date(ref),
-        html: (date, {strings}) => `<time datetime="${date.toString()}">${strings.count.date(date)}</time>`
+
+    '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',
     },
-    'flash': {
-        find: 'flash',
-        link: 'flash',
-        transformName(name, node, input) {
-            const nextCharacter = input[node.iEnd];
-            const lastCharacter = name[name.length - 1];
-            if (
-                ![' ', '\n', '<'].includes(nextCharacter) &&
-                lastCharacter === '.'
-            ) {
-                return name.slice(0, -1);
-            } else {
-                return name;
-            }
-        }
+
+    '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',
     },
-    'group': {
-        find: 'group',
-        link: 'groupInfo'
+
+    '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',
     },
-    'group-gallery': {
-        find: 'group',
-        link: 'groupGallery'
+
+    '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',
     },
-    'listing-index': {
-        find: null,
-        link: 'listingIndex'
+
+    // 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.
+    'skip-thumbs': {
+      help: `Skip processing and generating thumbnails in media directory (speeds up subsequent builds, but remove this option [or use --thumbs-only] and re-run once when you add or modify media files to ensure thumbnails stay up-to-date!)`,
+      type: 'flag',
     },
-    'listing': {
-        find: 'listing',
-        link: 'listing'
+
+    // Or, if you *only* want to gener8te newly upd8ted thum8nails, you can
+    // pass this flag! It exits 8efore 8uilding the rest of the site.
+    'thumbs-only': {
+      help: `Skip everything besides processing media directory and generating up-to-date thumbnails (useful when using --skip-thumbs for most runs)`,
+      type: 'flag',
     },
-    'media': {
-        find: null,
-        link: 'media'
+
+    'migrate-thumbs': {
+      help: `Transfer automatically generated thumbnail files out of an existing media directory and into the easier-to-manage media-cache directory`,
+      type: 'flag',
     },
-    'news-index': {
-        find: null,
-        link: 'newsIndex'
+
+    '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',
     },
-    'news-entry': {
-        find: 'newsEntry',
-        link: 'newsEntry'
+
+    'refresh-online-thumbs': {
+      help: `Downloads a fresh copy of the online file size cache, so changes there are immediately reflected`,
+      type: 'flag',
     },
-    'root': {
-        find: null,
-        link: 'root'
+
+    'skip-file-sizes': {
+      help: `Skips preloading file sizes for images and additional files, which will be left blank in the build`,
+      type: 'flag',
     },
-    'site': {
-        find: null,
-        link: 'site'
+
+    'refresh-online-file-sizes': {
+      help: `Downloads a fresh copy of the online file size cache, so changes there are immediately reflected`,
+      type: 'flag',
     },
-    'static': {
-        find: 'staticPage',
-        link: 'staticPage'
+
+    'skip-sorting-validation': {
+      help: `Skips checking the if custom sorting rules for this wiki are satisfied`,
+      type: 'flag',
     },
-    'string': {
-        find: null,
-        value: ref => ref,
-        html: (ref, {strings, args}) => strings(ref, args)
+
+    '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',
     },
-    'tag': {
-        find: 'tag',
-        link: 'tag'
+
+    'refresh-search': {
+      help: `Generate the text search index this build, instead of waiting for the automatic delay`,
+      type: 'flag',
     },
-    'track': {
-        find: 'track',
-        link: 'track'
-    }
-};
 
-if (!validateReplacerSpec(replacerSpec, unbound_link)) {
-    process.exit();
-}
+    'skip-search': {
+      help: `Skip creation of the text search index no matter what, even if it'd normally be scheduled for now`,
+      type: 'flag',
+    },
 
-function parseAttributes(string, {to}) {
-    const attributes = Object.create(null);
-    const skipWhitespace = i => {
-        const ws = /\s/;
-        if (ws.test(string[i])) {
-            const match = string.slice(i).match(/[^\s]/);
-            if (match) {
-                return i + match.index;
-            } else {
-                return string.length;
-            }
-        } else {
-            return i;
-        }
-    };
+    // 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.
+    'no-build': {
+      help: `Don't run a build of the site at all; only process data/media and report any errors detected`,
+      type: 'flag',
+    },
 
-    for (let i = 0; i < string.length;) {
-        i = skipWhitespace(i);
-        const aStart = i;
-        const aEnd = i + string.slice(i).match(/[\s=]|$/).index;
-        const attribute = string.slice(aStart, aEnd);
-        i = skipWhitespace(aEnd);
-        if (string[i] === '=') {
-            i = skipWhitespace(i + 1);
-            let end, endOffset;
-            if (string[i] === '"' || string[i] === "'") {
-                end = string[i];
-                endOffset = 1;
-                i++;
-            } else {
-                end = '\\s';
-                endOffset = 0;
-            }
-            const vStart = i;
-            const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index;
-            const value = string.slice(vStart, vEnd);
-            i = vEnd + endOffset;
-            if (attribute === 'src' && value.startsWith('media/')) {
-                attributes[attribute] = to('media.path', value.slice('media/'.length));
-            } else {
-                attributes[attribute] = value;
-            }
-        } else {
-            attributes[attribute] = attribute;
-        }
+    'no-input': {
+      help: `Don't wait on input from stdin - assume the device is headless`,
+      type: 'flag',
+    },
+
+    'no-language-reloading': {
+      help: `Don't reload language files while the build is running\n\nApplied by default for --static-build`,
+      type: 'flag',
+    },
+
+    'no-language-reload': {alias: 'no-language-reloading'},
+
+    // Want sweet, sweet trace8ack info in aggreg8te error messages? This
+    // will print all the juicy details (or at least the first relevant
+    // line) right to your output, 8ut also pro8a8ly give you a headache
+    // 8ecause wow that is a lot of visual noise.
+    'show-traces': {
+      help: `Show JavaScript source code paths for reported errors in "aggregate" error displays\n\n(Debugging use only, but please enable this if you're reporting bugs for our issue tracker!)`,
+      type: 'flag',
+    },
+
+    'show-step-summary': {
+      help: `Show a summary of all the top-level build steps once hsmusic exits. This is mostly useful for progammer debugging!`,
+      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',
+      validate(size) {
+        if (parseInt(size) !== parseFloat(size)) return 'an integer';
+        if (parseInt(size) < 0) return 'a counting number or zero';
+        return true;
+      },
+    },
+    queue: {alias: 'queue-size'},
+
+    'magick-threads': {
+      help: `Process more or fewer thumbnail files at once with ImageMagick when generating thumbnails. (Each ImageMagick thread may also make use of multi-core processing at its own utility.)`,
+      type: 'value',
+      validate(threads) {
+        if (parseInt(threads) !== parseFloat(threads)) return 'an integer';
+        if (parseInt(threads) < 0) return 'a counting number or zero';
+        return true;
+      }
+    },
+    magick: {alias: 'magick-threads'},
+
+    'precache-mode': {
+      help:
+        `Change the way certain runtime-computed values are preemptively evaluated and cached\n\n` +
+        `common: Preemptively compute certain properties which are needed for basic data loading and site generation\n\n` +
+        `all: Compute every visible data property, optimizing rate of content generation, but causing a long stall before the build actually starts\n\n` +
+        `none: Don't preemptively compute any values - strictly the most efficient, but may result in unpredictably "lopsided" performance for individual steps of loading data and building the site\n\n` +
+        `Defaults to 'common'`,
+      type: 'value',
+      validate(value) {
+        if (['common', 'all', 'none'].includes(value)) return true;
+        return 'common, all, or none';
+      },
+    },
+  };
+
+  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.
+    // (This is a bit of a hack.)
+    ...buildModeFlagOptions,
+
+    ...commonOptions,
+    ...buildOptions,
+  });
+
+  shouldShowStepStatusSummary = cliOptions['show-step-summary'] ?? false;
+  shouldShowStepMemoryInSummary = cliOptions['show-step-memory'] ?? false;
+
+  if (cliOptions['help']) {
+    console.log(
+      colors.bright(`hsmusic (aka. Homestuck Music Wiki, HSMusic Wiki)\n`) +
+      `static wiki software cataloguing collaborative creation\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(
+      `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(``);
+
+    showHelpForOptions({
+      heading: `Common options`,
+      options: commonOptions,
+      wrap,
+    });
+
+    showHelpForOptions({
+      heading: `Build mode selection`,
+      options: buildModeFlagOptions,
+      wrap,
+    });
+
+    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.`);
     }
-    return Object.fromEntries(Object.entries(attributes).map(([ key, val ]) => [
-        key,
-        val === 'true' ? true :
-        val === 'false' ? false :
-        val === key ? true :
-        val
-    ]));
-}
 
-function transformMultiline(text, {
-    parseAttributes,
-    transformInline
-}) {
-    // Heck yes, HTML magics.
+    for (const step of Object.values(stepStatusSummary)) {
+      Object.assign(step, {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `--help provided`,
+      });
+    }
 
-    text = transformInline(text.trim());
+    return true;
+  }
 
-    const outLines = [];
+  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 indentString = ' '.repeat(4);
+  const thumbsOnly = cliOptions['thumbs-only'] ?? false;
+  const noInput = cliOptions['no-input'] ?? false;
 
-    let levelIndents = [];
-    const openLevel = indent => {
-        // opening a sublist is a pain: to be semantically *and* visually
-        // correct, we have to append the <ul> at the end of the existing
-        // previous <li>
-        const previousLine = outLines[outLines.length - 1];
-        if (previousLine?.endsWith('</li>')) {
-            // we will re-close the <li> later
-            outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>';
-        } else {
-            // if the previous line isn't a list item, this is the opening of
-            // the first list level, so no need for indent
-            outLines.push('<ul>');
-        }
-        levelIndents.push(indent);
-    };
-    const closeLevel = () => {
-        levelIndents.pop();
-        if (levelIndents.length) {
-            // closing a sublist, so close the list item containing it too
-            outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>');
+  const showAggregateTraces = cliOptions['show-traces'] ?? false;
+
+  const precacheMode = cliOptions['precache-mode'] ?? 'common';
+
+  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
+  // before proceeding to more page processing.
+  const queueSize = +(cliOptions['queue-size'] ?? defaultQueueSize);
+
+  const magickThreads = +(cliOptions['magick-threads'] ?? defaultMagickThreads);
+
+  if (!dataPath) {
+    logError`${`Expected --data-path option or HSMUSIC_DATA to be set`}`;
+  }
+
+  if (!mediaPath) {
+    logError`${`Expected --media-path option or HSMUSIC_MEDIA to be set`}`;
+  }
+
+  if (!wikiCachePath) {
+    logError`${`Expected --cache-path option or HSMUSIC_CACHE to be set`}`;
+  }
+
+  if (!dataPath || !mediaPath || !wikiCachePath) {
+    return false;
+  }
+
+  if (cliOptions['no-build']) {
+    logInfo`Won't generate any site or page files this run (--no-build passed).`;
+
+    Object.assign(stepStatusSummary.performBuild, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `--no-build provided`,
+    });
+  }
+
+  // Finish setting up defaults by combining information from all options.
+
+  const _fallbackStep = (stepKey, {
+    default: defaultValue,
+    cli: cliArg,
+    buildConfig: buildConfigKey = null,
+  }) => {
+    const buildConfig = selectedBuildMode?.config?.[buildConfigKey];
+    const {[stepKey]: step} = stepStatusSummary;
+
+    const cliEntries =
+      (cliArg === null || cliArg === undefined
+        ? []
+     : Array.isArray(cliArg)
+        ? cliArg
+        : [cliArg]);
+
+    for (const {
+      flag: cliFlag = null,
+      negate: cliFlagNegates = false,
+      warn: cliFlagWarning = null,
+      disable: cliFlagDisablesSteps = [],
+    } of cliEntries) {
+      if (!cliOptions[cliFlag]) {
+        continue;
+      }
+
+      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 {
-            // closing the final list level! no need for indent here
-            outLines.push('</ul>');
+          logWarn`${cliPart} provided, but this step isn't applicable for ${modePart}`;
+          logWarn`Ignoring option ${cliPart}`;
+          continue;
         }
-    };
+      }
 
-    // okay yes we should support nested formatting, more than one blockquote
-    // layer, etc, but hear me out here: making all that work would basically
-    // be the same as implementing an entire markdown converter, which im not
-    // interested in doing lol. sorry!!!
-    let inBlockquote = false;
-
-    for (let line of text.split(/\r|\n|\r\n/)) {
-        const imageLine = line.startsWith('<img');
-        line = line.replace(/<img (.*?)>/g, (match, attributes) => img({
-            lazy: true,
-            link: true,
-            thumb: 'medium',
-            ...parseAttributes(attributes)
-        }));
-
-        let indentThisLine = 0;
-        let lineContent = line;
-        let lineTag = 'p';
-
-        const listMatch = line.match(/^( *)- *(.*)$/);
-        if (listMatch) {
-            // is a list item!
-            if (!levelIndents.length) {
-                // first level is always indent = 0, regardless of actual line
-                // content (this is to avoid going to a lesser indent than the
-                // initial level)
-                openLevel(0);
-            } else {
-                // find level corresponding to indent
-                const indent = listMatch[1].length;
-                let i;
-                for (i = levelIndents.length - 1; i >= 0; i--) {
-                    if (levelIndents[i] <= indent) break;
-                }
-                // note: i cannot equal -1 because the first indentation level
-                // is always 0, and the minimum indentation is also 0
-                if (levelIndents[i] === indent) {
-                    // same indent! return to that level
-                    while (levelIndents.length - 1 > i) closeLevel();
-                    // (if this is already the current level, the above loop
-                    // will do nothing)
-                } else if (levelIndents[i] < indent) {
-                    // lesser indent! branch based on index
-                    if (i === levelIndents.length - 1) {
-                        // top level is lesser: add a new level
-                        openLevel(indent);
-                    } else {
-                        // lower level is lesser: return to that level
-                        while (levelIndents.length - 1 > i) closeLevel();
-                    }
-                }
-            }
-            // finally, set variables for appending content line
-            indentThisLine = levelIndents.length;
-            lineContent = listMatch[2];
-            lineTag = 'li';
+      if (buildConfig?.required === true) {
+        if (cliFlagNegates) {
+          logWarn`${cliPart} provided, but ${modePart} requires this step`;
+          logWarn`Ignoring option ${cliPart}`;
+          continue;
         } else {
-            // not a list item! close any existing list levels
-            while (levelIndents.length) closeLevel();
-
-            // like i said, no nested shenanigans - quotes only appear outside
-            // of lists. sorry!
-            const quoteMatch = line.match(/^> *(.*)$/);
-            if (quoteMatch) {
-                // is a quote! open a blockquote tag if it doesnt already exist
-                if (!inBlockquote) {
-                    inBlockquote = true;
-                    outLines.push('<blockquote>');
-                }
-                indentThisLine = 1;
-                lineContent = quoteMatch[1];
-            } else if (inBlockquote) {
-                // not a quote! close a blockquote tag if it exists
-                inBlockquote = false;
-                outLines.push('</blockquote>');
-            }
+          logWarn`${cliPart} provided, but ${modePart} already requires this step`;
+          logWarn`Redundant option ${cliPart}`;
+          continue;
         }
+      }
 
-        if (lineTag === 'p') {
-            // certain inline element tags should still be postioned within a
-            // paragraph; other elements (e.g. headings) should be added as-is
-            const elementMatch = line.match(/^<(.*?)[ >]/);
-            if (elementMatch && !imageLine && !['a', 'abbr', 'b', 'bdo', 'br', 'cite', 'code', 'data', 'datalist', 'del', 'dfn', 'em', 'i', 'img', 'ins', 'kbd', 'mark', 'output', 'picture', 'q', 'ruby', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'svg', 'time', 'var', 'wbr'].includes(elementMatch[1])) {
-                lineTag = '';
-            }
+      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);
         }
 
-        let pushString = indentString.repeat(indentThisLine);
-        if (lineTag) {
-            pushString += `<${lineTag}>${lineContent}</${lineTag}>`;
+        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 {
-            pushString += lineContent;
+          summary.status = STATUS_NOT_APPLICABLE;
+          summary.annotation = `--${cliFlag} provided`;
         }
-        outLines.push(pushString);
-    }
-
-    // after processing all lines...
+      }
 
-    // if still in a list, close all levels
-    while (levelIndents.length) closeLevel();
+      return;
+    }
 
-    // if still in a blockquote, close its tag
-    if (inBlockquote) {
-        inBlockquote = false;
-        outLines.push('</blockquote>');
+    if (buildConfig?.required === true) {
+      step.status = STATUS_NOT_STARTED;
+      step.annotation = `required for --${selectedBuildModeFlag}`;
+      return;
     }
 
-    return outLines.join('\n');
-}
+    if (buildConfig?.applicable === false) {
+      step.status = STATUS_NOT_APPLICABLE;
+      step.annotation = `N/A for --${selectedBuildModeFlag}`;
+      return;
+    }
 
-function transformLyrics(text, {
-    transformInline,
-    transformMultiline
-}) {
-    // Different from transformMultiline 'cuz it joins multiple lines together
-    // with line 8reaks (<br>); transformMultiline treats each line as its own
-    // complete paragraph (or list, etc).
-
-    // If it looks like old data, then like, oh god.
-    // Use the normal transformMultiline tool.
-    if (text.includes('<br')) {
-        return transformMultiline(text);
-    }
-
-    text = transformInline(text.trim());
-
-    let buildLine = '';
-    const addLine = () => outLines.push(`<p>${buildLine}</p>`);
-    const outLines = [];
-    for (const line of text.split('\n')) {
-        if (line.length) {
-            if (buildLine.length) {
-                buildLine += '<br>';
-            }
-            buildLine += line;
-        } else if (buildLine.length) {
-            addLine();
-            buildLine = '';
-        }
+    if (buildConfig?.default === 'skip') {
+      step.status = STATUS_NOT_APPLICABLE;
+      step.annotation = `default for --${selectedBuildModeFlag}`;
+      return;
     }
-    if (buildLine.length) {
-        addLine();
+
+    if (buildConfig?.default === 'perform') {
+      step.status = STATUS_NOT_STARTED;
+      step.annotation = `default for --${selectedBuildModeFlag}`;
+      return;
     }
-    return outLines.join('\n');
-}
 
-function getCommentaryField(lines) {
-    const text = getMultilineField(lines, 'Commentary');
-    if (text) {
-        const lines = text.split('\n');
-        if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) {
-            return {error: `An entry is missing commentary citation: "${lines[0].slice(0, 40)}..."`};
+    switch (defaultValue) {
+      case 'skip': {
+        step.status = STATUS_NOT_APPLICABLE;
+
+        const enablingFlags =
+          cliEntries
+            .filter(({negate}) => !negate)
+            .map(({flag}) => flag);
+
+        if (!empty(enablingFlags)) {
+          step.annotation =
+            enablingFlags.map(flag => `--${flag}`).join(', ') +
+            ` not provided`;
         }
-        return text;
-    } else {
-        return null;
-    }
-};
 
-async function processAlbumDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        // This function can return "error o8jects," which are really just
-        // ordinary o8jects with an error message attached. I'm not 8othering
-        // with error codes here or anywhere in this function; while this would
-        // normally 8e 8ad coding practice, it doesn't really matter here,
-        // 8ecause this isn't an API getting consumed 8y other services (e.g.
-        // translaction functions). If we return an error, the caller will just
-        // print the attached message in the output summary.
-        return {error: `Could not read ${file} (${error.code}).`};
+        break;
+      }
+
+      case 'perform':
+        break;
+
+      default:
+        throw new Error(`Invalid default step status ${defaultValue}`);
     }
+  };
 
-    // We're pro8a8ly supposed to, like, search for a header somewhere in the
-    // al8um contents, to make sure it's trying to 8e the intended structure
-    // and is a valid utf-8 (or at least ASCII) file. 8ut like, whatever.
-    // We'll just return more specific errors if it's missing necessary data
-    // fields.
+  {
+    let errored = false;
 
-    const contentLines = contents.split(/\r\n|\r|\n/);
+    const fallbackStep = (stepKey, options) => {
+      try {
+        _fallbackStep(stepKey, options);
+      } catch (error) {
+        logError`Error determining fallback for step ${stepKey}`;
+        showAggregate(error);
+        errored = true;
+      }
+    };
 
-    // In this line of code I defeat the purpose of using a generator in the
-    // first place. Sorry!!!!!!!!
-    const sections = Array.from(getSections(contentLines));
+    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.`,
+      },
+    });
 
-    const albumSection = sections[0];
-    const album = {};
+    fallbackStep('reportOrphanedArtworks', {
+      default: 'perform',
+      cli: {
+        flag: 'skip-orphaned-artwork-validation',
+        negate: true,
+        warn:
+          `Skipping orphaned artwork validation. Hopefully you're debugging!`,
+      },
+    });
 
-    album.name = getBasicField(albumSection, 'Album');
-    album.artists = getContributionField(albumSection, 'Artists') || getContributionField(albumSection, 'Artist');
-    album.wallpaperArtists = getContributionField(albumSection, 'Wallpaper Art');
-    album.wallpaperStyle = getMultilineField(albumSection, 'Wallpaper Style');
-    album.bannerArtists = getContributionField(albumSection, 'Banner Art');
-    album.bannerStyle = getMultilineField(albumSection, 'Banner Style');
-    album.bannerDimensions = getDimensionsField(albumSection, 'Banner Dimensions');
-    album.date = getBasicField(albumSection, 'Date');
-    album.trackArtDate = getBasicField(albumSection, 'Track Art Date') || album.date;
-    album.coverArtDate = getBasicField(albumSection, 'Cover Art Date') || album.date;
-    album.dateAdded = getBasicField(albumSection, 'Date Added');
-    album.coverArtists = getContributionField(albumSection, 'Cover Art');
-    album.hasTrackArt = getBooleanField(albumSection, 'Has Track Art') ?? true;
-    album.trackCoverArtists = getContributionField(albumSection, 'Track Art');
-    album.artTags = getListField(albumSection, 'Art Tags') || [];
-    album.commentary = getCommentaryField(albumSection);
-    album.urls = getListField(albumSection, 'URLs') || [];
-    album.groups = getListField(albumSection, 'Groups') || [];
-    album.directory = getBasicField(albumSection, 'Directory');
-    album.isMajorRelease = getBooleanField(albumSection, 'Major Release') ?? false;
-    album.isListedOnHomepage = getBooleanField(albumSection, 'Listed on Homepage') ?? true;
+    fallbackStep('filterReferenceErrors', {
+      default: 'perform',
+      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.`,
+      },
+    });
 
-    if (album.artists && album.artists.error) {
-        return {error: `${album.artists.error} (in ${album.name})`};
-    }
+    fallbackStep('reportContentTextErrors', {
+      default: 'perform',
+      cli: {
+        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.`,
+      },
+    });
 
-    if (album.coverArtists && album.coverArtists.error) {
-        return {error: `${album.coverArtists.error} (in ${album.name})`};
-    }
+    fallbackStep('generateThumbnails', {
+      default: 'perform',
+      buildConfig: 'thumbs',
+      cli: [
+        {flag: 'thumbs-only', disable: stepsNotFor('thumbs')},
+        {flag: 'skip-thumbs', negate: true},
+      ],
+    });
 
-    if (album.commentary && album.commentary.error) {
-        return {error: `${album.commentary.error} (in ${album.name})`};
-    }
+    fallbackStep('migrateThumbnails', {
+      default: 'skip',
+      cli: {
+        flag: 'migrate-thumbs',
+        disable: [
+          ...stepsNotFor('thumbs'),
+          'generateThumbnails',
+        ],
+      },
+    });
 
-    if (album.trackCoverArtists && album.trackCoverArtists.error) {
-        return {error: `${album.trackCoverArtists.error} (in ${album.name})`};
-    }
+    fallbackStep('preloadFileSizes', {
+      default: 'perform',
+      buildConfig: 'fileSizes',
+      cli: {
+        flag: 'skip-file-sizes',
+        negate: true,
+      },
+    });
 
-    if (!album.coverArtists) {
-        return {error: `The album "${album.name}" is missing the "Cover Art" field.`};
-    }
+    fallbackStep('identifyWebRoutes', {
+      default: 'perform',
+      buildConfig: 'webRoutes',
+    });
 
-    album.color = (
-        getBasicField(albumSection, 'Color') ||
-        getBasicField(albumSection, 'FG')
-    );
+    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`,
+          });
 
-    if (!album.name) {
-        return {error: `Expected "Album" (name) field!`};
-    }
+          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 (!album.date) {
-        return {error: `Expected "Date" field! (in ${album.name})`};
-    }
+          if (!paragraph) console.log('');
+          console.error(error);
 
-    if (!album.dateAdded) {
-        return {error: `Expected "Date Added" field! (in ${album.name})`};
-    }
+          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.`;
+        }
 
-    if (isNaN(Date.parse(album.date))) {
-        return {error: `Invalid Date field: "${album.date}" (in ${album.name})`};
-    }
+        paragraph = false;
+        break decideBuildSearchIndex;
+      }
 
-    if (isNaN(Date.parse(album.trackArtDate))) {
-        return {error: `Invalid Track Art Date field: "${album.trackArtDate}" (in ${album.name})`};
-    }
+      const delta = Date.now() - stats.mtimeMs;
+      const minute = 60 * 1000;
+      const delay = 45 * minute;
 
-    if (isNaN(Date.parse(album.coverArtDate))) {
-        return {error: `Invalid Cover Art Date field: "${album.coverArtDate}" (in ${album.name})`};
-    }
+      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`,
+        });
+      }
 
-    if (isNaN(Date.parse(album.dateAdded))) {
-        return {error: `Invalid Date Added field: "${album.dateAdded}" (in ${album.name})`};
+      paragraph = false;
     }
 
-    album.date = new Date(album.date);
-    album.trackArtDate = new Date(album.trackArtDate);
-    album.coverArtDate = new Date(album.coverArtDate);
-    album.dateAdded = new Date(album.dateAdded);
+    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',
+      cli: {
+        flag: 'skip-media-validation',
+        negate: true,
+        warning:
+          `Skipping media validation. If any media files are missing or misplaced,\n` +
+          `those errors will be silently passed along to the build.`,
+      },
+    });
 
-    if (!album.directory) {
-        album.directory = getKebabCase(album.name);
+    fallbackStep('watchLanguageFiles', {
+      default: 'perform',
+      buildConfig: 'languageReloading',
+      cli: {
+        flag: 'no-language-reloading',
+        negate: true,
+      },
+    });
+
+    if (errored) {
+      return false;
     }
+  }
 
-    album.tracks = [];
+  if (stepStatusSummary.generateThumbnails.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.loadOfflineThumbnailCache, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `using cache from thumbnail generation`,
+    });
+  }
 
-    // will be overwritten if a group section is found!
-    album.trackGroups = null;
+  if (stepStatusSummary.watchLanguageFiles.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.loadLanguageFiles, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `watching for changes instead`,
+    });
+  }
+
+  // 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':
+      if (stepStatusSummary.precacheAllData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheAllData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is common, not all`,
+        });
+      }
 
-    let group = null;
-    let trackIndex = 0;
+      break;
 
-    for (const section of sections.slice(1)) {
-        // Just skip empty sections. Sometimes I paste a 8unch of dividers,
-        // and this lets the empty sections doing that creates (temporarily)
-        // exist without raising an error.
-        if (!section.filter(Boolean).length) {
-            continue;
-        }
+    case 'all':
+      if (stepStatusSummary.precacheCommonData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheCommonData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is all, not common`,
+        });
+      }
 
-        const groupName = getBasicField(section, 'Group');
-        if (groupName) {
-            group = {
-                name: groupName,
-                color: (
-                    getBasicField(section, 'Color') ||
-                    getBasicField(section, 'FG') ||
-                    album.color
-                ),
-                originalDate: getBasicField(section, 'Original Date'),
-                startIndex: trackIndex,
-                tracks: []
-            };
-            if (group.originalDate) {
-                if (isNaN(Date.parse(group.originalDate))) {
-                    return {error: `The track group "${group.name}" has an invalid "Original Date" field: "${group.originalDate}"`};
-                }
-                group.originalDate = new Date(group.originalDate);
-                group.date = group.originalDate;
-            } else {
-                group.date = album.date;
-            }
-            if (album.trackGroups) {
-                album.trackGroups.push(group);
-            } else {
-                album.trackGroups = [group];
-            }
-            continue;
-        }
+      break;
 
-        trackIndex++;
-
-        const track = {};
-
-        track.name = getBasicField(section, 'Track');
-        track.commentary = getCommentaryField(section);
-        track.lyrics = getMultilineField(section, 'Lyrics');
-        track.originalDate = getBasicField(section, 'Original Date');
-        track.coverArtDate = getBasicField(section, 'Cover Art Date') || track.originalDate || album.trackArtDate;
-        track.references = getListField(section, 'References') || [];
-        track.artists = getContributionField(section, 'Artists') || getContributionField(section, 'Artist');
-        track.coverArtists = getContributionField(section, 'Track Art');
-        track.artTags = getListField(section, 'Art Tags') || [];
-        track.contributors = getContributionField(section, 'Contributors') || [];
-        track.directory = getBasicField(section, 'Directory');
-        track.aka = getBasicField(section, 'AKA');
-
-        if (!track.name) {
-            return {error: `A track section is missing the "Track" (name) field (in ${album.name}, previous: ${album.tracks[album.tracks.length - 1]?.name}).`};
-        }
+    case 'none':
+      if (stepStatusSummary.precacheCommonData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheCommonData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is none`,
+        });
+      }
 
-        let durationString = getBasicField(section, 'Duration') || '0:00';
-        track.duration = getDurationInSeconds(durationString);
+      if (stepStatusSummary.precacheAllData.status === STATUS_NOT_STARTED) {
+        Object.assign(stepStatusSummary.precacheAllData, {
+          status: STATUS_NOT_APPLICABLE,
+          annotation: `--precache-mode is none`,
+        });
+      }
 
-        if (track.contributors.error) {
-            return {error: `${track.contributors.error} (in ${track.name}, ${album.name})`};
-        }
+      break;
+  }
 
-        if (track.commentary && track.commentary.error) {
-            return {error: `${track.commentary.error} (in ${track.name}, ${album.name})`};
-        }
+  if (!langPath) {
+    Object.assign(stepStatusSummary.loadLanguageFiles, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `neither --lang-path nor HSMUSIC_LANG provided`,
+    });
 
-        if (!track.artists) {
-            // If an al8um has an artist specified (usually 8ecause it's a solo
-            // al8um), let tracks inherit that artist. We won't display the
-            // "8y <artist>" string on the al8um listing.
-            if (album.artists) {
-                track.artists = album.artists;
-            } else {
-                return {error: `The track "${track.name}" is missing the "Artist" field (in ${album.name}).`};
-            }
-        }
+    Object.assign(stepStatusSummary.watchLanguageFiles, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `neither --lang-path nor HSMUSIC_LANG provided`,
+    });
+  }
 
-        if (!track.coverArtists) {
-            if (getBasicField(section, 'Track Art') !== 'none' && album.hasTrackArt) {
-                if (album.trackCoverArtists) {
-                    track.coverArtists = album.trackCoverArtists;
-                } else {
-                    return {error: `The track "${track.name}" is missing the "Track Art" field (in ${album.name}).`};
-                }
-            }
-        }
+  if (stepStatusSummary.generateThumbnails.status === STATUS_NOT_APPLICABLE && thumbsOnly) {
+    logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`;
+    return false;
+  }
 
-        if (track.coverArtists && track.coverArtists.length && track.coverArtists[0] === 'none') {
-            track.coverArtists = null;
-        }
+  // 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 (!track.directory) {
-            track.directory = getKebabCase(track.name);
-        }
+  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`,
+        });
+      }
 
-        if (track.originalDate) {
-            if (isNaN(Date.parse(track.originalDate))) {
-                return {error: `The track "${track.name}"'s has an invalid "Original Date" field: "${track.originalDate}"`};
-            }
-            track.originalDate = new Date(track.originalDate);
-            track.date = new Date(track.originalDate);
-        } else if (group && group.originalDate) {
-            track.originalDate = group.originalDate;
-            track.date = group.originalDate;
-        } else {
-            track.date = album.date;
-        }
+      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;
+  }
 
-        track.coverArtDate = new Date(track.coverArtDate);
+  Object.assign(stepStatusSummary.determineMediaCachePath, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
-        const hasURLs = getBooleanField(section, 'Has URLs') ?? true;
+  const regenerateMissingThumbnailCache =
+    cliOptions['new-thumbs'] ?? false;
 
-        track.urls = hasURLs && (getListField(section, 'URLs') || []).filter(Boolean);
+  const {mediaCachePath, annotation: mediaCachePathAnnotation} =
+    await determineMediaCachePath({
+      mediaPath,
+      wikiCachePath,
 
-        if (hasURLs && !track.urls.length) {
-            return {error: `The track "${track.name}" should have at least one URL specified.`};
-        }
+      providedMediaCachePath:
+        cliOptions['media-cache-path'] || process.env.HSMUSIC_MEDIA_CACHE,
 
-        // 8ack-reference the al8um o8ject! This is very useful for when
-        // we're outputting the track pages.
-        track.album = album;
+      regenerateMissingThumbnailCache,
 
-        if (group) {
-            track.color = group.color;
-            group.tracks.push(track);
-        } else {
-            track.color = album.color;
-        }
+      disallowDoubling:
+        stepStatusSummary.migrateThumbnails.status === STATUS_NOT_STARTED,
+    });
 
-        album.tracks.push(track);
-    }
+  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 album;
-}
+        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(),
+        });
 
-async function processArtistDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
+        return false;
+      }
+    }
+  }
+
+  if (!mediaCachePath) {
+    logError`Couldn't determine a media cache path. (${mediaCachePathAnnotation})`;
+
+    switch (mediaCachePathAnnotation) {
+      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 `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 */
+        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(),
+    });
 
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
+    return false;
+  }
 
-    return sections.filter(s => s.filter(Boolean).length).map(section => {
-        const name = getBasicField(section, 'Artist');
-        const urls = (getListField(section, 'URLs') || []).filter(Boolean);
-        const alias = getBasicField(section, 'Alias');
-        const hasAvatar = getBooleanField(section, 'Has Avatar') ?? false;
-        const note = getMultilineField(section, 'Note');
-        let directory = getBasicField(section, 'Directory');
+  logInfo`Using media cache at: ${mediaCachePath} (${mediaCachePathAnnotation})`;
 
-        if (!name) {
-            return {error: 'Expected "Artist" (name) field!'};
-        }
+  Object.assign(stepStatusSummary.determineMediaCachePath, {
+    status: STATUS_DONE_CLEAN,
+    annotation: mediaCachePathAnnotation,
+    timeEnd: Date.now(),
+    memory: process.memoryUsage(),
+  });
 
-        if (!directory) {
-            directory = getKebabCase(name);
-        }
+  if (stepStatusSummary.migrateThumbnails.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.migrateThumbnails, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-        if (alias) {
-            return {name, directory, alias};
-        } else {
-            return {name, directory, urls, note, hasAvatar};
-        }
+    const result = await migrateThumbsIntoDedicatedCacheDirectory({
+      mediaPath,
+      mediaCachePath,
+      queueSize,
     });
-}
 
-async function processFlashDataFile(file) {
-    let contents;
+    if (result.succses) {
+      Object.assign(stepStatusSummary.migrateThumbnails, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      return false;
+    }
+
+    logInfo`Good to go! Run hsmusic again without ${'--migrate-thumbs'} to start`;
+    logInfo`using the migrated media cache.`;
+
+    Object.assign(stepStatusSummary.migrateThumbnails, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
+
+    return true;
+  }
+
+  const niceShowAggregate = (error, ...opts) => {
+    showAggregate(error, {
+      showTraces: showAggregateTraces,
+      pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
+      ...opts,
+    });
+  };
+
+  if (
+    stepStatusSummary.loadOfflineThumbnailCache.status === STATUS_NOT_STARTED &&
+    stepStatusSummary.generateThumbnails.status === STATUS_NOT_STARTED
+  ) {
+    throw new Error(`Unable to continue with both loadOfflineThumbnailCache and generateThumbnails`);
+  }
+
+  let thumbsCache;
+
+  // 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(),
+    });
+
+    const thumbsCachePath = path.join(mediaCachePath, thumbsCacheFile);
+
     try {
-        contents = await readFile(file, 'utf-8');
+      thumbsCache = JSON.parse(await readFile(thumbsCachePath));
     } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
-
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
-
-    let act, color;
-    return sections.map(section => {
-        if (getBasicField(section, 'ACT')) {
-            act = getBasicField(section, 'ACT');
-            color = (
-                getBasicField(section, 'Color') ||
-                getBasicField(section, 'FG')
-            );
-            const anchor = getBasicField(section, 'Anchor');
-            const jump = getBasicField(section, 'Jump');
-            const jumpColor = getBasicField(section, 'Jump Color') || color;
-            return {act8r8k: true, name: act, color, anchor, jump, jumpColor};
-        }
+      if (error.code === 'ENOENT') {
+        logError`The thumbnail cache doesn't exist, and it's necessary to build`
+        logError`the website. Please run once without ${'--skip-thumbs'} - after`
+        logError`that you'll be good to go and don't need to process thumbnails`
+        logError`again!`;
+
+        Object.assign(stepStatusSummary.loadOfflineThumbnailCache, {
+          status: STATUS_FATAL_ERROR,
+          annotation: `cache does not exist`,
+          timeEnd: Date.now(),
+          memory: process.memoryUsage(),
+        });
 
-        const name = getBasicField(section, 'Flash');
-        let page = getBasicField(section, 'Page');
-        let directory = getBasicField(section, 'Directory');
-        let date = getBasicField(section, 'Date');
-        const jiff = getBasicField(section, 'Jiff');
-        const tracks = getListField(section, 'Tracks') || [];
-        const contributors = getContributionField(section, 'Contributors') || [];
-        const urls = (getListField(section, 'URLs') || []).filter(Boolean);
-
-        if (!name) {
-            return {error: 'Expected "Flash" (name) field!'};
-        }
+        return false;
+      } else {
+        logError`Malformed or unreadable thumbnail cache file: ${error}`;
+        logError`Path: ${thumbsCachePath}`;
+        logError`The thumbbnail cache is necessary to build the site, so you'll`;
+        logError`have to investigate this to get the build working. Try running`;
+        logError`again without ${'--skip-thumbs'}. If you can't get it working,`;
+        logError`you're welcome to message in the HSMusic Discord and we'll try`;
+        logError`to help you out with troubleshooting!`;
+        logError`${'https://hsmusic.wiki/discord/'}`;
+
+        Object.assign(stepStatusSummary.loadOfflineThumbnailCache, {
+          status: STATUS_FATAL_ERROR,
+          annotation: `cache malformed or unreadable`,
+          timeEnd: Date.now(),
+          memory: process.memoryUsage(),
+        });
 
-        if (!page && !directory) {
-            return {error: 'Expected "Page" or "Directory" field!'};
-        }
+        return false;
+      }
+    }
 
-        if (!directory) {
-            directory = page;
-        }
+    logInfo`Thumbnail cache file successfully read.`;
 
-        if (!date) {
-            return {error: 'Expected "Date" field!'};
-        }
+    Object.assign(stepStatusSummary.loadOfflineThumbnailCache, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
 
-        if (isNaN(Date.parse(date))) {
-            return {error: `Invalid Date field: "${date}"`};
-        }
+    logInfo`Skipping thumbnail generation.`;
+  } else if (stepStatusSummary.generateThumbnails.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.generateThumbnails, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    logInfo`Begin thumbnail generation... -----+`;
 
-        date = new Date(date);
+    const result = await genThumbs({
+      mediaPath,
+      mediaCachePath,
 
-        return {name, page, directory, date, contributors, tracks, urls, act, color, jiff};
+      queueSize,
+      magickThreads,
+      quiet: !thumbsOnly,
     });
-}
 
-async function processNewsDataFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
+    logInfo`Done thumbnail generation! --------+`;
+
+    if (!result.success) {
+      Object.assign(stepStatusSummary.generateThumbnails, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      return false;
     }
 
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
+    Object.assign(stepStatusSummary.generateThumbnails, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
 
-    return sections.map(section => {
-        const name = getBasicField(section, 'Name');
-        if (!name) {
-            return {error: 'Expected "Name" field!'};
-        }
+    if (thumbsOnly) {
+      return true;
+    }
 
-        const directory = getBasicField(section, 'Directory') || getBasicField(section, 'ID');
-        if (!directory) {
-            return {error: 'Expected "Directory" field!'};
-        }
+    thumbsCache = result.cache;
+  } else {
+    thumbsCache = {};
+  }
 
-        let body = getMultilineField(section, 'Body');
-        if (!body) {
-            return {error: 'Expected "Body" field!'};
-        }
+  Object.assign(stepStatusSummary.loadDataFiles, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
-        let date = getBasicField(section, 'Date');
-        if (!date) {
-            return {error: 'Expected "Date" field!'};
-        }
+  let yamlDataSteps;
+  let yamlDocumentProcessingAggregate;
 
-        if (isNaN(Date.parse(date))) {
-            return {error: `Invalid date field: "${date}"`};
-        }
+  {
+    const whoops = (error, stage) => {
+      if (!paragraph) console.log('');
 
-        date = new Date(date);
+      console.error(error);
+      niceShowAggregate(error);
 
-        let bodyShort = body.split('<hr class="split">')[0];
+      logError`There was a JavaScript error ${stage}.`;
+      fileIssue();
 
-        return {
-            name,
-            directory,
-            body,
-            bodyShort,
-            date
-        };
-    });
-}
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `javascript error - view log for details`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      return false;
+    };
+
+    let loadAggregate, loadResult;
+    let processAggregate, processResult;
+    let saveAggregate, saveResult;
+
+    const dataSteps = getAllDataSteps();
 
-async function processTagDataFile(file) {
-    let contents;
     try {
-        contents = await readFile(file, 'utf-8');
+      ({aggregate: loadAggregate, result: loadResult} =
+          await loadYAMLDocumentsFromDataSteps(
+            dataSteps,
+            {dataPath}));
     } catch (error) {
-        if (error.code === 'ENOENT') {
-            return [];
-        } else {
-            return {error: `Could not read ${file} (${error.code}).`};
-        }
+      return whoops(error, `loading data files`);
     }
 
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
+    try {
+      loadAggregate.close();
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
 
-    return sections.map(section => {
-        let isCW = false;
+      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.`;
 
-        let name = getBasicField(section, 'Tag');
-        if (!name) {
-            name = getBasicField(section, 'CW');
-            isCW = true;
-            if (!name) {
-                return {error: 'Expected "Tag" or "CW" field!'};
-            }
-        }
+      paragraph = true;
+      console.log('');
 
-        let color;
-        if (!isCW) {
-            color = getBasicField(section, 'Color');
-            if (!color) {
-                return {error: 'Expected "Color" field!'};
-            }
-        }
-
-        const directory = getKebabCase(name);
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `error loading data files`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
 
-        return {
-            name,
-            directory,
-            isCW,
-            color
-        };
-    });
-}
+      return false;
+    }
 
-async function processGroupDataFile(file) {
-    let contents;
     try {
-        contents = await readFile(file, 'utf-8');
+      ({aggregate: processAggregate, result: processResult} =
+          await processThingsFromDataSteps(
+            loadResult.documentLists,
+            loadResult.fileLists,
+            dataSteps,
+            {dataPath}));
     } catch (error) {
-        if (error.code === 'ENOENT') {
-            return [];
-        } else {
-            return {error: `Could not read ${file} (${error.code}).`};
-        }
+      return whoops(error, `processing data files`);
     }
 
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
+    try {
+      ({aggregate: saveAggregate, result: saveResult} =
+          saveThingsFromDataSteps(
+            processResult,
+            dataSteps));
 
-    let category, color;
-    return sections.map(section => {
-        if (getBasicField(section, 'Category')) {
-            category = getBasicField(section, 'Category');
-            color = getBasicField(section, 'Color');
-            return {isCategory: true, name: category, color};
-        }
+      saveAggregate.close();
+      saveAggregate = undefined;
+    } catch (error) {
+      return whoops(error, `finalizing data files`);
+    }
 
-        const name = getBasicField(section, 'Group');
-        if (!name) {
-            return {error: 'Expected "Group" field!'};
-        }
+    yamlDataSteps = dataSteps;
+    yamlDocumentProcessingAggregate = processAggregate;
 
-        let directory = getBasicField(section, 'Directory');
-        if (!directory) {
-            directory = getKebabCase(name);
-        }
+    Object.assign(wikiData, saveResult);
+  }
 
-        let description = getMultilineField(section, 'Description');
-        if (!description) {
-            return {error: 'Expected "Description" field!'};
-        }
+  {
+    const logThings = (prop, label) => {
+      const array =
+        (Array.isArray(prop)
+          ? prop
+          : wikiData[prop]);
 
-        let descriptionShort = description.split('<hr class="split">')[0];
-
-        const urls = (getListField(section, 'URLs') || []).filter(Boolean);
+      if (array && empty(array)) {
+        return;
+      }
 
-        return {
-            isGroup: true,
-            name,
-            directory,
-            description,
-            descriptionShort,
-            urls,
-            category,
-            color
-        };
-    });
-}
+      logInfo` - ${array?.length ?? colors.red('(Missing!)')} ${colors.normal(colors.dim(label))}`;
+    }
 
-async function processStaticPageDataFile(file) {
-    let contents;
     try {
-        contents = await readFile(file, 'utf-8');
+      if (!paragraph) console.log('');
+
+      logInfo`Loaded data and processed objects:`;
+      logThings('albumData', 'albums');
+      logThings('trackData', 'tracks');
+      logThings(
+        (wikiData.artistData
+          ? wikiData.artistData.filter(artist => !artist.isAlias)
+          : null),
+        'artists');
+      if (wikiData.flashData) {
+        logThings('flashData', 'flashes');
+        logThings('flashActData', 'flash acts');
+        logThings('flashSideData', 'flash sides');
+      }
+      logThings('groupData', 'groups');
+      logThings('groupCategoryData', 'group categories');
+      logThings('artTagData', 'art tags');
+      if (wikiData.newsData) {
+        logThings('newsData', 'news entries');
+      }
+      logThings('staticPageData', 'static pages');
+      logThings('sortingRules', 'sorting rules');
+      if (wikiData.homepageLayout) {
+        logInfo` - ${1} homepage layout (${
+          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) {
-        if (error.code === 'ENOENT') {
-            return [];
-        } else {
-            return {error: `Could not read ${file} (${error.code}).`};
-        }
+      console.error(`Error showing data summary:`, error);
+      paragraph = false;
     }
 
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
+    let errorless = true;
+    try {
+      yamlDocumentProcessingAggregate.close();
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
 
-    return sections.map(section => {
-        const name = getBasicField(section, 'Name');
-        if (!name) {
-            return {error: 'Expected "Name" field!'};
-        }
+      logWarn`The above errors were detected while processing data files.`;
 
-        const shortName = getBasicField(section, 'Short Name') || name;
+      errorless = false;
+    }
 
-        let directory = getBasicField(section, 'Directory');
-        if (!directory) {
-            return {error: 'Expected "Directory" field!'};
-        }
+    if (!wikiData.wikiInfo) {
+      logError`Can't proceed without wiki info file successfully loading.`;
 
-        let content = getMultilineField(section, 'Content');
-        if (!content) {
-            return {error: 'Expected "Content" field!'};
-        }
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `wiki info object not available`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
 
-        let stylesheet = getMultilineField(section, 'Style') || '';
+      return false;
+    }
 
-        let listed = getBooleanField(section, 'Listed') ?? true;
+    if (errorless) {
+      logInfo`All data files processed without any errors - nice!`;
+      paragraph = false;
 
-        return {
-            name,
-            shortName,
-            directory,
-            content,
-            stylesheet,
-            listed
-        };
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    } else {
+      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(),
+      });
+    }
+  }
+
+  // Link data arrays so that all essential references between objects are
+  // complete, so properties (like dates!) are inherited where that's
+  // appropriate.
+
+  Object.assign(stepStatusSummary.linkWikiDataArrays, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
+
+  linkWikiDataArrays(wikiData, {bindFind, bindReverse});
+
+  Object.assign(stepStatusSummary.linkWikiDataArrays, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+    memory: process.memoryUsage(),
+  });
+
+  if (precacheMode === 'common') {
+    Object.assign(stepStatusSummary.precacheCommonData, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
     });
-}
 
-async function processWikiInfoFile(file) {
-    let contents;
-    try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
-
-    // Unlike other data files, the site info data file isn't 8roken up into
-    // more than one entry. So we operate on the plain old contentLines array,
-    // rather than dividing into sections like we usually do!
-    const contentLines = contents.split('\n');
-
-    const name = getBasicField(contentLines, 'Name');
-    if (!name) {
-        return {error: 'Expected "Name" field!'};
-    }
-
-    const shortName = getBasicField(contentLines, 'Short Name') || name;
-
-    const color = getBasicField(contentLines, 'Color') || '#0088ff';
-
-    // This is optional! Without it, <meta rel="canonical"> tags won't 8e
-    // gener8ted.
-    const canonicalBase = getBasicField(contentLines, 'Canonical Base');
-
-    // This is optional! Without it, the site will default to 8uilding in
-    // English. (This is only really relevant if you've provided string files
-    // for non-English languages.)
-    const defaultLanguage = getBasicField(contentLines, 'Default Language');
-
-    // Also optional! In charge of <meta rel="description">.
-    const description = getBasicField(contentLines, 'Description');
-
-    const footer = getMultilineField(contentLines, 'Footer') || '';
-
-    // We've had a comment lying around for ages, just reading:
-    // "Might ena8le this later... we'll see! Eventually. May8e."
-    // We still haven't! 8ut hey, the option's here.
-    const enableArtistAvatars = getBooleanField(contentLines, 'Enable Artist Avatars') ?? false;
-
-    const enableFlashesAndGames = getBooleanField(contentLines, 'Enable Flashes & Games') ?? false;
-    const enableListings = getBooleanField(contentLines, 'Enable Listings') ?? false;
-    const enableNews = getBooleanField(contentLines, 'Enable News') ?? false;
-    const enableArtTagUI = getBooleanField(contentLines, 'Enable Art Tag UI') ?? false;
-    const enableGroupUI = getBooleanField(contentLines, 'Enable Group UI') ?? false;
-
-    return {
-        name,
-        shortName,
-        color,
-        canonicalBase,
-        defaultLanguage,
-        description,
-        footer,
-        features: {
-            artistAvatars: enableArtistAvatars,
-            flashesAndGames: enableFlashesAndGames,
-            listings: enableListings,
-            news: enableNews,
-            artTagUI: enableArtTagUI,
-            groupUI: enableGroupUI
-        }
+    const commonDataMap = {
+      albumData: new Set([
+        // Needed for sorting
+        'date', 'tracks',
+        // Needed for computing page paths
+        'aliasedArtist', 'commentary', 'coverArtistContribs',
+      ]),
+
+      artTagData: new Set([
+        // Needed for computing page paths
+        'isContentWarning',
+      ]),
+
+      flashData: new Set([
+        // Needed for sorting
+        'act', 'date',
+      ]),
+
+      flashActData: new Set([
+        // Needed for sorting
+        'flashes',
+      ]),
+
+      groupData: new Set([
+        // Needed for computing page paths
+        'albums',
+      ]),
+
+      listingSpec: new Set([
+        // Needed for computing page paths
+        'contentFunction', 'featureFlag',
+      ]),
+
+      trackData: new Set([
+        // Needed for sorting
+        'album', 'date',
+        // Needed for computing page paths
+        'commentary', 'coverArtistContribs',
+      ]),
     };
-}
 
-async function processHomepageInfoFile(file) {
-    let contents;
     try {
-        contents = await readFile(file, 'utf-8');
+      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) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
+      console.log('');
 
-    const contentLines = contents.split('\n');
-    const sections = Array.from(getSections(contentLines));
+      logError`There was an error precaching internal data objects.`;
+      fileIssue();
 
-    const [ firstSection, ...rowSections ] = sections;
+      Object.assign(stepStatusSummary.precacheCommonData, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `see log for details`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
 
-    const sidebar = getMultilineField(firstSection, 'Sidebar');
+      return false;
+    }
 
-    const validRowTypes = ['albums'];
+    Object.assign(stepStatusSummary.precacheCommonData, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
+  }
 
-    const rows = rowSections.map(section => {
-        const name = getBasicField(section, 'Row');
-        if (!name) {
-            return {error: 'Expected "Row" (name) field!'};
-        }
+  // Check for things with duplicate directories throughout the data,
+  // and halt if any are found.
 
-        const color = getBasicField(section, 'Color');
+  if (stepStatusSummary.reportDirectoryErrors.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.reportDirectoryErrors, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-        const type = getBasicField(section, 'Type');
-        if (!type) {
-            return {error: 'Expected "Type" field!'};
-        }
+    try {
+      reportDirectoryErrors(wikiData, {getAllFindSpecs});
+      logInfo`No duplicate directories found - nice!`;
+      paragraph = false;
+
+      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.`;
+
+      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(),
+    });
 
-        if (!validRowTypes.includes(type)) {
-            return {error: `Expected "Type" field to be one of: ${validRowTypes.join(', ')}`};
-        }
+    try {
+      reportOrphanedArtworks(wikiData, {getAllFindSpecs});
 
-        const row = {name, color, type};
+      Object.assign(stepStatusSummary.reportOrphanedArtworks, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    } catch (aggregate) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(aggregate);
 
-        switch (type) {
-            case 'albums': {
-                const group = getBasicField(section, 'Group') || null;
-                const albums = getListField(section, 'Albums') || [];
+      logError`Failed to initialize artwork data connections properly.`;
+      fileIssue();
 
-                if (!group && !albums) {
-                    return {error: 'Expected "Group" and/or "Albums" field!'};
-                }
+      Object.assign(stepStatusSummary.reportOrphanedArtworks, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `orphaned artworks found`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
 
-                let groupCount = getBasicField(section, 'Count');
-                if (group && !groupCount) {
-                    return {error: 'Expected "Count" field!'};
-                }
+      return false;
+    }
+  }
 
-                if (groupCount) {
-                    if (isNaN(parseInt(groupCount))) {
-                        return {error: `Invalid Count field: "${groupCount}"`};
-                    }
+  // Filter out any reference errors throughout the data, warning about these.
 
-                    groupCount = parseInt(groupCount);
-                }
+  if (stepStatusSummary.filterReferenceErrors.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.filterReferenceErrors, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-                const actions = getListField(section, 'Actions') || [];
+    const filterReferenceErrorsAggregate =
+      filterReferenceErrors(wikiData, {find, bindFind});
 
-                return {...row, group, groupCount, albums, actions};
-            }
-        }
+    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 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(),
+      });
+    }
+  }
+
+  if (stepStatusSummary.reportContentTextErrors.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.reportContentTextErrors, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
     });
 
-    return {sidebar, rows};
-}
+    try {
+      reportContentTextErrors(wikiData, {bindFind});
 
-function getDurationInSeconds(string) {
-    const parts = string.split(':').map(n => parseInt(n))
-    if (parts.length === 3) {
-        return parts[0] * 3600 + parts[1] * 60 + parts[2]
-    } else if (parts.length === 2) {
-        return parts[0] * 60 + parts[1]
-    } else {
-        return 0
-    }
-}
+      logInfo`All content text validated without any errors - nice!`;
+      paragraph = false;
 
-const stringifyIndent = 0;
+      Object.assign(stepStatusSummary.reportContentTextErrors, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    } catch (error) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(error);
 
-const toRefs = (label, objectOrArray) => {
-    if (Array.isArray(objectOrArray)) {
-        return objectOrArray.filter(Boolean).map(x => `${label}:${x.directory}`);
-    } else if (objectOrArray.directory) {
-        throw new Error('toRefs should not be passed a single object with directory');
-    } else if (typeof objectOrArray === 'object') {
-        return Object.fromEntries(Object.entries(objectOrArray)
-            .map(([ key, value ]) => [key, toRefs(key, value)]));
-    } else {
-        throw new Error('toRefs should be passed an array or object of arrays');
-    }
-};
-
-function stringifyRefs(key, value) {
-    switch (key) {
-        case 'tracks':
-        case 'references':
-        case 'referencedBy':
-            return toRefs('track', value);
-        case 'artists':
-        case 'contributors':
-        case 'coverArtists':
-        case 'trackCoverArtists':
-            return value && value.map(({ who, what }) => ({who: `artist:${who.directory}`, what}));
-        case 'albums': return toRefs('album', value);
-        case 'flashes': return toRefs('flash', value);
-        case 'groups': return toRefs('group', value);
-        case 'artTags': return toRefs('tag', value);
-        case 'aka': return value && `track:${value.directory}`;
-        default:
-            return value;
-    }
-}
+      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.`;
 
-function stringifyAlbumData({wikiData}) {
-    return JSON.stringify(wikiData.albumData, (key, value) => {
-        switch (key) {
-            case 'commentary':
-                return '';
-            default:
-                return stringifyRefs(key, value);
-        }
-    }, stringifyIndent);
-}
+      console.log('');
+      paragraph = true;
 
-function stringifyTrackData({wikiData}) {
-    return JSON.stringify(wikiData.trackData, (key, value) => {
-        switch (key) {
-            case 'album':
-            case 'commentary':
-            case 'otherReleases':
-                return undefined;
-            default:
-                return stringifyRefs(key, value);
-        }
-    }, stringifyIndent);
-}
+      Object.assign(stepStatusSummary.reportContentTextErrors, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    }
+  }
 
-function stringifyFlashData({wikiData}) {
-    return JSON.stringify(wikiData.flashData, (key, value) => {
-        switch (key) {
-            case 'act':
-            case 'commentary':
-                return undefined;
-            default:
-                return stringifyRefs(key, value);
-        }
-    }, stringifyIndent);
-}
+  // Sort data arrays so that they're all in order! This may use properties
+  // which are only available after the initial linking.
 
-function stringifyArtistData({wikiData}) {
-    return JSON.stringify(wikiData.artistData, (key, value) => {
-        switch (key) {
-            case 'asAny':
-                return;
-            case 'asArtist':
-            case 'asContributor':
-            case 'asCoverArtist':
-                return toRefs('track', value);
-            default:
-                return stringifyRefs(key, value);
-        }
-    }, stringifyIndent);
-}
+  Object.assign(stepStatusSummary.sortWikiDataArrays, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
-function img({
-    src,
-    alt,
-    thumb: thumbKey,
-    reveal,
-    id,
-    class: className,
-    width,
-    height,
-    link = false,
-    lazy = false,
-    square = false
-}) {
-    const willSquare = square;
-    const willLink = typeof link === 'string' || link;
-
-    const originalSrc = src;
-    const thumbSrc = thumbKey ? thumb[thumbKey](src) : src;
-
-    const imgAttributes = html.attributes({
-        id: link ? '' : id,
-        class: className,
-        alt,
-        width,
-        height
-    });
-
-    const nonlazyHTML = wrap(`<img src="${thumbSrc}" ${imgAttributes}>`);
-    const lazyHTML = lazy && wrap(`<img class="lazy" data-original="${thumbSrc}" ${imgAttributes}>`, true);
-
-    if (lazy) {
-        return fixWS`
-            <noscript>${nonlazyHTML}</noscript>
-            ${lazyHTML}
-        `;
-    } else {
-        return nonlazyHTML;
-    }
+  sortWikiDataArrays(yamlDataSteps, wikiData, {bindFind, bindReverse});
 
-    function wrap(input, hide = false) {
-        let wrapped = input;
+  Object.assign(stepStatusSummary.sortWikiDataArrays, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+    memory: process.memoryUsage(),
+  });
 
-        wrapped = `<div class="image-inner-area">${wrapped}</div>`;
-        wrapped = `<div class="image-container">${wrapped}</div>`;
+  if (precacheMode === 'all') {
+    Object.assign(stepStatusSummary.precacheAllData, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-        if (reveal) {
-            wrapped = fixWS`
-                <div class="reveal">
-                    ${wrapped}
-                    <span class="reveal-text">${reveal}</span>
-                </div>
-            `;
-        }
+    // TODO: Aggregate errors here, instead of just throwing.
+    progressCallAll('Caching all data values', Object.entries(wikiData)
+      .filter(([key]) =>
+        key !== 'listingSpec' &&
+        key !== 'listingTargetSpec')
+      .map(([key, value]) =>
+        key === 'wikiInfo' ? [key, [value]] :
+        key === 'homepageLayout' ? [key, [value]] :
+        [key, value])
+      .flatMap(([_key, things]) => things)
+      .map(thing => () => CacheableObject.cacheAllExposedProperties(thing)));
+
+    Object.assign(stepStatusSummary.precacheAllData, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
+  }
 
-        if (willSquare) {
-            wrapped = html.tag('div', {class: 'square-content'}, wrapped);
-            wrapped = html.tag('div', {class: ['square', hide && !willLink && 'js-hide']}, wrapped);
-        }
+  if (stepStatusSummary.sortWikiDataSourceFiles.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-        if (willLink) {
-            wrapped = html.tag('a', {
-                id,
-                class: ['box', hide && 'js-hide'],
-                href: typeof link === 'string' ? link : originalSrc
-            }, wrapped);
-        }
+    const {SortingRule} = thingConstructors;
+    const results =
+      await Array.fromAsync(SortingRule.go({dataPath, wikiData}));
 
-        return wrapped;
-    }
-}
+    if (results.some(result => result.changed)) {
+      logInfo`Updated data files to satisfy sorting.`;
+      logInfo`Restarting automatically, since that's now needed!`;
 
-function validateWritePath(path, urlGroup) {
-    if (!Array.isArray(path)) {
-        return {error: `Expected array, got ${path}`};
-    }
+      Object.assign(stepStatusSummary.sortWikiDataSourceFiles, {
+        status: STATUS_DONE_CLEAN,
+        annotation: `changes cueing restart`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
 
-    const { paths } = urlGroup;
+      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 definedKeys = Object.keys(paths);
-    const specifiedKey = path[0];
+    const {SortingRule} = thingConstructors;
+    const results =
+      await Array.fromAsync(SortingRule.go({dataPath, wikiData, dry: true}));
 
-    if (!definedKeys.includes(specifiedKey)) {
-        return {error: `Specified key ${specifiedKey} isn't defined`};
-    }
+    const needed = results.filter(result => result.changed);
 
-    const expectedArgs = paths[specifiedKey].match(/<>/g)?.length ?? 0;
-    const specifiedArgs = path.length - 1;
+    if (empty(needed)) {
+      logInfo`All sorting rules are satisfied. Nice!`;
+      paragraph = false;
 
-    if (specifiedArgs !== expectedArgs) {
-        return {error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`};
-    }
+      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();
+
+    if (precacheMode === 'all') {
+      return true;
+    }
+  }
+
+  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 {success: true};
-}
+    return false;
+  }
 
-function validateWriteObject(obj) {
-    if (typeof obj !== 'object') {
-        return {error: `Expected object, got ${typeof obj}`};
-    }
+  // We'll mutate this as we load other url spec files.
+  const urlSpec = structuredClone(internalURLSpec);
 
-    if (typeof obj.type !== 'string') {
-        return {error: `Expected type to be string, got ${obj.type}`};
-    }
+  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));
 
-    switch (obj.type) {
-        case 'legacy': {
-            if (typeof obj.write !== 'function') {
-                return {error: `Expected write to be string, got ${obj.write}`};
-            }
+  const getURLSpecKeyFromFile = file => {
+    const base = path.basename(file, path.extname(file));
+    if (base === 'urls') {
+      return base;
+    } else {
+      return base.replace(/^urls-/, '');
+    }
+  };
 
-            break;
-        }
+  const isDefaultURLSpecFile = file =>
+    getURLSpecKeyFromFile(file) === 'urls';
 
-        case 'page': {
-            const path = validateWritePath(obj.path, urlSpec.localized);
-            if (path.error) {
-                return {error: `Path validation failed: ${path.error}`};
-            }
+  const overrideDefaultURLSpecFile =
+    allURLSpecDataFiles.find(file => isDefaultURLSpecFile(file));
 
-            if (typeof obj.page !== 'function') {
-                return {error: `Expected page to be function, got ${obj.content}`};
-            }
+  const optionalURLSpecDataFiles =
+    allURLSpecDataFiles.filter(file => !isDefaultURLSpecFile(file));
 
-            break;
-        }
+  const optionalURLSpecDataKeys =
+    optionalURLSpecDataFiles.map(file => getURLSpecKeyFromFile(file));
 
-        case 'data': {
-            const path = validateWritePath(obj.path, urlSpec.data);
-            if (path.error) {
-                return {error: `Path validation failed: ${path.error}`};
-            }
+  const selectedURLSpecDataKeys = optionalURLSpecDataKeys.slice();
+  const selectedURLSpecDataFiles = optionalURLSpecDataFiles.slice();
 
-            if (typeof obj.data !== 'function') {
-                return {error: `Expected data to be function, got ${obj.data}`};
-            }
+  const {removed: [unusedURLSpecDataKeys]} =
+    filterMultipleArrays(
+      selectedURLSpecDataKeys,
+      selectedURLSpecDataFiles,
+      (key, _file) => wantedURLSpecKeys.includes(key));
 
-            break;
-        }
+  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(', ')}`;
+  }
 
-        case 'redirect': {
-            const fromPath = validateWritePath(obj.fromPath, urlSpec.localized);
-            if (fromPath.error) {
-                return {error: `Path (fromPath) validation failed: ${fromPath.error}`};
-            }
+  if (overrideDefaultURLSpecFile) {
+    try {
+      let aggregate;
+      let overrideDefaultURLSpec;
 
-            const toPath = validateWritePath(obj.toPath, urlSpec.localized);
-            if (toPath.error) {
-                return {error: `Path (toPath) validation failed: ${toPath.error}`};
-            }
+      ({aggregate, result: overrideDefaultURLSpec} =
+          await processURLSpecFromFile(overrideDefaultURLSpecFile));
 
-            if (typeof obj.title !== 'function') {
-                return {error: `Expected title to be function, got ${obj.title}`};
-            }
+      aggregate.close();
 
-            break;
-        }
+      ({aggregate} =
+          applyURLSpecOverriding(overrideDefaultURLSpec, urlSpec));
 
-        default: {
-            return {error: `Unknown type: ${obj.type}`};
-        }
-    }
+      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 {success: true};
-}
+    return false;
+  }
 
-async function writeData(subKey, directory, data) {
-    const paths = writePage.paths('', 'data.' + subKey, directory, {file: 'data.json'});
-    await writePage.write(JSON.stringify(data), {paths});
-}
+  if (showURLSpec) {
+    if (!paragraph) console.log('');
 
-// This used to 8e a function! It's long 8een divided into multiple helper
-// functions, and nowadays we just directly access those, rather than ever
-// touching the original one (which had contained everything).
-const writePage = {};
-
-writePage.to = ({
-    baseDirectory,
-    pageSubKey,
-    paths
-}) => (targetFullKey, ...args) => {
-    const [ groupKey, subKey ] = targetFullKey.split('.');
-    let path = paths.subdirectoryPrefix;
-    // When linking to *outside* the localized area of the site, we need to
-    // make sure the result is correctly relative to the 8ase directory.
-    if (groupKey !== 'localized' && baseDirectory) {
-        path += urls.from('localizedWithBaseDirectory.' + pageSubKey).to(targetFullKey, ...args);
-    } else {
-        // If we're linking inside the localized area (or there just is no
-        // 8ase directory), the 8ase directory doesn't matter.
-        path += urls.from('localized.' + pageSubKey).to(targetFullKey, ...args);
-    }
-    return path;
-};
-
-writePage.html = (pageFn, {
-    paths,
-    strings,
-    to,
-    transformMultiline,
-    wikiData
-}) => {
-    const { wikiInfo } = wikiData;
-
-    let {
-        title = '',
-        meta = {},
-        theme = '',
-        stylesheet = '',
-
-        // missing properties are auto-filled, see below!
-        body = {},
-        banner = {},
-        main = {},
-        sidebarLeft = {},
-        sidebarRight = {},
-        nav = {},
-        footer = {}
-    } = pageFn({to});
-
-    body.style ??= '';
-
-    theme = theme || getThemeString(wikiInfo.color);
-
-    banner ||= {};
-    banner.classes ??= [];
-    banner.src ??= '';
-    banner.position ??= '';
-    banner.dimensions ??= [0, 0];
-
-    main.classes ??= [];
-    main.content ??= '';
-
-    sidebarLeft ??= {};
-    sidebarRight ??= {};
-
-    for (const sidebar of [sidebarLeft, sidebarRight]) {
-        sidebar.classes ??= [];
-        sidebar.content ??= '';
-        sidebar.collapse ??= true;
-    }
-
-    nav.classes ??= [];
-    nav.content ??= '';
-    nav.links ??= [];
-
-    footer.classes ??= [];
-    footer.content ??= (wikiInfo.footer ? transformMultiline(wikiInfo.footer) : '');
-
-    const canonical = (wikiInfo.canonicalBase
-        ? wikiInfo.canonicalBase + paths.pathname
-        : '');
-
-    const collapseSidebars = (sidebarLeft.collapse !== false) && (sidebarRight.collapse !== false);
-
-    const mainHTML = main.content && html.tag('main', {
-        id: 'content',
-        class: main.classes
-    }, main.content);
-
-    const footerHTML = footer.content && html.tag('footer', {
-        id: 'footer',
-        class: footer.classes
-    }, footer.content);
-
-    const generateSidebarHTML = (id, {
-        content,
-        multiple,
-        classes,
-        collapse = true,
-        wide = false
-    }) => (content
-        ? html.tag('div',
-            {id, class: [
-                'sidebar-column',
-                'sidebar',
-                wide && 'wide',
-                !collapse && 'no-hide',
-                ...classes
-            ]},
-            content)
-        : multiple ? html.tag('div',
-            {id, class: [
-                'sidebar-column',
-                'sidebar-multiple',
-                wide && 'wide',
-                !collapse && 'no-hide'
-            ]},
-            multiple.map(content => html.tag('div',
-                {class: ['sidebar', ...classes]},
-                content)))
-        : '');
-
-    const sidebarLeftHTML = generateSidebarHTML('sidebar-left', sidebarLeft);
-    const sidebarRightHTML = generateSidebarHTML('sidebar-right', sidebarRight);
-
-    if (nav.simple) {
-        nav.links = [
-            {toHome: true},
-            {toCurrentPage: true}
-        ];
-    }
-
-    const links = (nav.links || []).filter(Boolean);
-
-    const navLinkParts = [];
-    for (let i = 0; i < links.length; i++) {
-        let cur = links[i];
-        const prev = links[i - 1];
-        const next = links[i + 1];
-
-        let { title: linkTitle } = cur;
-
-        if (cur.toHome) {
-            linkTitle ??= wikiInfo.shortName;
-        } else if (cur.toCurrentPage) {
-            linkTitle ??= title;
-        }
+    logInfo`Here's the final URL spec, via ${'--show-url-spec'}:`
+    console.log(urlSpec);
+    console.log('');
 
-        let part = prev && (cur.divider ?? true) ? '/ ' : '';
+    paragraph = true;
+  }
 
-        if (typeof cur.html === 'string') {
-            if (!cur.html) {
-                logWarn`Empty HTML in nav link ${JSON.stringify(cur)}`;
-            }
-            part += `<span>${cur.html}</span>`;
-        } else {
-            const attributes = {
-                class: (cur.toCurrentPage || i === links.length - 1) && 'current',
-                href: (
-                    cur.toCurrentPage ? '' :
-                    cur.toHome ? to('localized.home') :
-                    cur.path ? to(...cur.path) :
-                    cur.href ? call(() => {
-                        logWarn`Using legacy href format nav link in ${paths.pathname}`;
-                        return cur.href;
-                    }) :
-                    null)
-            };
-            if (attributes.href === null) {
-                throw new Error(`Expected some href specifier for link to ${linkTitle} (${JSON.stringify(cur)})`);
-            }
-            part += html.tag('a', attributes, linkTitle);
-        }
-        navLinkParts.push(part);
-    }
-
-    const navHTML = html.tag('nav', {
-        [html.onlyIfContent]: true,
-        id: 'header',
-        class: nav.classes
-    }, [
-        links.length && html.tag('h2', {class: 'highlight-last-link'}, navLinkParts),
-        nav.content
-    ]);
-
-    const bannerSrc = (
-        banner.src ? banner.src :
-        banner.path ? to(...banner.path) :
-        null);
-
-    const bannerHTML = banner.position && bannerSrc && html.tag('div',
-        {
-            id: 'banner',
-            class: banner.classes
-        },
-        html.tag('img', {
-            src: bannerSrc,
-            alt: banner.alt,
-            width: banner.dimensions[0] || 1100,
-            height: banner.dimensions[1] || 200
-        })
-    );
-
-    const layoutHTML = [
-        navHTML,
-        banner.position === 'top' && bannerHTML,
-        html.tag('div',
-            {class: ['layout-columns', !collapseSidebars && 'vertical-when-thin']},
-            [
-                sidebarLeftHTML,
-                mainHTML,
-                sidebarRightHTML
-            ]),
-        banner.position === 'bottom' && bannerHTML,
-        footerHTML
-    ].filter(Boolean).join('\n');
-
-    const infoCardHTML = fixWS`
-        <div id="info-card-container">
-            <div class="info-card-decor">
-                <div class="info-card">
-                    <div class="info-card-art-container no-reveal">
-                        ${img({
-                            class: 'info-card-art',
-                            src: '',
-                            link: true,
-                            square: true
-                        })}
-                    </div>
-                    <div class="info-card-art-container reveal">
-                        ${img({
-                            class: 'info-card-art',
-                            src: '',
-                            link: true,
-                            square: true,
-                            reveal: getRevealStringFromWarnings('<span class="info-card-art-warnings"></span>', {strings})
-                        })}
-                    </div>
-                    <h1 class="info-card-name"><a></a></h1>
-                    <p class="info-card-album">${strings('releaseInfo.from', {album: '<a></a>'})}</p>
-                    <p class="info-card-artists">${strings('releaseInfo.by', {artists: '<span></span>'})}</p>
-                    <p class="info-card-cover-artists">${strings('releaseInfo.coverArtBy', {artists: '<span></span>'})}</p>
-                </div>
-            </div>
-        </div>
-    `;
-
-    return filterEmptyLines(fixWS`
-        <!DOCTYPE html>
-        <html ${html.attributes({
-            lang: strings.code,
-            'data-rebase-localized': to('localized.root'),
-            'data-rebase-shared': to('shared.root'),
-            'data-rebase-media': to('media.root'),
-            'data-rebase-data': to('data.root')
-        })}>
-            <head>
-                <title>${title}</title>
-                <meta charset="utf-8">
-                <meta name="viewport" content="width=device-width, initial-scale=1">
-                ${Object.entries(meta).filter(([ key, value ]) => value).map(([ key, value ]) => `<meta ${key}="${html.escapeAttributeValue(value)}">`).join('\n')}
-                ${canonical && `<link rel="canonical" href="${canonical}">`}
-                <link rel="stylesheet" href="${to('shared.staticFile', `site.css?${CACHEBUST}`)}">
-                ${(theme || stylesheet) && fixWS`
-                    <style>
-                        ${theme}
-                        ${stylesheet}
-                    </style>
-                `}
-                <script src="${to('shared.staticFile', `lazy-loading.js?${CACHEBUST}`)}"></script>
-            </head>
-            <body ${html.attributes({style: body.style || ''})}>
-                <div id="page-container">
-                    ${mainHTML && fixWS`
-                        <div id="skippers">
-                            ${[
-                                ['#content', strings('misc.skippers.skipToContent')],
-                                sidebarLeftHTML && ['#sidebar-left', (sidebarRightHTML
-                                    ? strings('misc.skippers.skipToSidebar.left')
-                                    : strings('misc.skippers.skipToSidebar'))],
-                                sidebarRightHTML && ['#sidebar-right', (sidebarLeftHTML
-                                    ? strings('misc.skippers.skipToSidebar.right')
-                                    : strings('misc.skippers.skipToSidebar'))],
-                                footerHTML && ['#footer', strings('misc.skippers.skipToFooter')]
-                            ].filter(Boolean).map(([ href, title ]) => fixWS`
-                                <span class="skipper"><a href="${href}">${title}</a></span>
-                            `).join('\n')}
-                        </div>
-                    `}
-                    ${layoutHTML}
-                </div>
-                ${infoCardHTML}
-                <script type="module" src="${to('shared.staticFile', `client.js?${CACHEBUST}`)}"></script>
-            </body>
-        </html>
-    `);
-};
-
-writePage.write = async (content, {paths}) => {
-    await mkdir(paths.outputDirectory, {recursive: true});
-    await writeFile(paths.outputFile, content);
-};
-
-// TODO: This only supports one <>-style argument.
-writePage.paths = (baseDirectory, fullKey, directory = '', {
-    file = 'index.html'
-} = {}) => {
-    const [ groupKey, subKey ] = fullKey.split('.');
-
-    const pathname = (groupKey === 'localized' && baseDirectory
-        ? urls.from('shared.root').to('localizedWithBaseDirectory.' + subKey, baseDirectory, directory)
-        : urls.from('shared.root').to(fullKey, directory));
-
-    // Needed for the rare directory which itself contains a slash, e.g. for
-    // listings, with directories like 'albums/by-name'.
-    const subdirectoryPrefix = '../'.repeat(directory.split('/').length - 1);
-
-    const outputDirectory = path.join(outputPath, pathname);
-    const outputFile = path.join(outputDirectory, file);
-
-    return {
-        pathname,
-        subdirectoryPrefix,
-        outputDirectory, outputFile
-    };
-};
-
-function writeSymlinks() {
-    return progressPromiseAll('Writing site symlinks.', [
-        link(path.join(__dirname, UTILITY_DIRECTORY), 'shared.utilityRoot'),
-        link(path.join(__dirname, STATIC_DIRECTORY), 'shared.staticRoot'),
-        link(mediaPath, 'media.root')
-    ]);
-
-    async function link(directory, urlKey) {
-        const pathname = urls.from('shared.root').to(urlKey);
-        const file = path.join(outputPath, pathname);
-        try {
-            await unlink(file);
-        } catch (error) {
-            if (error.code !== 'ENOENT') {
-                throw error;
-            }
-        }
-        await symlink(path.resolve(directory), file);
-    }
-}
+  Object.assign(stepStatusSummary.loadURLFiles, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+    memory: process.memoryUsage(),
+  });
 
-function writeSharedFilesAndPages({strings, wikiData}) {
-    const { groupData, wikiInfo } = wikiData;
+  if (!getOrigin(urlSpec.thumb.prefix)) {
+    Object.assign(stepStatusSummary.loadOnlineThumbnailCache, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `using offline thumbs`,
+    });
+  }
 
-    const redirect = async (title, from, urlKey, directory) => {
-        const target = path.relative(from, urls.from('shared.root').to(urlKey, directory));
-        const content = generateRedirectPage(title, target, {strings});
-        await mkdir(path.join(outputPath, from), {recursive: true});
-        await writeFile(path.join(outputPath, from, 'index.html'), content);
-    };
+  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`,
+    });
+  }
 
-    return progressPromiseAll(`Writing files & pages shared across languages.`, [
-        groupData?.some(group => group.directory === 'fandom') &&
-        redirect('Fandom - Gallery', 'albums/fandom', 'localized.groupGallery', 'fandom'),
+  applyLocalizedWithBaseDirectory(urlSpec);
 
-        groupData?.some(group => group.directory === 'official') &&
-        redirect('Official - Gallery', 'albums/official', 'localized.groupGallery', 'official'),
+  const urls = generateURLs(urlSpec);
 
-        wikiInfo.features.listings &&
-        redirect('Album Commentary', 'list/all-commentary', 'localized.commentaryIndex', ''),
+  if (stepStatusSummary.loadOnlineThumbnailCache.status === STATUS_NOT_STARTED) loadOnlineThumbnailCache: {
+    Object.assign(stepStatusSummary.loadOnlineThumbnailCache, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-        writeFile(path.join(outputPath, 'data.json'), fixWS`
-            {
-                "albumData": ${stringifyAlbumData({wikiData})},
-                ${wikiInfo.features.flashesAndGames && `"flashData": ${stringifyFlashData({wikiData})},`}
-                "artistData": ${stringifyArtistData({wikiData})}
-            }
-        `)
-    ].filter(Boolean));
-}
+    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(),
+        });
 
-function generateRedirectPage(title, target, {strings}) {
-    return fixWS`
-        <!DOCTYPE html>
-        <html>
-            <head>
-                <title>${strings('redirectPage.title', {title})}</title>
-                <meta charset="utf-8">
-                <meta http-equiv="refresh" content="0;url=${target}">
-                <link rel="canonical" href="${target}">
-                <link rel="stylesheet" href="static/site-basic.css">
-            </head>
-            <body>
-                <main>
-                    <h1>${strings('redirectPage.title', {title})}</h1>
-                    <p>${strings('redirectPage.infoLine', {
-                        target: `<a href="${target}">${target}</a>`
-                    })}</p>
-                </main>
-            </body>
-        </html>
-    `;
-}
+        thumbsCache = onlineThumbsCache;
 
-// RIP toAnythingMan (previously getHrefOfAnythingMan), 2020-05-25<>2021-05-14.
-// ........Yet the function 8reathes life anew as linkAnythingMan! ::::)
-function linkAnythingMan(anythingMan, {link, wikiData, ...opts}) {
-    return (
-        wikiData.albumData.includes(anythingMan) ? link.album(anythingMan, opts) :
-        wikiData.trackData.includes(anythingMan) ? link.track(anythingMan, opts) :
-        wikiData.flashData?.includes(anythingMan) ? link.flash(anythingMan, opts) :
-        'idk bud'
-    )
-}
+        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;
+      }
+    }
 
-async function processLanguageFile(file, defaultStrings = null) {
-    let contents;
     try {
-        contents = await readFile(file, 'utf-8');
-    } catch (error) {
-        return {error: `Could not read ${file} (${error.code}).`};
-    }
+      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');
 
-    let json;
     try {
-        json = JSON.parse(contents);
+      onlineThumbsCache = await fetch(url).then(res => res.json());
     } catch (error) {
-        return {error: `Could not parse JSON from ${file} (${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;
+      }
     }
 
-    return genStrings(json, {
-        he,
-        defaultJSON: defaultStrings?.json,
-        bindUtilities: {
-            count,
-            list
-        }
+    Object.assign(stepStatusSummary.loadOnlineThumbnailCache, {
+      status: STATUS_DONE_CLEAN,
+      timeStart: Date.now(),
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
     });
-}
+  }
 
-// Wrapper function for running a function once for all languages.
-async function wrapLanguages(fn, {writeOneLanguage = null}) {
-    const k = writeOneLanguage;
-    const languagesToRun = (k
-        ? {[k]: languages[k]}
-        : languages);
+  const languageReloading =
+    stepStatusSummary.watchLanguageFiles.status === STATUS_NOT_STARTED;
 
-    const entries = Object.entries(languagesToRun)
-        .filter(([ key ]) => key !== 'default');
+  Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
-    for (let i = 0; i < entries.length; i++) {
-        const [ key, strings ] = entries[i];
+  let internalDefaultLanguage;
+  let internalDefaultLanguageWatcher;
 
-        const baseDirectory = (strings === languages.default ? '' : strings.code);
+  let errorLoadingInternalDefaultLanguage = false;
 
-        await fn({
-            baseDirectory,
-            strings
-        }, i, entries);
-    }
-}
+  if (languageReloading) {
+    internalDefaultLanguageWatcher = watchLanguageFile(internalDefaultStringsFile);
 
-async function main() {
-    Error.stackTraceLimit = Infinity;
-
-    const WD = wikiData;
-
-    WD.listingSpec = listingSpec;
-    WD.listingTargetSpec = listingTargetSpec;
-
-    const miscOptions = await parseOptions(process.argv.slice(2), {
-        // Data files for the site, including flash, artist, and al8um data,
-        // and like a jillion other things too. Pretty much everything which
-        // makes an individual wiki what it is goes here!
-        'data-path': {
-            type: 'value'
-        },
-
-        // Static media will 8e referenced in the site here! The contents are
-        // categorized; check out MEDIA_ALBUM_ART_DIRECTORY and other constants
-        // near the top of this file (upd8.js).
-        'media-path': {
-            type: 'value'
-        },
-
-        // String files! For the most part, this is used for translating the
-        // site to different languages, though you can also customize strings
-        // for your own 8uild of the site if you'd like. Files here should all
-        // match the format in strings-default.json in this repository. (If a
-        // language file is missing any strings, the site code will fall 8ack
-        // to what's specified in strings-default.json.)
-        //
-        // Unlike the other options here, this one's optional - the site will
-        // 8uild with the default (English) strings if this path is left
-        // unspecified.
-        'lang-path': {
-            type: 'value'
-        },
-
-        // This is the output directory. It's the one you'll upload online with
-        // rsync or whatever when you're pushing an upd8, and also the one
-        // you'd archive if you wanted to make a 8ackup of the whole dang
-        // site. Just keep in mind that the gener8ted result will contain a
-        // couple symlinked directories, so if you're uploading, you're pro8a8ly
-        // gonna want to resolve those yourself.
-        'out-path': {
-            type: 'value'
-        },
-
-        // 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.
-        'skip-thumbs': {
-            type: 'flag'
-        },
-
-        // Or, if you *only* want to gener8te newly upd8ted thum8nails, you can
-        // pass this flag! It exits 8efore 8uilding the rest of the site.
-        'thumbs-only': {
-            type: 'flag'
-        },
-
-        // Only want 8uild one language during testing? This can chop down
-        // 8uild times a pretty 8ig chunk! Just pass a single language code.
-        'lang': {
-            type: 'value'
-        },
-
-        'queue-size': {
-            type: 'value',
-            validate(size) {
-                if (parseInt(size) !== parseFloat(size)) return 'an integer';
-                if (parseInt(size) < 0) return 'a counting number or zero';
-                return true;
-            }
-        },
-        queue: {alias: 'queue-size'},
-
-        [parseOptions.handleUnknown]: () => {}
-    });
-
-    dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA;
-    mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA;
-    langPath = miscOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset!
-    outputPath = miscOptions['out-path'] || process.env.HSMUSIC_OUT;
-
-    const writeOneLanguage = miscOptions['lang'];
-
-    {
-        let errored = false;
-        const error = (cond, msg) => {
-            if (cond) {
-                console.error(`\x1b[31;1m${msg}\x1b[0m`);
-                errored = true;
-            }
+    try {
+      await new Promise((resolve, reject) => {
+        const watcher = internalDefaultLanguageWatcher;
+
+        const onReady = () => {
+          watcher.removeListener('ready', onReady);
+          watcher.removeListener('error', onError);
+          resolve();
         };
-        error(!dataPath,   `Expected --data-path option or HSMUSIC_DATA to be set`);
-        error(!mediaPath,  `Expected --media-path option or HSMUSIC_MEDIA to be set`);
-        error(!outputPath, `Expected --out-path option or HSMUSIC_OUT to be set`);
-        if (errored) {
-            return;
-        }
-    }
 
-    const skipThumbs = miscOptions['skip-thumbs'] ?? false;
-    const thumbsOnly = miscOptions['thumbs-only'] ?? false;
+        const onError = error => {
+          watcher.removeListener('ready', onReady);
+          watcher.removeListener('error', onError);
+          watcher.close();
+          reject(error);
+        };
 
-    if (skipThumbs && thumbsOnly) {
-        logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`;
-        return;
-    }
+        watcher.on('ready', onReady);
+        watcher.on('error', onError);
+      });
 
-    if (skipThumbs) {
-        logInfo`Skipping thumbnail generation.`;
-    } else {
-        logInfo`Begin thumbnail generation... -----+`;
-        const result = await genThumbs(mediaPath, {queueSize, quiet: true});
-        logInfo`Done thumbnail generation! --------+`;
-        if (!result) return;
-        if (thumbsOnly) return;
+      internalDefaultLanguage = internalDefaultLanguageWatcher.language;
+    } catch (_error) {
+      // No need to display the error here - it's already printed by
+      // watchLanguageFile.
+      errorLoadingInternalDefaultLanguage = true;
     }
+  } else {
+    internalDefaultLanguageWatcher = null;
 
-    const defaultStrings = await processLanguageFile(path.join(__dirname, DEFAULT_STRINGS_FILE));
-    if (defaultStrings.error) {
-        logError`Error loading default strings: ${defaultStrings.error}`;
-        return;
+    try {
+      internalDefaultLanguage = await processLanguageFile(internalDefaultStringsFile);
+    } catch (error) {
+      niceShowAggregate(error);
+      errorLoadingInternalDefaultLanguage = true;
     }
+  }
 
-    if (langPath) {
-        const languageDataFiles = await findFiles(langPath, f => path.extname(f) === '.json');
-        const results = await progressPromiseAll(`Reading & processing language files.`, languageDataFiles
-            .map(file => processLanguageFile(file, defaultStrings)));
+  if (errorLoadingInternalDefaultLanguage) {
+    logError`There was an error reading the internal language file.`;
+    fileIssue();
 
-        let error = false;
-        for (const strings of results) {
-            if (strings.error) {
-                logError`Error loading provided strings: ${strings.error}`;
-                error = true;
-            }
-        }
-        if (error) return;
+    Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
+      status: STATUS_FATAL_ERROR,
+      annotation: `see log for details`,
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
 
-        languages = Object.fromEntries(results.map(strings => [strings.code, strings]));
+    return false;
+  }
+
+  if (languageReloading) {
+    // Bypass node.js special-case handling for uncaught error events
+    internalDefaultLanguageWatcher.on('error', () => {});
+  }
+
+  Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+    memory: process.memoryUsage(),
+  });
+
+  let customLanguageWatchers;
+  let languages;
+
+  if (langPath) {
+    if (languageReloading) {
+      Object.assign(stepStatusSummary.watchLanguageFiles, {
+        status: STATUS_STARTED_NOT_DONE,
+        timeStart: Date.now(),
+      });
     } else {
-        languages = {};
+      Object.assign(stepStatusSummary.loadLanguageFiles, {
+        status: STATUS_STARTED_NOT_DONE,
+        timeStart: Date.now(),
+      });
     }
 
-    if (!languages[defaultStrings.code]) {
-        languages[defaultStrings.code] = defaultStrings;
-    }
+    const languageDataFiles =
+      (await readdir(langPath))
+        .filter(name => ['.json', '.yaml'].includes(path.extname(name)))
+        .map(name => path.join(langPath, name));
 
-    logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
+    let errorLoadingCustomLanguages = false;
 
-    if (writeOneLanguage && !(writeOneLanguage in languages)) {
-        logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`;
-        return;
-    } else if (writeOneLanguage) {
-        logInfo`Writing only language ${writeOneLanguage} this run.`;
+    if (languageReloading) watchCustomLanguages: {
+      Object.assign(stepStatusSummary.watchLanguageFiles, {
+        status: STATUS_STARTED_NOT_DONE,
+        timeStart: Date.now(),
+      });
+
+      customLanguageWatchers =
+        languageDataFiles.map(file => {
+          const watcher = watchLanguageFile(file);
+
+          // Bypass node.js special-case handling for uncaught error events
+          watcher.on('error', () => {});
+
+          return watcher;
+        });
+
+      const waitingOnWatchers = new Set(customLanguageWatchers);
+
+      const initialResults =
+        await Promise.allSettled(
+          customLanguageWatchers
+            .map(watcher => new Promise((resolve, reject) => {
+              const onReady = () => {
+                watcher.removeListener('ready', onReady);
+                watcher.removeListener('error', onError);
+                waitingOnWatchers.delete(watcher);
+                resolve();
+              };
+
+              const onError = error => {
+                watcher.removeListener('ready', onReady);
+                watcher.removeListener('error', onError);
+                reject(error);
+              };
+
+              watcher.on('ready', onReady);
+              watcher.on('error', onError);
+            })));
+
+      if (initialResults.some(({status}) => status === 'rejected')) {
+        logWarn`There were errors loading custom languages from the language path`;
+        logWarn`provided: ${langPath}`;
+
+        if (noInput) {
+          internalDefaultLanguageWatcher.close();
+
+          for (const watcher of Object.values(customLanguageWatchers)) {
+            watcher.close();
+          }
+
+          Object.assign(stepStatusSummary.watchLanguageFiles, {
+            status: STATUS_FATAL_ERROR,
+            annotation: `see log for details`,
+            timeEnd: Date.now(),
+            memory: process.memoryUsage(),
+          });
+
+          errorLoadingCustomLanguages = true;
+          break watchCustomLanguages;
+        }
+
+        logWarn`The build should start automatically if you investigate these.`;
+        logWarn`Or, exit by pressing ^C here (control+C) and run again without`;
+        logWarn`providing ${'--lang-path'} (or ${'HSMUSIC_LANG'}) to build without custom`;
+        logWarn`languages.`;
+
+        await new Promise(resolve => {
+          for (const watcher of waitingOnWatchers) {
+            watcher.once('ready', () => {
+              waitingOnWatchers.remove(watcher);
+              if (empty(waitingOnWatchers)) {
+                resolve();
+              }
+            });
+          }
+        });
+      }
+
+      languages =
+        Object.fromEntries(
+          customLanguageWatchers
+            .map(({language}) => [language.code, language]));
+
+      Object.assign(stepStatusSummary.watchLanguageFiles, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
     } else {
-        logInfo`Writing all languages.`;
-    }
+      languages = {};
 
-    WD.wikiInfo = await processWikiInfoFile(path.join(dataPath, WIKI_INFO_FILE));
-    if (WD.wikiInfo.error) {
-        console.log(`\x1b[31;1m${WD.wikiInfo.error}\x1b[0m`);
-        return;
-    }
+      const results =
+        await Promise.allSettled(
+          languageDataFiles
+            .map(file => processLanguageFile(file)));
 
-    // Update languages o8ject with the wiki-specified default language!
-    // This will make page files for that language 8e gener8ted at the root
-    // directory, instead of the language-specific su8directory.
-    if (WD.wikiInfo.defaultLanguage) {
-        if (Object.keys(languages).includes(WD.wikiInfo.defaultLanguage)) {
-            languages.default = languages[WD.wikiInfo.defaultLanguage];
+      for (const {status, value: language, reason: error} of results) {
+        if (status === 'rejected') {
+          errorLoadingCustomLanguages = true;
+          niceShowAggregate(error);
         } else {
-            logError`Wiki info file specified default language is ${WD.wikiInfo.defaultLanguage}, but no such language file exists!`;
-            if (langPath) {
-                logError`Check if an appropriate file exists in ${langPath}?`;
-            } else {
-                logError`Be sure to specify ${'--lang'} or ${'HSMUSIC_LANG'} with the path to language files.`;
-            }
-            return;
+          languages[language.code] = language;
         }
-    } else {
-        languages.default = defaultStrings;
-    }
+      }
 
-    WD.homepageInfo = await processHomepageInfoFile(path.join(dataPath, HOMEPAGE_INFO_FILE));
-
-    if (WD.homepageInfo.error) {
-        console.log(`\x1b[31;1m${WD.homepageInfo.error}\x1b[0m`);
-        return;
+      if (errorLoadingCustomLanguages) {
+        Object.assign(stepStatusSummary.loadLanguageFiles, {
+          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(),
+        });
+      }
     }
 
-    {
-        const errors = WD.homepageInfo.rows.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
+    if (errorLoadingCustomLanguages) {
+      logError`Failed to load language files. Please investigate these, or don't provide`;
+      logError`--lang-path (or HSMUSIC_LANG) and build again.`;
+      return false;
     }
+  } else {
+    languages = {};
+  }
 
-    // 8ut wait, you might say, how do we know which al8um these data files
-    // correspond to???????? You wouldn't dare suggest we parse the actual
-    // paths returned 8y this function, which ought to 8e of effectively
-    // unknown format except for their purpose as reada8le data files!?
-    // To that, I would say, yeah, you're right. Thanks a 8unch, my projection
-    // of "you". We're going to read these files later, and contained within
-    // will 8e the actual directory names that the data correspond to. Yes,
-    // that's redundant in some ways - we COULD just return the directory name
-    // in addition to the data path, and duplicating that name within the file
-    // itself suggests we 8e careful to avoid mismatching it - 8ut doing it
-    // this way lets the data files themselves 8e more porta8le (meaning we
-    // could store them all in one folder, if we wanted, and this program would
-    // still output to the correct al8um directories), and also does make the
-    // function's signature simpler (an array of strings, rather than some kind
-    // of structure containing 8oth data file paths and output directories).
-    // This is o8jectively a good thing, 8ecause it means the function can stay
-    // truer to its name, and have a narrower purpose: it doesn't need to
-    // concern itself with where we *output* files, or whatever other reasons
-    // we might (hypothetically) have for knowing the containing directory.
-    // And, in the strange case where we DO really need to know that info, we
-    // callers CAN use path.dirname to find out that data. 8ut we'll 8e
-    // avoiding that in our code 8ecause, again, we want to avoid assuming the
-    // format of the returned paths here - they're only meant to 8e used for
-    // reading as-is.
-    const albumDataFiles = await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY));
-
-    // Technically, we could do the data file reading and output writing at the
-    // same time, 8ut that kinda makes the code messy, so I'm not 8othering
-    // with it.
-    WD.albumData = await progressPromiseAll(`Reading & processing album files.`, albumDataFiles.map(processAlbumDataFile));
-
-    {
-        const errors = WD.albumData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
-    }
+  Object.assign(stepStatusSummary.initializeDefaultLanguage, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
-    sortByDate(WD.albumData);
+  let finalDefaultLanguage;
+  let finalDefaultLanguageWatcher;
+  let finalDefaultLanguageAnnotation;
 
-    WD.artistData = await processArtistDataFile(path.join(dataPath, ARTIST_DATA_FILE));
-    if (WD.artistData.error) {
-        console.log(`\x1b[31;1m${WD.artistData.error}\x1b[0m`);
-        return;
-    }
+  if (wikiData.wikiInfo.defaultLanguage) {
+    const customDefaultLanguage = languages[wikiData.wikiInfo.defaultLanguage];
 
-    {
-        const errors = WD.artistData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
+    if (!customDefaultLanguage) {
+      logError`Wiki info file specified default language is ${wikiData.wikiInfo.defaultLanguage}, but no such language file exists!`;
+      if (langPath) {
+        logError`Check if an appropriate file exists in ${langPath}?`;
+      } else {
+        logError`Be sure to specify ${'--lang-path'} or ${'HSMUSIC_LANG'} with the path to language files.`;
+      }
+
+      Object.assign(stepStatusSummary.initializeDefaultLanguage, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `wiki specifies default language whose file is not available`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      return false;
     }
 
-    WD.artistAliasData = WD.artistData.filter(x => x.alias);
-    WD.artistData = WD.artistData.filter(x => !x.alias);
+    logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`;
+    paragraph = false;
 
-    WD.trackData = getAllTracks(WD.albumData);
+    finalDefaultLanguage = customDefaultLanguage;
+    finalDefaultLanguageAnnotation = `using wiki-specified custom default language`;
 
-    if (WD.wikiInfo.features.flashesAndGames) {
-        WD.flashData = await processFlashDataFile(path.join(dataPath, FLASH_DATA_FILE));
-        if (WD.flashData.error) {
-            console.log(`\x1b[31;1m${WD.flashData.error}\x1b[0m`);
-            return;
-        }
+    if (languageReloading) {
+      finalDefaultLanguageWatcher =
+        customLanguageWatchers
+          .find(({language}) => language === customDefaultLanguage);
+    }
+  } else if (languages[internalDefaultLanguage.code]) {
+    const customDefaultLanguage = languages[internalDefaultLanguage.code];
 
-        const errors = WD.flashData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
+    finalDefaultLanguage = customDefaultLanguage;
+    finalDefaultLanguageAnnotation = `using inferred custom default language`;
+
+    if (languageReloading) {
+      finalDefaultLanguageWatcher =
+        customLanguageWatchers
+          .find(({language}) => language === customDefaultLanguage);
     }
+  } else {
+    languages[internalDefaultLanguage.code] = internalDefaultLanguage;
 
-    WD.flashActData = WD.flashData?.filter(x => x.act8r8k);
-    WD.flashData = WD.flashData?.filter(x => !x.act8r8k);
+    finalDefaultLanguage = internalDefaultLanguage;
+    finalDefaultLanguageAnnotation = `no custom default language specified`;
 
-    WD.tagData = await processTagDataFile(path.join(dataPath, TAG_DATA_FILE));
-    if (WD.tagData.error) {
-        console.log(`\x1b[31;1m${WD.tagData.error}\x1b[0m`);
-        return;
+    if (languageReloading) {
+      finalDefaultLanguageWatcher = internalDefaultLanguageWatcher;
     }
+  }
 
-    {
-        const errors = WD.tagData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
+  const closeLanguageWatchers = () => {
+    if (languageReloading) {
+      for (const watcher of [
+        internalDefaultLanguageWatcher,
+        ...customLanguageWatchers,
+      ]) {
+        watcher.close();
+      }
     }
+  };
 
-    WD.tagData.sort(sortByName);
+  const inheritStringsFromInternalLanguage = () => {
+    // The custom default language, if set, will be the new one providing fallback
+    // strings for other languages. But on its own, it still might not be a complete
+    // list of strings - so it falls back to the internal default language, which
+    // won't otherwise be presented in the build.
+    if (finalDefaultLanguage === internalDefaultLanguage) return;
+    const {strings: inheritedStrings} = internalDefaultLanguage;
+    Object.assign(finalDefaultLanguage, {inheritedStrings});
+  };
 
-    WD.groupData = await processGroupDataFile(path.join(dataPath, GROUP_DATA_FILE));
-    if (WD.groupData.error) {
-        console.log(`\x1b[31;1m${WD.groupData.error}\x1b[0m`);
-        return;
+  const inheritStringsFromDefaultLanguage = () => {
+    const {strings: inheritedStrings} = finalDefaultLanguage;
+    for (const language of Object.values(languages)) {
+      if (language === finalDefaultLanguage) continue;
+      Object.assign(language, {inheritedStrings});
     }
+  };
+
+  if (finalDefaultLanguage !== internalDefaultLanguage) {
+    inheritStringsFromInternalLanguage();
+  }
 
-    {
-        const errors = WD.groupData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
+  inheritStringsFromDefaultLanguage();
+
+  if (languageReloading) {
+    if (finalDefaultLanguage !== internalDefaultLanguage) {
+      internalDefaultLanguageWatcher.on('update', () => {
+        inheritStringsFromInternalLanguage();
+        inheritStringsFromDefaultLanguage();
+      });
     }
 
-    WD.groupCategoryData = WD.groupData.filter(x => x.isCategory);
-    WD.groupData = WD.groupData.filter(x => x.isGroup);
+    finalDefaultLanguageWatcher.on('update', () => {
+      inheritStringsFromDefaultLanguage();
+    });
+  }
+
+  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(),
+  });
+
+  let missingImagePaths;
+
+  if (stepStatusSummary.verifyImagePaths.status === STATUS_NOT_APPLICABLE) {
+    missingImagePaths = [];
+  } else if (stepStatusSummary.verifyImagePaths.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.verifyImagePaths, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-    WD.staticPageData = await processStaticPageDataFile(path.join(dataPath, STATIC_PAGE_DATA_FILE));
-    if (WD.staticPageData.error) {
-        console.log(`\x1b[31;1m${WD.staticPageData.error}\x1b[0m`);
-        return;
+    const results =
+      await verifyImagePaths(mediaPath, {urls, wikiData});
+
+    missingImagePaths = results.missing;
+    const misplacedImagePaths = results.misplaced;
+
+    if (empty(missingImagePaths) && empty(misplacedImagePaths)) {
+      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(),
+      });
     }
+  }
 
-    {
-        const errors = WD.staticPageData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
-    }
+  let getSizeOfMediaFile = () => null;
 
-    if (WD.wikiInfo.features.news) {
-        WD.newsData = await processNewsDataFile(path.join(dataPath, NEWS_DATA_FILE));
-        if (WD.newsData.error) {
-            console.log(`\x1b[31;1m${WD.newsData.error}\x1b[0m`);
-            return;
-        }
+  const fileSizePreloader =
+    new FileSizePreloader({
+      prefix: mediaPath,
+    });
 
-        const errors = WD.newsData.filter(obj => obj.error);
-        if (errors.length) {
-            for (const error of errors) {
-                console.log(`\x1b[31;1m${error.error}\x1b[0m`);
-            }
-            return;
-        }
+  if (stepStatusSummary.loadOnlineFileSizeCache.status === STATUS_NOT_STARTED) loadOnlineFileSizeCache: {
+    Object.assign(stepStatusSummary.loadOnlineFileSizeCache, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-        sortByDate(WD.newsData);
-        WD.newsData.reverse();
-    }
+    let onlineFileSizeCache = null;
 
-    {
-        const tagNames = new Set([...WD.trackData, ...WD.albumData].flatMap(thing => thing.artTags));
+    const makeFileSizeCacheAvailable = () => {
+      fileSizePreloader.loadFromCache(onlineFileSizeCache);
 
-        for (let { name, isCW } of WD.tagData) {
-            if (isCW) {
-                name = 'cw: ' + name;
-            }
-            tagNames.delete(name);
-        }
+      getSizeOfMediaFile = p =>
+        fileSizePreloader.getSizeOfPath(
+          path.resolve(
+            mediaPath,
+            decodeURIComponent(p).split('/').join(path.sep)));
+    };
 
-        if (tagNames.size) {
-            for (const name of Array.from(tagNames).sort()) {
-                console.log(`\x1b[33;1m- Missing tag: "${name}"\x1b[0m`);
-            }
-            return;
-        }
-    }
+    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(),
+        });
 
-    WD.justEverythingMan = sortByDate([...WD.albumData, ...WD.trackData, ...(WD.flashData || [])]);
-    WD.justEverythingSortedByArtDateMan = sortByArtDate(WD.justEverythingMan.slice());
-    // console.log(JSON.stringify(justEverythingSortedByArtDateMan.map(toAnythingMan), null, 2));
-
-    const artistNames = Array.from(new Set([
-        ...WD.artistData.filter(artist => !artist.alias).map(artist => artist.name),
-        ...[
-            ...WD.albumData.flatMap(album => [
-                ...album.artists || [],
-                ...album.coverArtists || [],
-                ...album.wallpaperArtists || [],
-                ...album.tracks.flatMap(track => [
-                    ...track.artists,
-                    ...track.coverArtists || [],
-                    ...track.contributors || []
-                ])
-            ]),
-            ...(WD.flashData?.flatMap(flash => [
-                ...flash.contributors || []
-            ]) || [])
-        ].map(contribution => contribution.who)
-    ]));
-
-    artistNames.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0);
-
-    {
-        let buffer = [];
-        const clearBuffer = function() {
-            if (buffer.length) {
-                for (const entry of buffer.slice(0, -1)) {
-                    console.log(`\x1b[2m... ${entry.name} ...\x1b[0m`);
-                }
-                const lastEntry = buffer[buffer.length - 1];
-                console.log(`\x1b[2m... \x1b[0m${lastEntry.name}\x1b[0;2m ...\x1b[0m`);
-                buffer = [];
-            }
-        };
-        const showWhere = (name, color) => {
-            const where = WD.justEverythingMan.filter(thing => [
-                ...thing.coverArtists || [],
-                ...thing.contributors || [],
-                ...thing.artists || []
-            ].some(({ who }) => who === name));
-            for (const thing of where) {
-                console.log(`\x1b[${color}m- ` + (thing.album ? `(\x1b[1m${thing.album.name}\x1b[0;${color}m)` : '') + ` \x1b[1m${thing.name}\x1b[0m`);
-            }
-        };
-        let CR4SH = false;
-        for (let name of artistNames) {
-            const entry = [...WD.artistData, ...WD.artistAliasData].find(entry => entry.name === name || entry.name.toLowerCase() === name.toLowerCase());
-            if (!entry) {
-                clearBuffer();
-                console.log(`\x1b[31mMissing entry for artist "\x1b[1m${name}\x1b[0;31m"\x1b[0m`);
-                showWhere(name, 31);
-                CR4SH = true;
-            } else if (entry.alias) {
-                console.log(`\x1b[33mArtist "\x1b[1m${name}\x1b[0;33m" should be named "\x1b[1m${entry.alias}\x1b[0;33m"\x1b[0m`);
-                showWhere(name, 33);
-                CR4SH = true;
-            } else if (entry.name !== name) {
-                console.log(`\x1b[33mArtist "\x1b[1m${name}\x1b[0;33m" should be named "\x1b[1m${entry.name}\x1b[0;33m"\x1b[0m`);
-                showWhere(name, 33);
-                CR4SH = true;
-            } else {
-                buffer.push(entry);
-                if (buffer.length > 3) {
-                    buffer.shift();
-                }
-            }
-        }
-        if (CR4SH) {
-            return;
-        }
-    }
+        delete onlineFileSizeCache._urlPrefix;
 
-    {
-        const directories = [];
-        for (const { directory, name } of WD.albumData) {
-            if (directories.includes(directory)) {
-                console.log(`\x1b[31;1mDuplicate album directory "${directory}" (${name})\x1b[0m`);
-                return;
-            }
-            directories.push(directory);
-        }
-    }
+        makeFileSizeCacheAvailable();
 
-    {
-        const directories = [];
-        const where = {};
-        for (const { directory, album } of WD.trackData) {
-            if (directories.includes(directory)) {
-                console.log(`\x1b[31;1mDuplicate track directory "${directory}"\x1b[0m`);
-                console.log(`Shows up in:`);
-                console.log(`- ${album.name}`);
-                console.log(`- ${where[directory].name}`);
-                return;
-            }
-            directories.push(directory);
-            where[directory] = album;
-        }
+        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;
+      }
     }
 
-    {
-        const artists = [];
-        const artistsLC = [];
-        for (const name of artistNames) {
-            if (!artists.includes(name) && artistsLC.includes(name.toLowerCase())) {
-                const other = artists.find(oth => oth.toLowerCase() === name.toLowerCase());
-                console.log(`\x1b[31;1mMiscapitalized artist name: ${name}, ${other}\x1b[0m`);
-                return;
-            }
-            artists.push(name);
-            artistsLC.push(name.toLowerCase());
-        }
-    }
+    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');
 
-    {
-        for (const { references, name, album } of WD.trackData) {
-            for (const ref of references) {
-                if (!find.track(ref, {wikiData})) {
-                    logWarn`Track not found "${ref}" in ${name} (${album.name})`;
-                }
-            }
-        }
+    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(),
+      });
+
+      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;
+      }
     }
 
-    WD.contributionData = Array.from(new Set([
-        ...WD.trackData.flatMap(track => [...track.artists || [], ...track.contributors || [], ...track.coverArtists || []]),
-        ...WD.albumData.flatMap(album => [...album.artists || [], ...album.coverArtists || [], ...album.wallpaperArtists || [], ...album.bannerArtists || []]),
-        ...(WD.flashData?.flatMap(flash => [...flash.contributors || []]) || [])
-    ]));
-
-    // Now that we have all the data, resolve references all 8efore actually
-    // gener8ting any of the pages, 8ecause page gener8tion is going to involve
-    // accessing these references a lot, and there's no reason to resolve them
-    // more than once. (We 8uild a few additional links that can't 8e cre8ted
-    // at initial data processing time here too.)
-
-    const filterNullArray = (parent, key) => {
-        for (const obj of parent) {
-            const array = obj[key];
-            for (let i = 0; i < array.length; i++) {
-                if (!array[i]) {
-                    const prev = array[i - 1] && array[i - 1].name;
-                    const next = array[i + 1] && array[i + 1].name;
-                    logWarn`Unexpected null in ${obj.name} (${obj.what}) (array key ${key} - prev: ${prev}, next: ${next})`;
-                }
-            }
-            array.splice(0, array.length, ...array.filter(Boolean));
-        }
-    };
+    Object.assign(stepStatusSummary.loadOnlineFileSizeCache, {
+      status: STATUS_DONE_CLEAN,
+      timeStart: Date.now(),
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
+  }
 
-    const filterNullValue = (parent, key) => {
-        parent.splice(0, parent.length, ...parent.filter(obj => {
-            if (!obj[key]) {
-                logWarn`Unexpected null in ${obj.name} (value key ${key})`;
-                return false;
-            }
-            return true;
-        }));
+  if (stepStatusSummary.preloadFileSizes.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.preloadFileSizes, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    const mediaFilePaths =
+      await traverse(mediaPath, {
+        pathStyle: 'device',
+        filterDir: dir => dir !== '.git',
+        filterFile: file => !isThumb(file),
+      }).then(files => files
+          .map(file => ({
+            device: file,
+            media:
+              urls
+                .from('media.root')
+                .to('media.path', path.relative(mediaPath, file).split(path.sep).join('/')),
+          })));
+
+    getSizeOfMediaFile = mediaPath => {
+      const pair = mediaFilePaths.find(({media}) => media === mediaPath);
+      if (!pair) return null;
+      return fileSizePreloader.getSizeOfPath(pair.device);
     };
 
-    WD.trackData.forEach(track => mapInPlace(track.references, r => find.track(r, {wikiData})));
-    WD.trackData.forEach(track => track.aka = find.track(track.aka, {wikiData}));
-    WD.trackData.forEach(track => mapInPlace(track.artTags, t => find.tag(t, {wikiData})));
-    WD.albumData.forEach(album => mapInPlace(album.groups, g => find.group(g, {wikiData})));
-    WD.albumData.forEach(album => mapInPlace(album.artTags, t => find.tag(t, {wikiData})));
-    WD.artistAliasData.forEach(artist => artist.alias = find.artist(artist.alias, {wikiData}));
-    WD.contributionData.forEach(contrib => contrib.who = find.artist(contrib.who, {wikiData}));
-
-    filterNullArray(WD.trackData, 'references');
-    filterNullArray(WD.trackData, 'artTags');
-    filterNullArray(WD.albumData, 'groups');
-    filterNullArray(WD.albumData, 'artTags');
-    filterNullValue(WD.artistAliasData, 'alias');
-    filterNullValue(WD.contributionData, 'who');
-
-    WD.trackData.forEach(track1 => track1.referencedBy = WD.trackData.filter(track2 => track2.references.includes(track1)));
-    WD.groupData.forEach(group => group.albums = WD.albumData.filter(album => album.groups.includes(group)));
-    WD.tagData.forEach(tag => tag.things = sortByArtDate([...WD.albumData, ...WD.trackData]).filter(thing => thing.artTags.includes(tag)));
-
-    WD.groupData.forEach(group => group.category = WD.groupCategoryData.find(x => x.name === group.category));
-    WD.groupCategoryData.forEach(category => category.groups = WD.groupData.filter(x => x.category === category));
-
-    WD.trackData.forEach(track => track.otherReleases = [
-        track.aka,
-        ...WD.trackData.filter(({ aka }) => aka === track || (track.aka && aka === track.aka)),
-    ].filter(x => x && x !== track));
-
-    if (WD.wikiInfo.features.flashesAndGames) {
-        WD.flashData.forEach(flash => mapInPlace(flash.tracks, t => find.track(t, {wikiData})));
-        WD.flashData.forEach(flash => flash.act = WD.flashActData.find(act => act.name === flash.act));
-        WD.flashActData.forEach(act => act.flashes = WD.flashData.filter(flash => flash.act === act));
-
-        filterNullArray(WD.flashData, 'tracks');
-
-        WD.trackData.forEach(track => track.flashes = WD.flashData.filter(flash => flash.tracks.includes(track)));
-    }
-
-    WD.artistData.forEach(artist => {
-        const filterProp = (array, prop) => array.filter(thing => thing[prop]?.some(({ who }) => who === artist));
-        const filterCommentary = array => array.filter(thing => thing.commentary && thing.commentary.replace(/<\/?b>/g, '').includes('<i>' + artist.name + ':</i>'));
-        artist.tracks = {
-            asArtist: filterProp(WD.trackData, 'artists'),
-            asCommentator: filterCommentary(WD.trackData),
-            asContributor: filterProp(WD.trackData, 'contributors'),
-            asCoverArtist: filterProp(WD.trackData, 'coverArtists'),
-            asAny: WD.trackData.filter(track => (
-                [...track.artists, ...track.contributors, ...track.coverArtists || []].some(({ who }) => who === artist)
-            ))
-        };
-        artist.albums = {
-            asArtist: filterProp(WD.albumData, 'artists'),
-            asCommentator: filterCommentary(WD.albumData),
-            asCoverArtist: filterProp(WD.albumData, 'coverArtists'),
-            asWallpaperArtist: filterProp(WD.albumData, 'wallpaperArtists'),
-            asBannerArtist: filterProp(WD.albumData, 'bannerArtists')
-        };
-        if (WD.wikiInfo.features.flashesAndGames) {
-            artist.flashes = {
-                asContributor: filterProp(WD.flashData, 'contributors')
-            };
-        }
+    logInfo`Preloading file sizes for ${mediaFilePaths.length} media files...`;
+
+    fileSizePreloader.loadPaths(...mediaFilePaths.map(path => path.device));
+    await fileSizePreloader.waitUntilDoneLoading();
+
+    if (fileSizePreloader.hasErrored) {
+      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!`;
+
+      Object.assign(stepStatusSummary.preloadFileSizes, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `see log for details`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    } else {
+      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(),
     });
 
-    WD.officialAlbumData = WD.albumData.filter(album => album.groups.some(group => group.directory === OFFICIAL_GROUP_DIRECTORY));
-    WD.fandomAlbumData = WD.albumData.filter(album => album.groups.every(group => group.directory !== OFFICIAL_GROUP_DIRECTORY));
+    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);
 
-    // Makes writing a little nicer on CPU theoretically, 8ut also costs in
-    // performance right now 'cuz it'll w8 for file writes to 8e completed
-    // 8efore moving on to more data processing. So, defaults to zero, which
-    // disa8les the queue feature altogether.
-    queueSize = +(miscOptions['queue-size'] ?? 0);
+      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!`;
 
-    const buildDictionary = pageSpecs;
+      console.log('');
+      paragraph = true;
 
-    // NOT for ena8ling or disa8ling specific features of the site!
-    // This is only in charge of what general groups of files to 8uild.
-    // They're here to make development quicker when you're only working
-    // on some particular area(s) of the site rather than making changes
-    // across all of them.
-    const writeFlags = await parseOptions(process.argv.slice(2), {
-        all: {type: 'flag'}, // Defaults to true if none 8elow specified.
+      Object.assign(stepStatusSummary.buildSearchIndex, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `see log for details`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    }
+  }
 
-        // Kinda a hack t8h!
-        ...Object.fromEntries(Object.keys(buildDictionary)
-            .map(key => [key, {type: 'flag'}])),
+  let webRouteSources = null;
+  let preparedWebRoutes = null;
 
-        [parseOptions.handleUnknown]: () => {}
+  if (stepStatusSummary.identifyWebRoutes.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.identifyWebRoutes, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
     });
 
-    const writeAll = !Object.keys(writeFlags).length || writeFlags.all;
+    const fromRoot = urls.from('shared.root');
 
-    logInfo`Writing site pages: ${writeAll ? 'all' : Object.keys(writeFlags).join(', ')}`;
+    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);
 
-    await writeSymlinks();
-    await writeSharedFilesAndPages({strings: defaultStrings, wikiData});
+      logError`There was an issue identifying web routes!`;
+      fileIssue();
 
-    const buildSteps = (writeAll
-        ? Object.entries(buildDictionary)
-        : (Object.entries(buildDictionary)
-            .filter(([ flag ]) => writeFlags[flag])));
+      console.log('');
+      paragraph = true;
 
-    let writes;
-    {
-        let error = false;
+      Object.assign(stepStatusSummary.identifyWebRoutes, {
+        status: STATUS_FATAL_ERROR,
+        message: `JavaScript error - view log for details`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
 
-        const buildStepsWithTargets = buildSteps.map(([ flag, pageSpec ]) => {
-            // Condition not met: skip this build step altogether.
-            if (pageSpec.condition && !pageSpec.condition({wikiData})) {
-                return null;
-            }
+      return false;
+    }
 
-            // May still call writeTargetless if present.
-            if (!pageSpec.targets) {
-                return {flag, pageSpec, targets: []};
-            }
+    logInfo`Successfully determined web routes - nice!`;
+    paragraph = false;
 
-            if (!pageSpec.write) {
-                logError`${flag + '.targets'} is specified, but ${flag + '.write'} is missing!`;
-                error = true;
-                return null;
-            }
+    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;
+  }
+
+  const developersComment =
+    `<!--\n` + [
+      wikiData.wikiInfo.canonicalBase
+        ? `hsmusic.wiki - ${wikiData.wikiInfo.name}, ${wikiData.wikiInfo.canonicalBase}`
+        : `hsmusic.wiki - ${wikiData.wikiInfo.name}`,
+      'Code copyright 2019-2023 Quasar Nebula et al (MIT License)',
+      ...wikiData.wikiInfo.canonicalBase === 'https://hsmusic.wiki/' ? [
+        'Data avidly compiled and localization brought to you',
+        'by our awesome team and community of wiki contributors',
+        '***',
+        'Want to contribute? Join our Discord or leave feedback!',
+        '- https://hsmusic.wiki/discord/',
+        '- https://hsmusic.wiki/feedback/',
+        '- https://github.com/hsmusic/',
+      ] : [
+        'https://github.com/hsmusic/',
+      ],
+      '***',
+      BUILD_TIME &&
+        `Site built: ${BUILD_TIME.toLocaleString('en-US', {
+          dateStyle: 'long',
+          timeStyle: 'long',
+        })}`,
+      COMMIT &&
+        `Latest code commit: ${COMMIT}`,
+    ]
+      .filter(Boolean)
+      .map(line => `    ` + line)
+      .join('\n') + `\n-->`;
+
+  Object.assign(stepStatusSummary.performBuild, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
+
+  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,
+      wikiCachePath,
+      srcRootPath: __dirname,
+
+      webRoutes: preparedWebRoutes,
+
+      closeLanguageWatchers,
+      niceShowAggregate,
+    });
+  } catch (error) {
+    console.error(error);
 
-            const targets = pageSpec.targets({wikiData});
-            return {flag, pageSpec, targets};
-        }).filter(Boolean);
+    logError`There was a JavaScript error performing the build.`;
+    fileIssue();
 
-        if (error) {
-            return;
-        }
+    Object.assign(stepStatusSummary.performBuild, {
+      status: STATUS_FATAL_ERROR,
+      message: `javascript error - view log for details`,
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
 
-        const validateWrites = (writes, fnName) => {
-            // Do a quick valid8tion! If one of the writeThingPages functions go
-            // wrong, this will stall out early and tell us which did.
-
-            if (!Array.isArray(writes)) {
-                logError`${fnName} didn't return an array!`;
-                error = true;
-                return false;
-            }
-
-            if (!(
-                writes.every(obj => typeof obj === 'object') &&
-                writes.every(obj => {
-                    const result = validateWriteObject(obj);
-                    if (result.error) {
-                        logError`Validating write object failed: ${result.error}`;
-                        return false;
-                    } else {
-                        return true;
-                    }
-                })
-            )) {
-                logError`${fnName} returned invalid entries!`;
-                error = true;
-                return false;
-            }
-
-            return true;
-        };
+    return false;
+  }
+
+  if (buildModeResult !== true) {
+    Object.assign(stepStatusSummary.performBuild, {
+      status: STATUS_HAS_WARNINGS,
+      annotation: `may not have completed - view log for details`,
+      timeEnd: Date.now(),
+      memory: process.memoryUsage(),
+    });
+
+    return false;
+  }
 
-        writes = buildStepsWithTargets.flatMap(({ flag, pageSpec, targets }) => {
-            const writes = targets.flatMap(target =>
-                pageSpec.write(target, {wikiData})?.slice() || []);
+  Object.assign(stepStatusSummary.performBuild, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+    memory: process.memoryUsage(),
+  });
 
-            if (!validateWrites(writes, flag + '.write')) {
-                return [];
-            }
+  return true;
+}
 
-            if (pageSpec.writeTargetless) {
-                const writes2 = pageSpec.writeTargetless({wikiData});
+// TODO: isMain detection isn't consistent across platforms here
+/* eslint-disable-next-line no-constant-condition */
+if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmusic') {
+  (async () => {
+    let result;
+    let numRestarts = 0;
+
+    const totalTimeStart = Date.now();
+
+    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);
+        }
+      }
 
-                if (!validateWrites(writes2, flag + '.writeTargetless')) {
-                    return [];
-                }
+      if (result === 'restart') {
+        console.log('');
 
-                writes.push(...writes2);
-            }
+        if (shouldShowStepStatusSummary) {
+          if (numRestarts >= 1) {
+            console.error(colors.bright(`Step summary since latest restart:`));
+          } else {
+            console.error(colors.bright(`Step summary before restart:`));
+          }
 
-            return writes;
-        });
+          showStepStatusSummary();
+          console.log('');
+        }
 
-        if (error) {
-            return;
+        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;
+      }
     }
 
-    const pageWrites = writes.filter(({ type }) => type === 'page');
-    const dataWrites = writes.filter(({ type }) => type === 'data');
-    const redirectWrites = writes.filter(({ type }) => type === 'redirect');
+    if (shouldShowStepStatusSummary)  {
+      if (numRestarts >= 1) {
+        console.error(colors.bright(`Step summary after final restart:`));
+      } else {
+        console.error(colors.bright(`Step summary:`));
+      }
 
-    if (writes.length) {
-        logInfo`Total of ${writes.length} writes returned. (${pageWrites.length} page, ${dataWrites.length} data, ${redirectWrites.length} redirect)`;
-    } else {
-        logWarn`No writes returned at all, so exiting early. This is probably a bug!`;
-        return;
-    }
+      const {anyStepsNotClean} =
+        showStepStatusSummary();
 
-    await progressPromiseAll(`Writing data files shared across languages.`, queue(
-        dataWrites.map(({path, data}) => () => {
-            const bound = {};
+      const totalTimeEnd = Date.now();
+      const totalDuration = formatDuration(totalTimeEnd - totalTimeStart);
 
-            bound.serializeLink = bindOpts(serializeLink, {});
+      console.error(colors.bright(`Done in ${totalDuration}.`));
 
-            bound.serializeContribs = bindOpts(serializeContribs, {});
+      if (result === true) {
+        if (anyStepsNotClean) {
+          console.error(colors.bright(`Final output is true, but some steps aren't clean.`));
+          process.exit(1);
+          return;
+        } else {
+          console.error(colors.bright(`Final output is true and all steps are clean.`));
+        }
+      } else if (result === false) {
+        console.error(colors.bright(`Final output is false.`));
+      } else {
+        console.error(colors.bright(`Final output is not true (${result}).`));
+      }
+    }
 
-            bound.serializeImagePaths = bindOpts(serializeImagePaths, {
-                thumb
-            });
+    if (result !== true) {
+      process.exit(1);
+      return;
+    }
 
-            bound.serializeCover = bindOpts(serializeCover, {
-                [bindOpts.bindIndex]: 2,
-                serializeImagePaths: bound.serializeImagePaths,
-                urls
-            });
+    decorateTime.displayTime();
 
-            bound.serializeGroupsForAlbum = bindOpts(serializeGroupsForAlbum, {
-                serializeLink
-            });
+    process.exit(0);
+  })();
+}
 
-            bound.serializeGroupsForTrack = bindOpts(serializeGroupsForTrack, {
-                serializeLink
-            });
+function formatDuration(timeDelta) {
+  const seconds = timeDelta / 1000;
 
-            // TODO: This only supports one <>-style argument.
-            return writeData(path[0], path[1], data({
-                ...bound
-            }));
-        }),
-        queueSize
-    ));
-
-    const perLanguageFn = async ({strings, ...opts}, i, entries) => {
-        console.log(`\x1b[34;1m${
-            (`[${i + 1}/${entries.length}] ${strings.code} (-> /${opts.baseDirectory}) `
-                .padEnd(60, '-'))
-        }\x1b[0m`);
-
-        await progressPromiseAll(`Writing ${strings.code}`, queue([
-            ...pageWrites.map(({type, ...props}) => () => {
-                const { path, page } = props;
-                const { baseDirectory } = opts;
-
-                // TODO: This only supports one <>-style argument.
-                const pageSubKey = path[0];
-                const directory = path[1];
-
-                const paths = writePage.paths(baseDirectory, 'localized.' + pageSubKey, directory);
-                const to = writePage.to({baseDirectory, pageSubKey, paths});
-
-                // TODO: Is there some nicer way to define these,
-                // may8e without totally re-8inding everything for
-                // each page?
-                const bound = {};
-
-                bound.link = withEntries(unbound_link, entries => entries
-                    .map(([ key, fn ]) => [key, bindOpts(fn, {to})]));
-
-                bound.linkAnythingMan = bindOpts(linkAnythingMan, {
-                    link: bound.link,
-                    wikiData
-                });
-
-                bound.parseAttributes = bindOpts(parseAttributes, {
-                    to
-                });
-
-                bound.transformInline = bindOpts(transformInline, {
-                    link: bound.link,
-                    replacerSpec,
-                    strings,
-                    to,
-                    wikiData
-                });
-
-                bound.transformMultiline = bindOpts(transformMultiline, {
-                    transformInline: bound.transformInline,
-                    parseAttributes: bound.parseAttributes
-                });
-
-                bound.transformLyrics = bindOpts(transformLyrics, {
-                    transformInline: bound.transformInline,
-                    transformMultiline: bound.transformMultiline
-                });
-
-                bound.iconifyURL = bindOpts(iconifyURL, {
-                    strings,
-                    to
-                });
-
-                bound.fancifyURL = bindOpts(fancifyURL, {
-                    strings
-                });
-
-                bound.fancifyFlashURL = bindOpts(fancifyFlashURL, {
-                    [bindOpts.bindIndex]: 2,
-                    strings
-                });
-
-                bound.getLinkThemeString = getLinkThemeString;
-
-                bound.getThemeString = getThemeString;
-
-                bound.getArtistString = bindOpts(getArtistString, {
-                    iconifyURL: bound.iconifyURL,
-                    link: bound.link,
-                    strings
-                });
-
-                bound.getAlbumCover = bindOpts(getAlbumCover, {
-                    to
-                });
-
-                bound.getTrackCover = bindOpts(getTrackCover, {
-                    to
-                });
-
-                bound.getFlashCover = bindOpts(getFlashCover, {
-                    to
-                });
-
-                bound.generateChronologyLinks = bindOpts(generateChronologyLinks, {
-                    link: bound.link,
-                    linkAnythingMan: bound.linkAnythingMan,
-                    strings,
-                    wikiData
-                });
-
-                bound.generateCoverLink = bindOpts(generateCoverLink, {
-                    [bindOpts.bindIndex]: 0,
-                    img,
-                    link: bound.link,
-                    strings,
-                    to,
-                    wikiData
-                });
-
-                bound.generateInfoGalleryLinks = bindOpts(generateInfoGalleryLinks, {
-                    [bindOpts.bindIndex]: 2,
-                    link: bound.link,
-                    strings
-                });
-
-                bound.generatePreviousNextLinks = bindOpts(generatePreviousNextLinks, {
-                    link: bound.link,
-                    strings
-                });
-
-                bound.getGridHTML = bindOpts(getGridHTML, {
-                    [bindOpts.bindIndex]: 0,
-                    getLinkThemeString,
-                    img,
-                    strings
-                });
-
-                bound.getAlbumGridHTML = bindOpts(getAlbumGridHTML, {
-                    [bindOpts.bindIndex]: 0,
-                    getAlbumCover: bound.getAlbumCover,
-                    getGridHTML: bound.getGridHTML,
-                    strings,
-                    to
-                });
-
-                bound.getFlashGridHTML = bindOpts(getFlashGridHTML, {
-                    [bindOpts.bindIndex]: 0,
-                    getFlashCover: bound.getFlashCover,
-                    getGridHTML: bound.getGridHTML,
-                    to
-                });
-
-                bound.getRevealStringFromTags = bindOpts(getRevealStringFromTags, {
-                    strings
-                });
-
-                bound.getRevealStringFromWarnings = bindOpts(getRevealStringFromWarnings, {
-                    strings
-                });
-
-                bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, {
-                    to
-                });
-
-                const pageFn = () => page({
-                    ...bound,
-                    strings,
-                    to
-                });
-
-                const content = writePage.html(pageFn, {
-                    paths,
-                    strings,
-                    to,
-                    transformMultiline: bound.transformMultiline,
-                    wikiData
-                });
-
-                return writePage.write(content, {paths});
-            }),
-            ...redirectWrites.map(({fromPath, toPath, title: titleFn}) => () => {
-                const { baseDirectory } = opts;
-
-                const title = titleFn({
-                    strings
-                });
-
-                // TODO: This only supports one <>-style argument.
-                const fromPaths = writePage.paths(baseDirectory, 'localized.' + fromPath[0], fromPath[1]);
-                const to = writePage.to({baseDirectory, pageSubKey: fromPath[0], paths: fromPaths});
-
-                const target = to('localized.' + toPath[0], ...toPath.slice(1));
-                const content = generateRedirectPage(title, target, {strings});
-                return writePage.write(content, {paths: fromPaths});
-            })
-        ], queueSize));
-    };
+  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';
+  }
 
-    await wrapLanguages(perLanguageFn, {
-        writeOneLanguage,
-        wikiData
+  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);
     });
 
-    decorateTime.displayTime();
+  const longestDurationLength =
+    Math.max(...stepDurations.map(duration => duration.length));
 
-    // The single most important step.
-    logInfo`Written!`;
-}
+  const stepMemories =
+    stepDetails.map(({memory}) =>
+      (memory
+        ? Math.round(memory["heapUsed"] / 1024 / 1024) + 'MB'
+        : '-'));
 
-main().catch(error => console.error(error));
+  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 a36bb0ee..75cd8006 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -1,93 +1,220 @@
-import {withEntries} from './util/sugar.js';
+// 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;
+      }
+    }
+  };
+}
 
-const urlSpec = {
-    data: {
-        prefix: 'data/',
+export function processGroupSpec(groupKey, groupSpec) {
+  const aggregate =
+    openAggregate({message: `Errors processing group "${groupKey}"`});
 
-        paths: {
-            root: '',
-            path: '<>',
+  const processToken = makeProcessToken(aggregate);
 
-            album: 'album/<>',
-            artist: 'artist/<>',
-            track: 'track/<>'
-        }
-    },
+  groupSpec.key = groupKey;
 
-    localized: {
-        // TODO: Implement this.
-        // prefix: '_languageCode',
+  processToken(groupSpec, 'prefix', processStringToken);
+  processToken(groupSpec, 'paths', processObjectToken);
 
-        paths: {
-            root: '',
-            path: '<>',
+  return {aggregate, result: groupSpec};
+}
 
-            home: '',
+export function processURLSpec(sourceSpec) {
+  const aggregate =
+    openAggregate({message: `Errors processing URL spec`});
 
-            album: 'album/<>/',
-            albumCommentary: 'commentary/album/<>/',
+  sourceSpec ??= {};
 
-            artist: 'artist/<>/',
-            artistGallery: 'artist/<>/gallery/',
+  const urlSpec = structuredClone(sourceSpec);
 
-            commentaryIndex: 'commentary/',
+  delete urlSpec.yamlAliases;
+  delete urlSpec.localizedWithBaseDirectory;
 
-            flashIndex: 'flash/',
-            flash: 'flash/<>/',
+  aggregate.nest({message: `Errors processing groups`}, groupsAggregate => {
+    Object.assign(urlSpec,
+      withEntries(urlSpec, entries =>
+        entries.map(([groupKey, groupSpec]) => [
+          groupKey,
+          groupsAggregate.receive(
+            processGroupSpec(groupKey, groupSpec)),
+        ])));
+  });
 
-            groupInfo: 'group/<>/',
-            groupGallery: 'group/<>/gallery/',
+  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`));
+      }
 
-            listingIndex: 'list/',
-            listing: 'list/<>/',
+      break;
+    }
 
-            newsIndex: 'news/',
-            newsEntry: 'news/<>/',
+    case undefined:
+      break;
 
-            staticPage: '<>/',
-            tag: 'tag/<>/',
-            track: 'track/<>/'
-        }
-    },
+    default:
+      aggregate.push(new Error(
+        `Expected 'localizedWithBaseDirectory' group to have value '<auto>' ` +
+        `or not be set`));
 
-    shared: {
-        paths: {
-            root: '',
-            path: '<>',
+      break;
+  }
 
-            utilityRoot: 'util',
-            staticRoot: 'static',
+  return {aggregate, result: urlSpec};
+}
 
-            utilityFile: 'util/<>',
-            staticFile: 'static/<>'
-        }
-    },
-
-    media: {
-        prefix: 'media/',
-
-        paths: {
-            root: '',
-            path: '<>',
-
-            albumCover: 'album-art/<>/cover.jpg',
-            albumWallpaper: 'album-art/<>/bg.jpg',
-            albumBanner: 'album-art/<>/banner.jpg',
-            trackCover: 'album-art/<>/<>.jpg',
-            artistAvatar: 'artist-avatar/<>.jpg',
-            flashArt: 'flash-art/<>.jpg',
-            flashArtGif: 'flash-art/<>.gif' // Hack! Sorry not sorry. ::::)
+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);
     }
-};
-
-// 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;
+  } 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..360907b9
--- /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 5r1
+
+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/urls.js b/src/urls.js
new file mode 100644
index 00000000..9cc4a554
--- /dev/null
+++ b/src/urls.js
@@ -0,0 +1,347 @@
+// Code that deals with URLs (really the pathnames that get referenced all
+// throughout the gener8ted HTML). Most nota8ly here is generateURLs, which
+// is in charge of pre-gener8ting a complete network of template strings
+// which can really quickly take su8stitute parameters to link from any one
+// place to another; 8ut there are also a few other utilities, too.
+
+import * as path from 'node:path';
+
+import {withEntries} from '#sugar';
+
+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) {
+      throw new Error(`Expected group key and subkey (got ${fullKey})`);
+    }
+
+    if (!Object.hasOwn(obj, groupKey)) {
+      throw new Error(`Expected valid group key (got ${groupKey})`);
+    }
+
+    const group = obj[groupKey];
+
+    if (!Object.hasOwn(group, subKey)) {
+      throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`);
+    }
+
+    return {
+      value: group[subKey],
+      group,
+    };
+  };
+
+  // This should be called on values which are going to be passed to
+  // path.relative, because relative will resolve a leading slash as the root
+  // directory of the working device, which we aren't looking for here.
+  const trimLeadingSlash = (P) => (P.startsWith('/') ? P.slice(1) : P);
+
+  const generateTo = (fromPath, fromGroup) => {
+    const A = trimLeadingSlash(fromPath);
+
+    const fromPrefix = fromGroup.prefix || '';
+
+    const rebasePrefix =
+      '../'.repeat(fromPrefix.split('/').filter(Boolean).length);
+
+    const fromOrigin = getOrigin(fromPrefix);
+
+    const pathHelper = (toPath, toGroup) => {
+      let B = trimLeadingSlash(toPath);
+
+      let argIndex = 0;
+      B = B.replaceAll('<>', () => `<${argIndex++}>`);
+
+      const suffix = toPath.endsWith('/') ? '/' : '';
+
+      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,
+          };
+        }
+      }
+
+      // 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 =>
+      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 toHelper =
+      ({device}) =>
+      (key, ...args) => {
+        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) => {
+          if (n < args.length) {
+            const value = args[n];
+            if (device) {
+              return value;
+            } else {
+              let encoded = encodeURIComponent(value);
+              encoded = encoded.replaceAll('%2F', '/');
+              return encoded;
+            }
+          } else {
+            missing++;
+          }
+        });
+
+        if (missing) {
+          throw new Error(
+            `Expected ${missing + args.length} arguments, ` +
+            `got ${args.length} (key ${key}, args [${args}])`);
+        }
+
+        return result;
+      };
+
+    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 = () => {
+    const map = withEntries(
+      urlSpec,
+      (entries) => entries.map(([key, group]) => [
+        key,
+        withEntries(group.paths, (entries) =>
+          entries.map(([key, path]) => [key, generateTo(path, group)])
+        ),
+      ]));
+
+    const from = (key) => getValueForFullKey(map, key).value;
+
+    return {from, map};
+  };
+
+  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');
+
+export const thumb = {
+  large: thumbnailHelper('.large'),
+  medium: thumbnailHelper('.medium'),
+  small: thumbnailHelper('.small'),
+};
+
+// Makes the generally-used and wiki-specialized "to" page utility.
+// "to" returns a relative path from the current page to the target.
+export function getURLsFrom({
+  baseDirectory,
+  pagePath,
+  urls,
+}) {
+  const pageSubKey = pagePath[0];
+  const subdirectoryPrefix = getPageSubdirectoryPrefix({pagePath});
+
+  return (targetFullKey, ...args) => {
+    const [groupKey, subKey] = targetFullKey.split('.');
+    let from, to;
+
+    // When linking to *outside* the localized area of the site, we need to
+    // make sure the result is correctly relative to the 8ase directory.
+    if (
+      groupKey !== 'localized' &&
+      groupKey !== 'localizedDefaultLanguage' &&
+      baseDirectory
+    ) {
+      from = 'localizedWithBaseDirectory.' + pageSubKey;
+      to = targetFullKey;
+    } else if (groupKey === 'localizedDefaultLanguage' && baseDirectory) {
+      // Special case for specifically linking *from* a page with base
+      // directory *to* a page without! Used for the language switcher and
+      // hopefully nothing else oh god.
+      from = 'localizedWithBaseDirectory.' + pageSubKey;
+      to = 'localized.' + subKey;
+    } else if (groupKey === 'localizedDefaultLanguage') {
+      // Linking to the default, except surprise, we're already IN the default
+      // (no baseDirectory set).
+      from = 'localized.' + pageSubKey;
+      to = 'localized.' + subKey;
+    } else {
+      // If we're linking inside the localized area (or there just is no
+      // 8ase directory), the 8ase directory doesn't matter.
+      from = 'localized.' + pageSubKey;
+      to = targetFullKey;
+    }
+
+    const toResult =
+      urls.from(from).to(to, ...args);
+
+    if (getOrigin(toResult)) {
+      return toResult;
+    } else {
+      return subdirectoryPrefix + toResult;
+    }
+  };
+}
+
+// Makes the generally-used and wiki-specialized "absoluteTo" page utility.
+// "absoluteTo" returns an absolute path, starting at site root (/) leading
+// to the target.
+export function getURLsFromRoot({
+  baseDirectory,
+  urls,
+}) {
+  const {to} = urls.from('shared.root');
+
+  return (targetFullKey, ...args) => {
+    const [groupKey, subKey] = targetFullKey.split('.');
+    const toResult =
+      (groupKey === 'localized' && baseDirectory
+        ? to('localizedWithBaseDirectory.' + subKey, baseDirectory, ...args)
+     : groupKey === 'localizedDefaultLanguage'
+        ? to('localized.' + subKey, ...args)
+        : to(targetFullKey, ...args));
+
+    if (getOrigin(toResult)) {
+      return toResult;
+    } else {
+      return '/' + toResult;
+    }
+  };
+}
+
+export function getPagePathname({
+  baseDirectory,
+  device = false,
+  pagePath,
+  urls,
+}) {
+  const {[device ? 'toDevice' : 'to']: to} = urls.from('shared.root');
+
+  return (baseDirectory
+    ? to('localizedWithBaseDirectory.' + pagePath[0], baseDirectory, ...pagePath.slice(1))
+    : to('localized.' + pagePath[0], ...pagePath.slice(1)));
+}
+
+// Needed for the rare path arguments which themselves contains one or more
+// slashes, e.g. for listings, with arguments like 'albums/by-name'.
+export function getPageSubdirectoryPrefix({
+  pagePath,
+}) {
+  const timesNestedDeeply = (pagePath
+    .slice(1) // skip URL key, only check arguments
+    .join('/')
+    .split('/')
+    .length - 1);
+  return '../'.repeat(timesNestedDeeply);
+}
diff --git a/src/util/cli.js b/src/util/cli.js
deleted file mode 100644
index 7f84be7c..00000000
--- a/src/util/cli.js
+++ /dev/null
@@ -1,210 +0,0 @@
-// 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 logColor = color => (literals, ...values) => {
-    const w = s => process.stdout.write(s);
-    w(`\x1b[${color}m`);
-    for (let i = 0; i < literals.length; i++) {
-        w(literals[i]);
-        if (values[i] !== undefined) {
-            w(`\x1b[1m`);
-            w(String(values[i]));
-            w(`\x1b[0;${color}m`);
-        }
-    }
-    w(`\x1b[0m\n`);
-};
-
-export const logInfo = logColor(2);
-export const logWarn = logColor(33);
-export const logError = logColor(31);
-
-// Stolen as #@CK from mtui!
-export async function parseOptions(options, optionDescriptorMap) {
-    // This function is sorely lacking in comments, but the basic usage is
-    // as such:
-    //
-    // options is the array of options you want to process;
-    // optionDescriptorMap is a mapping of option names to objects that describe
-    // the expected value for their corresponding options.
-    // Returned is a mapping of any specified option names to their values, or
-    // a process.exit(1) and error message if there were any issues.
-    //
-    // Here are examples of optionDescriptorMap to cover all the things you can
-    // do with it:
-    //
-    // optionDescriptorMap: {
-    //   'telnet-server': {type: 'flag'},
-    //   't': {alias: 'telnet-server'}
-    // }
-    //
-    // options: ['t'] -> result: {'telnet-server': true}
-    //
-    // optionDescriptorMap: {
-    //   'directory': {
-    //     type: 'value',
-    //     validate(name) {
-    //       // const whitelistedDirectories = ['apple', 'banana']
-    //       if (whitelistedDirectories.includes(name)) {
-    //         return true
-    //       } else {
-    //         return 'a whitelisted directory'
-    //       }
-    //     }
-    //   },
-    //   'files': {type: 'series'}
-    // }
-    //
-    // ['--directory', 'apple'] -> {'directory': 'apple'}
-    // ['--directory', 'artichoke'] -> (error)
-    // ['--files', 'a', 'b', 'c', ';'] -> {'files': ['a', 'b', 'c']}
-    //
-    // TODO: Be able to validate the values in a series option.
-
-    const handleDashless = optionDescriptorMap[parseOptions.handleDashless];
-    const handleUnknown = optionDescriptorMap[parseOptions.handleUnknown];
-    const result = Object.create(null);
-    for (let i = 0; i < options.length; i++) {
-        const option = options[i];
-        if (option.startsWith('--')) {
-            // --x can be a flag or expect a value or series of values
-            let name = option.slice(2).split('=')[0]; // '--x'.split('=') = ['--x']
-            let descriptor = optionDescriptorMap[name];
-            if (!descriptor) {
-                if (handleUnknown) {
-                    handleUnknown(option);
-                } else {
-                    console.error(`Unknown option name: ${name}`);
-                    process.exit(1);
-                }
-                continue;
-            }
-            if (descriptor.alias) {
-                name = descriptor.alias;
-                descriptor = optionDescriptorMap[name];
-            }
-            if (descriptor.type === 'flag') {
-                result[name] = true;
-            } else if (descriptor.type === 'value') {
-                let value = option.slice(2).split('=')[1];
-                if (!value) {
-                    value = options[++i];
-                    if (!value || value.startsWith('-')) {
-                        value = null;
-                    }
-                }
-                if (!value) {
-                    console.error(`Expected a value for --${name}`);
-                    process.exit(1);
-                }
-                result[name] = value;
-            } else if (descriptor.type === 'series') {
-                if (!options.slice(i).includes(';')) {
-                    console.error(`Expected a series of values concluding with ; (\\;) for --${name}`);
-                    process.exit(1);
-                }
-                const endIndex = i + options.slice(i).indexOf(';');
-                result[name] = options.slice(i + 1, endIndex);
-                i = endIndex;
-            }
-            if (descriptor.validate) {
-                const validation = await descriptor.validate(result[name]);
-                if (validation !== true) {
-                    console.error(`Expected ${validation} for --${name}`);
-                    process.exit(1);
-                }
-            }
-        } else if (option.startsWith('-')) {
-            // mtui doesn't use any -x=y or -x y format optionuments
-            // -x will always just be a flag
-            let name = option.slice(1);
-            let descriptor = optionDescriptorMap[name];
-            if (!descriptor) {
-                if (handleUnknown) {
-                    handleUnknown(option);
-                } else {
-                    console.error(`Unknown option name: ${name}`);
-                    process.exit(1);
-                }
-                continue;
-            }
-            if (descriptor.alias) {
-                name = descriptor.alias;
-                descriptor = optionDescriptorMap[name];
-            }
-            if (descriptor.type === 'flag') {
-                result[name] = true;
-            } else {
-                console.error(`Use --${name} (value) to specify ${name}`);
-                process.exit(1);
-            }
-        } else if (handleDashless) {
-            handleDashless(option);
-        }
-    }
-    return result;
-}
-
-export const handleDashless = Symbol();
-export const handleUnknown = Symbol();
-
-export function decorateTime(functionToBeWrapped) {
-    const fn = function(...args) {
-        const start = Date.now();
-        const ret = functionToBeWrapped(...args);
-        const end = Date.now();
-        fn.timeSpent += end - start;
-        fn.timesCalled++;
-        return ret;
-    };
-
-    fn.wrappedName = functionToBeWrapped.name;
-    fn.timeSpent = 0;
-    fn.timesCalled = 0;
-    fn.displayTime = function() {
-        const averageTime = fn.timeSpent / fn.timesCalled;
-        console.log(`\x1b[1m${fn.wrappedName}(...):\x1b[0m ${fn.timeSpent} ms / ${fn.timesCalled} calls \x1b[2m(avg: ${averageTime} ms)\x1b[0m`);
-    };
-
-    decorateTime.decoratedFunctions.push(fn);
-
-    return fn;
-}
-
-decorateTime.decoratedFunctions = [];
-decorateTime.displayTime = function() {
-    if (decorateTime.decoratedFunctions.length) {
-        console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m');
-        for (const fn of decorateTime.decoratedFunctions) {
-            fn.displayTime();
-        }
-    }
-};
-
-export function progressPromiseAll(msgOrMsgFn, array) {
-    if (!array.length) {
-        return Promise.resolve([]);
-    }
-
-    const msgFn = (typeof msgOrMsgFn === 'function'
-        ? msgOrMsgFn
-        : () => msgOrMsgFn);
-
-    let done = 0, total = array.length;
-    process.stdout.write(`\r${msgFn()} [0/${total}]`);
-    const start = Date.now();
-    return Promise.all(array.map(promise => Promise.resolve(promise).then(val => {
-        done++;
-        // const pc = `${done}/${total}`;
-        const pc = (Math.round(done / total * 1000) / 10 + '%').padEnd('99.9%'.length, ' ');
-        if (done === total) {
-            const time = Date.now() - start;
-            process.stdout.write(`\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`)
-        } else {
-            process.stdout.write(`\r${msgFn()} [${pc}] `);
-        }
-        return val;
-    })));
-}
diff --git a/src/util/colors.js b/src/util/colors.js
deleted file mode 100644
index 3a7ce8f3..00000000
--- a/src/util/colors.js
+++ /dev/null
@@ -1,21 +0,0 @@
-// Color and theming utility functions! Handy.
-
-// Graciously stolen from https://stackoverflow.com/a/54071699! ::::)
-// in: r,g,b in [0,1], out: h in [0,360) and s,l in [0,1]
-export function rgb2hsl(r, g, b) {
-    let a=Math.max(r,g,b), n=a-Math.min(r,g,b), f=(1-Math.abs(a+a-n-1));
-    let h= n && ((a==r) ? (g-b)/n : ((a==g) ? 2+(b-r)/n : 4+(r-g)/n));
-    return [60*(h<0?h+6:h), f ? n/f : 0, (a+a-n)/2];
-}
-
-export function getColors(primary) {
-    const [ r, g, b ] = primary.slice(1)
-        .match(/[0-9a-fA-F]{2,2}/g)
-        .slice(0, 3)
-        .map(val => parseInt(val, 16) / 255);
-    const [ h, s, l ] = rgb2hsl(r, g, b);
-    const dim = `hsl(${Math.round(h)}deg, ${Math.round(s * 50)}%, ${Math.round(l * 80)}%)`;
-    const bg = `hsla(${Math.round(h)}deg, ${Math.round(s * 15)}%, 12%, 0.80)`;
-
-    return {primary, dim, bg};
-}
diff --git a/src/util/find.js b/src/util/find.js
deleted file mode 100644
index 1cbeb82c..00000000
--- a/src/util/find.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import {
-    logWarn
-} from './cli.js';
-
-function findHelper(keys, dataProp, findFn) {
-    return (ref, {wikiData}) => {
-        if (!ref) return null;
-        ref = ref.replace(new RegExp(`^(${keys.join('|')}):`), '');
-
-        const found = findFn(ref, wikiData[dataProp]);
-        if (!found) {
-            logWarn`Didn't match anything for ${ref}! (${keys.join(', ')})`;
-        }
-
-        return found;
-    };
-}
-
-function matchDirectory(ref, data) {
-    return data.find(({ directory }) => directory === ref);
-}
-
-function matchDirectoryOrName(ref, data) {
-    let thing;
-
-    thing = matchDirectory(ref, data);
-    if (thing) return thing;
-
-    thing = data.find(({ name }) => name === ref);
-    if (thing) return thing;
-
-    thing = data.find(({ name }) => name.toLowerCase() === ref.toLowerCase());
-    if (thing) {
-        logWarn`Bad capitalization: ${'\x1b[31m' + ref} -> ${'\x1b[32m' + thing.name}`;
-        return thing;
-    }
-
-    return null;
-}
-
-const find = {
-    album: findHelper(['album', 'album-commentary'], 'albumData', matchDirectoryOrName),
-    artist: findHelper(['artist', 'artist-gallery'], 'artistData', matchDirectoryOrName),
-    flash: findHelper(['flash'], 'flashData', matchDirectory),
-    group: findHelper(['group', 'group-gallery'], 'groupData', matchDirectoryOrName),
-    listing: findHelper(['listing'], 'listingSpec', matchDirectory),
-    newsEntry: findHelper(['news-entry'], 'newsData', matchDirectory),
-    staticPage: findHelper(['static'], 'staticPageData', matchDirectory),
-    tag: findHelper(['tag'], 'tagData', (ref, data) =>
-        matchDirectoryOrName(ref.startsWith('cw: ') ? ref.slice(4) : ref, data)),
-    track: findHelper(['track'], 'trackData', matchDirectoryOrName)
-};
-
-export default find;
diff --git a/src/util/html.js b/src/util/html.js
deleted file mode 100644
index 94756984..00000000
--- a/src/util/html.js
+++ /dev/null
@@ -1,94 +0,0 @@
-// Some really simple functions for formatting HTML content.
-
-// Non-comprehensive. ::::P
-export const selfClosingTags = ['br', 'img'];
-
-// Pass to tag() as an attri8utes key to make tag() return a 8lank string
-// 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();
-
-export function tag(tagName, ...args) {
-    const selfClosing = selfClosingTags.includes(tagName);
-
-    let openTag;
-    let content;
-    let attrs;
-
-    if (typeof args[0] === 'object' && !Array.isArray(args[0])) {
-        attrs = args[0];
-        content = args[1];
-    } else {
-        content = args[0];
-    }
-
-    if (selfClosing && content) {
-        throw new Error(`Tag <${tagName}> is self-closing but got content!`);
-    }
-
-    if (attrs?.[onlyIfContent] && !content) {
-        return '';
-    }
-
-    if (attrs) {
-        const attrString = attributes(args[0]);
-        if (attrString) {
-            openTag = `${tagName} ${attrString}`;
-        }
-    }
-
-    if (!openTag) {
-        openTag = tagName;
-    }
-
-    if (Array.isArray(content)) {
-        content = content.filter(Boolean).join('\n');
-    }
-
-    if (content) {
-        if (content.includes('\n')) {
-            return (
-                `<${openTag}>\n` +
-                content.split('\n').map(line => '    ' + line + '\n').join('') +
-                `</${tagName}>`
-            );
-        } else {
-            return `<${openTag}>${content}</${tagName}>`;
-        }
-    } else {
-        if (selfClosing) {
-            return `<${openTag}>`;
-        } else {
-            return `<${openTag}></${tagName}>`;
-        }
-    }
-}
-
-export function escapeAttributeValue(value) {
-    return value
-        .replaceAll('"', '&quot;')
-        .replaceAll("'", '&apos;');
-}
-
-export function attributes(attribs) {
-    return Object.entries(attribs)
-        .map(([ key, val ]) => {
-            if (typeof val === 'undefined' || val === null)
-                return [key, val, false];
-            else if (typeof val === 'string')
-                return [key, val, true];
-            else if (typeof val === 'boolean')
-                return [key, val, val];
-            else if (typeof val === 'number')
-                return [key, val.toString(), true];
-            else if (Array.isArray(val))
-                return [key, val.filter(Boolean).join(' '), val.length > 0];
-            else
-                throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`);
-        })
-        .filter(([ key, val, keep ]) => keep)
-        .map(([ key, val ]) => (typeof val === 'boolean'
-            ? `${key}`
-            : `${key}="${escapeAttributeValue(val)}"`))
-        .join(' ');
-}
diff --git a/src/util/link.js b/src/util/link.js
deleted file mode 100644
index 7ed5fd8e..00000000
--- a/src/util/link.js
+++ /dev/null
@@ -1,80 +0,0 @@
-// This file is essentially one level of a8straction a8ove urls.js (and the
-// urlSpec it gets its paths from). It's a 8unch of utility functions which
-// take certain types of wiki data o8jects (colloquially known as "things")
-// and return actual <a href> HTML link tags.
-//
-// The functions we're cre8ting here (all factory-style) take a "to" argument,
-// which is roughly a function which takes a urlSpec key and spits out a path
-// to 8e stuck in an href or src or suchever. There are also a few other
-// options availa8le in all the functions, making a common interface for
-// gener8ting just a8out any link on the site.
-
-import * as html from './html.js'
-import { getColors } from './colors.js'
-
-export function getLinkThemeString(color) {
-    if (!color) return '';
-
-    const { primary, dim } = getColors(color);
-    return `--primary-color: ${primary}; --dim-color: ${dim}`;
-}
-
-const linkHelper = (hrefFn, {color = true, attr = null} = {}) =>
-    (thing, {
-        to,
-        text = '',
-        attributes = null,
-        class: className = '',
-        color: color2 = true,
-        hash = ''
-    }) => (
-        html.tag('a', {
-            ...attr ? attr(thing) : {},
-            ...attributes ? attributes : {},
-            href: hrefFn(thing, {to}) + (hash ? (hash.startsWith('#') ? '' : '#') + hash : ''),
-            style: (
-                typeof color2 === 'string' ? getLinkThemeString(color2) :
-                color2 && color ? getLinkThemeString(thing.color) :
-                ''),
-            class: className
-        }, text || thing.name)
-    );
-
-const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) =>
-    linkHelper((thing, {to}) => to('localized.' + key, thing.directory), {
-        attr: thing => ({
-            ...attr ? attr(thing) : {},
-            ...expose ? {[expose]: thing.directory} : {}
-        }),
-        ...conf
-    });
-
-const linkPathname = (key, conf) => linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf);
-const linkIndex = (key, conf) => linkHelper((_, {to}) => to('localized.' + key), conf);
-
-const link = {
-    album: linkDirectory('album'),
-    albumCommentary: linkDirectory('albumCommentary'),
-    artist: linkDirectory('artist', {color: false}),
-    artistGallery: linkDirectory('artistGallery', {color: false}),
-    commentaryIndex: linkIndex('commentaryIndex', {color: false}),
-    flashIndex: linkIndex('flashIndex', {color: false}),
-    flash: linkDirectory('flash'),
-    groupInfo: linkDirectory('groupInfo'),
-    groupGallery: linkDirectory('groupGallery'),
-    home: linkIndex('home', {color: false}),
-    listingIndex: linkIndex('listingIndex'),
-    listing: linkDirectory('listing'),
-    newsIndex: linkIndex('newsIndex', {color: false}),
-    newsEntry: linkDirectory('newsEntry', {color: false}),
-    staticPage: linkDirectory('staticPage', {color: false}),
-    tag: linkDirectory('tag'),
-    track: linkDirectory('track', {expose: 'data-track'}),
-
-    media: linkPathname('media.path', {color: false}),
-    root: linkPathname('shared.path', {color: false}),
-    data: linkPathname('data.path', {color: false}),
-    site: linkPathname('localized.path', {color: false})
-};
-
-export default link;
diff --git a/src/util/magic-constants.js b/src/util/magic-constants.js
deleted file mode 100644
index 3174daec..00000000
--- a/src/util/magic-constants.js
+++ /dev/null
@@ -1,11 +0,0 @@
-// Magic constants only! These are hard-coded, and any use of them should be
-// considered a flaw in the codebase - areas where we use hard-coded behavior
-// to support one use of the wiki software (i.e. HSMusic, usually), rather than
-// implementing the feature more generally/customizably.
-//
-// All such uses should eventually be replaced with better code in due time
-// (TM).
-
-export const UNRELEASED_TRACKS_DIRECTORY = 'unreleased-tracks';
-export const OFFICIAL_GROUP_DIRECTORY = 'official';
-export const FANDOM_GROUP_DIRECTORY = 'fandom';
diff --git a/src/util/node-utils.js b/src/util/node-utils.js
deleted file mode 100644
index d660612e..00000000
--- a/src/util/node-utils.js
+++ /dev/null
@@ -1,27 +0,0 @@
-// Utility functions which are only relevant to particular Node.js constructs.
-
-// Very cool function origin8ting in... http-music pro8a8ly!
-// Sorry if we happen to 8e violating past-us's copyright, lmao.
-export function promisifyProcess(proc, showLogging = true) {
-    // Takes a process (from the child_process module) and returns a promise
-    // that resolves when the process exits (or rejects, if the exit code is
-    // non-zero).
-    //
-    // Ayy look, no alpha8etical second letter! Couldn't tell this was written
-    // like three years ago 8efore I was me. 8888)
-
-    return new Promise((resolve, reject) => {
-        if (showLogging) {
-            proc.stdout.pipe(process.stdout);
-            proc.stderr.pipe(process.stderr);
-        }
-
-        proc.on('exit', code => {
-            if (code === 0) {
-                resolve();
-            } else {
-                reject(code);
-            }
-        })
-    })
-}
diff --git a/src/util/replacer.js b/src/util/replacer.js
deleted file mode 100644
index 0c16dc8b..00000000
--- a/src/util/replacer.js
+++ /dev/null
@@ -1,424 +0,0 @@
-import find from './find.js';
-import {logError, logWarn} from './cli.js';
-import {escapeRegex} from './sugar.js';
-
-export function validateReplacerSpec(replacerSpec, link) {
-    let success = true;
-
-    for (const [key, {link: linkKey, find: findKey, value, html}] of Object.entries(replacerSpec)) {
-        if (!html && !link[linkKey]) {
-            logError`The replacer spec ${key} has invalid link key ${linkKey}! Specify it in link specs or fix typo.`;
-            success = false;
-        }
-        if (findKey && !find[findKey]) {
-            logError`The replacer spec ${key} has invalid find key ${findKey}! Specify it in find specs or fix typo.`;
-            success = false;
-        }
-    }
-
-    return success;
-}
-
-// Syntax literals.
-const tagBeginning = '[[';
-const tagEnding = ']]';
-const tagReplacerValue = ':';
-const tagHash = '#';
-const tagArgument = '*';
-const tagArgumentValue = '=';
-const tagLabel = '|';
-
-const noPrecedingWhitespace = '(?<!\\s)';
-
-const R_tagBeginning =
-    escapeRegex(tagBeginning);
-
-const R_tagEnding =
-    escapeRegex(tagEnding);
-
-const R_tagReplacerValue =
-    noPrecedingWhitespace +
-    escapeRegex(tagReplacerValue);
-
-const R_tagHash =
-    noPrecedingWhitespace +
-    escapeRegex(tagHash);
-
-const R_tagArgument =
-    escapeRegex(tagArgument);
-
-const R_tagArgumentValue =
-    escapeRegex(tagArgumentValue);
-
-const R_tagLabel =
-    escapeRegex(tagLabel);
-
-const regexpCache = {};
-
-const makeError = (i, message) => ({i, type: 'error', data: {message}});
-const endOfInput = (i, comment) => makeError(i, `Unexpected end of input (${comment}).`);
-
-// These are 8asically stored on the glo8al scope, which might seem odd
-// for a recursive function, 8ut the values are only ever used immediately
-// after they're set.
-let stopped,
-    stop_iMatch,
-    stop_iParse,
-    stop_literal;
-
-function parseOneTextNode(input, i, stopAt) {
-    return parseNodes(input, i, stopAt, true)[0];
-}
-
-function parseNodes(input, i, stopAt, textOnly) {
-    let nodes = [];
-    let escapeNext = false;
-    let string = '';
-    let iString = 0;
-
-    stopped = false;
-
-    const pushTextNode = (isLast) => {
-        string = input.slice(iString, i);
-
-        // If this is the last text node 8efore stopping (at a stopAt match
-        // or the end of the input), trim off whitespace at the end.
-        if (isLast) {
-            string = string.trimEnd();
-        }
-
-        if (string.length) {
-            nodes.push({i: iString, iEnd: i, type: 'text', data: string});
-            string = '';
-        }
-    };
-
-    const literalsToMatch = stopAt ? stopAt.concat([R_tagBeginning]) : [R_tagBeginning];
-
-    // The 8ackslash stuff here is to only match an even (or zero) num8er
-    // of sequential 'slashes. Even amounts always cancel out! Odd amounts
-    // don't, which would mean the following literal is 8eing escaped and
-    // should 8e counted only as part of the current string/text.
-    //
-    // Inspired 8y this: https://stackoverflow.com/a/41470813
-    const regexpSource = `(?<!\\\\)(?:\\\\{2})*(${literalsToMatch.join('|')})`;
-
-    // There are 8asically only a few regular expressions we'll ever use,
-    // 8ut it's a pain to hard-code them all, so we dynamically gener8te
-    // and cache them for reuse instead.
-    let regexp;
-    if (regexpCache.hasOwnProperty(regexpSource)) {
-        regexp = regexpCache[regexpSource];
-    } else {
-        regexp = new RegExp(regexpSource);
-        regexpCache[regexpSource] = regexp;
-    }
-
-    // Skip whitespace at the start of parsing. This is run every time
-    // parseNodes is called (and thus parseOneTextNode too), so spaces
-    // at the start of syntax elements will always 8e skipped. We don't
-    // skip whitespace that shows up inside content (i.e. once we start
-    // parsing below), though!
-    const whitespaceOffset = input.slice(i).search(/[^\s]/);
-
-    // If the string is all whitespace, that's just zero content, so
-    // return the empty nodes array.
-    if (whitespaceOffset === -1) {
-        return nodes;
-    }
-
-    i += whitespaceOffset;
-
-    while (i < input.length) {
-        const match = input.slice(i).match(regexp);
-
-        if (!match) {
-            iString = i;
-            i = input.length;
-            pushTextNode(true);
-            break;
-        }
-
-        const closestMatch = match[0];
-        const closestMatchIndex = i + match.index;
-
-        if (textOnly && closestMatch === tagBeginning)
-            throw makeError(i, `Unexpected [[tag]] - expected only text here.`);
-
-        const stopHere = (closestMatch !== tagBeginning);
-
-        iString = i;
-        i = closestMatchIndex;
-        pushTextNode(stopHere);
-
-        i += closestMatch.length;
-
-        if (stopHere) {
-            stopped = true;
-            stop_iMatch = closestMatchIndex;
-            stop_iParse = i;
-            stop_literal = closestMatch;
-            break;
-        }
-
-        if (closestMatch === tagBeginning) {
-            const iTag = closestMatchIndex;
-
-            let N;
-
-            // Replacer key (or value)
-
-            N = parseOneTextNode(input, i, [R_tagReplacerValue, R_tagHash, R_tagArgument, R_tagLabel, R_tagEnding]);
-
-            if (!stopped) throw endOfInput(i, `reading replacer key`);
-
-            if (!N) {
-                switch (stop_literal) {
-                    case tagReplacerValue:
-                    case tagArgument:
-                        throw makeError(i, `Expected text (replacer key).`);
-                    case tagLabel:
-                    case tagHash:
-                    case tagEnding:
-                        throw makeError(i, `Expected text (replacer key/value).`);
-                }
-            }
-
-            const replacerFirst = N;
-            i = stop_iParse;
-
-            // Replacer value (if explicit)
-
-            let replacerSecond;
-
-            if (stop_literal === tagReplacerValue) {
-                N = parseNodes(input, i, [R_tagHash, R_tagArgument, R_tagLabel, R_tagEnding]);
-
-                if (!stopped) throw endOfInput(i, `reading replacer value`);
-                if (!N.length) throw makeError(i, `Expected content (replacer value).`);
-
-                replacerSecond = N;
-                i = stop_iParse
-            }
-
-            // Assign first & second to replacer key/value
-
-            let replacerKey,
-                replacerValue;
-
-            // Value is an array of nodes, 8ut key is just one (or null).
-            // So if we use replacerFirst as the value, we need to stick
-            // it in an array (on its own).
-            if (replacerSecond) {
-                replacerKey = replacerFirst;
-                replacerValue = replacerSecond;
-            } else {
-                replacerKey = null;
-                replacerValue = [replacerFirst];
-            }
-
-            // Hash
-
-            let hash;
-
-            if (stop_literal === tagHash) {
-                N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
-
-                if (!stopped) throw endOfInput(i, `reading hash`);
-
-                if (!N)
-                    throw makeError(i, `Expected content (hash).`);
-
-                hash = N;
-                i = stop_iParse;
-            }
-
-            // Arguments
-
-            const args = [];
-
-            while (stop_literal === tagArgument) {
-                N = parseOneTextNode(input, i, [R_tagArgumentValue, R_tagArgument, R_tagLabel, R_tagEnding]);
-
-                if (!stopped) throw endOfInput(i, `reading argument key`);
-
-                if (stop_literal !== tagArgumentValue)
-                    throw makeError(i, `Expected ${tagArgumentValue.literal} (tag argument).`);
-
-                if (!N)
-                    throw makeError(i, `Expected text (argument key).`);
-
-                const key = N;
-                i = stop_iParse;
-
-                N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
-
-                if (!stopped) throw endOfInput(i, `reading argument value`);
-                if (!N.length) throw makeError(i, `Expected content (argument value).`);
-
-                const value = N;
-                i = stop_iParse;
-
-                args.push({key, value});
-            }
-
-            let label;
-
-            if (stop_literal === tagLabel) {
-                N = parseOneTextNode(input, i, [R_tagEnding]);
-
-                if (!stopped) throw endOfInput(i, `reading label`);
-                if (!N) throw makeError(i, `Expected text (label).`);
-
-                label = N;
-                i = stop_iParse;
-            }
-
-            nodes.push({i: iTag, iEnd: i, type: 'tag', data: {replacerKey, replacerValue, hash, args, label}});
-
-            continue;
-        }
-    }
-
-    return nodes;
-};
-
-export function parseInput(input) {
-    try {
-        return parseNodes(input, 0);
-    } catch (errorNode) {
-        if (errorNode.type !== 'error') {
-            throw errorNode;
-        }
-
-        const { i, data: { message } } = errorNode;
-
-        let lineStart = input.slice(0, i).lastIndexOf('\n');
-        if (lineStart >= 0) {
-            lineStart += 1;
-        } else {
-            lineStart = 0;
-        }
-
-        let lineEnd = input.slice(i).indexOf('\n');
-        if (lineEnd >= 0) {
-            lineEnd += i;
-        } else {
-            lineEnd = input.length;
-        }
-
-        const line = input.slice(lineStart, lineEnd);
-
-        const cursor = i - lineStart;
-
-        throw new SyntaxError(fixWS`
-            Parse error (at pos ${i}): ${message}
-            ${line}
-            ${'-'.repeat(cursor) + '^'}
-        `);
-    }
-}
-
-function evaluateTag(node, opts) {
-    const { input, link, replacerSpec, strings, to, wikiData } = opts;
-
-    const source = input.slice(node.i, node.iEnd);
-
-    const replacerKey = node.data.replacerKey?.data || 'track';
-
-    if (!replacerSpec[replacerKey]) {
-        logWarn`The link ${source} has an invalid replacer key!`;
-        return source;
-    }
-
-    const {
-        find: findKey,
-        link: linkKey,
-        value: valueFn,
-        html: htmlFn,
-        transformName
-    } = replacerSpec[replacerKey];
-
-    const replacerValue = transformNodes(node.data.replacerValue, opts);
-
-    const value = (
-        valueFn ? valueFn(replacerValue) :
-        findKey ? find[findKey](replacerValue, {wikiData}) :
-        {
-            directory: replacerValue,
-            name: null
-        });
-
-    if (!value) {
-        logWarn`The link ${source} does not match anything!`;
-        return source;
-    }
-
-    const enteredLabel = node.data.label && transformNode(node.data.label, opts);
-
-    const label = (enteredLabel
-        || transformName && transformName(value.name, node, input)
-        || value.name);
-
-    if (!valueFn && !label) {
-        logWarn`The link ${source} requires a label be entered!`;
-        return source;
-    }
-
-    const hash = node.data.hash && transformNodes(node.data.hash, opts);
-
-    const args = node.data.args && Object.fromEntries(node.data.args.map(
-        ({ key, value }) => [
-            transformNode(key, opts),
-            transformNodes(value, opts)
-        ]));
-
-    const fn = (htmlFn
-        ? htmlFn
-        : link[linkKey]);
-
-    try {
-        return fn(value, {text: label, hash, args, strings, to});
-    } catch (error) {
-        logError`The link ${source} failed to be processed: ${error}`;
-        return source;
-    }
-}
-
-function transformNode(node, opts) {
-    if (!node) {
-        throw new Error('Expected a node!');
-    }
-
-    if (Array.isArray(node)) {
-        throw new Error('Got an array - use transformNodes here!');
-    }
-
-    switch (node.type) {
-        case 'text':
-            return node.data;
-        case 'tag':
-            return evaluateTag(node, opts);
-        default:
-            throw new Error(`Unknown node type ${node.type}`);
-    }
-}
-
-function transformNodes(nodes, opts) {
-    if (!nodes || !Array.isArray(nodes)) {
-        throw new Error(`Expected an array of nodes! Got: ${nodes}`);
-    }
-
-    return nodes.map(node => transformNode(node, opts)).join('');
-}
-
-export function transformInline(input, {replacerSpec, link, strings, to, wikiData}) {
-    if (!replacerSpec) throw new Error('Expected replacerSpec');
-    if (!link) throw new Error('Expected link');
-    if (!strings) throw new Error('Expected strings');
-    if (!to) throw new Error('Expected to');
-    if (!wikiData) throw new Error('Expected wikiData');
-
-    const nodes = parseInput(input);
-    return transformNodes(nodes, {input, link, replacerSpec, strings, to, wikiData});
-}
diff --git a/src/util/serialize.js b/src/util/serialize.js
deleted file mode 100644
index 7b0f890f..00000000
--- a/src/util/serialize.js
+++ /dev/null
@@ -1,71 +0,0 @@
-export function serializeLink(thing) {
-    const ret = {};
-    ret.name = thing.name;
-    ret.directory = thing.directory;
-    if (thing.color) ret.color = thing.color;
-    return ret;
-}
-
-export function serializeContribs(contribs) {
-    return contribs.map(({ who, what }) => {
-        const ret = {};
-        ret.artist = serializeLink(who);
-        if (what) ret.contribution = what;
-        return ret;
-    });
-}
-
-export function serializeImagePaths(original, {thumb}) {
-    return {
-        original,
-        medium: thumb.medium(original),
-        small: thumb.small(original)
-    };
-}
-
-export function serializeCover(thing, pathFunction, {
-    serializeImagePaths,
-    urls
-}) {
-    const coverPath = pathFunction(thing, {
-        to: urls.from('media.root').to
-    });
-
-    const { artTags } = thing;
-
-    const cwTags = artTags.filter(tag => tag.isCW);
-    const linkTags = artTags.filter(tag => !tag.isCW);
-
-    return {
-        paths: serializeImagePaths(coverPath),
-        tags: linkTags.map(serializeLink),
-        warnings: cwTags.map(tag => tag.name)
-    };
-}
-
-export function serializeGroupsForAlbum(album, {
-    serializeLink
-}) {
-    return album.groups.map(group => {
-        const index = group.albums.indexOf(album);
-        const next = group.albums[index + 1] || null;
-        const previous = group.albums[index - 1] || null;
-        return {group, index, next, previous};
-    }).map(({group, index, next, previous}) => ({
-        link: serializeLink(group),
-        descriptionShort: group.descriptionShort,
-        albumIndex: index,
-        nextAlbum: next && serializeLink(next),
-        previousAlbum: previous && serializeLink(previous),
-        urls: group.urls
-    }));
-}
-
-export function serializeGroupsForTrack(track, {
-    serializeLink
-}) {
-    return track.album.groups.map(group => ({
-        link: serializeLink(group),
-        urls: group.urls,
-    }));
-}
diff --git a/src/util/strings.js b/src/util/strings.js
deleted file mode 100644
index e749b945..00000000
--- a/src/util/strings.js
+++ /dev/null
@@ -1,287 +0,0 @@
-import { logError, logWarn } from './cli.js';
-import { bindOpts } from './sugar.js';
-
-// Localiz8tion time! Or l10n as the neeeeeeeerds call it. Which is a terri8le
-// name and not one I intend on using, thank you very much. (Don't even get me
-// started on """"a11y"""".)
-//
-// All the default strings are in strings-default.json, if you're curious what
-// those actually look like. Pretty much it's "I like {ANIMAL}" for example.
-// For each language, the o8ject gets turned into a single function of form
-// f(key, {args}). It searches for a key in the o8ject and uses the string it
-// finds (or the one in strings-default.json) as a templ8 evaluated with the
-// arguments passed. (This function gets treated as an o8ject too; it gets
-// the language code attached.)
-//
-// The function's also responsi8le for getting rid of dangerous characters
-// (quotes and angle tags), though only within the templ8te (not the args),
-// and it converts the keys of the arguments o8ject from camelCase to
-// CONSTANT_CASE too.
-//
-// This function also takes an optional "bindUtilities" argument; it should
-// look like a dictionary each value of which is itself a util dictionary,
-// each value of which is a function in the format (value, opts) => (...).
-// Each of those util dictionaries will 8e attached to the final returned
-// strings() function, containing functions which automatically have that
-// same strings() function provided as part of its opts argument (alongside
-// any additional arguments passed).
-//
-// Basically, it's so that instead of doing:
-//
-//     count.tracks(album.tracks.length, {strings})
-//
-// ...you can just do:
-//
-//     strings.count.tracks(album.tracks.length)
-//
-// Definitely note bindUtilities expects an OBJECT, not an array, otherwise
-// it won't 8e a8le to know what keys to attach the utilities 8y!
-//
-// Oh also it'll need access to the he.encode() function, and callers have to
-// provide that themselves, 'cuz otherwise we can't reference this file from
-// client-side code.
-export function genStrings(stringsJSON, {
-    he,
-    defaultJSON = null,
-    bindUtilities = []
-}) {
-    // genStrings will only 8e called once for each language, and it happens
-    // right at the start of the program (or at least 8efore 8uilding pages).
-    // So, now's a good time to valid8te the strings and let any warnings be
-    // known.
-
-    // May8e contrary to the argument name, the arguments should 8e o8jects,
-    // not actual JSON-formatted strings!
-    if (typeof stringsJSON !== 'object' || stringsJSON.constructor !== Object) {
-        return {error: `Expected an object (parsed JSON) for stringsJSON.`};
-    }
-    if (typeof defaultJSON !== 'object') { // typeof null === object. I h8 JS.
-        return {error: `Expected an object (parsed JSON) or null for defaultJSON.`};
-    }
-
-    // All languages require a language code.
-    const code = stringsJSON['meta.languageCode'];
-    if (!code) {
-        return {error: `Missing language code.`};
-    }
-    if (typeof code !== 'string') {
-        return {error: `Expected language code to be a string.`};
-    }
-
-    // Every value on the provided o8ject should be a string.
-    // (This is lazy, but we only 8other checking this on stringsJSON, on the
-    // assumption that defaultJSON was passed through this function too, and so
-    // has already been valid8ted.)
-    {
-        let err = false;
-        for (const [ key, value ] of Object.entries(stringsJSON)) {
-            if (typeof value !== 'string') {
-                logError`(${code}) The value for ${key} should be a string.`;
-                err = true;
-            }
-        }
-        if (err) {
-            return {error: `Expected all values to be a string.`};
-        }
-    }
-
-    // Checking is generally done against the default JSON, so we'll skip out
-    // if that isn't provided (which should only 8e the case when it itself is
-    // 8eing processed as the first loaded language).
-    if (defaultJSON) {
-        // Warn for keys that are missing or unexpected.
-        const expectedKeys = Object.keys(defaultJSON);
-        const presentKeys = Object.keys(stringsJSON);
-        for (const key of presentKeys) {
-            if (!expectedKeys.includes(key)) {
-                logWarn`(${code}) Unexpected translation key: ${key} - this won't be used!`;
-            }
-        }
-        for (const key of expectedKeys) {
-            if (!presentKeys.includes(key)) {
-                logWarn`(${code}) Missing translation key: ${key} - this won't be localized!`;
-            }
-        }
-    }
-
-    // Valid8tion is complete, 8ut We can still do a little caching to make
-    // repeated actions faster.
-
-    // We're gonna 8e mut8ting the strings dictionary o8ject from here on out.
-    // We make a copy so we don't mess with the one which was given to us.
-    stringsJSON = Object.assign({}, stringsJSON);
-
-    // Preemptively pass everything through HTML encoding. This will prevent
-    // strings from embedding HTML tags or accidentally including characters
-    // that throw HTML parsers off.
-    for (const key of Object.keys(stringsJSON)) {
-        stringsJSON[key] = he.encode(stringsJSON[key], {useNamedReferences: true});
-    }
-
-    // It's time to cre8te the actual langauge function!
-
-    // In the function, we don't actually distinguish 8etween the primary and
-    // default (fall8ack) strings - any relevant warnings have already 8een
-    // presented a8ove, at the time the language JSON is processed. Now we'll
-    // only 8e using them for indexing strings to use as templ8tes, and we can
-    // com8ine them for that.
-    const stringIndex = Object.assign({}, defaultJSON, stringsJSON);
-
-    // We do still need the list of valid keys though. That's 8ased upon the
-    // default strings. (Or stringsJSON, 8ut only if the defaults aren't
-    // provided - which indic8tes that the single o8ject provided *is* the
-    // default.)
-    const validKeys = Object.keys(defaultJSON || stringsJSON);
-
-    const invalidKeysFound = [];
-
-    const strings = (key, args = {}) => {
-        // Ok, with the warning out of the way, it's time to get to work.
-        // First make sure we're even accessing a valid key. (If not, return
-        // an error string as su8stitute.)
-        if (!validKeys.includes(key)) {
-            // We only want to warn a8out a given key once. More than that is
-            // just redundant!
-            if (!invalidKeysFound.includes(key)) {
-                invalidKeysFound.push(key);
-                logError`(${code}) Accessing invalid key ${key}. Fix a typo or provide this in strings-default.json!`;
-            }
-            return `MISSING: ${key}`;
-        }
-
-        const template = stringIndex[key];
-
-        // Convert the keys on the args dict from camelCase to CONSTANT_CASE.
-        // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut
-        // like, who cares, dude?) Also, this is an array, 8ecause it's handy
-        // for the iterating we're a8out to do.
-        const processedArgs = Object.entries(args)
-            .map(([ k, v ]) => [k.replace(/[A-Z]/g, '_$&').toUpperCase(), v]);
-
-        // Replacement time! Woot. Reduce comes in handy here!
-        const output = processedArgs.reduce(
-            (x, [ k, v ]) => x.replaceAll(`{${k}}`, v),
-            template);
-
-        // Post-processing: if any expected arguments *weren't* replaced, that
-        // is almost definitely an error.
-        if (output.match(/\{[A-Z_]+\}/)) {
-            logError`(${code}) Args in ${key} were missing - output: ${output}`;
-        }
-
-        return output;
-    };
-
-    // And lastly, we add some utility stuff to the strings function.
-
-    // Store the language code, for convenience of access.
-    strings.code = code;
-
-    // Store the strings dictionary itself, also for convenience.
-    strings.json = stringsJSON;
-
-    // Store Intl o8jects that can 8e reused for value formatting.
-    strings.intl = {
-        date: new Intl.DateTimeFormat(code, {full: true}),
-        number: new Intl.NumberFormat(code),
-        list: {
-            conjunction: new Intl.ListFormat(code, {type: 'conjunction'}),
-            disjunction: new Intl.ListFormat(code, {type: 'disjunction'}),
-            unit: new Intl.ListFormat(code, {type: 'unit'})
-        },
-        plural: {
-            cardinal: new Intl.PluralRules(code, {type: 'cardinal'}),
-            ordinal: new Intl.PluralRules(code, {type: 'ordinal'})
-        }
-    };
-
-    // And the provided utility dictionaries themselves, of course!
-    for (const [key, utilDict] of Object.entries(bindUtilities)) {
-        const boundUtilDict = {};
-        for (const [key, fn] of Object.entries(utilDict)) {
-            boundUtilDict[key] = bindOpts(fn, {strings});
-        }
-        strings[key] = boundUtilDict;
-    }
-
-    return strings;
-}
-
-const countHelper = (stringKey, argName = stringKey) => (value, {strings, unit = false}) => strings(
-    (unit
-        ? `count.${stringKey}.withUnit.` + strings.intl.plural.cardinal.select(value)
-        : `count.${stringKey}`),
-    {[argName]: strings.intl.number.format(value)});
-
-export const count = {
-    date: (date, {strings}) => {
-        return strings.intl.date.format(date);
-    },
-
-    dateRange: ([startDate, endDate], {strings}) => {
-        return strings.intl.date.formatRange(startDate, endDate);
-    },
-
-    duration: (secTotal, {strings, approximate = false, unit = false}) => {
-        if (secTotal === 0) {
-            return strings('count.duration.missing');
-        }
-
-        const hour = Math.floor(secTotal / 3600);
-        const min = Math.floor((secTotal - hour * 3600) / 60);
-        const sec = Math.floor(secTotal - hour * 3600 - min * 60);
-
-        const pad = val => val.toString().padStart(2, '0');
-
-        const stringSubkey = unit ? '.withUnit' : '';
-
-        const duration = (hour > 0
-            ? strings('count.duration.hours' + stringSubkey, {
-                hours: hour,
-                minutes: pad(min),
-                seconds: pad(sec)
-            })
-            : strings('count.duration.minutes' + stringSubkey, {
-                minutes: min,
-                seconds: pad(sec)
-            }));
-
-        return (approximate
-            ? strings('count.duration.approximate', {duration})
-            : duration);
-    },
-
-    index: (value, {strings}) => {
-        return strings('count.index.' + strings.intl.plural.ordinal.select(value), {index: value});
-    },
-
-    number: value => strings.intl.number.format(value),
-
-    words: (value, {strings, unit = false}) => {
-        const num = strings.intl.number.format(value > 1000
-            ? Math.floor(value / 100) / 10
-            : value);
-
-        const words = (value > 1000
-            ? strings('count.words.thousand', {words: num})
-            : strings('count.words', {words: num}));
-
-        return strings('count.words.withUnit.' + strings.intl.plural.cardinal.select(value), {words});
-    },
-
-    albums: countHelper('albums'),
-    commentaryEntries: countHelper('commentaryEntries', 'entries'),
-    contributions: countHelper('contributions'),
-    coverArts: countHelper('coverArts'),
-    timesReferenced: countHelper('timesReferenced'),
-    timesUsed: countHelper('timesUsed'),
-    tracks: countHelper('tracks')
-};
-
-const listHelper = type => (list, {strings}) => strings.intl.list[type].format(list);
-
-export const list = {
-    unit: listHelper('unit'),
-    or: listHelper('disjunction'),
-    and: listHelper('conjunction')
-};
diff --git a/src/util/sugar.js b/src/util/sugar.js
deleted file mode 100644
index 38c8047f..00000000
--- a/src/util/sugar.js
+++ /dev/null
@@ -1,272 +0,0 @@
-// Syntactic sugar! (Mostly.)
-// Generic functions - these are useful just a8out everywhere.
-//
-// Friendly(!) disclaimer: these utility functions haven't 8een tested all that
-// much. Do not assume it will do exactly what you want it to do in all cases.
-// It will likely only do exactly what I want it to, and only in the cases I
-// decided were relevant enough to 8other handling.
-
-// 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
-// actually use this. 8ut it's still awesome, 8ecause I say so.
-export function* splitArray(array, fn) {
-    let lastIndex = 0;
-    while (lastIndex < array.length) {
-        let nextIndex = array.findIndex((item, index) => index >= lastIndex && fn(item));
-        if (nextIndex === -1) {
-            nextIndex = array.length;
-        }
-        yield array.slice(lastIndex, nextIndex);
-        // Plus one because we don't want to include the dividing line in the
-        // next array we yield.
-        lastIndex = nextIndex + 1;
-    }
-};
-
-export const mapInPlace = (array, fn) => array.splice(0, array.length, ...array.map(fn));
-
-export const filterEmptyLines = string => string.split('\n').filter(line => line.trim()).join('\n');
-
-export const unique = arr => Array.from(new Set(arr));
-
-// Stolen from jq! Which pro8a8ly stole the concept from other places. Nice.
-export const withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj)));
-
-// Nothin' more to it than what it says. Runs a function in-place. Provides an
-// altern8tive syntax to the usual IIFEs (e.g. (() => {})()) when you want to
-// open a scope and run some statements while inside an existing expression.
-export const call = fn => fn();
-
-export function queue(array, max = 50) {
-    if (max === 0) {
-        return array.map(fn => fn());
-    }
-
-    const begin = [];
-    let current = 0;
-    const ret = array.map(fn => new Promise((resolve, reject) => {
-        begin.push(() => {
-            current++;
-            Promise.resolve(fn()).then(value => {
-                current--;
-                if (current < max && begin.length) {
-                    begin.shift()();
-                }
-                resolve(value);
-            }, reject);
-        });
-    }));
-
-    for (let i = 0; i < max && begin.length; i++) {
-        begin.shift()();
-    }
-
-    return ret;
-}
-
-export function delay(ms) {
-    return new Promise(res => setTimeout(res, ms));
-}
-
-// Stolen from here: https://stackoverflow.com/a/3561711
-//
-// There's a proposal for a native JS function like this, 8ut it's not even
-// past stage 1 yet: https://github.com/tc39/proposal-regex-escaping
-export function escapeRegex(string) {
-    return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
-}
-
-export function bindOpts(fn, bind) {
-    const bindIndex = bind[bindOpts.bindIndex] ?? 1;
-
-    return (...args) => {
-        const opts = args[bindIndex] ?? {};
-        return fn(...args.slice(0, bindIndex), {...bind, ...opts});
-    };
-}
-
-bindOpts.bindIndex = Symbol();
-
-// Utility function for providing useful interfaces to the JS AggregateError
-// class.
-//
-// Generally, this works by returning a set of interfaces which operate on
-// functions: wrap() takes a function and returns a new function which passes
-// its arguments through and appends any resulting error to the internal error
-// list; call() simplifies this process by wrapping the provided function and
-// then calling it immediately. Once the process for which errors should be
-// aggregated is complete, close() constructs and throws an AggregateError
-// object containing all caught errors (or doesn't throw anything if there were
-// no errors).
-export function openAggregate({
-    // Constructor to use, defaulting to the builtin AggregateError class.
-    // Anything passed here should probably extend from that! May be used for
-    // letting callers programatically distinguish between multiple aggregate
-    // errors.
-    //
-    // This should be provided using the aggregateThrows utility function.
-    [openAggregate.errorClassSymbol]: errorClass = AggregateError,
-
-    // Optional human-readable message to describe the aggregate error, if
-    // constructed.
-    message = '',
-
-    // Value to return when a provided function throws an error. If this is a
-    // function, it will be called with the arguments given to the function.
-    // (This is primarily useful when wrapping a function and then providing it
-    // to another utility, e.g. array.map().)
-    returnOnFail = null
-} = {}) {
-    const errors = [];
-
-    const aggregate = {};
-
-    aggregate.wrap = fn => (...args) => {
-        try {
-            return fn(...args);
-        } catch (error) {
-            errors.push(error);
-            return (typeof returnOnFail === 'function'
-                ? returnOnFail(...args)
-                : returnOnFail);
-        }
-    };
-
-    aggregate.call = (fn, ...args) => {
-        return aggregate.wrap(fn)(...args);
-    };
-
-    aggregate.nest = (...args) => {
-        return aggregate.call(() => withAggregate(...args));
-    };
-
-    aggregate.map = (...args) => {
-        const parent = aggregate;
-        const { result, aggregate: child } = mapAggregate(...args);
-        parent.call(child.close);
-        return result;
-    };
-
-    aggregate.filter = (...args) => {
-        const parent = aggregate;
-        const { result, aggregate: child } = filterAggregate(...args);
-        parent.call(child.close);
-        return result;
-    };
-
-    aggregate.throws = aggregateThrows;
-
-    aggregate.close = () => {
-        if (errors.length) {
-            throw Reflect.construct(errorClass, [errors, message]);
-        }
-    };
-
-    return aggregate;
-}
-
-openAggregate.errorClassSymbol = Symbol('error class');
-
-// Utility function for providing {errorClass} parameter to aggregate functions.
-export function aggregateThrows(errorClass) {
-    return {[openAggregate.errorClassSymbol]: errorClass};
-}
-
-// Performs an ordinary array map with the given function, collating into a
-// results array (with errored inputs filtered out) and an error aggregate.
-//
-// Optionally, override returnOnFail to disable filtering and map errored inputs
-// to a particular output.
-//
-// Note the aggregate property is the result of openAggregate(), still unclosed;
-// use aggregate.close() to throw the error. (This aggregate may be passed to a
-// parent aggregate: `parent.call(aggregate.close)`!)
-export function mapAggregate(array, fn, aggregateOpts) {
-    const failureSymbol = Symbol();
-
-    const aggregate = openAggregate({
-        returnOnFail: failureSymbol,
-        ...aggregateOpts
-    });
-
-    const result = array.map(aggregate.wrap(fn))
-        .filter(value => value !== failureSymbol);
-
-    return {result, aggregate};
-}
-
-// Performs an ordinary array filter with the given function, collating into a
-// results array (with errored inputs filtered out) and an error aggregate.
-//
-// Optionally, override returnOnFail to disable filtering errors and map errored
-// inputs to a particular output.
-//
-// As with mapAggregate, the returned aggregate property is not yet closed.
-export function filterAggregate(array, fn, aggregateOpts) {
-    const failureSymbol = Symbol();
-
-    const aggregate = openAggregate({
-        returnOnFail: failureSymbol,
-        ...aggregateOpts
-    });
-
-    const result = array.map(aggregate.wrap((x, ...rest) => ({
-        input: x,
-        output: fn(x, ...rest)
-    })))
-        .filter(value => {
-            // Filter out results which match the failureSymbol, i.e. errored
-            // inputs.
-            if (value === failureSymbol) return false;
-
-            // Always keep results which match the overridden returnOnFail
-            // value, if provided.
-            if (value === aggregateOpts.returnOnFail) return true;
-
-            // Otherwise, filter according to the returned value of the wrapped
-            // function.
-            return value.output;
-        })
-        .map(value => {
-            // Then turn the results back into their corresponding input, or, if
-            // provided, the overridden returnOnFail value.
-            return (value === aggregateOpts.returnOnFail
-                ? value
-                : value.input);
-        });
-
-    return {result, aggregate};
-}
-
-// Totally sugar function for opening an aggregate, running the provided
-// function with it, then closing the function and returning the result (if
-// there's no throw).
-export function withAggregate(aggregateOpts, fn) {
-    if (typeof aggregateOpts === 'function') {
-        fn = aggregateOpts;
-        aggregateOpts = {};
-    }
-
-    const aggregate = openAggregate(aggregateOpts);
-    const result = fn(aggregate);
-    aggregate.close();
-    return result;
-}
-
-export function showAggregate(topError) {
-    const recursive = error => {
-        const header = `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'}`;
-        if (error instanceof AggregateError) {
-            return header + '\n' + (error.errors
-                .map(recursive)
-                .flatMap(str => str.split('\n'))
-                .map(line => ` | ` + line)
-                .join('\n'));
-        } else {
-            return header;
-        }
-    };
-
-    console.log(recursive(topError));
-}
diff --git a/src/util/urls.js b/src/util/urls.js
deleted file mode 100644
index f0f9cdb1..00000000
--- a/src/util/urls.js
+++ /dev/null
@@ -1,102 +0,0 @@
-// Code that deals with URLs (really the pathnames that get referenced all
-// throughout the gener8ted HTML). Most nota8ly here is generateURLs, which
-// is in charge of pre-gener8ting a complete network of template strings
-// which can really quickly take su8stitute parameters to link from any one
-// place to another; 8ut there are also a few other utilities, too.
-//
-// Nota8ly, everything here is string-8ased, for gener8ting and transforming
-// actual path strings. More a8stract operations using wiki data o8jects is
-// the domain of link.js.
-
-import * as path from 'path';
-import { withEntries } from './sugar.js';
-
-export function generateURLs(urlSpec) {
-    const getValueForFullKey = (obj, fullKey, prop = null) => {
-        const [ groupKey, subKey ] = fullKey.split('.');
-        if (!groupKey || !subKey) {
-            throw new Error(`Expected group key and subkey (got ${fullKey})`);
-        }
-
-        if (!obj.hasOwnProperty(groupKey)) {
-            throw new Error(`Expected valid group key (got ${groupKey})`);
-        }
-
-        const group = obj[groupKey];
-
-        if (!group.hasOwnProperty(subKey)) {
-            throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`);
-        }
-
-        return {
-            value: group[subKey],
-            group
-        };
-    };
-
-    const generateTo = (fromPath, fromGroup) => {
-        const rebasePrefix = '../'.repeat((fromGroup.prefix || '').split('/').filter(Boolean).length);
-
-        const pathHelper = (toPath, toGroup) => {
-            let target = toPath;
-
-            let argIndex = 0;
-            target = target.replaceAll('<>', () => `<${argIndex++}>`);
-
-            if (toGroup.prefix !== fromGroup.prefix) {
-                // TODO: Handle differing domains in prefixes.
-                target = rebasePrefix + (toGroup.prefix || '') + target;
-            }
-
-            return (path.relative(fromPath, target)
-                + (toPath.endsWith('/') ? '/' : ''));
-        };
-
-        const groupSymbol = Symbol();
-
-        const groupHelper = urlGroup => ({
-            [groupSymbol]: 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 to = (key, ...args) => {
-            const { value: template, group: {[groupSymbol]: toGroup} } = getValueForFullKey(relative, key)
-            let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => args[n]);
-
-            // Kinda hacky lol, 8ut it works.
-            const missing = result.match(/<([0-9]+)>/g);
-            if (missing) {
-                throw new Error(`Expected ${missing[missing.length - 1]} arguments, got ${args.length}`);
-            }
-
-            return result;
-        };
-
-        return {to, relative};
-    };
-
-    const generateFrom = () => {
-        const map = withEntries(urlSpec, entries => entries
-            .map(([key, group]) => [key, withEntries(group.paths, entries => entries
-                .map(([key, path]) => [key, generateTo(path, group)])
-            )]));
-
-        const from = key => getValueForFullKey(map, key).value;
-
-        return {from, map};
-    };
-
-    return generateFrom();
-}
-
-const thumbnailHelper = name => file =>
-    file.replace(/\.(jpg|png)$/, name + '.jpg');
-
-export const thumb = {
-    medium: thumbnailHelper('.medium'),
-    small: thumbnailHelper('.small')
-};
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
deleted file mode 100644
index 2f705f91..00000000
--- a/src/util/wiki-data.js
+++ /dev/null
@@ -1,283 +0,0 @@
-// Utility functions for interacting with wiki data.
-
-import {
-    UNRELEASED_TRACKS_DIRECTORY
-} from '../util/magic-constants.js';
-
-// Generic value operations
-
-export function getKebabCase(name) {
-    return name
-        .split(' ')
-        .join('-')
-        .replace(/&/g, 'and')
-        .replace(/[^a-zA-Z0-9\-]/g, '')
-        .replace(/-{2,}/g, '-')
-        .replace(/^-+|-+$/g, '')
-        .toLowerCase();
-}
-
-export function chunkByConditions(array, conditions) {
-    if (array.length === 0) {
-        return [];
-    } else if (conditions.length === 0) {
-        return [array];
-    }
-
-    const out = [];
-    let cur = [array[0]];
-    for (let i = 1; i < array.length; i++) {
-        const item = array[i];
-        const prev = array[i - 1];
-        let chunk = false;
-        for (const condition of conditions) {
-            if (condition(item, prev)) {
-                chunk = true;
-                break;
-            }
-        }
-        if (chunk) {
-            out.push(cur);
-            cur = [item];
-        } else {
-            cur.push(item);
-        }
-    }
-    out.push(cur);
-    return out;
-}
-
-export function chunkByProperties(array, properties) {
-    return chunkByConditions(array, properties.map(p => (a, b) => {
-        if (a[p] instanceof Date && b[p] instanceof Date)
-            return +a[p] !== +b[p];
-
-        if (a[p] !== b[p]) return true;
-
-        // Not sure if this line is still necessary with the specific check for
-        // d8tes a8ove, 8ut, uh, keeping it anyway, just in case....?
-        if (a[p] != b[p]) return true;
-
-        return false;
-    }))
-        .map(chunk => ({
-            ...Object.fromEntries(properties.map(p => [p, chunk[0][p]])),
-            chunk
-        }));
-}
-
-// Sorting functions
-
-export function sortByName(a, b) {
-    let an = a.name.toLowerCase();
-    let bn = b.name.toLowerCase();
-    if (an.startsWith('the ')) an = an.slice(4);
-    if (bn.startsWith('the ')) bn = bn.slice(4);
-    return an < bn ? -1 : an > bn ? 1 : 0;
-}
-
-// This function was originally made to sort just al8um data, 8ut its exact
-// code works fine for sorting tracks too, so I made the varia8les and names
-// more general.
-export function sortByDate(data, dateKey = 'date') {
-    // Just to 8e clear: sort is a mutating function! I only return the array
-    // 8ecause then you don't have to define it as a separate varia8le 8efore
-    // passing it into this function.
-    return data.sort((a, b) => a[dateKey] - b[dateKey]);
-}
-
-// Same details as the sortByDate, 8ut for covers~
-export function sortByArtDate(data) {
-    return data.sort((a, b) => (a.coverArtDate || a.date) - (b.coverArtDate || b.date));
-}
-
-// Specific data utilities
-
-export function filterAlbumsByCommentary(albums) {
-    return albums.filter(album => [album, ...album.tracks].some(x => x.commentary));
-}
-
-export function getAlbumCover(album, {to}) {
-    return to('media.albumCover', album.directory);
-}
-
-export function getAlbumListTag(album) {
-    // TODO: This is hard-coded! No. 8ad.
-    return (album.directory === UNRELEASED_TRACKS_DIRECTORY ? 'ul' : 'ol');
-}
-
-// This gets all the track o8jects defined in every al8um, and sorts them 8y
-// date released. Generally, albumData will pro8a8ly already 8e sorted 8efore
-// you pass it to this function, 8ut individual tracks can have their own
-// original release d8, distinct from the al8um's d8. I allowed that 8ecause
-// in Homestuck, the first four Vol.'s were com8ined into one al8um really
-// early in the history of the 8andcamp, and I still want to use that as the
-// al8um listing (not the original four al8um listings), 8ut if I only did
-// that, all the tracks would 8e sorted as though they were released at the
-// same time as the compilation al8um - i.e, after some other al8ums (including
-// Vol.'s 5 and 6!) were released. That would mess with chronological listings
-// including tracks from multiple al8ums, like artist pages. So, to fix that,
-// I gave tracks an Original Date field, defaulting to the release date of the
-// al8um if not specified. Pretty reasona8le, I think! Oh, and this feature can
-// 8e used for other projects too, like if you wanted to have an al8um listing
-// compiling a 8unch of songs with radically different & interspersed release
-// d8s, 8ut still keep the al8um listing in a specific order, since that isn't
-// sorted 8y date.
-export function getAllTracks(albumData) {
-    return sortByDate(albumData.flatMap(album => album.tracks));
-}
-
-export function getArtistNumContributions(artist) {
-    return (
-        artist.tracks.asAny.length +
-        artist.albums.asCoverArtist.length +
-        (artist.flashes ? artist.flashes.asContributor.length : 0)
-    );
-}
-
-export function getArtistCommentary(artist, {justEverythingMan}) {
-    return justEverythingMan.filter(thing =>
-        (thing?.commentary
-            .replace(/<\/?b>/g, '')
-            .includes('<i>' + artist.name + ':</i>')));
-}
-
-export function getFlashCover(flash, {to}) {
-    return (flash.jiff
-        ? to('media.flashArtGif', flash.directory)
-        : to('media.flashArt', flash.directory));
-}
-
-export function getFlashLink(flash) {
-    return `https://homestuck.com/story/${flash.page}`;
-}
-
-export function getTotalDuration(tracks) {
-    return tracks.reduce((duration, track) => duration + track.duration, 0);
-}
-
-export function getTrackCover(track, {to}) {
-    // Some al8ums don't have any track art at all, and in those, every track
-    // just inherits the al8um's own cover art.
-    if (track.coverArtists === null) {
-        return getAlbumCover(track.album, {to});
-    } else {
-        return to('media.trackCover', track.album.directory, track.directory);
-    }
-}
-
-// Big-ass homepage row functions
-
-export function getNewAdditions(numAlbums, {wikiData}) {
-    const { albumData } = wikiData;
-
-    // Sort al8ums, in descending order of priority, 8y...
-    //
-    // * D8te of addition to the wiki (descending).
-    // * Major releases first.
-    // * D8te of release (descending).
-    //
-    // Major releases go first to 8etter ensure they show up in the list (and
-    // are usually at the start of the final output for a given d8 of release
-    // too).
-    const sortedAlbums = albumData.filter(album => album.isListedOnHomepage).sort((a, b) => {
-        if (a.dateAdded > b.dateAdded) return -1;
-        if (a.dateAdded < b.dateAdded) return 1;
-        if (a.isMajorRelease && !b.isMajorRelease) return -1;
-        if (!a.isMajorRelease && b.isMajorRelease) return 1;
-        if (a.date > b.date) return -1;
-        if (a.date < b.date) return 1;
-    });
-
-    // When multiple al8ums are added to the wiki at a time, we want to show
-    // all of them 8efore pulling al8ums from the next (earlier) date. We also
-    // want to show a diverse selection of al8ums - with limited space, we'd
-    // rather not show only the latest al8ums, if those happen to all 8e
-    // closely rel8ted!
-    //
-    // Specifically, we're concerned with avoiding too much overlap amongst
-    // the primary (first/top-most) group. We do this 8y collecting every
-    // primary group present amongst the al8ums for a given d8 into one
-    // (ordered) array, initially sorted (inherently) 8y latest al8um from
-    // the group. Then we cycle over the array, adding one al8um from each
-    // group until all the al8ums from that release d8 have 8een added (or
-    // we've met the total target num8er of al8ums). Once we've added all the
-    // al8ums for a given group, it's struck from the array (so the groups
-    // with the most additions on one d8 will have their oldest releases
-    // collected more towards the end of the list).
-
-    const albums = [];
-
-    let i = 0;
-    outerLoop: while (i < sortedAlbums.length) {
-        // 8uild up a list of groups and their al8ums 8y order of decending
-        // release, iter8ting until we're on a different d8. (We use a map for
-        // indexing so we don't have to iter8te through the entire array each
-        // time we access one of its entries. This is 8asically unnecessary
-        // since this will never 8e an expensive enough task for that to
-        // matter.... 8ut it's nicer code. BBBB) )
-        const currentDate = sortedAlbums[i].dateAdded;
-        const groupMap = new Map();
-        const groupArray = [];
-        for (let album; (album = sortedAlbums[i]) && +album.dateAdded === +currentDate; i++) {
-            const primaryGroup = album.groups[0];
-            if (groupMap.has(primaryGroup)) {
-                groupMap.get(primaryGroup).push(album);
-            } else {
-                const entry = [album]
-                groupMap.set(primaryGroup, entry);
-                groupArray.push(entry);
-            }
-        }
-
-        // Then cycle over that sorted array, adding one al8um from each to
-        // the main array until we've run out or have met the target num8er
-        // of al8ums.
-        while (groupArray.length) {
-            let j = 0;
-            while (j < groupArray.length) {
-                const entry = groupArray[j];
-                const album = entry.shift();
-                albums.push(album);
-
-
-                // This is the only time we ever add anything to the main al8um
-                // list, so it's also the only place we need to check if we've
-                // met the target length.
-                if (albums.length === numAlbums) {
-                    // If we've met it, 8r8k out of the outer loop - we're done
-                    // here!
-                    break outerLoop;
-                }
-
-                if (entry.length) {
-                    j++;
-                } else {
-                    groupArray.splice(j, 1);
-                }
-            }
-        }
-    }
-
-    // Finally, do some quick mapping shenanigans to 8etter display the result
-    // in a grid. (This should pro8a8ly 8e a separ8te, shared function, 8ut
-    // whatevs.)
-    return albums.map(album => ({large: album.isMajorRelease, item: album}));
-}
-
-export function getNewReleases(numReleases, {wikiData}) {
-    const { albumData } = wikiData;
-
-    const latestFirst = albumData.filter(album => album.isListedOnHomepage).reverse();
-    const majorReleases = latestFirst.filter(album => album.isMajorRelease);
-    majorReleases.splice(1);
-
-    const otherReleases = latestFirst
-        .filter(album => !majorReleases.includes(album))
-        .slice(0, numReleases - majorReleases.length);
-
-    return [
-        ...majorReleases.map(album => ({large: true, item: album})),
-        ...otherReleases.map(album => ({large: false, item: album}))
-    ];
-}
diff --git a/src/validators.js b/src/validators.js
new file mode 100644
index 00000000..6badc93a
--- /dev/null
+++ b/src/validators.js
@@ -0,0 +1,1147 @@
+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,
+  oldStyleLyricsDetectionRegex,
+} from '#wiki-data';
+
+function inspect(value) {
+  return nodeInspect(value, {colors: ENABLE_COLOR});
+}
+
+export function getValidatorCreator(validator) {
+  return validator[Symbol.for(`hsmusic.validator.creator`)] ?? null;
+}
+
+export function getValidatorCreatorMeta(validator) {
+  return validator[Symbol.for(`hsmusic.validator.creatorMeta`)] ?? null;
+}
+
+export function setValidatorCreatorMeta(validator, creator, meta) {
+  validator[Symbol.for(`hsmusic.validator.creator`)] = creator;
+  validator[Symbol.for(`hsmusic.validator.creatorMeta`)] = meta;
+  return validator;
+}
+
+// Basic types (primitives)
+
+export function a(noun) {
+  return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`;
+}
+
+export function validateType(type) {
+  const fn = value => {
+    if (typeof value !== type)
+      throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`);
+
+    return true;
+  };
+
+  setValidatorCreatorMeta(fn, validateType, {type});
+
+  return fn;
+}
+
+export const isBoolean =
+  validateType('boolean');
+
+export const isFunction =
+  validateType('function');
+
+export const isNumber =
+  validateType('number');
+
+export const isString =
+  validateType('string');
+
+export const isSymbol =
+  validateType('symbol');
+
+// Use isObject instead, which disallows null.
+export const isTypeofObject =
+  validateType('object');
+
+export function isPositive(number) {
+  isNumber(number);
+
+  if (number <= 0) throw new TypeError(`Expected positive number`);
+
+  return true;
+}
+
+export function isNegative(number) {
+  isNumber(number);
+
+  if (number >= 0) throw new TypeError(`Expected negative number`);
+
+  return true;
+}
+
+export function isPositiveOrZero(number) {
+  isNumber(number);
+
+  if (number < 0) throw new TypeError(`Expected positive number or zero`);
+
+  return true;
+}
+
+export function isNegativeOrZero(number) {
+  isNumber(number);
+
+  if (number > 0) throw new TypeError(`Expected negative number or zero`);
+
+  return true;
+}
+
+export function isInteger(number) {
+  isNumber(number);
+
+  if (number % 1 !== 0) throw new TypeError(`Expected integer`);
+
+  return true;
+}
+
+export function isCountingNumber(number) {
+  isInteger(number);
+  isPositive(number);
+
+  return true;
+}
+
+export function isWholeNumber(number) {
+  isInteger(number);
+  isPositiveOrZero(number);
+
+  return true;
+}
+
+export function isStringNonEmpty(value) {
+  isString(value);
+
+  if (value.trim().length === 0)
+    throw new TypeError(`Expected non-empty string`);
+
+  return true;
+}
+
+export function optional(validator) {
+  return value =>
+    value === null ||
+    value === undefined ||
+    validator(value);
+}
+
+// Complex types (non-primitives)
+
+export function isInstance(value, constructor) {
+  isObject(value);
+
+  if (!(value instanceof constructor))
+    throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`);
+
+  return true;
+}
+
+export function isDate(value) {
+  isInstance(value, Date);
+
+  if (isNaN(value))
+    throw new TypeError(`Expected valid date`);
+
+  return true;
+}
+
+export function isObject(value) {
+  isTypeofObject(value);
+
+  // Note: Please remember that null is always a valid value for properties
+  // held by a CacheableObject. This assertion is exclusively for use in other
+  // contexts.
+  if (value === null)
+    throw new TypeError(`Expected an object, got null`);
+
+  return true;
+}
+
+export function isArray(value) {
+  if (typeof value !== 'object' || value === null || !Array.isArray(value))
+    throw new TypeError(`Expected an array, got ${typeAppearance(value)}`);
+
+  return true;
+}
+
+// This one's shaped a bit different from other "is" functions.
+// More like validate functions, it returns a function.
+export function is(...values) {
+  if (Array.isArray(values)) {
+    values = new Set(values);
+  }
+
+  if (values.size === 1) {
+    const expected = Array.from(values)[0];
+
+    return (value) => {
+      if (value !== expected) {
+        throw new TypeError(`Expected ${expected}, got ${value}`);
+      }
+
+      return true;
+    };
+  }
+
+  const fn = (value) => {
+    if (!values.has(value)) {
+      throw new TypeError(`Expected one of ${Array.from(values).join(' ')}, got ${value}`);
+    }
+
+    return true;
+  };
+
+  setValidatorCreatorMeta(fn, is, {values});
+
+  return fn;
+}
+
+function validateArrayItemsHelper(itemValidator) {
+  return (item, index, array) => {
+    try {
+      const value = itemValidator(item, index, array);
+
+      if (value !== true) {
+        throw new Error(`Expected validator to return true`);
+      }
+    } catch (caughtError) {
+      const indexPart = colors.yellow(`zero-index ${index}`)
+      const itemPart = inspect(item);
+      const message = `Error at ${indexPart}: ${itemPart}`;
+      const error = new Error(message, {cause: caughtError});
+      error[Symbol.for('hsmusic.annotateError.indexInSourceArray')] = index;
+      throw error;
+    }
+  };
+}
+
+export function validateArrayItems(itemValidator) {
+  const helper = validateArrayItemsHelper(itemValidator);
+
+  return (array) => {
+    isArray(array);
+
+    withAggregate({message: 'Errors validating array items'}, ({call}) => {
+      for (let index = 0; index < array.length; index++) {
+        call(helper, array[index], index, array);
+      }
+    });
+
+    return true;
+  };
+}
+
+export function strictArrayOf(itemValidator) {
+  return validateArrayItems(itemValidator);
+}
+
+export function sparseArrayOf(itemValidator) {
+  return validateArrayItems((item, index, array) => {
+    if (item === false || item === null) {
+      return true;
+    }
+
+    return itemValidator(item, index, array);
+  });
+}
+
+export function looseArrayOf(itemValidator) {
+  return validateArrayItems((item, index, array) => {
+    if (item === false || item === null || item === undefined) {
+      return true;
+    }
+
+    return itemValidator(item, index, array);
+  });
+}
+
+export function validateInstanceOf(constructor) {
+  const fn = (object) => isInstance(object, constructor);
+
+  setValidatorCreatorMeta(fn, validateInstanceOf, {constructor});
+
+  return fn;
+}
+
+// Wiki data (primitives & non-primitives)
+
+export function isColor(color) {
+  isStringNonEmpty(color);
+
+  if (color.startsWith('#')) {
+    if (![4, 5, 7, 9].includes(color.length))
+      throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`);
+
+    if (/[^0-9a-fA-F]/.test(color.slice(1)))
+      throw new TypeError(`Expected hexadecimal digits`);
+
+    return true;
+  }
+
+  throw new TypeError(`Unknown color format`);
+}
+
+export function validateContentEntries({
+  headingPhrase,
+  entryPhrase,
+
+  caseInsensitiveRegex,
+  caseSensitiveOneShotRegex,
+}) {
+  return content => {
+    isContentString(content);
+
+    const rawMatches =
+      Array.from(content.matchAll(caseInsensitiveRegex));
+
+    if (empty(rawMatches)) {
+      throw new TypeError(`Expected at least one ${headingPhrase}`);
+    }
+
+    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 ${headingPhrase} to be at top`);
+      }
+
+      const ownInput = content.slice(position, position + length);
+      const restOfInput = content.slice(position + length);
+
+      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)`);
+      }
+
+      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 nextHeading =
+        (index === niceMatches.length - 1
+          ? content.length
+          : niceMatches[index + 1].position);
+
+      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;
+  };
+}
+
+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) {
+  const {
+    [validateProperties.validateOtherKeys]: validateOtherKeys = null,
+    [validateProperties.allowOtherKeys]: allowOtherKeys = false,
+  } = spec;
+
+  const specEntries = Object.entries(spec);
+  const specKeys = Object.keys(spec);
+
+  return (object) => {
+    isObject(object);
+
+    if (Array.isArray(object))
+      throw new TypeError(`Expected an object, got array`);
+
+    withAggregate({message: `Errors validating object properties`}, ({push}) => {
+      const testEntries = specEntries.slice();
+
+      const unknownKeys = Object.keys(object).filter((key) => !specKeys.includes(key));
+      if (validateOtherKeys) {
+        for (const key of unknownKeys) {
+          testEntries.push([key, validateOtherKeys]);
+        }
+      }
+
+      for (const [specKey, specValidator] of testEntries) {
+        const value = object[specKey];
+        try {
+          specValidator(value);
+        } catch (caughtError) {
+          const keyPart = colors.green(specKey);
+          const valuePart = inspect(value);
+          const message = `Error for key ${keyPart}: ${valuePart}`;
+          push(new Error(message, {cause: caughtError}));
+        }
+      }
+
+      if (!validateOtherKeys && !allowOtherKeys && !empty(unknownKeys)) {
+        push(new Error(
+          `Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`));
+      }
+    });
+
+    return true;
+  };
+}
+
+validateProperties.validateOtherKeys = Symbol();
+validateProperties.allowOtherKeys = Symbol();
+
+export const validateAllPropertyValues = (validator) =>
+  validateProperties({
+    [validateProperties.validateOtherKeys]: validator,
+  });
+
+const illeaglInvisibleSpace = {
+  action: 'delete',
+};
+
+const illegalVisibleSpace = {
+  action: 'replace',
+  with: ' ',
+  withAnnotation: `normal space`,
+};
+
+const illegalContentSpec = [
+  {illegal: '\u200b', annotation: `zero-width space`, ...illeaglInvisibleSpace},
+  {illegal: '\u2005', annotation: `four-per-em space`, ...illegalVisibleSpace},
+  {illegal: '\u205f', annotation: `medium mathematical space`, ...illegalVisibleSpace},
+  {illegal: '\xa0', annotation: `non-breaking space`, ...illegalVisibleSpace},
+
+  {
+    action: 'replace',
+    illegal: '<a href',
+    annotation: `HTML-style link`,
+    with: '[...](...)',
+    withAnnotation: `markdown`,
+  },
+];
+
+for (const entry of illegalContentSpec) {
+  entry.test = string =>
+    string.startsWith(entry.illegal);
+
+  if (entry.action === 'replace') {
+    entry.enact = string =>
+      string.replaceAll(entry.illegal, entry.with);
+  }
+}
+
+const illegalSequencesInContent =
+  illegalContentSpec
+    .map(entry => entry.illegal)
+    .map(illegal =>
+      (illegal.length === 1
+        ? `${illegal}+`
+        : `(?:${illegal})+`))
+    .join('|');
+
+const illegalContentRegexp =
+  new RegExp(illegalSequencesInContent, 'g');
+
+const legalContentNearEndRegexp =
+  new RegExp(`(?<=^|${illegalSequencesInContent})(?:(?!${illegalSequencesInContent}).)+$`);
+
+const legalContentNearStartRegexp =
+  new RegExp(`^(?:(?!${illegalSequencesInContent}).)+`);
+
+const trimWhitespaceNearBothSidesRegexp =
+  /^ +| +$/gm;
+
+const trimWhitespaceNearEndRegexp =
+  / +$/gm;
+
+export function isContentString(content) {
+  isString(content);
+
+  const mainAggregate = openAggregate({
+    message: `Errors validating content string`,
+    translucent: 'single',
+  });
+
+  const illegalAggregate = openAggregate({
+    message: `Illegal characters found in content string`,
+  });
+
+  for (const {match, where} of matchMultiline(content, illegalContentRegexp)) {
+    const {annotation, action, ...options} =
+      illegalContentSpec
+        .find(entry => entry.test(match[0]));
+
+    const matchStart = match.index;
+    const matchEnd = match.index + match[0].length;
+
+    const before =
+      content
+        .slice(Math.max(0, matchStart - 3), matchStart)
+        .match(legalContentNearEndRegexp)
+        ?.[0];
+
+    const after =
+      content
+        .slice(matchEnd, Math.min(content.length, matchEnd + 3))
+        .match(legalContentNearStartRegexp)
+        ?.[0];
+
+    const beforePart =
+      before && `"${before}"`;
+
+    const afterPart =
+      after && `"${after}"`;
+
+    const surroundings =
+      (before && after
+        ? `between ${beforePart} and ${afterPart}`
+     : before
+        ? `after ${beforePart}`
+     : after
+        ? `before ${afterPart}`
+        : ``);
+
+    const illegalPart =
+      colors.red(
+        (annotation
+          ? `"${match[0]}" (${annotation})`
+          : `"${match[0]}"`));
+
+    const replacement =
+      (action === 'replace'
+        ? options.enact(match[0])
+        : null);
+
+    const replaceWithPart =
+      (action === 'replace'
+        ? colors.green(
+            (options.withAnnotation
+              ? `"${replacement}" (${options.withAnnotation})`
+              : `"${replacement}"`))
+        : null);
+
+    const actionPart =
+      (action === `delete`
+        ? `Delete ${illegalPart}`
+     : action === 'replace'
+        ? `Replace ${illegalPart} with ${replaceWithPart}`
+        : `Matched ${illegalPart}`);
+
+    const parts = [
+      actionPart,
+      surroundings,
+      `(${colors.yellow(where)})`,
+    ].filter(Boolean);
+
+    illegalAggregate.push(new TypeError(parts.join(` `)));
+  }
+
+  const isMultiline = content.includes('\n');
+
+  const trimWhitespaceAggregate = openAggregate({
+    message:
+      (isMultiline
+        ? `Whitespace found at end of line`
+        : `Whitespace found at start or end`),
+  });
+
+  const trimWhitespaceRegexp =
+    (isMultiline
+      ? trimWhitespaceNearEndRegexp
+      : trimWhitespaceNearBothSidesRegexp);
+
+  for (
+    const {match, lineNumber, columnNumber, containingLine} of
+    matchMultiline(content, trimWhitespaceRegexp, {
+      formatWhere: false,
+      getContainingLine: true,
+    })
+  ) {
+    const linePart =
+      colors.yellow(`line ${lineNumber + 1}`);
+
+    const where =
+      (match[0].length === containingLine.length
+        ? `as all of ${linePart}`
+     : columnNumber === 0
+        ? (isMultiline
+            ? `at start of ${linePart}`
+            : `at start`)
+        : (isMultiline
+            ? `at end of ${linePart}`
+            : `at end`));
+
+    const whitespacePart =
+      colors.red(`"${match[0]}"`);
+
+    const parts = [
+      `Matched ${whitespacePart}`,
+      where,
+    ];
+
+    trimWhitespaceAggregate.push(new TypeError(parts.join(` `)));
+  }
+
+  mainAggregate.call(() => illegalAggregate.close());
+  mainAggregate.call(() => trimWhitespaceAggregate.close());
+  mainAggregate.close();
+
+  return true;
+}
+
+export function isThingClass(thingClass) {
+  isFunction(thingClass);
+
+  // 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({
+  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),
+  files: optional(validateArrayItems(isString)),
+});
+
+export const isAdditionalFileList = validateArrayItems(isAdditionalFile);
+
+export const isTrackSection = validateProperties({
+  name: optional(isName),
+  color: optional(isColor),
+  dateOriginallyReleased: optional(isDate),
+  isDefaultTrackSection: optional(isBoolean),
+  tracks: optional(validateReferenceList('track')),
+});
+
+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);
+
+  if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`);
+
+  if (dimensions[0] !== null) {
+    isPositive(dimensions[0]);
+    isInteger(dimensions[0]);
+  }
+
+  if (dimensions[1] !== null) {
+    isPositive(dimensions[1]);
+    isInteger(dimensions[1]);
+  }
+
+  return true;
+}
+
+export function isDirectory(directory) {
+  isStringNonEmpty(directory);
+
+  if (directory.match(/[^a-zA-Z0-9_-]/))
+    throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`);
+
+  return true;
+}
+
+export function isDuration(duration) {
+  isNumber(duration);
+  isPositiveOrZero(duration);
+
+  return true;
+}
+
+export function isFileExtension(string) {
+  isStringNonEmpty(string);
+
+  if (string[0] === '.')
+    throw new TypeError(`Expected no dot (.) at the start of file extension`);
+
+  if (string.match(/[^a-zA-Z0-9_]/))
+    throw new TypeError(`Expected only alphanumeric and underscore`);
+
+  return true;
+}
+
+export function isLanguageCode(string) {
+  // TODO: This is a stub function because really we don't need a detailed
+  // is-language-code parser right now.
+
+  isString(string);
+
+  return true;
+}
+
+export function isName(name) {
+  return isContentString(name);
+}
+
+export function isURL(string) {
+  isStringNonEmpty(string);
+
+  new URL(string);
+
+  return true;
+}
+
+export function validateReference(type) {
+  return (ref) => {
+    isStringNonEmpty(ref);
+
+    const match = ref
+      .trim()
+      .match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/);
+
+    if (!match) throw new TypeError(`Malformed reference`);
+
+    const {groups: {typePart, directoryPart}} = match;
+
+    if (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);
+    }
+
+    isName(ref);
+
+    return true;
+  };
+}
+
+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`);
+  }
+
+  validateWikiData_cache[referenceType] ??= {};
+  validateWikiData_cache[referenceType][allowMixedTypes] ??= new WeakMap();
+
+  const isArrayOfObjects = validateArrayItems(isObject);
+
+  return (array) => {
+    const subcache = validateWikiData_cache[referenceType][allowMixedTypes];
+    if (subcache.has(array)) return subcache.get(array);
+
+    let OK = false;
+
+    try {
+      isArrayOfObjects(array);
+
+      if (empty(array)) {
+        OK = true; return true;
+      }
+
+      const allRefTypes = new Set();
+
+      let foundThing = false;
+      let foundOtherObject = false;
+
+      for (const object of array) {
+        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`);
+          }
+
+          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;
+        }
+      }
+
+      if (foundOtherObject && !foundThing) {
+        throw new TypeError(`Expected array of wiki data objects, got array of other objects`);
+      }
+
+      if (allRefTypes.size > 1) {
+        if (allowMixedTypes) {
+          OK = true; return true;
+        }
+
+        const types = () => Array.from(allRefTypes).join(', ');
+
+        if (referenceType) {
+          if (allRefTypes.has(referenceType)) {
+            allRefTypes.remove(referenceType);
+            throw new TypeError(`Expected array of only ${referenceType}, also got other types: ${types()}`)
+          } else {
+            throw new TypeError(`Expected array of only ${referenceType}, got other types: ${types()}`);
+          }
+        }
+
+        throw new TypeError(`Expected array of unmixed reference types, got multiple: ${types()}`);
+      }
+
+      const onlyRefType = Array.from(allRefTypes)[0];
+
+      if (referenceType && onlyRefType !== referenceType) {
+        throw new TypeError(`Expected array of ${referenceType}, got array of ${onlyRefType}`)
+      }
+
+      OK = true; return true;
+    } finally {
+      subcache.set(array, OK);
+    }
+  };
+}
+
+export const isAdditionalName = validateProperties({
+  name: isContentString,
+  annotation: optional(isContentString),
+});
+
+export const isAdditionalNameList = validateArrayItems(isAdditionalName);
+
+// Compositional utilities
+
+export function anyOf(...validators) {
+  const validConstants = new Set();
+  const validConstructors = new Set();
+  const validTypes = new Set();
+
+  const constantValidators = [];
+  const constructorValidators = [];
+  const typeValidators = [];
+
+  const leftoverValidators = [];
+
+  for (const validator of validators) {
+    const creator = getValidatorCreator(validator);
+    const creatorMeta = getValidatorCreatorMeta(validator);
+
+    switch (creator) {
+      case is:
+        for (const value of creatorMeta.values) {
+          validConstants.add(value);
+        }
+
+        constantValidators.push(validator);
+        break;
+
+      case validateInstanceOf:
+        validConstructors.add(creatorMeta.constructor);
+        constructorValidators.push(validator);
+        break;
+
+      case validateType:
+        validTypes.add(creatorMeta.type);
+        typeValidators.push(validator);
+        break;
+
+      default:
+        leftoverValidators.push(validator);
+        break;
+    }
+  }
+
+  return (value) => {
+    const errorInfo = [];
+
+    if (validConstants.has(value)) {
+      return true;
+    }
+
+    if (!empty(validTypes)) {
+      if (validTypes.has(typeof value)) {
+        return true;
+      }
+    }
+
+    for (const constructor of validConstructors) {
+      if (value instanceof constructor) {
+        return true;
+      }
+    }
+
+    for (const [i, validator] of leftoverValidators.entries()) {
+      try {
+        const result = validator(value);
+
+        if (result !== true) {
+          throw new Error(`Check returned false`);
+        }
+
+        return true;
+      } catch (error) {
+        errorInfo.push([validator, i, error]);
+      }
+    }
+
+    // Don't process error messages until every validator has failed.
+
+    const errors = [];
+    const prefaceErrorInfo = [];
+
+    let offset = 0;
+
+    if (!empty(validConstants)) {
+      const constants =
+        Array.from(validConstants);
+
+      const gotPart = `, got ${value}`;
+
+      prefaceErrorInfo.push([
+        constantValidators,
+        offset++,
+        new TypeError(
+          `Expected any of ${constants.join(' ')}` + gotPart),
+      ]);
+    }
+
+    if (!empty(validTypes)) {
+      const types =
+        Array.from(validTypes);
+
+      const gotType = typeAppearance(value);
+      const gotPart = `, got ${gotType}`;
+
+      prefaceErrorInfo.push([
+        typeValidators,
+        offset++,
+        new TypeError(
+          `Expected any of ${types.join(', ')}` + gotPart),
+      ]);
+    }
+
+    if (!empty(validConstructors)) {
+      const names =
+        Array.from(validConstructors)
+          .map(constructor => constructor.name);
+
+      const gotName = value?.constructor?.name;
+      const gotPart = (gotName ? `, got ${gotName}` : ``);
+
+      prefaceErrorInfo.push([
+        constructorValidators,
+        offset++,
+        new TypeError(
+          `Expected any of ${names.join(', ')}` + gotPart),
+      ]);
+    }
+
+    for (const info of errorInfo) {
+      info[1] += offset;
+    }
+
+    for (const [validator, i, error] of prefaceErrorInfo.concat(errorInfo)) {
+      error.message =
+        (validator?.name
+          ? `${i + 1}. "${validator.name}": ${error.message}`
+          : `${i + 1}. ${error.message}`);
+
+      error.check =
+        (Array.isArray(validator) && validator.length === 1
+          ? validator[0]
+          : validator);
+
+      errors.push(error);
+    }
+
+    const total = offset + leftoverValidators.length;
+    throw new AggregateError(errors,
+      `Expected any of ${total} possible checks, ` +
+      `but none were true`);
+  };
+}
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
new file mode 100644
index 00000000..d55ab215
--- /dev/null
+++ b/src/write/bind-utilities.js
@@ -0,0 +1,71 @@
+// Ties lots and lots of functions together in a convenient package accessible
+// to page write functions. This is kept in a separate file from other write
+// areas to keep imports neat and isolated.
+
+import chroma from 'chroma-js';
+
+import {getColors} from '#colors';
+import {bindFind} from '#find';
+import * as html from '#html';
+import {bindOpts} from '#sugar';
+import {thumb} from '#urls';
+
+import {
+  checkIfImagePathHasCachedThumbnails,
+  getDimensionsOfImagePath,
+  getThumbnailEqualOrSmaller,
+  getThumbnailsAvailableForDimensions,
+} from '#thumbs';
+
+export function bindUtilities({
+  absoluteTo,
+  defaultLanguage,
+  getSizeOfMediaFile,
+  language,
+  languages,
+  missingImagePaths,
+  pagePath,
+  pagePathStringFromRoot,
+  thumbsCache,
+  to,
+  urls,
+  wikiData,
+}) {
+  const bound = {};
+
+  Object.assign(bound, {
+    absoluteTo,
+    defaultLanguage,
+    getSizeOfMediaFile,
+    getThumbnailsAvailableForDimensions,
+    html,
+    language,
+    languages,
+    missingImagePaths,
+    pagePath,
+    pagePathStringFromRoot,
+    thumb,
+    to,
+    urls,
+    wikiData,
+    wikiInfo: wikiData.wikiInfo,
+  });
+
+  bound.getColors = bindOpts(getColors, {chroma});
+
+  bound.find = bindFind(wikiData, {mode: 'warn'});
+
+  bound.checkIfImagePathHasCachedThumbnails =
+    (imagePath) =>
+      checkIfImagePathHasCachedThumbnails(imagePath, thumbsCache);
+
+  bound.getDimensionsOfImagePath =
+    (imagePath) =>
+      getDimensionsOfImagePath(imagePath, thumbsCache);
+
+  bound.getThumbnailEqualOrSmaller =
+    (preferred, imagePath) =>
+      getThumbnailEqualOrSmaller(preferred, imagePath, thumbsCache);
+
+  return bound;
+}
diff --git a/src/write/build-modes/index.js b/src/write/build-modes/index.js
new file mode 100644
index 00000000..4b61619d
--- /dev/null
+++ b/src/write/build-modes/index.js
@@ -0,0 +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
new file mode 100644
index 00000000..ecb9df21
--- /dev/null
+++ b/src/write/build-modes/live-dev-server.js
@@ -0,0 +1,576 @@
+import {spawn} from 'node:child_process';
+import * as http from 'node:http';
+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 {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';
+import * as pageSpecs from '#page-specs';
+import {serializeThings} from '#serialize';
+
+import {
+  getPagePathname,
+  getURLsFrom,
+  getURLsFromRoot,
+} from '#urls';
+
+import {bindUtilities} from '../bind-utilities.js';
+import {generateRandomLinkDataJSON, generateRedirectHTML} from '../common-templates.js';
+
+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\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: 'perform',
+  },
+
+  languageReloading: {
+    default: 'perform',
+  },
+
+  mediaValidation: {
+    default: 'perform',
+  },
+
+  thumbs: {
+    default: 'perform',
+  },
+
+  webRoutes: {
+    required: true,
+  },
+};
+
+function inspect(value, opts = {}) {
+  return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
+}
+
+export function getCLIOptions() {
+  return {
+    host: {
+      help: `Hostname to which HTTP server is bound\nDefaults to ${defaultHost}`,
+      type: 'value',
+    },
+
+    port: {
+      help: `Port to which HTTP server is bound\nDefaults to ${defaultPort}`,
+      type: 'value',
+      validate(size) {
+        if (parseInt(size) !== parseFloat(size)) return 'an integer';
+        if (parseInt(size) < 1024 || parseInt(size) > 49151) return 'a user/registered port (1024-49151)';
+        return true;
+      },
+    },
+
+    'loud-responses': {
+      help: `Enables outputting [200] and [404] responses in the server log, which are suppressed by default`,
+      type: 'flag',
+    },
+
+    'show-timings': {
+      help: `Enables outputting timings in the server log for how long pages to take to generate`,
+      type: 'flag',
+    },
+
+    'serve-sfx': {
+      help: `Plays the specified sound file once the HTTP server is ready (this requires mpv)`,
+      type: 'value',
+    },
+
+    'skip-serving': {
+      help: `Causes the build to exit when it would start serving over HTTP instead\n\nMainly useful for testing performance`,
+      type: 'flag',
+    },
+  };
+}
+
+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,
+
+  universalUtilities,
+
+  defaultLanguage,
+  languages,
+  urls,
+  webRoutes,
+  wikiData,
+
+  niceShowAggregate,
+}) {
+  const showError = (error) => {
+    if (niceShowAggregate) {
+      if (error.errors || error.cause) {
+        niceShowAggregate(error);
+        return;
+      }
+    }
+
+    console.error(inspect(error, {depth: Infinity}));
+  };
+
+  const host = cliOptions['host'] ?? defaultHost;
+  const port = parseInt(cliOptions['port'] ?? defaultPort);
+  const loudResponses = cliOptions['loud-responses'] ?? false;
+  const showTimings = cliOptions['show-timings'] ?? false;
+  const skipServing = cliOptions['skip-serving'] ?? false;
+  const serveSFX = cliOptions['serve-sfx'] ?? null;
+
+  const contentDependenciesWatcher = await watchContentDependencies({
+    showAggregate: niceShowAggregate,
+  });
+
+  const {contentDependencies} = contentDependenciesWatcher;
+
+  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 paths for ${targetSpecPairs.length} targets.`,
+    targetSpecPairs.flatMap(({
+      pageSpec,
+      target,
+      targetless,
+    }) => () => {
+      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
+    .filter(page => page.type === 'page' || page.type === 'redirect')
+    .flatMap(page => {
+      let servePath;
+      if (page.type === 'page')
+        servePath = page.path;
+      else if (page.type === 'redirect')
+        servePath = page.fromPath;
+
+      return Object.values(languages).map(language => {
+        const baseDirectory =
+          language === defaultLanguage ? '' : language.code;
+
+        const pathname = getPagePathname({
+          baseDirectory,
+          pagePath: servePath,
+          urls,
+        });
+
+        return [pathname, {
+          baseDirectory,
+          language,
+          page,
+          servePath,
+        }];
+      });
+    }));
+
+  const server = http.createServer(async (request, response) => {
+    const contentTypeHTML = {'Content-Type': 'text/html; charset=utf-8'};
+    const contentTypeJSON = {'Content-Type': 'application/json; charset=utf-8'};
+    const contentTypePlain = {'Content-Type': 'text/plain; charset=utf-8'};
+
+    const requestTime = new Date().toLocaleDateString('en-US', {hour: '2-digit', minute: '2-digit', second: '2-digit'});
+    const requestHead =
+      (loudResponses
+        ? `${requestTime} - ${request.socket.remoteAddress}`
+        : `${requestTime}`);
+
+    let url;
+    try {
+      url = new URL(request.url, `http://${request.headers.host}`);
+    } catch (error) {
+      response.writeHead(500, contentTypePlain);
+      response.end('Failed to parse request URL\n');
+      return;
+    }
+
+    const {pathname} = url;
+
+    // Specialized routes
+
+    if (pathname === '/random-link-data.json') {
+      try {
+        const json = generateRandomLinkDataJSON({
+          serializeThings,
+          wikiData,
+        });
+
+        response.writeHead(200, contentTypeJSON);
+        response.end(json);
+        if (loudResponses) console.log(`${requestHead} [200] ${pathname} (${colors.yellow(`special`)})`);
+      } catch (error) {
+        response.writeHead(500, contentTypeJSON);
+        response.end(`Internal error serializing wiki JSON`);
+        console.error(`${requestHead} [500] ${pathname}`);
+        showError(error);
+      }
+      return;
+    }
+
+    const matchedWebRoute =
+      webRoutes
+        .find(({to}) => pathname.startsWith('/' + to));
+
+    if (matchedWebRoute) {
+      const localFilePath = pathname.slice(1 + matchedWebRoute.to.length);
+
+      // Not security tested, man, this is a dev server!!
+      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(`File not found for: ${safePath}`);
+        console.log(`${requestHead} [404] ${pathname}`);
+        console.log(`Failed to decode request pathname`);
+      }
+
+      try {
+        await stat(filePath);
+      } catch (error) {
+        if (error.code === 'ENOENT') {
+          response.writeHead(404, contentTypePlain);
+          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 file for: ${safePath}`);
+          console.error(`${requestHead} [500] ${pathname}`);
+          showError(error);
+        }
+        return;
+      }
+
+      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;
+      }
+
+      response.writeHead(200, {
+        ...contentType ? {'Content-Type': contentType} : {},
+        'Content-Length': size,
+      });
+
+      try {
+        await pipeline(fd.createReadStream(), response);
+      } catch (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('/') ? '' : '/');
+
+    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} (no page)`);
+      return;
+    }
+
+    // All pages expect to be served at a URL with a trailing slash, which must
+    // be fulfilled for relative URLs (ex. href="../lofam5/") to work. Redirect
+    // if there is no trailing slash in the request URL.
+    if (!pathname.endsWith('/')) {
+      const target = pathname + '/';
+      response.writeHead(301, {
+        ...contentTypePlain,
+        'Location': target,
+      });
+      response.end(`Redirecting to: ${target}\n`);
+      console.log(`${requestHead} [301] (trl. slash) ${pathname}`);
+      return;
+    }
+
+    const {
+      baseDirectory,
+      language,
+      page,
+      servePath,
+    } = urlToPageMap[pathnameKey];
+
+    const to = getURLsFrom({
+      baseDirectory,
+      pagePath: servePath,
+      urls,
+    });
+
+    const absoluteTo = getURLsFromRoot({
+      baseDirectory,
+      urls,
+    });
+
+    try {
+      if (page.type === 'redirect') {
+        const title =
+          page.title ??
+          page.getTitle?.({language});
+
+        const target = to('localized.' + page.toPath[0], ...page.toPath.slice(1));
+
+        response.writeHead(301, {
+          ...contentTypeHTML,
+          'Location': target,
+        });
+
+        const redirectHTML = generateRedirectHTML(title, target, {language});
+
+        response.end(redirectHTML);
+
+        console.log(`${requestHead} [301] (redirect) ${pathname}`);
+        return;
+      }
+
+      const timing = startTiming();
+
+      const bound = bindUtilities({
+        ...commonUtilities,
+
+        absoluteTo,
+        language,
+        pagePath: servePath,
+        pagePathStringFromRoot: pathname.replace(/^\//, ''),
+        to: page.absoluteLinks ? absoluteTo : to,
+      });
+
+      const topLevelResult =
+        quickEvaluate({
+          contentDependencies,
+          extraDependencies: {...bound, appendIndexHTML: false},
+
+          name: page.contentFunction.name,
+          args: page.contentFunction.args ?? [],
+        });
+
+      const {pageHTML} = html.resolve(topLevelResult);
+
+      const timeString = timing();
+      const status = (timeString ? `200 ${timeString}` : `200`);
+      if (showTimings || loudResponses) {
+        console.log(`${requestHead} [${status}] ${pathname} (${colors.blue(`page`)})`);
+      }
+
+      response.writeHead(200, contentTypeHTML);
+      response.end(pageHTML);
+    } catch (error) {
+      console.error(`${requestHead} [500] ${pathname}`);
+      showError(error);
+      response.writeHead(500, contentTypePlain);
+      response.end(`Error generating page, view server log for details\n`);
+    }
+  });
+
+  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') {
+      logWarn`Port ${port} is already in use - will (continually) retry after 10 seconds.`;
+      logWarn`Press ^C here (control+C) to exit and change ${'--port'} number, or stop the server currently running on port ${port}.`;
+      setTimeout(() => {
+        server.close();
+        server.listen(port, host);
+      }, 10_000);
+    } else {
+      console.error(`Server error detected (code: ${error.code})`);
+      showError(error);
+    }
+  });
+
+  server.on('listening', () => {
+    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.`;
+    } else if (showTimings) {
+      logInfo`Printing page generation timings.`;
+    } else if (loudResponses) {
+      logInfo`Printing all HTTP responses.`
+    } else {
+      logInfo`Suppressing [200] and [404] response logging.`
+      logInfo`(Pass --loud-responses to show these.)`;
+    }
+
+    if (serveSFX) {
+      spawn('mpv', [serveSFX, '--volume=75']);
+    }
+  });
+
+  if (skipServing) {
+    logInfo`Ready to serve! But --skip-serving was passed, so all done.`;
+  } else {
+    process.on('SIGINT', () => {
+      process.stdout.write('\n');
+      server.close();
+    });
+
+    await new Promise(resolve => {
+      server.listen(port, host);
+      server.on('close', () => resolve());
+    });
+  }
+
+  return true;
+}
+
+function getPageSpecsWithTargets({
+  wikiData,
+}) {
+  return Object.values(pageSpecs)
+    .filter(pageSpec => pageSpec.condition?.({wikiData}) ?? true)
+    .flatMap(pageSpec => [
+      ...pageSpec.targets
+        ? pageSpec.targets({wikiData})
+            .map(target => ({pageSpec, target}))
+        : [],
+      Object.hasOwn(pageSpec, 'pathsTargetless') &&
+        {pageSpec, targetless: true},
+    ])
+    .filter(Boolean);
+}
diff --git a/src/write/build-modes/repl.js b/src/write/build-modes/repl.js
new file mode 100644
index 00000000..920ad9f7
--- /dev/null
+++ b/src/write/build-modes/repl.js
@@ -0,0 +1,165 @@
+export const description = `Provide command-line interactive access to wiki data objects`;
+
+export const config = {
+  fileSizes: {
+    default: 'skip',
+  },
+
+  languageReloading: {
+    default: 'perform',
+  },
+
+  mediaValidation: {
+    default: 'skip',
+  },
+
+  search: {
+    default: 'skip',
+  },
+
+  thumbs: {
+    applicable: false,
+  },
+};
+
+export function getCLIOptions() {
+  return {
+    'no-repl-history': {
+      help: `Disable locally logging commands entered into the REPL in your home directory`,
+      type: 'flag',
+    },
+  };
+}
+
+import * as os from 'node:os';
+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';
+import * as serialize from '#serialize';
+import * as sort from '#sort';
+import * as sugar from '#sugar';
+import thingConstructors from '#things';
+import * as wikiDataUtils from '#wiki-data';
+
+export async function getContextAssignments({
+  dataPath,
+  mediaPath,
+  mediaCachePath,
+
+  universalUtilities,
+
+  defaultLanguage,
+  wikiData,
+
+  niceShowAggregate: showAggregate,
+}) {
+  let find;
+  try {
+    find = bindFind(wikiData);
+  } catch (error) {
+    console.error(error);
+    logWarn`Failed to prepare wikiData-bound find() functions`;
+    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,
+
+    language: defaultLanguage,
+
+    wikiData,
+    ...wikiData,
+    WD: wikiData,
+
+    ...thingConstructors,
+    CacheableObject,
+    debugComposite,
+
+    ...sort,
+    ...sugar,
+    ...wikiDataUtils,
+
+    serialize,
+    S: serialize,
+
+    _find,
+    find,
+    bindFind,
+
+    _reverse,
+    reverse,
+    bindReverse,
+
+    showAggregate,
+  };
+
+  replContext.replContext = replContext;
+
+  return replContext;
+}
+
+export async function go(buildOptions) {
+  const {
+    cliOptions,
+    closeLanguageWatchers,
+  } = buildOptions;
+
+  const disableHistory = cliOptions['no-repl-history'] ?? false;
+
+  console.log('HSMusic data REPL');
+
+  const replServer = repl.start();
+
+  Object.assign(replServer.context,
+    await getContextAssignments(buildOptions));
+
+  if (disableHistory) {
+    console.log(`\rInput history disabled (--no-repl-history provided)`);
+  } else {
+    const historyFile = path.join(os.homedir(), '.hsmusic_repl_history');
+    await new Promise(resolve => {
+      replServer.setupHistory(historyFile, (err) => {
+        if (err) {
+          console.error(`\rFailed to begin locally logging input history to ${historyFile} (provide --no-repl-history to disable)`);
+        } else {
+          console.log(`\rLogging input history to ${historyFile} (provide --no-repl-history to disable)`);
+        }
+        resolve();
+      });
+    });
+  }
+
+  replServer.displayPrompt(true);
+
+  let resolveDone;
+
+  replServer.on('exit', () => {
+    closeLanguageWatchers();
+    resolveDone();
+  });
+
+  await new Promise(resolve => {
+    resolveDone = resolve;
+  });
+
+  return true;
+}
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
new file mode 100644
index 00000000..2baed816
--- /dev/null
+++ b/src/write/build-modes/static-build.js
@@ -0,0 +1,632 @@
+import * as path from 'node:path';
+
+import {
+  copyFile,
+  cp,
+  mkdir,
+  stat,
+  symlink,
+  writeFile,
+  unlink,
+} from 'node:fs/promises';
+
+import {rimraf} from 'rimraf';
+
+import {quickLoadContentDependencies} from '#content-dependencies';
+import {quickEvaluate} from '#content-function';
+import * as html from '#html';
+import * as pageSpecs from '#page-specs';
+import {empty, queue, withEntries} from '#sugar';
+
+import {
+  fileIssue,
+  logError,
+  logInfo,
+  logWarn,
+  progressPromiseAll,
+} from '#cli';
+
+import {
+  getOrigin,
+  getPagePathname,
+  getURLsFrom,
+  getURLsFromRoot,
+} from '#urls';
+
+import {bindUtilities} from '../bind-utilities.js';
+import {generateRedirectHTML, generateRandomLinkDataJSON} from '../common-templates.js';
+
+const pageFlags = Object.keys(pageSpecs);
+
+export const description = `Generates all page content in one build (according to the contents of data files at build time) and writes them to disk, preparing the output folder for upload and serving by any static web host\n\nIntended for any production or public-facing release of a wiki; serviceable for local development, but can be a bit unwieldy and time/CPU-expensive`;
+
+export const config = {
+  fileSizes: {
+    default: 'perform',
+  },
+
+  languageReloading: {
+    applicable: false,
+  },
+
+  mediaValidation: {
+    default: 'perform',
+  },
+
+  search: {
+    default: 'perform',
+  },
+
+  thumbs: {
+    default: 'perform',
+  },
+
+  webRoutes: {
+    required: true,
+  },
+};
+
+export function getCLIOptions() {
+  return {
+    // This is the output directory. It's the one you'll upload online with
+    // rsync or whatever when you're pushing an upd8, and also the one
+    // you'd archive if you wanted to make a 8ackup of the whole dang
+    // site. Just keep in mind that the gener8ted result will contain a
+    // couple symlinked directories, so if you're uploading, you're pro8a8ly
+    // gonna want to resolve those yourself.
+    'out-path': {
+      help: `Specify path to output directory, into which HTML page files and other output are written and other directories are linked\n\nAlways required alongside --static-build mode, but may be provided via the HSMUSIC_OUT environment variable instead`,
+      type: 'value',
+    },
+
+    // Working without a dev server and just using file:// URLs in your we8
+    // 8rowser? This will automatically append index.html to links across
+    // the site. Not recommended for production, since it isn't guaranteed
+    // 100% error-free (and index.html-style links are less pretty anyway).
+    'append-index-html': {
+      help: `Apply "index.html" to the end of page links, instead of just linking to the directory (ex. "/track/ng2yu/"); useful when no local server hosting option is available and browsing build output directly off the disk drive\n\nDefinitely not intended for production: this option isn't extensively tested and may include conspicuous oddities`,
+      type: 'flag',
+    },
+
+    // Only want to 8uild one language during testing? This can chop down
+    // 8uild times a pretty 8ig chunk! Just pass a single language code.
+    'lang': {
+      help: `Skip rest and build only pages for this locale language (specify a language code)`,
+      type: 'value',
+    },
+
+    // NOT for neatly ena8ling or disa8ling specific features of the site!
+    // This is only in charge of what general groups of files to write.
+    // They're here to make development quicker when you're only working
+    // on some particular area(s) of the site rather than making changes
+    // across all of them.
+    ...withEntries(pageSpecs, entries => entries.map(
+      ([key, spec]) => [key, {
+        help: spec.description &&
+          `Skip rest and build only:\n${spec.description}`,
+        type: 'flag',
+      }])),
+  };
+}
+
+export async function go({
+  cliOptions,
+  queueSize,
+
+  universalUtilities,
+
+  mediaPath,
+
+  defaultLanguage,
+  languages,
+  urls,
+  webRoutes,
+  wikiData,
+
+  niceShowAggregate,
+}) {
+  const outputPath = cliOptions['out-path'] || process.env.HSMUSIC_OUT;
+  const appendIndexHTML = cliOptions['append-index-html'] ?? false;
+  const writeOneLanguage = cliOptions['lang'] ?? null;
+
+  if (!outputPath) {
+    logError`Expected ${'--out-path'} option or ${'HSMUSIC_OUT'} to be set`;
+    return false;
+  }
+
+  if (appendIndexHTML) {
+    logWarn`Appending index.html to link hrefs. (Note: not recommended for production release!)`;
+  }
+
+  if (writeOneLanguage && !(writeOneLanguage in languages)) {
+    logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`;
+    return false;
+  } else if (writeOneLanguage) {
+    logInfo`Writing only language ${writeOneLanguage} this run.`;
+  } else {
+    logInfo`Writing all languages.`;
+  }
+
+  const selectedPageFlags = Object.keys(cliOptions)
+    .filter(key => pageFlags.includes(key));
+
+  const writeAll = empty(selectedPageFlags) || selectedPageFlags.includes('all');
+  logInfo`Writing site pages: ${writeAll ? 'all' : selectedPageFlags.join(', ')}`;
+
+  await mkdir(outputPath, {recursive: true});
+
+  await writeWebRouteSymlinks({
+    outputPath,
+    webRoutes,
+  });
+
+  await writeWebRouteCopies({
+    outputPath,
+    webRoutes,
+  });
+
+  if (writeAll) {
+    await writeFavicon({
+      mediaPath,
+      outputPath,
+    });
+
+    await writeSharedFilesAndPages({
+      outputPath,
+      randomLinkDataJSON: generateRandomLinkDataJSON({wikiData}),
+    });
+  } else {
+    logInfo`Skipping favicon and shared files (not writing all site pages).`
+  }
+
+  const buildSteps = writeAll
+    ? Object.entries(pageSpecs)
+    : Object.entries(pageSpecs)
+        .filter(([flag]) => selectedPageFlags.includes(flag));
+
+  let writes;
+  {
+    let error = false;
+
+    // TODO: Port this to aggregate error
+    writes = buildSteps
+      .map(([flag, pageSpec]) => {
+        if (pageSpec.condition && !pageSpec.condition({wikiData})) {
+          return null;
+        }
+
+        let paths = [];
+
+        if (pageSpec.pathsTargetless) {
+          const result = pageSpec.pathsTargetless({wikiData});
+          if (Array.isArray(result)) {
+            paths.push(...result);
+          } else {
+            paths.push(result);
+          }
+        }
+
+        if (pageSpec.targets) {
+          if (!pageSpec.pathsForTarget) {
+            logError`${flag + '.targets'} is specified, but ${flag + '.pathsForTarget'} is missing!`;
+            error = true;
+            return null;
+          }
+
+          const targets = pageSpec.targets({wikiData});
+
+          if (!Array.isArray(targets)) {
+            logError`${flag + '.targets'} was called, but it didn't return an array! (${targets})`;
+            error = true;
+            return null;
+          }
+
+          paths.push(...targets.flatMap(target => pageSpec.pathsForTarget(target)));
+          // TODO: Validate each pathsForTargets entry
+        }
+
+        paths =
+          paths.filter(path => path.condition?.() ?? true);
+
+        return paths;
+      })
+      .filter(Boolean)
+      .flat();
+
+    if (error) {
+      return false;
+    }
+  }
+
+  const pageWrites = writes.filter(({type}) => type === 'page');
+  const dataWrites = writes.filter(({type}) => type === 'data');
+  const redirectWrites = writes.filter(({type}) => type === 'redirect');
+
+  if (writes.length) {
+    logInfo`Total of ${writes.length} writes returned. (${pageWrites.length} page, ${dataWrites.length} data [currently skipped], ${redirectWrites.length} redirect)`;
+  } else {
+    logWarn`No writes returned at all, so exiting early. This is probably a bug!`;
+    return false;
+  }
+
+  /*
+  await progressPromiseAll(`Writing data files shared across languages.`, queue(
+    dataWrites.map(({path, data}) => () => {
+      const bound = {};
+
+      bound.serializeLink = bindOpts(serializeLink, {});
+
+      bound.serializeContribs = bindOpts(serializeContribs, {});
+
+      bound.serializeImagePaths = bindOpts(serializeImagePaths, {
+        thumb
+      });
+
+      bound.serializeCover = bindOpts(serializeCover, {
+        [bindOpts.bindIndex]: 2,
+        serializeImagePaths: bound.serializeImagePaths,
+        urls
+      });
+
+      bound.serializeGroupsForAlbum = bindOpts(serializeGroupsForAlbum, {
+        serializeLink
+      });
+
+      bound.serializeGroupsForTrack = bindOpts(serializeGroupsForTrack, {
+        serializeLink
+      });
+
+      // TODO: This only supports one <>-style argument.
+      return writeData(path[0], path[1], data({...bound}));
+    }),
+    queueSize
+  ));
+  */
+
+  let errored = false;
+
+  const contentDependencies = await quickLoadContentDependencies({
+    showAggregate: niceShowAggregate,
+  });
+
+  const commonUtilities = {...universalUtilities};
+
+  const perLanguageFn = async (language, i, entries) => {
+    const baseDirectory =
+      language === defaultLanguage ? '' : language.code;
+
+    console.log(`\x1b[34;1m${`[${i + 1}/${entries.length}] ${language.code} (-> /${baseDirectory}) `.padEnd(60, '-')}\x1b[0m`);
+
+    await progressPromiseAll(`Writing ${language.code}`, queue([
+      ...pageWrites.map(page => () => {
+        const pagePath = page.path;
+
+        const pathname = getPagePathname({
+          baseDirectory,
+          pagePath,
+          urls,
+        });
+
+        const to = getURLsFrom({
+          baseDirectory,
+          pagePath,
+          urls,
+        });
+
+        const absoluteTo = getURLsFromRoot({
+          baseDirectory,
+          urls,
+        });
+
+        const bound = bindUtilities({
+          ...commonUtilities,
+
+          absoluteTo,
+          language,
+          pagePath,
+          pagePathStringFromRoot: pathname,
+          to: page.absoluteLinks ? absoluteTo : to,
+        });
+
+        let pageHTML, oEmbedJSON;
+        try {
+          const topLevelResult =
+            quickEvaluate({
+              contentDependencies,
+              extraDependencies: {...bound, appendIndexHTML},
+
+              name: page.contentFunction.name,
+              args: page.contentFunction.args ?? [],
+            });
+
+          ({pageHTML, oEmbedJSON} = html.resolve(topLevelResult));
+        } catch (error) {
+          logError`\rError generating page: ${pathname}`;
+          niceShowAggregate(error);
+          errored = true;
+          return;
+        }
+
+        return writePage({
+          pageHTML,
+          oEmbedJSON,
+          outputDirectory: path.join(outputPath, getPagePathname({
+            baseDirectory,
+            device: true,
+            pagePath,
+            urls,
+          })),
+        });
+      }),
+
+      ...redirectWrites.map(({fromPath, toPath, title, getTitle}) => () => {
+        title ??= getTitle?.({language});
+
+        const to = getURLsFrom({
+          baseDirectory,
+          pagePath: fromPath,
+          urls,
+        });
+
+        const target = to('localized.' + toPath[0], ...toPath.slice(1));
+        const pageHTML = generateRedirectHTML(title, target, {language});
+
+        return writePage({
+          pageHTML,
+          outputDirectory: path.join(outputPath, getPagePathname({
+            baseDirectory,
+            device: true,
+            pagePath: fromPath,
+            urls,
+          })),
+        });
+      }),
+    ], queueSize));
+  };
+
+  await wrapLanguages(perLanguageFn, {
+    languages,
+    writeOneLanguage,
+  });
+
+  // The single most important step.
+  logInfo`Written!`;
+
+  if (errored) {
+    logWarn`The code generating content for some pages ended up erroring.`;
+    logWarn`These pages were skipped, so if you ran a build previously and`;
+    logWarn`they didn't error that time, then the old version is still`;
+    logWarn`available - albeit possibly outdated! Please scroll up and send`;
+    logWarn`the HSMusic developers a copy of the errors:`;
+    fileIssue({topMessage: null});
+
+    return false;
+  }
+
+  return true;
+}
+
+// Wrapper function for running a function once for all languages.
+async function wrapLanguages(fn, {
+  languages,
+  writeOneLanguage = null,
+}) {
+  const k = writeOneLanguage;
+  const languagesToRun = k ? {[k]: languages[k]} : languages;
+
+  const entries = Object.entries(languagesToRun).filter(
+    ([key]) => key !== 'default'
+  );
+
+  for (let i = 0; i < entries.length; i++) {
+    const [_key, language] = entries[i];
+
+    await fn(language, i, entries);
+  }
+}
+
+async function writePage({
+  pageHTML,
+  oEmbedJSON = '',
+  outputDirectory,
+}) {
+  await mkdir(outputDirectory, {recursive: true});
+
+  await Promise.all([
+    writeFile(path.join(outputDirectory, 'index.html'), pageHTML),
+
+    oEmbedJSON &&
+      writeFile(path.join(outputDirectory, 'oembed.json'), oEmbedJSON),
+  ].filter(Boolean));
+}
+
+function filterNoOrigin(route) {
+  return !getOrigin(route.to);
+}
+
+function writeWebRouteSymlinks({
+  outputPath,
+  webRoutes,
+}) {
+  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;
+        }
+      }
+
+      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")`);
+  }
+}
+
+async function writeFavicon({
+  mediaPath,
+  outputPath,
+}) {
+  const faviconFile = 'favicon.ico';
+
+  try {
+    await stat(path.join(mediaPath, faviconFile));
+  } catch (error) {
+    return;
+  }
+
+  try {
+    await copyFile(
+      path.join(mediaPath, faviconFile),
+      path.join(outputPath, faviconFile));
+  } catch (error) {
+    logWarn`Failed to copy favicon! ${error.message}`;
+    return;
+  }
+
+  logInfo`Copied favicon to site root.`;
+}
+
+async function writeSharedFilesAndPages({
+  outputPath,
+  randomLinkDataJSON,
+}) {
+  return progressPromiseAll(`Writing files & pages shared across languages.`, [
+    randomLinkDataJSON &&
+      writeFile(
+        path.join(outputPath, 'random-link-data.json'),
+        randomLinkDataJSON),
+  ].filter(Boolean));
+}
diff --git a/src/write/common-templates.js b/src/write/common-templates.js
new file mode 100644
index 00000000..c9824a48
--- /dev/null
+++ b/src/write/common-templates.js
@@ -0,0 +1,57 @@
+import * as html from '#html';
+import {getArtistNumContributions} from '#wiki-data';
+
+export function generateRedirectHTML(title, target, {language}) {
+  return `<!DOCTYPE html>\n` + html.tag('html', [
+    html.tag('head', [
+      html.tag('title', language.$('redirectPage.title', {title})),
+      html.tag('meta', {charset: 'utf-8'}),
+
+      html.tag('meta', {
+        'http-equiv': 'refresh',
+        content: `0;url=${target}`,
+      }),
+
+      // TODO: Is this OK for localized pages?
+      html.tag('link', {
+        rel: 'canonical',
+        href: target,
+      }),
+    ]),
+
+    html.tag('body',
+      html.tag('main', [
+        html.tag('h1',
+          language.$('redirectPage.title', {title})),
+        html.tag('p',
+          language.$('redirectPage.infoLine', {
+            target: html.tag('a', {href: target}, target),
+          })),
+      ])),
+  ]);
+}
+
+export function generateRandomLinkDataJSON({wikiData}) {
+  const {albumData, artistData} = wikiData;
+
+  return JSON.stringify({
+    albumDirectories:
+      albumData
+        .map(album => album.directory),
+
+    albumTrackDirectories:
+      albumData
+        .map(album => album.tracks
+          .map(track => track.directory)),
+
+    artistDirectories:
+      artistData
+        .filter(artist => !artist.isAlias)
+        .map(artist => artist.directory),
+
+    artistNumContributions:
+      artistData
+        .filter(artist => !artist.isAlias)
+        .map(artist => getArtistNumContributions(artist)),
+  });
+}