« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.editorconfig2
-rw-r--r--.eslintrc.json21
-rw-r--r--.gitignore5
-rw-r--r--.prettierrc.json13
-rw-r--r--.taprc6
-rw-r--r--LICENSE.txt2
-rw-r--r--README.md186
-rw-r--r--coverage-map.js71
-rw-r--r--data-tests/index.js119
-rw-r--r--data-tests/test-no-short-tracks.js25
-rw-r--r--data-tests/test-order-of-album-groups.js55
-rw-r--r--nodemon.json5
-rw-r--r--package-lock.json9449
-rw-r--r--package.json64
-rw-r--r--src/content-function.js683
-rw-r--r--src/content/dependencies/generateAbsoluteDatetimestamp.js53
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js24
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunk.js53
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunkItem.js30
-rw-r--r--src/content/dependencies/generateAdditionalFilesShortcut.js27
-rw-r--r--src/content/dependencies/generateAdditionalNamesBox.js20
-rw-r--r--src/content/dependencies/generateAdditionalNamesBoxItem.js71
-rw-r--r--src/content/dependencies/generateAlbumAdditionalFilesList.js96
-rw-r--r--src/content/dependencies/generateAlbumBanner.js37
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js274
-rw-r--r--src/content/dependencies/generateAlbumCoverArtwork.js26
-rw-r--r--src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js20
-rw-r--r--src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js7
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js228
-rw-r--r--src/content/dependencies/generateAlbumGalleryStatsLine.js38
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js273
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js112
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js110
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNav.js168
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js47
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js116
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackListBox.js31
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js136
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbed.js74
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbedDescription.js48
-rw-r--r--src/content/dependencies/generateAlbumStyleRules.js72
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js181
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js133
-rw-r--r--src/content/dependencies/generateAlbumTrackListMissingDuration.js33
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js153
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js140
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js224
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js311
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js241
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunk.js91
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkItem.js60
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunkedList.js23
-rw-r--r--src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js269
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js149
-rw-r--r--src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js23
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkedList.js293
-rw-r--r--src/content/dependencies/generateArtistNavLinks.js100
-rw-r--r--src/content/dependencies/generateBanner.js33
-rw-r--r--src/content/dependencies/generateChronologyLinks.js82
-rw-r--r--src/content/dependencies/generateColorStyleAttribute.js37
-rw-r--r--src/content/dependencies/generateColorStyleRules.js42
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js89
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js98
-rw-r--r--src/content/dependencies/generateCommentaryIndexPage.js102
-rw-r--r--src/content/dependencies/generateCommentarySection.js29
-rw-r--r--src/content/dependencies/generateContentHeading.js45
-rw-r--r--src/content/dependencies/generateContributionList.js21
-rw-r--r--src/content/dependencies/generateCoverArtwork.js132
-rw-r--r--src/content/dependencies/generateCoverCarousel.js66
-rw-r--r--src/content/dependencies/generateCoverGrid.js59
-rw-r--r--src/content/dependencies/generateDatetimestampTemplate.js38
-rw-r--r--src/content/dependencies/generateFlashActGalleryPage.js91
-rw-r--r--src/content/dependencies/generateFlashActNavAccent.js71
-rw-r--r--src/content/dependencies/generateFlashActSidebar.js30
-rw-r--r--src/content/dependencies/generateFlashActSidebarCurrentActBox.js63
-rw-r--r--src/content/dependencies/generateFlashActSidebarSideMapBox.js85
-rw-r--r--src/content/dependencies/generateFlashCoverArtwork.js12
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js154
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js198
-rw-r--r--src/content/dependencies/generateFlashNavAccent.js73
-rw-r--r--src/content/dependencies/generateFooterLocalizationLinks.js59
-rw-r--r--src/content/dependencies/generateGridActionLinks.js22
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js198
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js222
-rw-r--r--src/content/dependencies/generateGroupNavLinks.js104
-rw-r--r--src/content/dependencies/generateGroupSecondaryNav.js100
-rw-r--r--src/content/dependencies/generateGroupSidebar.js39
-rw-r--r--src/content/dependencies/generateGroupSidebarCategoryDetails.js82
-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.js282
-rw-r--r--src/content/dependencies/generateListingSidebar.js29
-rw-r--r--src/content/dependencies/generateListingsIndexPage.js89
-rw-r--r--src/content/dependencies/generateNewsEntryPage.js131
-rw-r--r--src/content/dependencies/generateNewsEntryReadAnotherLinks.js97
-rw-r--r--src/content/dependencies/generateNewsIndexPage.js93
-rw-r--r--src/content/dependencies/generatePageLayout.js672
-rw-r--r--src/content/dependencies/generatePageSidebar.js103
-rw-r--r--src/content/dependencies/generatePageSidebarBox.js20
-rw-r--r--src/content/dependencies/generatePageSidebarConjoinedBox.js42
-rw-r--r--src/content/dependencies/generatePreviousNextLinks.js50
-rw-r--r--src/content/dependencies/generateRelativeDatetimestamp.js69
-rw-r--r--src/content/dependencies/generateReleaseInfoContributionsLine.js42
-rw-r--r--src/content/dependencies/generateSecondaryNav.js20
-rw-r--r--src/content/dependencies/generateSocialEmbed.js65
-rw-r--r--src/content/dependencies/generateStaticPage.js46
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js34
-rw-r--r--src/content/dependencies/generateTextWithTooltip.js62
-rw-r--r--src/content/dependencies/generateTooltip.js30
-rw-r--r--src/content/dependencies/generateTrackAdditionalNamesBox.js53
-rw-r--r--src/content/dependencies/generateTrackCoverArtwork.js34
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js652
-rw-r--r--src/content/dependencies/generateTrackList.js59
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js53
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js90
-rw-r--r--src/content/dependencies/generateTrackSocialEmbed.js86
-rw-r--r--src/content/dependencies/generateTrackSocialEmbedDescription.js38
-rw-r--r--src/content/dependencies/generateWikiHomeAlbumsRow.js150
-rw-r--r--src/content/dependencies/generateWikiHomeContentRow.js28
-rw-r--r--src/content/dependencies/generateWikiHomeNewsBox.js85
-rw-r--r--src/content/dependencies/generateWikiHomePage.js115
-rw-r--r--src/content/dependencies/image.js383
-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.js14
-rw-r--r--src/content/dependencies/linkAlbumGallery.js8
-rw-r--r--src/content/dependencies/linkArtTag.js8
-rw-r--r--src/content/dependencies/linkArtist.js8
-rw-r--r--src/content/dependencies/linkArtistGallery.js8
-rw-r--r--src/content/dependencies/linkCommentaryIndex.js12
-rw-r--r--src/content/dependencies/linkContribution.js145
-rw-r--r--src/content/dependencies/linkExternal.js136
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js51
-rw-r--r--src/content/dependencies/linkFlash.js8
-rw-r--r--src/content/dependencies/linkFlashAct.js14
-rw-r--r--src/content/dependencies/linkFlashIndex.js12
-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/linkPathFromMedia.js13
-rw-r--r--src/content/dependencies/linkPathFromRoot.js13
-rw-r--r--src/content/dependencies/linkPathFromSite.js13
-rw-r--r--src/content/dependencies/linkStaticPage.js8
-rw-r--r--src/content/dependencies/linkStationaryIndex.js24
-rw-r--r--src/content/dependencies/linkTemplate.js73
-rw-r--r--src/content/dependencies/linkThing.js154
-rw-r--r--src/content/dependencies/linkTrack.js8
-rw-r--r--src/content/dependencies/linkTrackDynamically.js34
-rw-r--r--src/content/dependencies/linkWikiHome.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.js1
-rw-r--r--src/content/dependencies/listArtistsByCommentaryEntries.js58
-rw-r--r--src/content/dependencies/listArtistsByContributions.js160
-rw-r--r--src/content/dependencies/listArtistsByDuration.js60
-rw-r--r--src/content/dependencies/listArtistsByGroup.js133
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js319
-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.js193
-rw-r--r--src/content/dependencies/listTagsByName.js54
-rw-r--r--src/content/dependencies/listTagsByUses.js59
-rw-r--r--src/content/dependencies/listTracksByAlbum.js48
-rw-r--r--src/content/dependencies/listTracksByDate.js85
-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.js595
-rw-r--r--src/content/util/getChronologyRelations.js55
-rw-r--r--src/content/util/groupTracksByGroup.js23
-rw-r--r--src/data/cacheable-object.js420
-rw-r--r--src/data/checks.js704
-rw-r--r--src/data/composite.js1297
-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/index.js14
-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/withResultOfAvailabilityCheck.js71
-rw-r--r--src/data/composite/data/excludeFromList.js55
-rw-r--r--src/data/composite/data/fillMissingListItems.js50
-rw-r--r--src/data/composite/data/index.js17
-rw-r--r--src/data/composite/data/withFilteredList.js50
-rw-r--r--src/data/composite/data/withFlattenedList.js47
-rw-r--r--src/data/composite/data/withMappedList.js39
-rw-r--r--src/data/composite/data/withPropertiesFromList.js92
-rw-r--r--src/data/composite/data/withPropertiesFromObject.js87
-rw-r--r--src/data/composite/data/withPropertyFromList.js82
-rw-r--r--src/data/composite/data/withPropertyFromObject.js69
-rw-r--r--src/data/composite/data/withSortedList.js121
-rw-r--r--src/data/composite/data/withUnflattenedList.js72
-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/withTrackSections.js127
-rw-r--r--src/data/composite/things/album/withTracks.js51
-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/exitWithoutUniqueCoverArt.js26
-rw-r--r--src/data/composite/things/track/index.js11
-rw-r--r--src/data/composite/things/track/inferredAdditionalNameList.js67
-rw-r--r--src/data/composite/things/track/inheritFromOriginalRelease.js50
-rw-r--r--src/data/composite/things/track/sharedAdditionalNameList.js38
-rw-r--r--src/data/composite/things/track/trackAdditionalNameList.js38
-rw-r--r--src/data/composite/things/track/trackReverseReferenceList.js38
-rw-r--r--src/data/composite/things/track/withAlbum.js22
-rw-r--r--src/data/composite/things/track/withAlwaysReferenceByDirectory.js78
-rw-r--r--src/data/composite/things/track/withContainingTrackSection.js42
-rw-r--r--src/data/composite/things/track/withHasUniqueCoverArt.js61
-rw-r--r--src/data/composite/things/track/withOriginalRelease.js78
-rw-r--r--src/data/composite/things/track/withOtherReleases.js41
-rw-r--r--src/data/composite/things/track/withPropertyFromAlbum.js40
-rw-r--r--src/data/composite/wiki-data/exitWithoutContribs.js47
-rw-r--r--src/data/composite/wiki-data/index.js16
-rw-r--r--src/data/composite/wiki-data/inputWikiData.js17
-rw-r--r--src/data/composite/wiki-data/withParsedCommentaryEntries.js179
-rw-r--r--src/data/composite/wiki-data/withResolvedContribs.js76
-rw-r--r--src/data/composite/wiki-data/withResolvedReference.js57
-rw-r--r--src/data/composite/wiki-data/withResolvedReferenceList.js101
-rw-r--r--src/data/composite/wiki-data/withReverseContributionList.js83
-rw-r--r--src/data/composite/wiki-data/withReverseReferenceList.js81
-rw-r--r--src/data/composite/wiki-data/withThingsSortedAlphabetically.js122
-rw-r--r--src/data/composite/wiki-data/withUniqueReferencingThing.js52
-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/color.js12
-rw-r--r--src/data/composite/wiki-properties/commentary.js30
-rw-r--r--src/data/composite/wiki-properties/commentatorArtists.js49
-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.js35
-rw-r--r--src/data/composite/wiki-properties/dimensions.js13
-rw-r--r--src/data/composite/wiki-properties/directory.js23
-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/index.js28
-rw-r--r--src/data/composite/wiki-properties/name.js11
-rw-r--r--src/data/composite/wiki-properties/referenceList.js45
-rw-r--r--src/data/composite/wiki-properties/reverseContributionList.js24
-rw-r--r--src/data/composite/wiki-properties/reverseReferenceList.js30
-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/urls.js14
-rw-r--r--src/data/composite/wiki-properties/wikiData.js27
-rw-r--r--src/data/language.js331
-rw-r--r--src/data/patches.js598
-rw-r--r--src/data/serialize.js31
-rw-r--r--src/data/thing.js77
-rw-r--r--src/data/things.js1720
-rw-r--r--src/data/things/album.js434
-rw-r--r--src/data/things/art-tag.js107
-rw-r--r--src/data/things/artist.js392
-rw-r--r--src/data/things/flash.js370
-rw-r--r--src/data/things/group.js194
-rw-r--r--src/data/things/homepage-layout.js223
-rw-r--r--src/data/things/index.js188
-rw-r--r--src/data/things/language.js730
-rw-r--r--src/data/things/news-entry.js73
-rw-r--r--src/data/things/static-page.js80
-rw-r--r--src/data/things/track.js566
-rw-r--r--src/data/things/wiki-info.js110
-rw-r--r--src/data/validators.js1018
-rw-r--r--src/data/yaml.js1978
-rw-r--r--src/file-size-preloader.js130
-rw-r--r--src/find.js253
-rw-r--r--src/gen-thumbs.js1499
-rw-r--r--src/listing-spec.js1116
-rw-r--r--src/misc-templates.js494
-rw-r--r--src/page/album-commentary.js143
-rw-r--r--src/page/album.js519
-rw-r--r--src/page/artist-alias.js32
-rw-r--r--src/page/artist.js595
-rw-r--r--src/page/flash-act.js23
-rw-r--r--src/page/flash.js267
-rw-r--r--src/page/group.js300
-rw-r--r--src/page/homepage.js136
-rw-r--r--src/page/index.js43
-rw-r--r--src/page/listing.js220
-rw-r--r--src/page/news.js138
-rw-r--r--src/page/static.js52
-rw-r--r--src/page/tag.js115
-rw-r--r--src/page/track.js345
-rw-r--r--src/repl.js70
-rw-r--r--src/static/client.js419
-rw-r--r--src/static/client3.js3483
-rw-r--r--src/static/icons.svg50
-rw-r--r--src/static/lazy-loading.js63
-rw-r--r--src/static/site-basic.css12
-rw-r--r--src/static/site.css986
-rw-r--r--src/static/site6.css2401
-rw-r--r--src/static/warning.svg93
-rw-r--r--src/strings-default.json372
-rw-r--r--src/strings-default.yaml1850
-rwxr-xr-xsrc/upd8.js3624
-rw-r--r--src/url-spec.js147
-rw-r--r--src/util/aggregate.js647
-rw-r--r--src/util/cli.js553
-rw-r--r--src/util/colors.js59
-rw-r--r--src/util/external-links.js998
-rw-r--r--src/util/find.js155
-rw-r--r--src/util/html.js1899
-rw-r--r--src/util/io.js14
-rw-r--r--src/util/link.js116
-rw-r--r--src/util/magic-constants.js10
-rw-r--r--src/util/node-utils.js111
-rw-r--r--src/util/replacer.js883
-rw-r--r--src/util/serialize.js108
-rw-r--r--src/util/sort.js405
-rw-r--r--src/util/sugar.js961
-rw-r--r--src/util/urls.js303
-rw-r--r--src/util/wiki-data.js546
-rw-r--r--src/write/bind-utilities.js73
-rw-r--r--src/write/build-modes/index.js3
-rw-r--r--src/write/build-modes/live-dev-server.js505
-rw-r--r--src/write/build-modes/repl.js156
-rw-r--r--src/write/build-modes/static-build.js507
-rw-r--r--src/write/common-templates.js57
-rw-r--r--tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs14
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs56
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumBanner.js.test.cjs18
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs37
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs40
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs33
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs31
-rw-r--r--tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs130
-rw-r--r--tap-snapshots/test/snapshot/generateBanner.js.test.cjs14
-rw-r--r--tap-snapshots/test/snapshot/generateCoverArtwork.js.test.cjs37
-rw-r--r--tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs28
-rw-r--r--tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs99
-rw-r--r--tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs50
-rw-r--r--tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs36
-rw-r--r--tap-snapshots/test/snapshot/image.js.test.cjs70
-rw-r--r--tap-snapshots/test/snapshot/linkArtist.js.test.cjs14
-rw-r--r--tap-snapshots/test/snapshot/linkContribution.js.test.cjs163
-rw-r--r--tap-snapshots/test/snapshot/linkExternal.js.test.cjs228
-rw-r--r--tap-snapshots/test/snapshot/linkTemplate.js.test.cjs32
-rw-r--r--tap-snapshots/test/snapshot/linkThing.js.test.cjs45
-rw-r--r--tap-snapshots/test/snapshot/transformContent.js.test.cjs132
-rw-r--r--test/cacheable-object.js274
-rw-r--r--test/data-validators.js277
-rw-r--r--test/lib/content-function.js255
-rw-r--r--test/lib/generic-mock.js314
-rw-r--r--test/lib/index.js6
-rw-r--r--test/lib/strict-match-error.js50
-rw-r--r--test/lib/wiki-data.js72
-rw-r--r--test/snapshot/generateAdditionalFilesShortcut.js36
-rw-r--r--test/snapshot/generateAlbumAdditionalFilesList.js84
-rw-r--r--test/snapshot/generateAlbumBanner.js34
-rw-r--r--test/snapshot/generateAlbumCoverArtwork.js36
-rw-r--r--test/snapshot/generateAlbumReleaseInfo.js74
-rw-r--r--test/snapshot/generateAlbumSecondaryNav.js55
-rw-r--r--test/snapshot/generateAlbumSidebarGroupBox.js57
-rw-r--r--test/snapshot/generateAlbumTrackList.js104
-rw-r--r--test/snapshot/generateBanner.js22
-rw-r--r--test/snapshot/generateCoverArtwork.js31
-rw-r--r--test/snapshot/generatePreviousNextLinks.js35
-rw-r--r--test/snapshot/generateTrackAdditionalNamesBox.js107
-rw-r--r--test/snapshot/generateTrackCoverArtwork.js61
-rw-r--r--test/snapshot/generateTrackReleaseInfo.js51
-rw-r--r--test/snapshot/image.js125
-rw-r--r--test/snapshot/linkArtist.js30
-rw-r--r--test/snapshot/linkContribution.js104
-rw-r--r--test/snapshot/linkExternal.js225
-rw-r--r--test/snapshot/linkTemplate.js63
-rw-r--r--test/snapshot/linkThing.js94
-rw-r--r--test/snapshot/transformContent.js161
-rw-r--r--test/things.js71
-rw-r--r--test/unit/content/dependencies/generateAlbumTrackList.js40
-rw-r--r--test/unit/content/dependencies/linkArtist.js31
-rw-r--r--test/unit/content/dependencies/linkContribution.js122
-rw-r--r--test/unit/data/cacheable-object.js355
-rw-r--r--test/unit/data/composite/control-flow/exposeConstant.js42
-rw-r--r--test/unit/data/composite/control-flow/exposeDependency.js64
-rw-r--r--test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js195
-rw-r--r--test/unit/data/composite/data/withPropertiesFromObject.js254
-rw-r--r--test/unit/data/composite/data/withPropertyFromObject.js122
-rw-r--r--test/unit/data/composite/data/withUniqueItemsOnly.js84
-rw-r--r--test/unit/data/composite/things/track/withAlbum.js91
-rw-r--r--test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js102
-rw-r--r--test/unit/data/compositeFrom.js345
-rw-r--r--test/unit/data/templateCompositeFrom.js209
-rw-r--r--test/unit/data/things/album.js451
-rw-r--r--test/unit/data/things/art-tag.js71
-rw-r--r--test/unit/data/things/flash.js55
-rw-r--r--test/unit/data/things/track.js759
-rw-r--r--test/unit/data/things/validators.js440
-rw-r--r--test/unit/util/html.js927
423 files changed, 64673 insertions, 14721 deletions
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..ea29422
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,2 @@
+indent_style = space
+indent_size = 2
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..5568290
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,21 @@
+{
+  "env": {
+    "es2021": true,
+    "node": true
+  },
+  "extends": "eslint:recommended",
+  "parserOptions": {
+    "ecmaVersion": "latest",
+    "sourceType": "module"
+  },
+  "rules": {
+    "indent": ["off"],
+    "no-unexpected-multiline": ["off"],
+    "no-unused-labels": ["off"],
+    "no-unused-vars": ["error", {
+      "argsIgnorePattern": "^_",
+      "destructuredArrayIgnorePattern": "^"
+    }],
+    "no-cond-assign": ["off"]
+  }
+}
diff --git a/.gitignore b/.gitignore
index 438d6ec..a9dda76 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
-node_modules
-site
+/node_modules
+/.tap
+.DS_Store
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..bba89fe
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,13 @@
+{
+  "arrowParens": "always",
+  "bracketSpacing": false,
+  "printWidth": 80,
+  "proseWrap": "never",
+  "quoteProps": "as-needed",
+  "requirePragma": true,
+  "semi": true,
+  "singleQuote": true,
+  "tabWidth": 2,
+  "trailingComma": "es5",
+  "useTabs": false
+}
diff --git a/.taprc b/.taprc
new file mode 100644
index 0000000..01ac7b6
--- /dev/null
+++ b/.taprc
@@ -0,0 +1,6 @@
+# vim: set filetype=yaml :
+coverage-map: coverage-map.js
+exclude:
+  - test/lib/*
+test-env:
+  - CLICOLOR_FORCE=0
diff --git a/LICENSE.txt b/LICENSE.txt
index 113dea8..5872985 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -1,4 +1,4 @@
-Copyright 2019-2022 Quasar Nebula et al <qznebula@protonmail.com>
+Copyright 2019-2023 Quasar Nebula et al <qznebula@protonmail.com>
 
 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
 
diff --git a/README.md b/README.md
index 2a67c00..06480de 100644
--- a/README.md
+++ b/README.md
@@ -1,47 +1,183 @@
 # HSMusic
 
-HSMusic, short for the *Homestuck Music Wiki*, is a revitalization and reimagining of [earlier][fandom] [projects][nsnd] archiving and celebrating the expansive history of Homestuck official and fan music. Roughly periodic releases of the website are released at [hsmusic.wiki][hsmusic]; all development occurs in this public Git repository, which can be accessed at [notabug.org][notabug].
+HSMusic, short for the *Homestuck Music Wiki*, is a revitalization and reimagining of [earlier][fandom] [projects][nsnd] archiving and celebrating the expansive history of Homestuck official and fan music. Roughly periodic releases of the website are released at [hsmusic.wiki][hsmusic]; all development occurs in this public Git repository, which can be accessed at [github.com][github].
 
-## Project Structure
+## Quick Start
+
+Install dependencies:
+
+- [Node.js](https://nodejs.org/en/) - we recommend using [nvm](https://github.com/nvm-sh/nvm) to install Node and keep easy track of any versions you've got installed; development is generally tested on latest but 20.x LTS should also work
+- [ImageMagick](https://imagemagick.org/) - check your package manager if it's available (e.g. apt or homebrew) or follow [installation info right from the official website](https://imagemagick.org/script/download.php)
+
+Make a new empty folder for storing all your HSMusic repositories, then clone 'em with git:
+
+```
+$ cd /path/to/my/projects/
+$ mkdir hsmusic
+$ cd hsmusic
+$ git clone https://github.com/hsmusic/hsmusic-wiki code
+Cloning into 'code'...
+$ git clone https://github.com/hsmusic/hsmusic-data data
+Cloning into 'data'...
+$ git clone https://github.com/hsmusic/hsmusic-media media
+Cloning into 'media'...
+```
+
+Install NPM dependencies (packages) used by HSMusic:
+
+```
+$ cd code
+$ npm install
+added 413 packages, and audited 612 packages in 10s
+```
+
+Optionally, use `npm link` to make `hsmusic` available from the command line anywhere on your device:
+
+```
+$ npm link
+# This doesn't work reliably on every device. If it shows
+# an error about permissions (and you aren't interested in
+# working out the details yourself), you can just move on.
+```
+
+Go back to the main directory (containing all the repos) and make an empty folder for the first and subsequent builds:
+
+```
+$ cd ..
+
+$ pwd
+/path/to/my/projects/hsmusic
+$ ls
+code/  data/  media/
+# If you don't see the above info, you've moved to the wrong directory.
+# Just do cd /path/to/my/projects/hsmusic (with whatever path you created
+# the main directory in) to get back.
 
-**Disclaimer:** most of the code here *sucks*. It's been shambled together over the course of over a year, and while we're fairly confident it's all at minimum functional, we can't guarantee the same about its understandability! Still, for the official release of [hsmusic.wiki][hsmusic], we've done our best to put together a codebase which is *somewhat* navigable. The description below summarizes it:
+$ mkdir out
+```
 
-* `src/upd8.js`: "Build" code for the site. Everything specific to generating the structure and HTML content of the website is conatined in this file. As expected, it's pretty massive, and is currently undergoing some much-belated restructuring.
-* `src/static`: Static code and supporting files. Everything here is wholly client-side and referenced by the generated HTML files.
-* `src/common`: Code which is depended upon by both client- and server-side code. For the most part, this is constants such as directory paths, though there are a few handy algorithms here too.
-* In the not quite so far past, we used to have `data` and `media` folders too. Today, for portability and convenience in project structure, those are saved in separate repositories, and you can pass hsmusic paths to them through the `--data-path` and `--media-path` options, or the `HSMUSIC_DATA` and `HSMUSIC_MEDIA` environment variables.
-  * Data directory: The majority of data files belonging to the wiki are here. If you were to, say, create a fork of hsmusic for some other music archival project, you'd want to change the files here. Data files are all a custom text format designed to be easy to edit, process, and maintain; they should be self-descriptive.
-  * Media directory: Images and other static files referenced by generated and static content across the site. Many of the files here are cover art, and their names match the automatically generated "kebab case" identifiers for tracks and albums (or a manually overridden one).
-* Same for the output root: previously it was in a `site` folder; today, use `--out-path` or `HSMUSIC_OUT`!
+Then build the site:
 
-The upd8 code process was politely introduced by 2019!us back when we were beginning the site, and it's essentially the same structure followed today. In summary:
+```
+# If you used npm link:
+$ hsmusic --data-path data --media-path media --out-path out
 
-1. Locate and read data files, processing them into relatively usable JS object-style formats. (The formats themselves are hard-coded and somewhat arbitrary, and are often extended when more or different data is useful.)
-2. Validate the data and show any errors that might've been caught during processing. (These aren't exhaustive test cases; they're designed to catch a majority of common errors and typos.)
-3. Create symlinks for static files and generate the basic directory structure for the site.
-4. Generate and write HTML files containing all content. (Rather than use external templates and a complex build system, we just use template strings in combination with [a whitespace utility][fixws] and some handy tricks for manipulating strings and JS.)
+# If you didn't:
+$ node code/src/upd8.js --data-path data --media-path media --out-path out
+```
 
-The majority of the code volume is generated HTML content and supporting utility functions; while we've attempted to keep the update file more or less organized, the most reliable way to navigate is to just ctrl-F for the function definitions of whatever you intend to work on. Code order isn't super strict since everything is handled by separate function calls (which all branch off of the "main" function at the end of the file).
+You should get a bunch of info eventually showing the site building! It may take a while (especially since HSMusic has a lot of data nowadays).
 
-In the past, data, HTML, and media files were all interspersed with each other. Yea, even the generated HTML files were included as part of the repository; their diffs, part of every commit. Those were dark times indeed.
+If all goes according to plan and there aren't any errors, all the site HTML should have been written to the `out` directory. Use a simple HTTP server to view it in your browser:
+
+```
+$ cd site
+
+# choose your favorite HTTP server
+$ npx http-server -p 8002
+$ python3 -m http.server 8002
+$ python2 -m SimpleHTTPServer 8002
+```
+
+If you don't have access to an HTTP server or lack device permissions to run one, you can also just view the generated HTML files in your browser and *most* features should still work. (Try `--append-index-html` in the `hsmusic`/`upd8.js` command to make generated links more direct.) This isn't an officially supported way to develop, so there might be bugs, but most of the site should still work.
+
+**If you encounter any errors along the way, or would like help getting the wiki working,** please feel welcomed to reach out through the [HSMusic Community Discord Server][discord]. We're a fairly active group there and are always happy to help! **This also applies if you don't have much experience with Git, GitHub, Node, or any of the necessary tooling, and want help getting used to them.**
+
+## Project Structure
+
+### General build process
+
+When you run HSMusic to build the wiki, several processes happen in succession. Any errors along the way will be reported - we hope with human-readable feedback, but [pop by the Discord][discord] if you have any questions or need help understanding errors or parts of the code.
+
+1. Update thumbnails in the media repo so that any new images automatically get thumbnails.
+2. Locate and read data files, processing them into relatively usable JS object-style formats.
+3. Validate the data and show any errors caught during processing.
+4. Create symlinks for static files and generate the basic directory structure for the site.
+5. Generate and write HTML files (and any supporting files) containing all content.
+
+### Multiple repositories
+
+HSMusic works using a number of repositories in tandem:
+
+- [`hsmusic-wiki`][github-code] (colloquially "code"): The code repository, including all behavior required to process data and content from the other repositories and turn it into an actual website. This is probably the repo you're viewing right now.
+  - Code is written entirely in modern JavaScript, with the actual website a static combination of HTML and CSS (with inexhaustive JavaScript for certain features).
+  - More details about the code repository below.
+- [`hsmusic-data`][github-data]: The data repository, comprising all the data which makes a given wiki what it *is*. The repository linked here is for the [Homestuck Music Wiki][hsmusic] itself, but it may be swapped out for other data repos to build other completely different wikis.
+  - This repo covers albums, tracks, artists, groups, and a variety of other things which make up the content of a music wiki.
+  - The data repo also contains all the metadata which makes one wiki unique from another: layout info, static pages (like "About & Credits"), whether or not certain site features are enabled (like "Flashes & Games" or UI for browsing groups), and so on.
+  - All data is written and accessed in the YAML format, and every file follows a specific structure described within this (code) repository. See below and the `src/data` directory for details.
+- [`hsmusic-media`][github-media]: The media repository, holding all album, track, and layout media used across the site in one place. Media and organization directly corresponds to entries in the data repository; generally the data and media repositories go together and are swapped out for another together.
+- [`hsmusic-lang`][github-lang]: The language repository, holding up-to-date strings and other localization info for HSMusic.
+  - Strings and language info are stored in top-level YAML files within this repository. They're based off the `src/strings-default.yaml` file within the code repo (and don't need to provide translations for all strings to be used for site building).
+
+The code repository as well as the data and media repositories are require for site building, with the language repo optionally provided to add localization support to the wiki build.
+
+The path to each repo may be specified respectively by the `--data-path`, `--media-path`, and `--lang-path` arguments (when building the site or using e.g. data-related CLI tools). If you find it inconvenient to type or keep track of these values, you may alternatively set environment variables `HSMUSIC_DATA`, `HSMUSIC_MEDIA`, and `HSMUSIC_LANG` to provide the same values. One convenient layout for locally organizing the HSMusic repositories is shown below:
+
+    path/to/my/projects/
+      hsmusic/
+        code/   <clone of hsmusic-wiki>
+        data/   <clone of hsmusic-data>
+        media/  <clone of hsmusic-media>
+        out/    <empty directory> (will be overwritten)
+        env.sh
+
+The `env.sh` script shown above is a straightforward utility for loading those variables into the envronment, so you don't need to type path arguments every time:
+
+    #!/bin/bash
+    base="$(realpath "$(dirname ${BASH_SOURCE[0]})")"
+    export HSMUSIC_DATA="$base/data/"
+    export HSMUSIC_MEDIA="$base/media/"
+    # export HSMUSIC_LANG="$base/lang/" # uncomment if present
+    export HSMUSIC_OUT="$base/out/"
+
+Then use `source env.sh` when starting work from the CLI to get access to all the convenient environment variables. (This setup is written for Bash of course, but you can use the same idea to export env variables with your own shell's syntax.)
+
+### Code repository source structure
+
+The source code for HSMusic is divided across a number of source files, loosely grouped together in a number of directories:
+
+- `src/`
+  - `data/`
+    - `things/`: Descriptors for individual types of data objects used across the wiki, notably including:
+      - `cacheable-object.js`: Backbone of how data objects (colloquially "things") store, share, and compute their properties
+      - `thing.js`: Common superclass for most data objects, with a bunch of utilities and common behavior
+      - `validators.js`: Convenient error-throwing utilities which help ensure properties set on data objects follow the right format
+    - `yaml.js`: Mappings from YAML documents (the format used in `hsmusic-data`) to things (actual data objects), and a full suite of utilities used to actually load that data from scratch
+  - `content/`: Functions which generate HTML content; these go from bite-sized, commonly reused utilities (like `linkTemplate`) all the way up to entire page definitions (like `generateArtistInfoPage`)
+  - `page/`: Definitions for page paths, mapping data objects to paths served over an HTTP server (or written to an output folder) and to the functions which actually generate those pages' content
+  - `write/`: Common utilities and output methods for controlling what hsmusic does to turn data and media into something you actually visit; these each define a variety of command-line arguments and are basically the interchangeable  "second half" of upd8.js
+    - `live-dev-server.js`: Gets the site available for viewing as quickly as possible, generating and serving pages as they are requested from a local HTTP server; reacts live to code changes in the `content` directory
+    - `static-build.js`: Builds the entire site at once, writing all the output to one self-contained folder which can be uploaded to a static file server
+    - `repl.js`: Provides a convenient REPL to run filters and transformations on data objects right from the Node.js command line
+  - `static/`: Purely client-side files are kept here, e.g. site CSS, icon SVGs, and client-side JS
+  - `util/`: Common utilities which generally may be accessed from both Node.js or the client (web browser)
+  - `upd8.js`: Main entry point which controls and directs site generation from start to finish
+  - `gen-thumbs.js`: Standalone utility also called every time HSMusic is run (unless `--skip-thumbs` is provided) which keeps a persistent cache of media MD5s and (re)generates thumbnails for new or updated image files
+  - `listing-spec.js`: Descriptors for computations and HTML templates used for the Listings part of the site
+  - `url-spec.js`: Index of output paths where generated HTML ends up; also controls where `<a>`, `<img>`, etc tags link
+  - `file-size-preloader.js`: Simple utility for calculating size of files in media directory
+  - `strings-default.json`: Template for localization strings and index of default (English) strings used all across the site layout
 
 ## Forking
 
 hsmusic is a relatively generic music wiki software, so you're more than encouraged to create a fork for your own archival or cataloguing purposes! You're encouraged to [drop us a link][feedback] if you do - we'd love to hear from you.
 
-Still, at present moment, a fair bit of the wiki design is baked into the update code itself - any configuration (such as getting rid of the "flashes & games") section will have you digging into the code yourself. In the future, we'd love to make the wiki software more customizable from a forking perspective, but we haven't gotten to it yet. Let us know if this is something you're interested in - we'd love to chat about what additions or changes would be useful in making a more versatile generic music wiki software!
-
 ## Pull Requests
 
-As mentioned, part of the focus of the hsmusic.wiki release was to create a more modular and develop-able repository. So, on the curious chance anyone would like to contribute code to the repo, such is certainly capable now!
+As mentioned, part of the focus of the hsmusic.wiki release, as well as most development since, has been to create a more modular and developer-friendly repository. So, on the curious chance anyone would like to contribute code to the repo, that's more possible now than it used to be!
 
-Still, for larger additions, I'd encourage you to throw an email or contact ([links here][feedback]) before writing all the implementation code: besides code tips which might make your life a bit easier (questions are welcome), I'd also love to discuss feature designs and values while they're still being brainstormed! That way, I don't need to tell you there are fundamental ideas or code details I'd want rebuilt - the last thing I want is anyone putting hours into code which could have been avoided being poured down the drain!
+Still, for larger additions, we encourage you to [drop the main devs an email][feedback] or, better yet, [pop by the Discord][discord] before writing all the implementation code: besides code tips which might make your life a bit easier (questions are welcome), we also love to discuss feature designs and values while they're still being brainstormed! That way, nobody has to tell you there are fundamental ideas or implementation details that should be rebuilt from the ground up - the last thing we want is anyone putting hours into code that has to be replaced by another implementation before it ever ends up part of the wiki!
 
 As ever, feedback is always welcome, and may be shared via the usual links. Thank you for checking the repository out!
 
+  [discord]: https://hsmusic.wiki/discord/
   [fandom]: https://homestuck-and-mspa-music.fandom.com/wiki/Homestuck_and_MSPA_Music_Wiki
-  [nsnd]: https://homestuck.net/music/references.html
-  [hsmusic]: https://hsmusic.wiki
-  [notabug]: https://notabug.org/hsmusic/hsmusic
-  [fixws]: https://www.npmjs.com/package/fix-whitespace
   [feedback]: https://hsmusic.wiki/feedback/
+  [github]: https://github.com/hsmusic/hsmusic-wiki
+  [github-code]: https://github.com/hsmusic/hsmusic-wiki
+  [github-data]: https://github.com/hsmusic/hsmusic-data
+  [github-lang]: https://github.com/hsmusic/hsmusic-lang
+  [github-media]: https://github.com/hsmusic/hsmusic-media
+  [hsmusic]: https://hsmusic.wiki
+  [nsnd]: https://homestuck.net/music/references.html
diff --git a/coverage-map.js b/coverage-map.js
new file mode 100644
index 0000000..beff9e8
--- /dev/null
+++ b/coverage-map.js
@@ -0,0 +1,71 @@
+// node-tap test -> src coverage map
+// https://node-tap.org/coverage/
+
+export default function map(F) {
+  let match;
+
+  // unit/content/...
+
+  match = F.match(/^test\/unit\/content\/(.*)$/);
+  if (match) {
+    const f = match[1];
+
+    match = f.match(/^dependencies\/(.*)\.js$/);
+    if (match) {
+      return `src/content/dependencies/${match[1]}.js`;
+    }
+  }
+
+  // unit/data/...
+
+  match = F.match(/^test\/unit\/data\/(.*)$/);
+  if (match) {
+    const f = match[1];
+
+    match = f.match(/^composite\/(.*)$/);
+    if (match) {
+      return `src/data/composite/${match[1]}`;
+    }
+
+    match = f.match(/^things\/(.*)\.js$/);
+    if (match) {
+      return `src/data/things/${match[1]}.js`;
+    }
+
+    match = f.match(/^cacheable-object\.js$/);
+    if (match) {
+      return `src/data/things/cacheable-object.js`;
+    }
+
+    match = f.match(/^(templateCompositeFrom|compositeFrom)\.js$/);
+    if (match) {
+      return `src/data/things/composite.js`;
+    }
+  }
+
+  // unit/util/...
+
+  match = F.match(/^test\/unit\/util\/(.*)$/);
+  if (match) {
+    const f = match[1];
+
+    switch (f) {
+      case 'html.js':
+        return 'src/util/html.js';
+    }
+  }
+
+  // snapshot/...
+
+  match = F.match(/^test\/snapshot\/(.*)$/);
+  if (match) {
+    const f = match[1];
+
+    match = f.match(/^(.*)\.js$/);
+    if (match) {
+      return `src/content/dependencies/${match[1]}.js`;
+    }
+  }
+
+  return null;
+}
diff --git a/data-tests/index.js b/data-tests/index.js
new file mode 100644
index 0000000..3901db0
--- /dev/null
+++ b/data-tests/index.js
@@ -0,0 +1,119 @@
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+import chokidar from 'chokidar';
+
+import {colors, logError, logInfo, logWarn, parseOptions} from '#cli';
+import {bindFind, getAllFindSpecs} from '#find';
+import {isMain} from '#node-utils';
+import {getContextAssignments} from '#repl';
+import {bindOpts, showAggregate} from '#sugar';
+import {quickLoadAllFromYAML} from '#yaml';
+
+async function main() {
+  const miscOptions = await parseOptions(process.argv.slice(2), {
+    'data-path': {
+      type: 'value',
+    },
+  });
+
+  const dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA;
+
+  if (!dataPath) {
+    logError`Expected --data-path option or HSMUSIC_DATA to be set`;
+    return;
+  }
+
+  console.log(`HSMusic automated data tests`);
+  console.log(`${colors.bright(colors.yellow(`:star:`))} Now featuring quick-reloading! ${colors.bright(colors.cyan(`:earth:`))}`);
+
+  // Watch adjacent files in data-tests directory
+  const metaPath = fileURLToPath(import.meta.url);
+  const metaDirname = path.dirname(metaPath);
+  const watcher = chokidar.watch(metaDirname);
+
+  const wikiData = await quickLoadAllFromYAML(dataPath, {
+    bindFind,
+    getAllFindSpecs,
+
+    showAggregate: bindOpts(showAggregate, {
+      showTraces: false,
+    }),
+  });
+
+  const context = await getContextAssignments({
+    wikiData,
+  });
+
+  let resolveNext;
+
+  const queue = [];
+
+  watcher.on('all', (event, path) => {
+    if (!['add', 'change'].includes(event)) return;
+    if (path === metaPath) return;
+    if (resolveNext) {
+      resolveNext(path);
+    } else if (!queue.includes(path)) {
+      queue.push(path);
+    }
+  });
+
+  logInfo`Awaiting file changes.`;
+
+  /* eslint-disable-next-line no-constant-condition */
+  while (true) {
+    const testPath = (queue.length
+      ? queue.shift()
+      : await new Promise(resolve => {
+          resolveNext = resolve;
+        }));
+
+    resolveNext = null;
+
+    const shortPath = path.basename(testPath);
+
+    logInfo`Path updated: ${shortPath} - running this test!`;
+
+    let imp;
+    try {
+      imp = await import(`${testPath}?${Date.now()}`)
+    } catch (error) {
+      logWarn`Failed to import ${shortPath} - ${error.constructor.name} details below:`;
+      console.error(error);
+      continue;
+    }
+
+    const {default: testFn} = imp;
+
+    if (!testFn) {
+      logWarn`No default export for ${shortPath}`;
+      logWarn`Skipping this test for now!`;
+      continue;
+    }
+
+    if (typeof testFn !== 'function') {
+      logWarn`Default export for ${shortPath} is ${typeof testFn}, not function`;
+      logWarn`Skipping this test for now!`;
+      continue;
+    }
+
+    try {
+      await testFn(context);
+    } catch (error) {
+      showAggregate(error, {
+        pathToFileURL: f => path.relative(metaDirname, fileURLToPath(f)),
+      });
+    }
+  }
+}
+
+if (isMain(import.meta.url)) {
+  main().catch((error) => {
+    if (error instanceof AggregateError) {
+      showAggregate(error);
+    } else {
+      console.error(error);
+    }
+  });
+}
diff --git a/data-tests/test-no-short-tracks.js b/data-tests/test-no-short-tracks.js
new file mode 100644
index 0000000..7635609
--- /dev/null
+++ b/data-tests/test-no-short-tracks.js
@@ -0,0 +1,25 @@
+export default function({
+  albumData,
+  getTotalDuration,
+}) {
+  const shortAlbums = albumData
+    .filter(album => album.tracks.length > 1)
+    .map(album => ({
+      album,
+      duration: getTotalDuration(album.tracks),
+    }))
+    .filter(album => album.duration)
+    .filter(album => album.duration < 60 * 15);
+
+  if (!shortAlbums.length) return true;
+
+  shortAlbums.sort((a, b) => a.duration - b.duration);
+
+  console.log(`Found ${shortAlbums.length} short albums! Oh nooooooo!`);
+  console.log(`Here are the shortest 10:`);
+  for (const {duration, album} of shortAlbums.slice(0, 10)) {
+    console.log(`- (${duration}s)`, album);
+  }
+
+  return false;
+}
diff --git a/data-tests/test-order-of-album-groups.js b/data-tests/test-order-of-album-groups.js
new file mode 100644
index 0000000..57500e3
--- /dev/null
+++ b/data-tests/test-order-of-album-groups.js
@@ -0,0 +1,55 @@
+import {inspect} from 'node:util';
+
+export default function({
+  albumData,
+  groupCategoryData,
+}) {
+  const groupSchemaTemplate = [
+    ['Projects beyond Homestuck', 'Fandom projects'],
+    ['Solo musicians', 'Fan-musician groups'],
+    ['HSMusic'],
+  ];
+
+  const groupSchema =
+    groupSchemaTemplate.map(names => names.flatMap(
+      name => groupCategoryData
+        .find(gc => gc.name === name)
+        .groups));
+
+  const badAlbums = albumData.filter(album => {
+    const groups = album.groups.slice();
+    const disallowed = [];
+    for (const allowed of groupSchema) {
+      while (groups.length) {
+        if (disallowed.includes(groups[0]))
+          return true;
+        else if (allowed.includes(groups[0]))
+          groups.shift();
+        else break;
+      }
+      disallowed.push(...allowed);
+    }
+    return false;
+  });
+
+  if (!badAlbums.length) return true;
+
+  console.log(`Some albums don't list their groups in the right order:`);
+  for (const album of badAlbums) {
+    console.log('-', album);
+    for (const group of album.groups) {
+      console.log(`  - ${inspect(group)}`)
+    }
+  }
+
+  console.log(`Here's the group schema they should be updated to match:`);
+  for (const section of groupSchemaTemplate) {
+    if (section.length > 1) {
+      console.log(`- Groups from any of: ${section.join(', ')}`);
+    } else {
+      console.log(`- Groups from: ${section}`);
+    }
+  }
+
+  return false;
+}
diff --git a/nodemon.json b/nodemon.json
new file mode 100644
index 0000000..f14074d
--- /dev/null
+++ b/nodemon.json
@@ -0,0 +1,5 @@
+{
+  "ignore": [
+    "tap-snapshots/**"
+  ]
+}
diff --git a/package-lock.json b/package-lock.json
index fb17557..ad7c5ab 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,643 +9,2942 @@
             "version": "0.1.0",
             "license": "GPL-3.0",
             "dependencies": {
-                "fix-whitespace": "^1.0.4",
+                "@js-temporal/polyfill": "^0.4.4",
+                "chroma-js": "^2.4.2",
+                "command-exists": "^1.2.9",
+                "eslint": "^8.37.0",
                 "he": "^1.2.0",
-                "js-yaml": "^4.1.0"
+                "image-size": "^1.0.2",
+                "js-yaml": "^4.1.0",
+                "marked": "^10.0.0",
+                "striptags": "^4.0.0-alpha.4",
+                "word-wrap": "^1.2.3"
             },
             "bin": {
                 "hsmusic": "src/upd8.js"
             },
             "devDependencies": {
-                "tape": "^5.4.1"
+                "chokidar": "^3.5.3",
+                "tap": "^18.4.0",
+                "tcompare": "^6.0.0"
+            },
+            "engines": {
+                "node": ">= 20.9.0"
             }
         },
-        "node_modules/argparse": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
-            "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
-        },
-        "node_modules/array.prototype.every": {
-            "version": "1.1.3",
-            "resolved": "https://registry.npmjs.org/array.prototype.every/-/array.prototype.every-1.1.3.tgz",
-            "integrity": "sha512-vWnriJI//SOMOWtXbU/VXhJ/InfnNHPF6BLKn5WfY8xXy+NWql0fUy20GO3sdqBhCAO+qw8S/E5nJiZX+QFdCA==",
+        "node_modules/@alcalzone/ansi-tokenize": {
+            "version": "0.1.3",
+            "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz",
+            "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3",
-                "es-abstract": "^1.19.0",
-                "is-string": "^1.0.7"
+                "ansi-styles": "^6.2.1",
+                "is-fullwidth-code-point": "^4.0.0"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=14.13.1"
+            }
+        },
+        "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": {
+            "version": "6.2.1",
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+            "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
             }
         },
-        "node_modules/available-typed-arrays": {
-            "version": "1.0.5",
-            "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz",
-            "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==",
+        "node_modules/@base2/pretty-print-object": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz",
+            "integrity": "sha512-4iri8i1AqYHJE2DstZYkyEprg6Pq6sKx3xn5FpySk9sNhH7qN2LLlHJCfDTZRILNwQNPD7mATWM0TBui7uC1pA==",
+            "dev": true
+        },
+        "node_modules/@bcoe/v8-coverage": {
+            "version": "0.2.3",
+            "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
+            "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
+            "dev": true
+        },
+        "node_modules/@cspotcode/source-map-support": {
+            "version": "0.8.1",
+            "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+            "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
             "dev": true,
+            "dependencies": {
+                "@jridgewell/trace-mapping": "0.3.9"
+            },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=12"
+            }
+        },
+        "node_modules/@eslint-community/eslint-utils": {
+            "version": "4.4.0",
+            "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+            "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+            "dependencies": {
+                "eslint-visitor-keys": "^3.3.0"
+            },
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            },
+            "peerDependencies": {
+                "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+            }
+        },
+        "node_modules/@eslint-community/regexpp": {
+            "version": "4.5.0",
+            "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.0.tgz",
+            "integrity": "sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ==",
+            "engines": {
+                "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+            }
+        },
+        "node_modules/@eslint/eslintrc": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.2.tgz",
+            "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==",
+            "dependencies": {
+                "ajv": "^6.12.4",
+                "debug": "^4.3.2",
+                "espree": "^9.5.1",
+                "globals": "^13.19.0",
+                "ignore": "^5.2.0",
+                "import-fresh": "^3.2.1",
+                "js-yaml": "^4.1.0",
+                "minimatch": "^3.1.2",
+                "strip-json-comments": "^3.1.1"
+            },
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://opencollective.com/eslint"
             }
         },
-        "node_modules/balanced-match": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
-            "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
-            "dev": true
+        "node_modules/@eslint/js": {
+            "version": "8.37.0",
+            "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.37.0.tgz",
+            "integrity": "sha512-x5vzdtOOGgFVDCUs81QRB2+liax8rFg3+7hqM+QhBG0/G3F1ZsoYl97UrqgHgQ9KKT7G6c4V+aTUCgu/n22v1A==",
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            }
         },
-        "node_modules/brace-expansion": {
-            "version": "1.1.11",
-            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
-            "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+        "node_modules/@humanwhocodes/config-array": {
+            "version": "0.11.8",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
+            "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
+            "dependencies": {
+                "@humanwhocodes/object-schema": "^1.2.1",
+                "debug": "^4.1.1",
+                "minimatch": "^3.0.5"
+            },
+            "engines": {
+                "node": ">=10.10.0"
+            }
+        },
+        "node_modules/@humanwhocodes/module-importer": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+            "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+            "engines": {
+                "node": ">=12.22"
+            },
+            "funding": {
+                "type": "github",
+                "url": "https://github.com/sponsors/nzakas"
+            }
+        },
+        "node_modules/@humanwhocodes/object-schema": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
+            "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA=="
+        },
+        "node_modules/@isaacs/cliui": {
+            "version": "8.0.2",
+            "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+            "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
             "dev": true,
             "dependencies": {
-                "balanced-match": "^1.0.0",
-                "concat-map": "0.0.1"
+                "string-width": "^5.1.2",
+                "string-width-cjs": "npm:string-width@^4.2.0",
+                "strip-ansi": "^7.0.1",
+                "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+                "wrap-ansi": "^8.1.0",
+                "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+            },
+            "engines": {
+                "node": ">=12"
             }
         },
-        "node_modules/call-bind": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
-            "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+        "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+            "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+            }
+        },
+        "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
+            "version": "7.1.0",
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+            "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
             "dev": true,
             "dependencies": {
-                "function-bind": "^1.1.1",
-                "get-intrinsic": "^1.0.2"
+                "ansi-regex": "^6.0.1"
+            },
+            "engines": {
+                "node": ">=12"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/chalk/strip-ansi?sponsor=1"
             }
         },
-        "node_modules/concat-map": {
-            "version": "0.0.1",
-            "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
-            "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+        "node_modules/@isaacs/ts-node-temp-fork-for-pr-2009": {
+            "version": "10.9.5",
+            "resolved": "https://registry.npmjs.org/@isaacs/ts-node-temp-fork-for-pr-2009/-/ts-node-temp-fork-for-pr-2009-10.9.5.tgz",
+            "integrity": "sha512-hEDlwpHhIabtB+Urku8muNMEkGui0LVGlYLS3KoB9QBDf0Pw3r7q0RrfoQmFuk8CvRpGzErO3/vLQd9Ys+/g4g==",
+            "dev": true,
+            "dependencies": {
+                "@cspotcode/source-map-support": "^0.8.0",
+                "@tsconfig/node14": "*",
+                "@tsconfig/node16": "*",
+                "@tsconfig/node18": "*",
+                "@tsconfig/node20": "*",
+                "acorn": "^8.4.1",
+                "acorn-walk": "^8.1.1",
+                "arg": "^4.1.0",
+                "diff": "^4.0.1",
+                "make-error": "^1.1.1",
+                "v8-compile-cache-lib": "^3.0.1"
+            },
+            "bin": {
+                "ts-node": "dist/bin.js",
+                "ts-node-cwd": "dist/bin-cwd.js",
+                "ts-node-esm": "dist/bin-esm.js",
+                "ts-node-script": "dist/bin-script.js",
+                "ts-node-transpile-only": "dist/bin-transpile.js"
+            },
+            "peerDependencies": {
+                "@swc/core": ">=1.2.50",
+                "@swc/wasm": ">=1.2.50",
+                "@types/node": "*",
+                "typescript": ">=4.2"
+            },
+            "peerDependenciesMeta": {
+                "@swc/core": {
+                    "optional": true
+                },
+                "@swc/wasm": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@isaacs/ts-node-temp-fork-for-pr-2009/node_modules/diff": {
+            "version": "4.0.2",
+            "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+            "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.3.1"
+            }
+        },
+        "node_modules/@istanbuljs/schema": {
+            "version": "0.1.3",
+            "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+            "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/@jridgewell/resolve-uri": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
+            "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@jridgewell/sourcemap-codec": {
+            "version": "1.4.15",
+            "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
+            "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
             "dev": true
         },
-        "node_modules/deep-equal": {
+        "node_modules/@jridgewell/trace-mapping": {
+            "version": "0.3.9",
+            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+            "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+            "dev": true,
+            "dependencies": {
+                "@jridgewell/resolve-uri": "^3.0.3",
+                "@jridgewell/sourcemap-codec": "^1.4.10"
+            }
+        },
+        "node_modules/@js-temporal/polyfill": {
+            "version": "0.4.4",
+            "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.4.4.tgz",
+            "integrity": "sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==",
+            "dependencies": {
+                "jsbi": "^4.3.0",
+                "tslib": "^2.4.1"
+            },
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@nodelib/fs.scandir": {
+            "version": "2.1.5",
+            "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+            "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+            "dependencies": {
+                "@nodelib/fs.stat": "2.0.5",
+                "run-parallel": "^1.1.9"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/@nodelib/fs.stat": {
             "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.0.5.tgz",
-            "integrity": "sha512-nPiRgmbAtm1a3JsnLCf6/SLfXcjyN5v8L1TXzdCmHrXJ4hx+gW/w1YCcn7z8gJtSiDArZCgYtbao3QqLm/N1Sw==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.0",
-                "es-get-iterator": "^1.1.1",
-                "get-intrinsic": "^1.0.1",
-                "is-arguments": "^1.0.4",
-                "is-date-object": "^1.0.2",
-                "is-regex": "^1.1.1",
-                "isarray": "^2.0.5",
-                "object-is": "^1.1.4",
-                "object-keys": "^1.1.1",
-                "object.assign": "^4.1.2",
-                "regexp.prototype.flags": "^1.3.0",
-                "side-channel": "^1.0.3",
-                "which-boxed-primitive": "^1.0.1",
-                "which-collection": "^1.0.1",
-                "which-typed-array": "^1.1.2"
+            "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+            "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/@nodelib/fs.walk": {
+            "version": "1.2.8",
+            "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+            "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+            "dependencies": {
+                "@nodelib/fs.scandir": "2.1.5",
+                "fastq": "^1.6.0"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">= 8"
             }
         },
-        "node_modules/define-properties": {
-            "version": "1.1.3",
-            "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
-            "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
+        "node_modules/@npmcli/agent": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.0.tgz",
+            "integrity": "sha512-2yThA1Es98orMkpSLVqlDZAMPK3jHJhifP2gnNUdk1754uZ8yI5c+ulCoVG+WlntQA6MzhrURMXjSd9Z7dJ2/Q==",
             "dev": true,
             "dependencies": {
-                "object-keys": "^1.0.12"
+                "agent-base": "^7.1.0",
+                "http-proxy-agent": "^7.0.0",
+                "https-proxy-agent": "^7.0.1",
+                "lru-cache": "^10.0.1",
+                "socks-proxy-agent": "^8.0.1"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": "^16.14.0 || >=18.0.0"
             }
         },
-        "node_modules/defined": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
-            "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=",
-            "dev": true
+        "node_modules/@npmcli/fs": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz",
+            "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==",
+            "dev": true,
+            "dependencies": {
+                "semver": "^7.3.5"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
         },
-        "node_modules/dotignore": {
-            "version": "0.1.2",
-            "resolved": "https://registry.npmjs.org/dotignore/-/dotignore-0.1.2.tgz",
-            "integrity": "sha512-UGGGWfSauusaVJC+8fgV+NVvBXkCTmVv7sk6nojDZZvuOUNGUy0Zk4UpHQD6EDjS0jpBwcACvH4eofvyzBcRDw==",
+        "node_modules/@npmcli/git": {
+            "version": "5.0.4",
+            "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.4.tgz",
+            "integrity": "sha512-nr6/WezNzuYUppzXRaYu/W4aT5rLxdXqEFupbh6e/ovlYFQ8hpu1UUPV3Ir/YTl+74iXl2ZOMlGzudh9ZPUchQ==",
             "dev": true,
             "dependencies": {
-                "minimatch": "^3.0.4"
+                "@npmcli/promise-spawn": "^7.0.0",
+                "lru-cache": "^10.0.1",
+                "npm-pick-manifest": "^9.0.0",
+                "proc-log": "^3.0.0",
+                "promise-inflight": "^1.0.1",
+                "promise-retry": "^2.0.1",
+                "semver": "^7.3.5",
+                "which": "^4.0.0"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@npmcli/git/node_modules/isexe": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+            "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=16"
+            }
+        },
+        "node_modules/@npmcli/git/node_modules/which": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+            "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+            "dev": true,
+            "dependencies": {
+                "isexe": "^3.1.1"
             },
             "bin": {
-                "ignored": "bin/ignored"
-            }
-        },
-        "node_modules/es-abstract": {
-            "version": "1.19.1",
-            "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz",
-            "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.2",
-                "es-to-primitive": "^1.2.1",
-                "function-bind": "^1.1.1",
-                "get-intrinsic": "^1.1.1",
-                "get-symbol-description": "^1.0.0",
-                "has": "^1.0.3",
-                "has-symbols": "^1.0.2",
-                "internal-slot": "^1.0.3",
-                "is-callable": "^1.2.4",
-                "is-negative-zero": "^2.0.1",
-                "is-regex": "^1.1.4",
-                "is-shared-array-buffer": "^1.0.1",
-                "is-string": "^1.0.7",
-                "is-weakref": "^1.0.1",
-                "object-inspect": "^1.11.0",
-                "object-keys": "^1.1.1",
-                "object.assign": "^4.1.2",
-                "string.prototype.trimend": "^1.0.4",
-                "string.prototype.trimstart": "^1.0.4",
-                "unbox-primitive": "^1.0.1"
+                "node-which": "bin/which.js"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": "^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@npmcli/installed-package-contents": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz",
+            "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==",
+            "dev": true,
+            "dependencies": {
+                "npm-bundled": "^3.0.0",
+                "npm-normalize-package-bin": "^3.0.0"
+            },
+            "bin": {
+                "installed-package-contents": "lib/index.js"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@npmcli/node-gyp": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz",
+            "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==",
+            "dev": true,
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@npmcli/promise-spawn": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz",
+            "integrity": "sha512-P4KkF9jX3y+7yFUxgcUdDtLy+t4OlDGuEBLNs57AZsfSfg+uV6MLndqGpnl4831ggaEdXwR50XFoZP4VFtHolg==",
+            "dev": true,
+            "dependencies": {
+                "which": "^4.0.0"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@npmcli/promise-spawn/node_modules/isexe": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+            "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=16"
+            }
+        },
+        "node_modules/@npmcli/promise-spawn/node_modules/which": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+            "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+            "dev": true,
+            "dependencies": {
+                "isexe": "^3.1.1"
+            },
+            "bin": {
+                "node-which": "bin/which.js"
+            },
+            "engines": {
+                "node": "^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@npmcli/run-script": {
+            "version": "7.0.3",
+            "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.3.tgz",
+            "integrity": "sha512-ZMWGLHpzMq3rBGIwPyeaoaleaLMvrBrH8nugHxTi5ACkJZXTxXPtVuEH91ifgtss5hUwJQ2VDnzDBWPmz78rvg==",
+            "dev": true,
+            "dependencies": {
+                "@npmcli/node-gyp": "^3.0.0",
+                "@npmcli/promise-spawn": "^7.0.0",
+                "node-gyp": "^10.0.0",
+                "read-package-json-fast": "^3.0.0",
+                "which": "^4.0.0"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@npmcli/run-script/node_modules/isexe": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+            "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=16"
+            }
+        },
+        "node_modules/@npmcli/run-script/node_modules/which": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+            "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+            "dev": true,
+            "dependencies": {
+                "isexe": "^3.1.1"
+            },
+            "bin": {
+                "node-which": "bin/which.js"
+            },
+            "engines": {
+                "node": "^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@pkgjs/parseargs": {
+            "version": "0.11.0",
+            "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+            "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+            "dev": true,
+            "optional": true,
+            "engines": {
+                "node": ">=14"
+            }
+        },
+        "node_modules/@sigstore/bundle": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz",
+            "integrity": "sha512-89uOo6yh/oxaU8AeOUnVrTdVMcGk9Q1hJa7Hkvalc6G3Z3CupWk4Xe9djSgJm9fMkH69s0P0cVHUoKSOemLdng==",
+            "dev": true,
+            "dependencies": {
+                "@sigstore/protobuf-specs": "^0.2.1"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@sigstore/protobuf-specs": {
+            "version": "0.2.1",
+            "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.2.1.tgz",
+            "integrity": "sha512-XTWVxnWJu+c1oCshMLwnKvz8ZQJJDVOlciMfgpJBQbThVjKTCG8dwyhgLngBD2KN0ap9F/gOV8rFDEx8uh7R2A==",
+            "dev": true,
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@sigstore/sign": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.2.0.tgz",
+            "integrity": "sha512-AAbmnEHDQv6CSfrWA5wXslGtzLPtAtHZleKOgxdQYvx/s76Fk6T6ZVt7w2IGV9j1UrFeBocTTQxaXG2oRrDhYA==",
+            "dev": true,
+            "dependencies": {
+                "@sigstore/bundle": "^2.1.0",
+                "@sigstore/protobuf-specs": "^0.2.1",
+                "make-fetch-happen": "^13.0.0"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@sigstore/tuf": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.2.0.tgz",
+            "integrity": "sha512-KKATZ5orWfqd9ZG6MN8PtCIx4eevWSuGRKQvofnWXRpyMyUEpmrzg5M5BrCpjM+NfZ0RbNGOh5tCz/P2uoRqOA==",
+            "dev": true,
+            "dependencies": {
+                "@sigstore/protobuf-specs": "^0.2.1",
+                "tuf-js": "^2.1.0"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@tapjs/after": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/after/-/after-1.1.17.tgz",
+            "integrity": "sha512-14qeP+mHZ8nIMDGtdCwTgvKclLlHxfARMTasb9fw//tmF/8ZDZhTemtCDxAP75wihxy5P7nzVZo/6TpVeOZrwg==",
+            "dev": true,
+            "dependencies": {
+                "is-actual-promise": "^1.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
+        },
+        "node_modules/@tapjs/after-each": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/after-each/-/after-each-1.1.17.tgz",
+            "integrity": "sha512-ia8sr00Wilni+2+wO4MKYCYikeRwUC41HamV8EPN63R2UmiBEOe/cMSf+KYADIh56JvxAiH7Xa0+GSFU+N2FQQ==",
+            "dev": true,
+            "dependencies": {
+                "function-loop": "^4.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
+        },
+        "node_modules/@tapjs/asserts": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/asserts/-/asserts-1.1.17.tgz",
+            "integrity": "sha512-eKmbWBORDXu9bUHtPTu7qFrXNj5UeeH2nABJeP9BGHIn2ydmTgMEWCO3E+ljf7tisHchY5/x672lr99+O/mbTQ==",
+            "dev": true,
+            "dependencies": {
+                "@tapjs/stack": "1.2.7",
+                "is-actual-promise": "^1.0.0",
+                "tcompare": "6.4.5",
+                "trivial-deferred": "^2.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
             }
         },
-        "node_modules/es-get-iterator": {
-            "version": "1.1.2",
-            "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.2.tgz",
-            "integrity": "sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ==",
+        "node_modules/@tapjs/before": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/before/-/before-1.1.17.tgz",
+            "integrity": "sha512-pAmEAIMIqF9MPNUgEsnuWCM00iD/FJOX0P5eXSsWexWHjuZAkv5tIT/4qpXO9KYj+9c51Lh+7YSY2Xvk1Jjolw==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.0",
-                "has-symbols": "^1.0.1",
-                "is-arguments": "^1.1.0",
-                "is-map": "^2.0.2",
-                "is-set": "^2.0.2",
-                "is-string": "^1.0.5",
-                "isarray": "^2.0.5"
+                "is-actual-promise": "^1.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
+        },
+        "node_modules/@tapjs/before-each": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/before-each/-/before-each-1.1.17.tgz",
+            "integrity": "sha512-d2Um3Y2j0m563QNsSxczh+QeSg5sBngnBFGOelUtQVqmq91oNWU/7mY1pwN6ip8mMIQYD75CIhq5/Z57DGomWQ==",
+            "dev": true,
+            "dependencies": {
+                "function-loop": "^4.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
+        },
+        "node_modules/@tapjs/config": {
+            "version": "2.4.14",
+            "resolved": "https://registry.npmjs.org/@tapjs/config/-/config-2.4.14.tgz",
+            "integrity": "sha512-dkjPVJGbLJC9BxCAxudAGiijnKc6XcQbpBSMAGJ/+VoRSqXlPkMWz0d8Ad3rNt7s+g2GBEWBx1kV7wcKtLlxmw==",
+            "dev": true,
+            "dependencies": {
+                "@tapjs/core": "1.4.6",
+                "@tapjs/test": "1.3.17",
+                "chalk": "^5.2.0",
+                "jackspeak": "^2.3.6",
+                "polite-json": "^4.0.1",
+                "tap-yaml": "2.2.1",
+                "walk-up-path": "^3.0.1"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6",
+                "@tapjs/test": "1.3.17"
+            }
+        },
+        "node_modules/@tapjs/config/node_modules/chalk": {
+            "version": "5.3.0",
+            "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+            "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+            "dev": true,
+            "engines": {
+                "node": "^12.17.0 || ^14.13 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/chalk?sponsor=1"
+            }
+        },
+        "node_modules/@tapjs/core": {
+            "version": "1.4.6",
+            "resolved": "https://registry.npmjs.org/@tapjs/core/-/core-1.4.6.tgz",
+            "integrity": "sha512-cAKtdGJslrziwi/RJBU7jF930P/eSsemv295t6yLekNVP0XUCNtLFYirxuS1Xwob0nt0g/k+94xXB7o1wdTQvA==",
+            "dev": true,
+            "dependencies": {
+                "@tapjs/processinfo": "^3.1.6",
+                "@tapjs/stack": "1.2.7",
+                "@tapjs/test": "1.3.17",
+                "async-hook-domain": "^4.0.1",
+                "diff": "^5.1.0",
+                "is-actual-promise": "^1.0.0",
+                "minipass": "^7.0.3",
+                "signal-exit": "4.1",
+                "tap-parser": "15.3.1",
+                "tap-yaml": "2.2.1",
+                "tcompare": "6.4.5",
+                "trivial-deferred": "^2.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             }
         },
-        "node_modules/es-to-primitive": {
+        "node_modules/@tapjs/error-serdes": {
             "version": "1.2.1",
-            "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
-            "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+            "resolved": "https://registry.npmjs.org/@tapjs/error-serdes/-/error-serdes-1.2.1.tgz",
+            "integrity": "sha512-/7eLEcrGo+Qz3eWrjkhDC+VSEOjabkkzr9eRADeU+OLFeZaik8L/GRk0SGhnp4YsQkv0jcNV00A42bEx2HIZcw==",
             "dev": true,
             "dependencies": {
-                "is-callable": "^1.1.4",
-                "is-date-object": "^1.0.1",
-                "is-symbol": "^1.0.2"
+                "minipass": "^7.0.3"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/fix-whitespace": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/fix-whitespace/-/fix-whitespace-1.0.4.tgz",
-            "integrity": "sha512-TYJpw4orIgDpaINRkw1BVJQF8rPTNSUbW/s4mLYSApUt0MquGfI+iripYHibg9l9fe795VauuVCLTpDvy8KFWQ=="
+        "node_modules/@tapjs/filter": {
+            "version": "1.2.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/filter/-/filter-1.2.17.tgz",
+            "integrity": "sha512-ytsqoPThV92ML1+M+cHlhAS7nOQpDNRBJiPqw20/GmNeoQXsDzVUlWR89DP3WNNUPrr/c1pCVr9XHVhCIeYk0w==",
+            "dev": true,
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
         },
-        "node_modules/for-each": {
-            "version": "0.3.3",
-            "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
-            "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
+        "node_modules/@tapjs/fixture": {
+            "version": "1.2.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/fixture/-/fixture-1.2.17.tgz",
+            "integrity": "sha512-eOOQxtsEcQ/sBxaZhpqdF9DCNxXAvLuiE5HgyL6d1eB4eceu57uIUKK7NDtFVv+vlbQH/NoiSTxmN/IBRbKT8w==",
             "dev": true,
             "dependencies": {
-                "is-callable": "^1.1.3"
+                "mkdirp": "^3.0.0",
+                "rimraf": "^5.0.5"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
             }
         },
-        "node_modules/foreach": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz",
-            "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=",
-            "dev": true
+        "node_modules/@tapjs/fixture/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
         },
-        "node_modules/fs.realpath": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
-            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
-            "dev": true
+        "node_modules/@tapjs/fixture/node_modules/glob": {
+            "version": "10.3.10",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "dev": true,
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^2.3.5",
+                "minimatch": "^9.0.1",
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                "path-scurry": "^1.10.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
         },
-        "node_modules/function-bind": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
-            "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
-            "dev": true
+        "node_modules/@tapjs/fixture/node_modules/minimatch": {
+            "version": "9.0.3",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "dev": true,
+            "dependencies": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
         },
-        "node_modules/get-intrinsic": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
-            "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+        "node_modules/@tapjs/fixture/node_modules/rimraf": {
+            "version": "5.0.5",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
+            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
             "dev": true,
             "dependencies": {
-                "function-bind": "^1.1.1",
-                "has": "^1.0.3",
-                "has-symbols": "^1.0.1"
+                "glob": "^10.3.7"
+            },
+            "bin": {
+                "rimraf": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=14"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/get-package-type": {
-            "version": "0.1.0",
-            "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
-            "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+        "node_modules/@tapjs/intercept": {
+            "version": "1.2.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/intercept/-/intercept-1.2.17.tgz",
+            "integrity": "sha512-CNuYBxiFBMNALS1PxH3yGI10H8ObxOoD67C2xGWyzXeYrPJ/R4x31Sda9bqaoK3uf/vj28bC9kSECCFjRsNAEg==",
             "dev": true,
+            "dependencies": {
+                "@tapjs/after": "1.1.17",
+                "@tapjs/stack": "1.2.7"
+            },
             "engines": {
-                "node": ">=8.0.0"
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
             }
         },
-        "node_modules/get-symbol-description": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
-            "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+        "node_modules/@tapjs/mock": {
+            "version": "1.2.15",
+            "resolved": "https://registry.npmjs.org/@tapjs/mock/-/mock-1.2.15.tgz",
+            "integrity": "sha512-uXfVNDAMAbCGOu46B9jbryTau2pLSQjCdWnkAm/OUgZh/OtO0i7OORz9HdEPfEF2tuy1tLo9+vsCZm3lPU5F7w==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
+                "@tapjs/after": "1.1.17",
+                "@tapjs/stack": "1.2.7",
+                "resolve-import": "^1.4.5",
+                "walk-up-path": "^3.0.1"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
             }
         },
-        "node_modules/glob": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
-            "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
+        "node_modules/@tapjs/node-serialize": {
+            "version": "1.2.6",
+            "resolved": "https://registry.npmjs.org/@tapjs/node-serialize/-/node-serialize-1.2.6.tgz",
+            "integrity": "sha512-xj1OJEsdTr0pQFlirfe/apN0dHUCMCx2Nm5H3SoiSOW4D1/FUKS65VZpWgo3mXMPxRyb/2T1DH3xON1eSGq4ww==",
             "dev": true,
             "dependencies": {
-                "fs.realpath": "^1.0.0",
-                "inflight": "^1.0.4",
-                "inherits": "2",
-                "minimatch": "^3.0.4",
-                "once": "^1.3.0",
-                "path-is-absolute": "^1.0.0"
+                "@tapjs/error-serdes": "1.2.1",
+                "@tapjs/stack": "1.2.7",
+                "tap-parser": "15.3.1"
             },
             "engines": {
-                "node": "*"
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "funding": {
                 "url": "https://github.com/sponsors/isaacs"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
             }
         },
-        "node_modules/has": {
-            "version": "1.0.3",
-            "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
-            "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+        "node_modules/@tapjs/processinfo": {
+            "version": "3.1.6",
+            "resolved": "https://registry.npmjs.org/@tapjs/processinfo/-/processinfo-3.1.6.tgz",
+            "integrity": "sha512-ktDsaf79wJsLaoG1Pp+stHSRf6a1k/JydoRAaYVG5iJnd3DooL6yewZsciUi2yiN/WQc5tAXCIFTXL4uXGB8LA==",
             "dev": true,
             "dependencies": {
-                "function-bind": "^1.1.1"
+                "pirates": "^4.0.5",
+                "process-on-spawn": "^1.0.0",
+                "signal-exit": "^4.0.2",
+                "uuid": "^8.3.2"
             },
             "engines": {
-                "node": ">= 0.4.0"
+                "node": ">=16.17"
             }
         },
-        "node_modules/has-bigints": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
-            "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==",
+        "node_modules/@tapjs/reporter": {
+            "version": "1.3.15",
+            "resolved": "https://registry.npmjs.org/@tapjs/reporter/-/reporter-1.3.15.tgz",
+            "integrity": "sha512-us1vXd6TW1V8wJxxnP2a8DNSP1WFTpODyYukqWg7ym5nCalREYnz2MFsn65rRNu/xJlmqsmv+9P63rupud7Zlg==",
             "dev": true,
+            "dependencies": {
+                "@tapjs/config": "2.4.14",
+                "@tapjs/stack": "1.2.7",
+                "chalk": "^5.2.0",
+                "ink": "^4.4.1",
+                "minipass": "^7.0.3",
+                "ms": "^2.1.3",
+                "patch-console": "^2.0.0",
+                "prismjs-terminal": "^1.2.3",
+                "react": "^18.2.0",
+                "string-length": "^6.0.0",
+                "tap-parser": "15.3.1",
+                "tap-yaml": "2.2.1",
+                "tcompare": "6.4.5"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
+        },
+        "node_modules/@tapjs/reporter/node_modules/chalk": {
+            "version": "5.3.0",
+            "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+            "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+            "dev": true,
+            "engines": {
+                "node": "^12.17.0 || ^14.13 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/chalk?sponsor=1"
+            }
+        },
+        "node_modules/@tapjs/reporter/node_modules/ms": {
+            "version": "2.1.3",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+            "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+            "dev": true
+        },
+        "node_modules/@tapjs/run": {
+            "version": "1.4.16",
+            "resolved": "https://registry.npmjs.org/@tapjs/run/-/run-1.4.16.tgz",
+            "integrity": "sha512-ZTESjBDj5SitZgWz2hQdzfBoxgaFs89jQjWzqobcdfro0iF7TVRpSrvpz9GTMdo2Tu9aeFfMNfmaAtwNWnDabw==",
+            "dev": true,
+            "dependencies": {
+                "@tapjs/after": "1.1.17",
+                "@tapjs/before": "1.1.17",
+                "@tapjs/config": "2.4.14",
+                "@tapjs/processinfo": "^3.1.6",
+                "@tapjs/reporter": "1.3.15",
+                "@tapjs/spawn": "1.1.17",
+                "@tapjs/stdin": "1.1.17",
+                "@tapjs/test": "1.3.17",
+                "c8": "^8.0.1",
+                "chalk": "^5.3.0",
+                "chokidar": "^3.5.3",
+                "foreground-child": "^3.1.1",
+                "glob": "^10.3.10",
+                "minipass": "^7.0.3",
+                "mkdirp": "^3.0.1",
+                "opener": "^1.5.2",
+                "pacote": "^17.0.3",
+                "resolve-import": "^1.4.5",
+                "rimraf": "^5.0.5",
+                "semver": "^7.5.4",
+                "signal-exit": "^4.1.0",
+                "tap-parser": "15.3.1",
+                "tap-yaml": "2.2.1",
+                "tcompare": "6.4.5",
+                "trivial-deferred": "^2.0.0",
+                "which": "^4.0.0"
+            },
+            "bin": {
+                "tap-run": "dist/esm/index.js"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
             }
         },
-        "node_modules/has-dynamic-import": {
+        "node_modules/@tapjs/run/node_modules/brace-expansion": {
             "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/has-dynamic-import/-/has-dynamic-import-2.0.1.tgz",
-            "integrity": "sha512-X3fbtsZmwb6W7fJGR9o7x65fZoodygCrZ3TVycvghP62yYQfS0t4RS0Qcz+j5tQYUKeSWS09tHkWW6WhFV3XhQ==",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/@tapjs/run/node_modules/chalk": {
+            "version": "5.3.0",
+            "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+            "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+            "dev": true,
+            "engines": {
+                "node": "^12.17.0 || ^14.13 || >=16.0.0"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/chalk/chalk?sponsor=1"
             }
         },
-        "node_modules/has-symbols": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
-            "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
+        "node_modules/@tapjs/run/node_modules/glob": {
+            "version": "10.3.10",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
             "dev": true,
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^2.3.5",
+                "minimatch": "^9.0.1",
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                "path-scurry": "^1.10.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
+            },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=16 || 14 >=14.17"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/has-tostringtag": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
-            "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+        "node_modules/@tapjs/run/node_modules/isexe": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+            "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=16"
+            }
+        },
+        "node_modules/@tapjs/run/node_modules/minimatch": {
+            "version": "9.0.3",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
             "dev": true,
             "dependencies": {
-                "has-symbols": "^1.0.2"
+                "brace-expansion": "^2.0.1"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=16 || 14 >=14.17"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/he": {
-            "version": "1.2.0",
-            "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
-            "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+        "node_modules/@tapjs/run/node_modules/rimraf": {
+            "version": "5.0.5",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
+            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+            "dev": true,
+            "dependencies": {
+                "glob": "^10.3.7"
+            },
             "bin": {
-                "he": "bin/he"
+                "rimraf": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=14"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/inflight": {
-            "version": "1.0.6",
-            "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
-            "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+        "node_modules/@tapjs/run/node_modules/which": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+            "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
             "dev": true,
             "dependencies": {
-                "once": "^1.3.0",
-                "wrappy": "1"
+                "isexe": "^3.1.1"
+            },
+            "bin": {
+                "node-which": "bin/which.js"
+            },
+            "engines": {
+                "node": "^16.13.0 || >=18.0.0"
             }
         },
-        "node_modules/inherits": {
-            "version": "2.0.4",
-            "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+        "node_modules/@tapjs/snapshot": {
+            "version": "1.2.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/snapshot/-/snapshot-1.2.17.tgz",
+            "integrity": "sha512-xDHys854ZA8s/1uCkE5PgBz4H1vYKChD6a4xjLVkaoRxpBHVp/IJZCD+8d69DRGnyuA4x2MGh0JLClTA9bLGrA==",
+            "dev": true,
+            "dependencies": {
+                "is-actual-promise": "^1.0.0",
+                "tcompare": "6.4.5",
+                "trivial-deferred": "^2.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
+        },
+        "node_modules/@tapjs/spawn": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/spawn/-/spawn-1.1.17.tgz",
+            "integrity": "sha512-Bbyxd91bgXEcglvXYKrRl2MaNHk00RajTZJ1kKe3Scr1ivaYv0maE6ZInAl4UE0a4SJl4Dskec+uKoZY3qGUYQ==",
+            "dev": true,
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
+        },
+        "node_modules/@tapjs/stack": {
+            "version": "1.2.7",
+            "resolved": "https://registry.npmjs.org/@tapjs/stack/-/stack-1.2.7.tgz",
+            "integrity": "sha512-7qUDWDmd+y7ZQ0vTrDTvFlWnJ+ND32NemS5HVuT1ZggHtBwJ62PQHIyCx/B5RopETBb6NvFPfUE21yTiex9Jkw==",
+            "dev": true,
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/@tapjs/stdin": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/stdin/-/stdin-1.1.17.tgz",
+            "integrity": "sha512-mDutFFPDnlVM2oYDAfyYKA+fC+aEiyz5n08D8x6YAbwZNbTIVp+h6ucyp7ygJ04fshd4l3s1HUmCZLSmHb2xEw==",
+            "dev": true,
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
+        },
+        "node_modules/@tapjs/test": {
+            "version": "1.3.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/test/-/test-1.3.17.tgz",
+            "integrity": "sha512-yQ4uHC2GaDS+Gr5qwx9uMGxqvpYgnlVY+QexBReSeYZthWIN0KD8HDvnVt4An5Sx/Qhd7UlnNpNMBd6AkvPEew==",
+            "dev": true,
+            "dependencies": {
+                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.5",
+                "@tapjs/after": "1.1.17",
+                "@tapjs/after-each": "1.1.17",
+                "@tapjs/asserts": "1.1.17",
+                "@tapjs/before": "1.1.17",
+                "@tapjs/before-each": "1.1.17",
+                "@tapjs/filter": "1.2.17",
+                "@tapjs/fixture": "1.2.17",
+                "@tapjs/intercept": "1.2.17",
+                "@tapjs/mock": "1.2.15",
+                "@tapjs/node-serialize": "1.2.6",
+                "@tapjs/snapshot": "1.2.17",
+                "@tapjs/spawn": "1.1.17",
+                "@tapjs/stdin": "1.1.17",
+                "@tapjs/typescript": "1.3.6",
+                "@tapjs/worker": "1.1.17",
+                "glob": "^10.3.10",
+                "jackspeak": "^2.3.6",
+                "mkdirp": "^3.0.0",
+                "resolve-import": "^1.4.5",
+                "rimraf": "^5.0.5",
+                "sync-content": "^1.0.1",
+                "tap-parser": "15.3.1",
+                "tshy": "^1.2.2",
+                "typescript": "5.2"
+            },
+            "bin": {
+                "generate-tap-test-class": "scripts/build.mjs"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
+        },
+        "node_modules/@tapjs/test/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/@tapjs/test/node_modules/glob": {
+            "version": "10.3.10",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "dev": true,
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^2.3.5",
+                "minimatch": "^9.0.1",
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                "path-scurry": "^1.10.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/@tapjs/test/node_modules/minimatch": {
+            "version": "9.0.3",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "dev": true,
+            "dependencies": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/@tapjs/test/node_modules/rimraf": {
+            "version": "5.0.5",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
+            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+            "dev": true,
+            "dependencies": {
+                "glob": "^10.3.7"
+            },
+            "bin": {
+                "rimraf": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=14"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/@tapjs/typescript": {
+            "version": "1.3.6",
+            "resolved": "https://registry.npmjs.org/@tapjs/typescript/-/typescript-1.3.6.tgz",
+            "integrity": "sha512-bHqQb06HcD1vFvSwElH0WK4cnCNthvA5OX/KBs5w1TNFHIeRHemp/hsSnGSNDwYwDETuOxD68rDZNTpNbzysBg==",
+            "dev": true,
+            "dependencies": {
+                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.5"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
+        },
+        "node_modules/@tapjs/worker": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/worker/-/worker-1.1.17.tgz",
+            "integrity": "sha512-DCRzEBT+OgP518rQqzlX6KawvGTegkeEjPVa/TB6Iifj8WOHJ+XtunkR7riIRGEoCEOMD49DCJXj70c+XP0jNw==",
+            "dev": true,
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "peerDependencies": {
+                "@tapjs/core": "1.4.6"
+            }
+        },
+        "node_modules/@tsconfig/node14": {
+            "version": "14.1.0",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-14.1.0.tgz",
+            "integrity": "sha512-VmsCG04YR58ciHBeJKBDNMWWfYbyP8FekWVuTlpstaUPlat1D0x/tXzkWP7yCMU0eSz9V4OZU0LBWTFJ3xZf6w==",
             "dev": true
         },
-        "node_modules/internal-slot": {
-            "version": "1.0.3",
-            "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
-            "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+        "node_modules/@tsconfig/node16": {
+            "version": "16.1.1",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-16.1.1.tgz",
+            "integrity": "sha512-+pio93ejHN4nINX4pXqfnR/fPLRtJBaT4ORaa5RH0Oc1zoYmo2B2koG+M328CQhHKn1Wj6FcOxCDFXAot9NhvA==",
+            "dev": true
+        },
+        "node_modules/@tsconfig/node18": {
+            "version": "18.2.2",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.2.tgz",
+            "integrity": "sha512-d6McJeGsuoRlwWZmVIeE8CUA27lu6jLjvv1JzqmpsytOYYbVi1tHZEnwCNVOXnj4pyLvneZlFlpXUK+X9wBWyw==",
+            "dev": true
+        },
+        "node_modules/@tsconfig/node20": {
+            "version": "20.1.2",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.2.tgz",
+            "integrity": "sha512-madaWq2k+LYMEhmcp0fs+OGaLFk0OenpHa4gmI4VEmCKX4PJntQ6fnnGADVFrVkBj0wIdAlQnK/MrlYTHsa1gQ==",
+            "dev": true
+        },
+        "node_modules/@tufjs/canonical-json": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
+            "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==",
+            "dev": true,
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@tufjs/models": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.0.tgz",
+            "integrity": "sha512-c8nj8BaOExmZKO2DXhDfegyhSGcG9E/mPN3U13L+/PsoWm1uaGiHHjxqSHQiasDBQwDA3aHuw9+9spYAP1qvvg==",
             "dev": true,
             "dependencies": {
-                "get-intrinsic": "^1.1.0",
-                "has": "^1.0.3",
-                "side-channel": "^1.0.4"
+                "@tufjs/canonical-json": "2.0.0",
+                "minimatch": "^9.0.3"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/@tufjs/models/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0"
             }
         },
-        "node_modules/is-arguments": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
-            "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
+        "node_modules/@tufjs/models/node_modules/minimatch": {
+            "version": "9.0.3",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
+                "brace-expansion": "^2.0.1"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=16 || 14 >=14.17"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/is-bigint": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
-            "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+        "node_modules/@types/istanbul-lib-coverage": {
+            "version": "2.0.6",
+            "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
+            "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
+            "dev": true
+        },
+        "node_modules/@types/node": {
+            "version": "20.10.6",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz",
+            "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==",
             "dev": true,
+            "peer": true,
             "dependencies": {
-                "has-bigints": "^1.0.1"
+                "undici-types": "~5.26.4"
+            }
+        },
+        "node_modules/abbrev": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
+            "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
+            "dev": true,
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/acorn": {
+            "version": "8.8.2",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
+            "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
+            "bin": {
+                "acorn": "bin/acorn"
+            },
+            "engines": {
+                "node": ">=0.4.0"
+            }
+        },
+        "node_modules/acorn-jsx": {
+            "version": "5.3.2",
+            "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+            "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+            "peerDependencies": {
+                "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+            }
+        },
+        "node_modules/acorn-walk": {
+            "version": "8.3.1",
+            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz",
+            "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.4.0"
+            }
+        },
+        "node_modules/agent-base": {
+            "version": "7.1.0",
+            "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
+            "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==",
+            "dev": true,
+            "dependencies": {
+                "debug": "^4.3.4"
+            },
+            "engines": {
+                "node": ">= 14"
+            }
+        },
+        "node_modules/aggregate-error": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+            "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+            "dev": true,
+            "dependencies": {
+                "clean-stack": "^2.0.0",
+                "indent-string": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/aggregate-error/node_modules/indent-string": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+            "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/ajv": {
+            "version": "6.12.6",
+            "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+            "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+            "dependencies": {
+                "fast-deep-equal": "^3.1.1",
+                "fast-json-stable-stringify": "^2.0.0",
+                "json-schema-traverse": "^0.4.1",
+                "uri-js": "^4.2.2"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "type": "github",
+                "url": "https://github.com/sponsors/epoberezkin"
             }
         },
-        "node_modules/is-boolean-object": {
-            "version": "1.1.2",
-            "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
-            "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
+        "node_modules/ansi-escapes": {
+            "version": "6.2.0",
+            "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz",
+            "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
+                "type-fest": "^3.0.0"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=14.16"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/is-callable": {
-            "version": "1.2.4",
-            "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
-            "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+        "node_modules/ansi-escapes/node_modules/type-fest": {
+            "version": "3.13.1",
+            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
+            "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
             "dev": true,
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=14.16"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/is-core-module": {
-            "version": "2.8.1",
-            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz",
-            "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==",
+        "node_modules/ansi-regex": {
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+            "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/ansi-styles": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+            "dependencies": {
+                "color-convert": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            }
+        },
+        "node_modules/anymatch": {
+            "version": "3.1.3",
+            "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+            "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
             "dev": true,
             "dependencies": {
-                "has": "^1.0.3"
+                "normalize-path": "^3.0.0",
+                "picomatch": "^2.0.4"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/arg": {
+            "version": "4.1.3",
+            "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+            "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+            "dev": true
+        },
+        "node_modules/argparse": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+            "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+        },
+        "node_modules/async-hook-domain": {
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/async-hook-domain/-/async-hook-domain-4.0.1.tgz",
+            "integrity": "sha512-bSktexGodAjfHWIrSrrqxqWzf1hWBZBpmPNZv+TYUMyWa2eoefFc6q6H1+KtdHYSz35lrhWdmXt/XK9wNEZvww==",
+            "dev": true,
+            "engines": {
+                "node": ">=16"
+            }
+        },
+        "node_modules/auto-bind": {
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
+            "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==",
+            "dev": true,
+            "engines": {
+                "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/is-date-object": {
-            "version": "1.0.5",
-            "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
-            "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
+        "node_modules/balanced-match": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+            "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+        },
+        "node_modules/binary-extensions": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+            "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/brace-expansion": {
+            "version": "1.1.11",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+            "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+            "dependencies": {
+                "balanced-match": "^1.0.0",
+                "concat-map": "0.0.1"
+            }
+        },
+        "node_modules/braces": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+            "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
             "dev": true,
             "dependencies": {
-                "has-tostringtag": "^1.0.0"
+                "fill-range": "^7.0.1"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=8"
+            }
+        },
+        "node_modules/builtins": {
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz",
+            "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==",
+            "dev": true,
+            "dependencies": {
+                "semver": "^7.0.0"
+            }
+        },
+        "node_modules/c8": {
+            "version": "8.0.1",
+            "resolved": "https://registry.npmjs.org/c8/-/c8-8.0.1.tgz",
+            "integrity": "sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w==",
+            "dev": true,
+            "dependencies": {
+                "@bcoe/v8-coverage": "^0.2.3",
+                "@istanbuljs/schema": "^0.1.3",
+                "find-up": "^5.0.0",
+                "foreground-child": "^2.0.0",
+                "istanbul-lib-coverage": "^3.2.0",
+                "istanbul-lib-report": "^3.0.1",
+                "istanbul-reports": "^3.1.6",
+                "rimraf": "^3.0.2",
+                "test-exclude": "^6.0.0",
+                "v8-to-istanbul": "^9.0.0",
+                "yargs": "^17.7.2",
+                "yargs-parser": "^21.1.1"
+            },
+            "bin": {
+                "c8": "bin/c8.js"
+            },
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/c8/node_modules/foreground-child": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz",
+            "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==",
+            "dev": true,
+            "dependencies": {
+                "cross-spawn": "^7.0.0",
+                "signal-exit": "^3.0.2"
+            },
+            "engines": {
+                "node": ">=8.0.0"
+            }
+        },
+        "node_modules/c8/node_modules/signal-exit": {
+            "version": "3.0.7",
+            "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+            "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+            "dev": true
+        },
+        "node_modules/cacache": {
+            "version": "18.0.2",
+            "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.2.tgz",
+            "integrity": "sha512-r3NU8h/P+4lVUHfeRw1dtgQYar3DZMm4/cm2bZgOvrFC/su7budSOeqh52VJIC4U4iG1WWwV6vRW0znqBvxNuw==",
+            "dev": true,
+            "dependencies": {
+                "@npmcli/fs": "^3.1.0",
+                "fs-minipass": "^3.0.0",
+                "glob": "^10.2.2",
+                "lru-cache": "^10.0.1",
+                "minipass": "^7.0.3",
+                "minipass-collect": "^2.0.1",
+                "minipass-flush": "^1.0.5",
+                "minipass-pipeline": "^1.2.4",
+                "p-map": "^4.0.0",
+                "ssri": "^10.0.0",
+                "tar": "^6.1.11",
+                "unique-filename": "^3.0.0"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/cacache/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/cacache/node_modules/glob": {
+            "version": "10.3.10",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "dev": true,
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^2.3.5",
+                "minimatch": "^9.0.1",
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                "path-scurry": "^1.10.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/is-map": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz",
-            "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==",
+        "node_modules/cacache/node_modules/minimatch": {
+            "version": "9.0.3",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
             "dev": true,
+            "dependencies": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/is-negative-zero": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
-            "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+        "node_modules/callsites": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+            "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/chalk": {
+            "version": "4.1.2",
+            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+            "dependencies": {
+                "ansi-styles": "^4.1.0",
+                "supports-color": "^7.1.0"
+            },
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/chalk?sponsor=1"
+            }
+        },
+        "node_modules/chokidar": {
+            "version": "3.5.3",
+            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+            "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
             "dev": true,
+            "funding": [
+                {
+                    "type": "individual",
+                    "url": "https://paulmillr.com/funding/"
+                }
+            ],
+            "dependencies": {
+                "anymatch": "~3.1.2",
+                "braces": "~3.0.2",
+                "glob-parent": "~5.1.2",
+                "is-binary-path": "~2.1.0",
+                "is-glob": "~4.0.1",
+                "normalize-path": "~3.0.0",
+                "readdirp": "~3.6.0"
+            },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">= 8.10.0"
+            },
+            "optionalDependencies": {
+                "fsevents": "~2.3.2"
+            }
+        },
+        "node_modules/chokidar/node_modules/glob-parent": {
+            "version": "5.1.2",
+            "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+            "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+            "dev": true,
+            "dependencies": {
+                "is-glob": "^4.0.1"
+            },
+            "engines": {
+                "node": ">= 6"
+            }
+        },
+        "node_modules/chownr": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+            "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/chroma-js": {
+            "version": "2.4.2",
+            "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz",
+            "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A=="
+        },
+        "node_modules/ci-info": {
+            "version": "3.9.0",
+            "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
+            "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/sibiraj-s"
+                }
+            ],
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/clean-stack": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+            "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+            "dev": true,
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/cli-boxes": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
+            "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
+            "dev": true,
+            "engines": {
+                "node": ">=10"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/is-number-object": {
-            "version": "1.0.6",
-            "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz",
-            "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==",
+        "node_modules/cli-cursor": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
+            "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==",
             "dev": true,
             "dependencies": {
-                "has-tostringtag": "^1.0.0"
+                "restore-cursor": "^4.0.0"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/cli-truncate": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz",
+            "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==",
+            "dev": true,
+            "dependencies": {
+                "slice-ansi": "^5.0.0",
+                "string-width": "^5.0.0"
+            },
+            "engines": {
+                "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/cli-truncate/node_modules/ansi-styles": {
+            "version": "6.2.1",
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+            "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            }
+        },
+        "node_modules/cli-truncate/node_modules/slice-ansi": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz",
+            "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==",
+            "dev": true,
+            "dependencies": {
+                "ansi-styles": "^6.0.0",
+                "is-fullwidth-code-point": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+            }
+        },
+        "node_modules/cliui": {
+            "version": "8.0.1",
+            "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+            "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+            "dev": true,
+            "dependencies": {
+                "string-width": "^4.2.0",
+                "strip-ansi": "^6.0.1",
+                "wrap-ansi": "^7.0.0"
+            },
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/cliui/node_modules/emoji-regex": {
+            "version": "8.0.0",
+            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+            "dev": true
+        },
+        "node_modules/cliui/node_modules/is-fullwidth-code-point": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+            "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/cliui/node_modules/string-width": {
+            "version": "4.2.3",
+            "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+            "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+            "dev": true,
+            "dependencies": {
+                "emoji-regex": "^8.0.0",
+                "is-fullwidth-code-point": "^3.0.0",
+                "strip-ansi": "^6.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/cliui/node_modules/wrap-ansi": {
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+            "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+            "dev": true,
+            "dependencies": {
+                "ansi-styles": "^4.0.0",
+                "string-width": "^4.1.0",
+                "strip-ansi": "^6.0.0"
+            },
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+            }
+        },
+        "node_modules/code-excerpt": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
+            "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==",
+            "dev": true,
+            "dependencies": {
+                "convert-to-spaces": "^2.0.1"
+            },
+            "engines": {
+                "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+            }
+        },
+        "node_modules/color-convert": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+            "dependencies": {
+                "color-name": "~1.1.4"
+            },
+            "engines": {
+                "node": ">=7.0.0"
             }
         },
-        "node_modules/is-regex": {
+        "node_modules/color-name": {
             "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
-            "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+        },
+        "node_modules/command-exists": {
+            "version": "1.2.9",
+            "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
+            "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="
+        },
+        "node_modules/concat-map": {
+            "version": "0.0.1",
+            "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+            "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+        },
+        "node_modules/convert-source-map": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+            "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+            "dev": true
+        },
+        "node_modules/convert-to-spaces": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
+            "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==",
             "dev": true,
+            "engines": {
+                "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+            }
+        },
+        "node_modules/cross-spawn": {
+            "version": "7.0.3",
+            "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+            "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
+                "path-key": "^3.1.0",
+                "shebang-command": "^2.0.0",
+                "which": "^2.0.1"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">= 8"
+            }
+        },
+        "node_modules/debug": {
+            "version": "4.3.4",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+            "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+            "dependencies": {
+                "ms": "2.1.2"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/deep-is": {
+            "version": "0.1.4",
+            "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+            "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
+        },
+        "node_modules/diff": {
+            "version": "5.1.0",
+            "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
+            "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.3.1"
+            }
+        },
+        "node_modules/doctrine": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+            "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+            "dependencies": {
+                "esutils": "^2.0.2"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/eastasianwidth": {
+            "version": "0.2.0",
+            "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+            "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+            "dev": true
+        },
+        "node_modules/emoji-regex": {
+            "version": "9.2.2",
+            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+            "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+            "dev": true
+        },
+        "node_modules/encoding": {
+            "version": "0.1.13",
+            "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
+            "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
+            "dev": true,
+            "optional": true,
+            "dependencies": {
+                "iconv-lite": "^0.6.2"
+            }
+        },
+        "node_modules/env-paths": {
+            "version": "2.2.1",
+            "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+            "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+            "dev": true,
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/err-code": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
+            "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
+            "dev": true
+        },
+        "node_modules/escalade": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+            "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+            "dev": true,
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/escape-string-regexp": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+            "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+            "engines": {
+                "node": ">=10"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/is-set": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz",
-            "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==",
+        "node_modules/eslint": {
+            "version": "8.37.0",
+            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.37.0.tgz",
+            "integrity": "sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==",
+            "dependencies": {
+                "@eslint-community/eslint-utils": "^4.2.0",
+                "@eslint-community/regexpp": "^4.4.0",
+                "@eslint/eslintrc": "^2.0.2",
+                "@eslint/js": "8.37.0",
+                "@humanwhocodes/config-array": "^0.11.8",
+                "@humanwhocodes/module-importer": "^1.0.1",
+                "@nodelib/fs.walk": "^1.2.8",
+                "ajv": "^6.10.0",
+                "chalk": "^4.0.0",
+                "cross-spawn": "^7.0.2",
+                "debug": "^4.3.2",
+                "doctrine": "^3.0.0",
+                "escape-string-regexp": "^4.0.0",
+                "eslint-scope": "^7.1.1",
+                "eslint-visitor-keys": "^3.4.0",
+                "espree": "^9.5.1",
+                "esquery": "^1.4.2",
+                "esutils": "^2.0.2",
+                "fast-deep-equal": "^3.1.3",
+                "file-entry-cache": "^6.0.1",
+                "find-up": "^5.0.0",
+                "glob-parent": "^6.0.2",
+                "globals": "^13.19.0",
+                "grapheme-splitter": "^1.0.4",
+                "ignore": "^5.2.0",
+                "import-fresh": "^3.0.0",
+                "imurmurhash": "^0.1.4",
+                "is-glob": "^4.0.0",
+                "is-path-inside": "^3.0.3",
+                "js-sdsl": "^4.1.4",
+                "js-yaml": "^4.1.0",
+                "json-stable-stringify-without-jsonify": "^1.0.1",
+                "levn": "^0.4.1",
+                "lodash.merge": "^4.6.2",
+                "minimatch": "^3.1.2",
+                "natural-compare": "^1.4.0",
+                "optionator": "^0.9.1",
+                "strip-ansi": "^6.0.1",
+                "strip-json-comments": "^3.1.0",
+                "text-table": "^0.2.0"
+            },
+            "bin": {
+                "eslint": "bin/eslint.js"
+            },
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/eslint"
+            }
+        },
+        "node_modules/eslint-scope": {
+            "version": "7.1.1",
+            "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
+            "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
+            "dependencies": {
+                "esrecurse": "^4.3.0",
+                "estraverse": "^5.2.0"
+            },
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            }
+        },
+        "node_modules/eslint-visitor-keys": {
+            "version": "3.4.0",
+            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz",
+            "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==",
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/eslint"
+            }
+        },
+        "node_modules/espree": {
+            "version": "9.5.1",
+            "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz",
+            "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==",
+            "dependencies": {
+                "acorn": "^8.8.0",
+                "acorn-jsx": "^5.3.2",
+                "eslint-visitor-keys": "^3.4.0"
+            },
+            "engines": {
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/eslint"
+            }
+        },
+        "node_modules/esquery": {
+            "version": "1.5.0",
+            "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
+            "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+            "dependencies": {
+                "estraverse": "^5.1.0"
+            },
+            "engines": {
+                "node": ">=0.10"
+            }
+        },
+        "node_modules/esrecurse": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+            "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+            "dependencies": {
+                "estraverse": "^5.2.0"
+            },
+            "engines": {
+                "node": ">=4.0"
+            }
+        },
+        "node_modules/estraverse": {
+            "version": "5.3.0",
+            "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+            "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+            "engines": {
+                "node": ">=4.0"
+            }
+        },
+        "node_modules/esutils": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+            "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/events-to-array": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/events-to-array/-/events-to-array-2.0.3.tgz",
+            "integrity": "sha512-f/qE2gImHRa4Cp2y1stEOSgw8wTFyUdVJX7G//bMwbaV9JqISFxg99NbmVQeP7YLnDUZ2un851jlaDrlpmGehQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/exponential-backoff": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz",
+            "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==",
+            "dev": true
+        },
+        "node_modules/fast-deep-equal": {
+            "version": "3.1.3",
+            "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+            "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+        },
+        "node_modules/fast-json-stable-stringify": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+            "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
+        },
+        "node_modules/fast-levenshtein": {
+            "version": "2.0.6",
+            "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+            "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
+        },
+        "node_modules/fastq": {
+            "version": "1.15.0",
+            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
+            "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
+            "dependencies": {
+                "reusify": "^1.0.4"
+            }
+        },
+        "node_modules/file-entry-cache": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+            "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+            "dependencies": {
+                "flat-cache": "^3.0.4"
+            },
+            "engines": {
+                "node": "^10.12.0 || >=12.0.0"
+            }
+        },
+        "node_modules/fill-range": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+            "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
             "dev": true,
+            "dependencies": {
+                "to-regex-range": "^5.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/find-up": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+            "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+            "dependencies": {
+                "locate-path": "^6.0.0",
+                "path-exists": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=10"
+            },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/is-shared-array-buffer": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz",
-            "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==",
+        "node_modules/flat-cache": {
+            "version": "3.0.4",
+            "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+            "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+            "dependencies": {
+                "flatted": "^3.1.0",
+                "rimraf": "^3.0.2"
+            },
+            "engines": {
+                "node": "^10.12.0 || >=12.0.0"
+            }
+        },
+        "node_modules/flatted": {
+            "version": "3.2.5",
+            "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
+            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
+        },
+        "node_modules/foreground-child": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
+            "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
+            "dev": true,
+            "dependencies": {
+                "cross-spawn": "^7.0.0",
+                "signal-exit": "^4.0.1"
+            },
+            "engines": {
+                "node": ">=14"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/fromentries": {
+            "version": "1.3.2",
+            "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz",
+            "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/feross"
+                },
+                {
+                    "type": "patreon",
+                    "url": "https://www.patreon.com/feross"
+                },
+                {
+                    "type": "consulting",
+                    "url": "https://feross.org/support"
+                }
+            ]
+        },
+        "node_modules/fs-minipass": {
+            "version": "3.0.3",
+            "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz",
+            "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==",
+            "dev": true,
+            "dependencies": {
+                "minipass": "^7.0.3"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/fs.realpath": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+        },
+        "node_modules/fsevents": {
+            "version": "2.3.2",
+            "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+            "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+            "dev": true,
+            "hasInstallScript": true,
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+            }
+        },
+        "node_modules/function-bind": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+            "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
             "dev": true,
             "funding": {
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/is-string": {
-            "version": "1.0.7",
-            "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
-            "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+        "node_modules/function-loop": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/function-loop/-/function-loop-4.0.0.tgz",
+            "integrity": "sha512-f34iQBedYF3XcI93uewZZOnyscDragxgTK/eTvVB74k3fCD0ZorOi5BV9GS4M8rz/JoNi0Kl3qX5Y9MH3S/CLQ==",
+            "dev": true
+        },
+        "node_modules/get-caller-file": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+            "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
             "dev": true,
+            "engines": {
+                "node": "6.* || 8.* || >= 10.*"
+            }
+        },
+        "node_modules/glob": {
+            "version": "7.2.3",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+            "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
             "dependencies": {
-                "has-tostringtag": "^1.0.0"
+                "fs.realpath": "^1.0.0",
+                "inflight": "^1.0.4",
+                "inherits": "2",
+                "minimatch": "^3.1.1",
+                "once": "^1.3.0",
+                "path-is-absolute": "^1.0.0"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": "*"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/glob-parent": {
+            "version": "6.0.2",
+            "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+            "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+            "dependencies": {
+                "is-glob": "^4.0.3"
+            },
+            "engines": {
+                "node": ">=10.13.0"
+            }
+        },
+        "node_modules/globals": {
+            "version": "13.20.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz",
+            "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==",
+            "dependencies": {
+                "type-fest": "^0.20.2"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/is-symbol": {
+        "node_modules/graceful-fs": {
+            "version": "4.2.11",
+            "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+            "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+            "dev": true
+        },
+        "node_modules/grapheme-splitter": {
             "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
-            "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+            "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
+            "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
+        },
+        "node_modules/has-flag": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/hasown": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
+            "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
             "dev": true,
             "dependencies": {
-                "has-symbols": "^1.0.2"
+                "function-bind": "^1.1.2"
             },
             "engines": {
                 "node": ">= 0.4"
+            }
+        },
+        "node_modules/he": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+            "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+            "bin": {
+                "he": "bin/he"
+            }
+        },
+        "node_modules/hosted-git-info": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz",
+            "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==",
+            "dev": true,
+            "dependencies": {
+                "lru-cache": "^10.0.1"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
             }
         },
-        "node_modules/is-typed-array": {
-            "version": "1.1.8",
-            "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.8.tgz",
-            "integrity": "sha512-HqH41TNZq2fgtGT8WHVFVJhBVGuY3AnP3Q36K8JKXUxSxRgk/d+7NjmwG2vo2mYmXK8UYZKu0qH8bVP5gEisjA==",
+        "node_modules/html-escaper": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+            "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+            "dev": true
+        },
+        "node_modules/http-cache-semantics": {
+            "version": "4.1.1",
+            "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
+            "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
+            "dev": true
+        },
+        "node_modules/http-proxy-agent": {
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz",
+            "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==",
             "dev": true,
             "dependencies": {
-                "available-typed-arrays": "^1.0.5",
-                "call-bind": "^1.0.2",
-                "es-abstract": "^1.18.5",
-                "foreach": "^2.0.5",
-                "has-tostringtag": "^1.0.0"
+                "agent-base": "^7.1.0",
+                "debug": "^4.3.4"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">= 14"
+            }
+        },
+        "node_modules/https-proxy-agent": {
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz",
+            "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==",
+            "dev": true,
+            "dependencies": {
+                "agent-base": "^7.0.2",
+                "debug": "4"
             },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+            "engines": {
+                "node": ">= 14"
             }
         },
-        "node_modules/is-weakmap": {
+        "node_modules/iconv-lite": {
+            "version": "0.6.3",
+            "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+            "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+            "dev": true,
+            "optional": true,
+            "dependencies": {
+                "safer-buffer": ">= 2.1.2 < 3.0.0"
+            },
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/ignore": {
+            "version": "5.2.4",
+            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
+            "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
+            "engines": {
+                "node": ">= 4"
+            }
+        },
+        "node_modules/ignore-walk": {
+            "version": "6.0.4",
+            "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.4.tgz",
+            "integrity": "sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw==",
+            "dev": true,
+            "dependencies": {
+                "minimatch": "^9.0.0"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/ignore-walk/node_modules/brace-expansion": {
             "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz",
-            "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/ignore-walk/node_modules/minimatch": {
+            "version": "9.0.3",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
             "dev": true,
+            "dependencies": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/is-weakref": {
+        "node_modules/image-size": {
             "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
-            "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
+            "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.2.tgz",
+            "integrity": "sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==",
+            "dependencies": {
+                "queue": "6.0.2"
+            },
+            "bin": {
+                "image-size": "bin/image-size.js"
+            },
+            "engines": {
+                "node": ">=14.0.0"
+            }
+        },
+        "node_modules/import-fresh": {
+            "version": "3.3.0",
+            "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+            "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+            "dependencies": {
+                "parent-module": "^1.0.0",
+                "resolve-from": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=6"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/imurmurhash": {
+            "version": "0.1.4",
+            "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+            "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+            "engines": {
+                "node": ">=0.8.19"
+            }
+        },
+        "node_modules/indent-string": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
+            "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/inflight": {
+            "version": "1.0.6",
+            "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+            "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+            "dependencies": {
+                "once": "^1.3.0",
+                "wrappy": "1"
+            }
+        },
+        "node_modules/inherits": {
+            "version": "2.0.4",
+            "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+        },
+        "node_modules/ink": {
+            "version": "4.4.1",
+            "resolved": "https://registry.npmjs.org/ink/-/ink-4.4.1.tgz",
+            "integrity": "sha512-rXckvqPBB0Krifk5rn/5LvQGmyXwCUpBfmTwbkQNBY9JY8RSl3b8OftBNEYxg4+SWUhEKcPifgope28uL9inlA==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2"
+                "@alcalzone/ansi-tokenize": "^0.1.3",
+                "ansi-escapes": "^6.0.0",
+                "auto-bind": "^5.0.1",
+                "chalk": "^5.2.0",
+                "cli-boxes": "^3.0.0",
+                "cli-cursor": "^4.0.0",
+                "cli-truncate": "^3.1.0",
+                "code-excerpt": "^4.0.0",
+                "indent-string": "^5.0.0",
+                "is-ci": "^3.0.1",
+                "is-lower-case": "^2.0.2",
+                "is-upper-case": "^2.0.2",
+                "lodash": "^4.17.21",
+                "patch-console": "^2.0.0",
+                "react-reconciler": "^0.29.0",
+                "scheduler": "^0.23.0",
+                "signal-exit": "^3.0.7",
+                "slice-ansi": "^6.0.0",
+                "stack-utils": "^2.0.6",
+                "string-width": "^5.1.2",
+                "type-fest": "^0.12.0",
+                "widest-line": "^4.0.1",
+                "wrap-ansi": "^8.1.0",
+                "ws": "^8.12.0",
+                "yoga-wasm-web": "~0.3.3"
+            },
+            "engines": {
+                "node": ">=14.16"
+            },
+            "peerDependencies": {
+                "@types/react": ">=18.0.0",
+                "react": ">=18.0.0",
+                "react-devtools-core": "^4.19.1"
+            },
+            "peerDependenciesMeta": {
+                "@types/react": {
+                    "optional": true
+                },
+                "react-devtools-core": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/ink/node_modules/chalk": {
+            "version": "5.3.0",
+            "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+            "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+            "dev": true,
+            "engines": {
+                "node": "^12.17.0 || ^14.13 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/chalk?sponsor=1"
+            }
+        },
+        "node_modules/ink/node_modules/signal-exit": {
+            "version": "3.0.7",
+            "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+            "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+            "dev": true
+        },
+        "node_modules/ink/node_modules/type-fest": {
+            "version": "0.12.0",
+            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.12.0.tgz",
+            "integrity": "sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg==",
+            "dev": true,
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/ip": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
+            "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
+            "dev": true
+        },
+        "node_modules/is-actual-promise": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/is-actual-promise/-/is-actual-promise-1.0.1.tgz",
+            "integrity": "sha512-PlsL4tNv62lx5yN2HSqaRSTgIpUAPW7U6+crVB8HfWm5161rZpeqWbl0ZSqH2MAfRKXWSZVPRNbE/r8qPcb13g==",
+            "dev": true,
+            "dependencies": {
+                "tshy": "^1.7.0"
+            }
+        },
+        "node_modules/is-binary-path": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+            "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+            "dev": true,
+            "dependencies": {
+                "binary-extensions": "^2.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/is-ci": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz",
+            "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==",
+            "dev": true,
+            "dependencies": {
+                "ci-info": "^3.2.0"
+            },
+            "bin": {
+                "is-ci": "bin.js"
+            }
+        },
+        "node_modules/is-core-module": {
+            "version": "2.13.1",
+            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
+            "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
+            "dev": true,
+            "dependencies": {
+                "hasown": "^2.0.0"
             },
             "funding": {
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/is-weakset": {
+        "node_modules/is-extglob": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+            "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/is-fullwidth-code-point": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
+            "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/is-glob": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+            "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+            "dependencies": {
+                "is-extglob": "^2.1.1"
+            },
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/is-lambda": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz",
+            "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==",
+            "dev": true
+        },
+        "node_modules/is-lower-case": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz",
+            "integrity": "sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==",
+            "dev": true,
+            "dependencies": {
+                "tslib": "^2.0.3"
+            }
+        },
+        "node_modules/is-number": {
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+            "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.12.0"
+            }
+        },
+        "node_modules/is-path-inside": {
+            "version": "3.0.3",
+            "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+            "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/is-plain-object": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+            "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/is-upper-case": {
             "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz",
-            "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==",
+            "resolved": "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz",
+            "integrity": "sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==",
+            "dev": true,
+            "dependencies": {
+                "tslib": "^2.0.3"
+            }
+        },
+        "node_modules/isexe": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
+        },
+        "node_modules/istanbul-lib-coverage": {
+            "version": "3.2.2",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+            "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/istanbul-lib-report": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+            "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+            "dev": true,
+            "dependencies": {
+                "istanbul-lib-coverage": "^3.0.0",
+                "make-dir": "^4.0.0",
+                "supports-color": "^7.1.0"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/istanbul-reports": {
+            "version": "3.1.6",
+            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz",
+            "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==",
+            "dev": true,
+            "dependencies": {
+                "html-escaper": "^2.0.0",
+                "istanbul-lib-report": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/jackspeak": {
+            "version": "2.3.6",
+            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
+            "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
+                "@isaacs/cliui": "^8.0.2"
+            },
+            "engines": {
+                "node": ">=14"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
+            },
+            "optionalDependencies": {
+                "@pkgjs/parseargs": "^0.11.0"
             }
         },
-        "node_modules/isarray": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
-            "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+        "node_modules/js-sdsl": {
+            "version": "4.4.0",
+            "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",
+            "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==",
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/js-sdsl"
+            }
+        },
+        "node_modules/js-tokens": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+            "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
             "dev": true
         },
         "node_modules/js-yaml": {
@@ -659,11 +2958,164 @@
                 "js-yaml": "bin/js-yaml.js"
             }
         },
-        "node_modules/minimatch": {
-            "version": "3.0.4",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
-            "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+        "node_modules/jsbi": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
+            "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
+        },
+        "node_modules/json-parse-even-better-errors": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz",
+            "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==",
+            "dev": true,
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/json-schema-traverse": {
+            "version": "0.4.1",
+            "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+            "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
+        },
+        "node_modules/json-stable-stringify-without-jsonify": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+            "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="
+        },
+        "node_modules/jsonparse": {
+            "version": "1.3.1",
+            "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
+            "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==",
             "dev": true,
+            "engines": [
+                "node >= 0.2.0"
+            ]
+        },
+        "node_modules/levn": {
+            "version": "0.4.1",
+            "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+            "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+            "dependencies": {
+                "prelude-ls": "^1.2.1",
+                "type-check": "~0.4.0"
+            },
+            "engines": {
+                "node": ">= 0.8.0"
+            }
+        },
+        "node_modules/locate-path": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+            "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+            "dependencies": {
+                "p-locate": "^5.0.0"
+            },
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/lodash": {
+            "version": "4.17.21",
+            "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+            "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+            "dev": true
+        },
+        "node_modules/lodash.merge": {
+            "version": "4.6.2",
+            "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+            "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
+        },
+        "node_modules/loose-envify": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+            "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+            "dev": true,
+            "dependencies": {
+                "js-tokens": "^3.0.0 || ^4.0.0"
+            },
+            "bin": {
+                "loose-envify": "cli.js"
+            }
+        },
+        "node_modules/lru-cache": {
+            "version": "10.1.0",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz",
+            "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==",
+            "dev": true,
+            "engines": {
+                "node": "14 || >=16.14"
+            }
+        },
+        "node_modules/make-dir": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+            "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+            "dev": true,
+            "dependencies": {
+                "semver": "^7.5.3"
+            },
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/make-error": {
+            "version": "1.3.6",
+            "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+            "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+            "dev": true
+        },
+        "node_modules/make-fetch-happen": {
+            "version": "13.0.0",
+            "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.0.tgz",
+            "integrity": "sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==",
+            "dev": true,
+            "dependencies": {
+                "@npmcli/agent": "^2.0.0",
+                "cacache": "^18.0.0",
+                "http-cache-semantics": "^4.1.1",
+                "is-lambda": "^1.0.1",
+                "minipass": "^7.0.2",
+                "minipass-fetch": "^3.0.0",
+                "minipass-flush": "^1.0.5",
+                "minipass-pipeline": "^1.2.4",
+                "negotiator": "^0.6.3",
+                "promise-retry": "^2.0.1",
+                "ssri": "^10.0.0"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/marked": {
+            "version": "10.0.0",
+            "resolved": "https://registry.npmjs.org/marked/-/marked-10.0.0.tgz",
+            "integrity": "sha512-YiGcYcWj50YrwBgNzFoYhQ1hT6GmQbFG8SksnYJX1z4BXTHSOrz1GB5/Jm2yQvMg4nN1FHP4M6r03R10KrVUiA==",
+            "bin": {
+                "marked": "bin/marked.js"
+            },
+            "engines": {
+                "node": ">= 18"
+            }
+        },
+        "node_modules/mimic-fn": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+            "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+            "dev": true,
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/minimatch": {
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+            "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
             "dependencies": {
                 "brace-expansion": "^1.1.7"
             },
@@ -671,563 +3123,3980 @@
                 "node": "*"
             }
         },
-        "node_modules/minimist": {
-            "version": "1.2.5",
-            "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
-            "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
-            "dev": true
+        "node_modules/minipass": {
+            "version": "7.0.4",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
+            "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            }
+        },
+        "node_modules/minipass-collect": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz",
+            "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==",
+            "dev": true,
+            "dependencies": {
+                "minipass": "^7.0.3"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            }
+        },
+        "node_modules/minipass-fetch": {
+            "version": "3.0.4",
+            "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz",
+            "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==",
+            "dev": true,
+            "dependencies": {
+                "minipass": "^7.0.3",
+                "minipass-sized": "^1.0.3",
+                "minizlib": "^2.1.2"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            },
+            "optionalDependencies": {
+                "encoding": "^0.1.13"
+            }
+        },
+        "node_modules/minipass-flush": {
+            "version": "1.0.5",
+            "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz",
+            "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==",
+            "dev": true,
+            "dependencies": {
+                "minipass": "^3.0.0"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/minipass-flush/node_modules/minipass": {
+            "version": "3.3.6",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+            "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+            "dev": true,
+            "dependencies": {
+                "yallist": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
         },
-        "node_modules/object-inspect": {
-            "version": "1.12.0",
-            "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
-            "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+        "node_modules/minipass-json-stream": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz",
+            "integrity": "sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg==",
+            "dev": true,
+            "dependencies": {
+                "jsonparse": "^1.3.1",
+                "minipass": "^3.0.0"
+            }
+        },
+        "node_modules/minipass-json-stream/node_modules/minipass": {
+            "version": "3.3.6",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+            "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+            "dev": true,
+            "dependencies": {
+                "yallist": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/minipass-pipeline": {
+            "version": "1.2.4",
+            "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
+            "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
             "dev": true,
+            "dependencies": {
+                "minipass": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/minipass-pipeline/node_modules/minipass": {
+            "version": "3.3.6",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+            "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+            "dev": true,
+            "dependencies": {
+                "yallist": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/minipass-sized": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz",
+            "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==",
+            "dev": true,
+            "dependencies": {
+                "minipass": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/minipass-sized/node_modules/minipass": {
+            "version": "3.3.6",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+            "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+            "dev": true,
+            "dependencies": {
+                "yallist": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/minizlib": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+            "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+            "dev": true,
+            "dependencies": {
+                "minipass": "^3.0.0",
+                "yallist": "^4.0.0"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/minizlib/node_modules/minipass": {
+            "version": "3.3.6",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+            "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+            "dev": true,
+            "dependencies": {
+                "yallist": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/mkdirp": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
+            "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
+            "dev": true,
+            "bin": {
+                "mkdirp": "dist/cjs/src/bin.js"
+            },
+            "engines": {
+                "node": ">=10"
+            },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/ms": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+        },
+        "node_modules/natural-compare": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+            "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
+        },
+        "node_modules/negotiator": {
+            "version": "0.6.3",
+            "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+            "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 0.6"
             }
         },
-        "node_modules/object-is": {
-            "version": "1.1.5",
-            "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
-            "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==",
+        "node_modules/node-gyp": {
+            "version": "10.0.1",
+            "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.0.1.tgz",
+            "integrity": "sha512-gg3/bHehQfZivQVfqIyy8wTdSymF9yTyP4CJifK73imyNMU8AIGQE2pUa7dNWfmMeG9cDVF2eehiRMv0LC1iAg==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "env-paths": "^2.2.0",
+                "exponential-backoff": "^3.1.1",
+                "glob": "^10.3.10",
+                "graceful-fs": "^4.2.6",
+                "make-fetch-happen": "^13.0.0",
+                "nopt": "^7.0.0",
+                "proc-log": "^3.0.0",
+                "semver": "^7.3.5",
+                "tar": "^6.1.2",
+                "which": "^4.0.0"
+            },
+            "bin": {
+                "node-gyp": "bin/node-gyp.js"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/node-gyp/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/node-gyp/node_modules/glob": {
+            "version": "10.3.10",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "dev": true,
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^2.3.5",
+                "minimatch": "^9.0.1",
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                "path-scurry": "^1.10.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/object-keys": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
-            "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+        "node_modules/node-gyp/node_modules/isexe": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+            "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
             "dev": true,
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=16"
             }
         },
-        "node_modules/object.assign": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
-            "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
+        "node_modules/node-gyp/node_modules/minimatch": {
+            "version": "9.0.3",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.0",
-                "define-properties": "^1.1.3",
-                "has-symbols": "^1.0.1",
-                "object-keys": "^1.1.1"
+                "brace-expansion": "^2.0.1"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=16 || 14 >=14.17"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/node-gyp/node_modules/which": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+            "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+            "dev": true,
+            "dependencies": {
+                "isexe": "^3.1.1"
+            },
+            "bin": {
+                "node-which": "bin/which.js"
+            },
+            "engines": {
+                "node": "^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/nopt": {
+            "version": "7.2.0",
+            "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.0.tgz",
+            "integrity": "sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==",
+            "dev": true,
+            "dependencies": {
+                "abbrev": "^2.0.0"
+            },
+            "bin": {
+                "nopt": "bin/nopt.js"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/normalize-package-data": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.0.tgz",
+            "integrity": "sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==",
+            "dev": true,
+            "dependencies": {
+                "hosted-git-info": "^7.0.0",
+                "is-core-module": "^2.8.1",
+                "semver": "^7.3.5",
+                "validate-npm-package-license": "^3.0.4"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/normalize-path": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+            "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/npm-bundled": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz",
+            "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==",
+            "dev": true,
+            "dependencies": {
+                "npm-normalize-package-bin": "^3.0.0"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/npm-install-checks": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz",
+            "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==",
+            "dev": true,
+            "dependencies": {
+                "semver": "^7.1.1"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/npm-normalize-package-bin": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz",
+            "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==",
+            "dev": true,
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/npm-package-arg": {
+            "version": "11.0.1",
+            "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.1.tgz",
+            "integrity": "sha512-M7s1BD4NxdAvBKUPqqRW957Xwcl/4Zvo8Aj+ANrzvIPzGJZElrH7Z//rSaec2ORcND6FHHLnZeY8qgTpXDMFQQ==",
+            "dev": true,
+            "dependencies": {
+                "hosted-git-info": "^7.0.0",
+                "proc-log": "^3.0.0",
+                "semver": "^7.3.5",
+                "validate-npm-package-name": "^5.0.0"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/npm-packlist": {
+            "version": "8.0.1",
+            "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.1.tgz",
+            "integrity": "sha512-MQpL27ZrsJQ2kiAuQPpZb5LtJwydNRnI15QWXsf3WHERu4rzjRj6Zju/My2fov7tLuu3Gle/uoIX/DDZ3u4O4Q==",
+            "dev": true,
+            "dependencies": {
+                "ignore-walk": "^6.0.4"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/npm-pick-manifest": {
+            "version": "9.0.0",
+            "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.0.tgz",
+            "integrity": "sha512-VfvRSs/b6n9ol4Qb+bDwNGUXutpy76x6MARw/XssevE0TnctIKcmklJZM5Z7nqs5z5aW+0S63pgCNbpkUNNXBg==",
+            "dev": true,
+            "dependencies": {
+                "npm-install-checks": "^6.0.0",
+                "npm-normalize-package-bin": "^3.0.0",
+                "npm-package-arg": "^11.0.0",
+                "semver": "^7.3.5"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/npm-registry-fetch": {
+            "version": "16.1.0",
+            "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.1.0.tgz",
+            "integrity": "sha512-PQCELXKt8Azvxnt5Y85GseQDJJlglTFM9L9U9gkv2y4e9s0k3GVDdOx3YoB6gm2Do0hlkzC39iCGXby+Wve1Bw==",
+            "dev": true,
+            "dependencies": {
+                "make-fetch-happen": "^13.0.0",
+                "minipass": "^7.0.2",
+                "minipass-fetch": "^3.0.0",
+                "minipass-json-stream": "^1.0.1",
+                "minizlib": "^2.1.2",
+                "npm-package-arg": "^11.0.0",
+                "proc-log": "^3.0.0"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
             }
         },
         "node_modules/once": {
             "version": "1.4.0",
             "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
             "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
-            "dev": true,
             "dependencies": {
                 "wrappy": "1"
             }
         },
+        "node_modules/onetime": {
+            "version": "5.1.2",
+            "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+            "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+            "dev": true,
+            "dependencies": {
+                "mimic-fn": "^2.1.0"
+            },
+            "engines": {
+                "node": ">=6"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/opener": {
+            "version": "1.5.2",
+            "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
+            "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
+            "dev": true,
+            "bin": {
+                "opener": "bin/opener-bin.js"
+            }
+        },
+        "node_modules/optionator": {
+            "version": "0.9.1",
+            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+            "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+            "dependencies": {
+                "deep-is": "^0.1.3",
+                "fast-levenshtein": "^2.0.6",
+                "levn": "^0.4.1",
+                "prelude-ls": "^1.2.1",
+                "type-check": "^0.4.0",
+                "word-wrap": "^1.2.3"
+            },
+            "engines": {
+                "node": ">= 0.8.0"
+            }
+        },
+        "node_modules/p-limit": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+            "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+            "dependencies": {
+                "yocto-queue": "^0.1.0"
+            },
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/p-locate": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+            "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+            "dependencies": {
+                "p-limit": "^3.0.2"
+            },
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/p-map": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+            "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+            "dev": true,
+            "dependencies": {
+                "aggregate-error": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/pacote": {
+            "version": "17.0.5",
+            "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.5.tgz",
+            "integrity": "sha512-TAE0m20zSDMnchPja9vtQjri19X3pZIyRpm2TJVeI+yU42leJBBDTRYhOcWFsPhaMxf+3iwQkFiKz16G9AEeeA==",
+            "dev": true,
+            "dependencies": {
+                "@npmcli/git": "^5.0.0",
+                "@npmcli/installed-package-contents": "^2.0.1",
+                "@npmcli/promise-spawn": "^7.0.0",
+                "@npmcli/run-script": "^7.0.0",
+                "cacache": "^18.0.0",
+                "fs-minipass": "^3.0.0",
+                "minipass": "^7.0.2",
+                "npm-package-arg": "^11.0.0",
+                "npm-packlist": "^8.0.0",
+                "npm-pick-manifest": "^9.0.0",
+                "npm-registry-fetch": "^16.0.0",
+                "proc-log": "^3.0.0",
+                "promise-retry": "^2.0.1",
+                "read-package-json": "^7.0.0",
+                "read-package-json-fast": "^3.0.0",
+                "sigstore": "^2.0.0",
+                "ssri": "^10.0.0",
+                "tar": "^6.1.11"
+            },
+            "bin": {
+                "pacote": "lib/bin.js"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/parent-module": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+            "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+            "dependencies": {
+                "callsites": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/patch-console": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz",
+            "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==",
+            "dev": true,
+            "engines": {
+                "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+            }
+        },
+        "node_modules/path-exists": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+            "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/path-is-absolute": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
             "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/path-key": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+            "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/path-scurry": {
+            "version": "1.10.1",
+            "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
+            "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
+            "dev": true,
+            "dependencies": {
+                "lru-cache": "^9.1.1 || ^10.0.0",
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/picomatch": {
+            "version": "2.3.1",
+            "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+            "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+            "dev": true,
+            "engines": {
+                "node": ">=8.6"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/jonschlinkert"
+            }
+        },
+        "node_modules/pirates": {
+            "version": "4.0.6",
+            "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
+            "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 6"
+            }
+        },
+        "node_modules/polite-json": {
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/polite-json/-/polite-json-4.0.1.tgz",
+            "integrity": "sha512-8LI5ZeCPBEb4uBbcYKNVwk4jgqNx1yHReWoW4H4uUihWlSqZsUDfSITrRhjliuPgxsNPFhNSudGO2Zu4cbWinQ==",
+            "dev": true,
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/prelude-ls": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+            "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+            "engines": {
+                "node": ">= 0.8.0"
+            }
+        },
+        "node_modules/prismjs": {
+            "version": "1.29.0",
+            "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
+            "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
+            "dev": true,
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/prismjs-terminal": {
+            "version": "1.2.3",
+            "resolved": "https://registry.npmjs.org/prismjs-terminal/-/prismjs-terminal-1.2.3.tgz",
+            "integrity": "sha512-xc0zuJ5FMqvW+DpiRkvxURlz98DdfDsZcFHdO699+oL+ykbFfgI7O4VDEgUyc07BSL2NHl3zdb8m/tZ/aaqUrw==",
+            "dev": true,
+            "dependencies": {
+                "chalk": "^5.2.0",
+                "prismjs": "^1.29.0",
+                "string-length": "^6.0.0"
+            },
+            "engines": {
+                "node": ">=16"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/prismjs-terminal/node_modules/chalk": {
+            "version": "5.3.0",
+            "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+            "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+            "dev": true,
+            "engines": {
+                "node": "^12.17.0 || ^14.13 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/chalk?sponsor=1"
+            }
+        },
+        "node_modules/proc-log": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz",
+            "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==",
+            "dev": true,
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/process-on-spawn": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz",
+            "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==",
+            "dev": true,
+            "dependencies": {
+                "fromentries": "^1.2.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/promise-inflight": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
+            "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==",
+            "dev": true
+        },
+        "node_modules/promise-retry": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
+            "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
+            "dev": true,
+            "dependencies": {
+                "err-code": "^2.0.2",
+                "retry": "^0.12.0"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/punycode": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+            "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/queue": {
+            "version": "6.0.2",
+            "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
+            "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
+            "dependencies": {
+                "inherits": "~2.0.3"
+            }
+        },
+        "node_modules/queue-microtask": {
+            "version": "1.2.3",
+            "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+            "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/feross"
+                },
+                {
+                    "type": "patreon",
+                    "url": "https://www.patreon.com/feross"
+                },
+                {
+                    "type": "consulting",
+                    "url": "https://feross.org/support"
+                }
+            ]
+        },
+        "node_modules/react": {
+            "version": "18.2.0",
+            "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
+            "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
             "dev": true,
+            "dependencies": {
+                "loose-envify": "^1.1.0"
+            },
             "engines": {
                 "node": ">=0.10.0"
             }
         },
-        "node_modules/path-parse": {
-            "version": "1.0.7",
-            "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
-            "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+        "node_modules/react-dom": {
+            "version": "18.2.0",
+            "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
+            "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
+            "dev": true,
+            "peer": true,
+            "dependencies": {
+                "loose-envify": "^1.1.0",
+                "scheduler": "^0.23.0"
+            },
+            "peerDependencies": {
+                "react": "^18.2.0"
+            }
+        },
+        "node_modules/react-element-to-jsx-string": {
+            "version": "15.0.0",
+            "resolved": "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-15.0.0.tgz",
+            "integrity": "sha512-UDg4lXB6BzlobN60P8fHWVPX3Kyw8ORrTeBtClmIlGdkOOE+GYQSFvmEU5iLLpwp/6v42DINwNcwOhOLfQ//FQ==",
+            "dev": true,
+            "dependencies": {
+                "@base2/pretty-print-object": "1.0.1",
+                "is-plain-object": "5.0.0",
+                "react-is": "18.1.0"
+            },
+            "peerDependencies": {
+                "react": "^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0",
+                "react-dom": "^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0"
+            }
+        },
+        "node_modules/react-is": {
+            "version": "18.1.0",
+            "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.1.0.tgz",
+            "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==",
             "dev": true
         },
-        "node_modules/regexp.prototype.flags": {
-            "version": "1.4.1",
-            "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.1.tgz",
-            "integrity": "sha512-pMR7hBVUUGI7PMA37m2ofIdQCsomVnas+Jn5UPGAHQ+/LlwKm/aTLJHdasmHRzlfeZwHiAOaRSo2rbBDm3nNUQ==",
+        "node_modules/react-reconciler": {
+            "version": "0.29.0",
+            "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.0.tgz",
+            "integrity": "sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "loose-envify": "^1.1.0",
+                "scheduler": "^0.23.0"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=0.10.0"
+            },
+            "peerDependencies": {
+                "react": "^18.2.0"
+            }
+        },
+        "node_modules/read-package-json": {
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.0.tgz",
+            "integrity": "sha512-uL4Z10OKV4p6vbdvIXB+OzhInYtIozl/VxUBPgNkBuUi2DeRonnuspmaVAMcrkmfjKGNmRndyQAbE7/AmzGwFg==",
+            "dev": true,
+            "dependencies": {
+                "glob": "^10.2.2",
+                "json-parse-even-better-errors": "^3.0.0",
+                "normalize-package-data": "^6.0.0",
+                "npm-normalize-package-bin": "^3.0.0"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/read-package-json-fast": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz",
+            "integrity": "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==",
+            "dev": true,
+            "dependencies": {
+                "json-parse-even-better-errors": "^3.0.0",
+                "npm-normalize-package-bin": "^3.0.0"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/read-package-json/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/read-package-json/node_modules/glob": {
+            "version": "10.3.10",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "dev": true,
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^2.3.5",
+                "minimatch": "^9.0.1",
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                "path-scurry": "^1.10.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/resolve": {
-            "version": "2.0.0-next.3",
-            "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.3.tgz",
-            "integrity": "sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q==",
+        "node_modules/read-package-json/node_modules/minimatch": {
+            "version": "9.0.3",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
             "dev": true,
             "dependencies": {
-                "is-core-module": "^2.2.0",
-                "path-parse": "^1.0.6"
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/resumer": {
-            "version": "0.0.0",
-            "resolved": "https://registry.npmjs.org/resumer/-/resumer-0.0.0.tgz",
-            "integrity": "sha1-8ej0YeQGS6Oegq883CqMiT0HZ1k=",
+        "node_modules/readdirp": {
+            "version": "3.6.0",
+            "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+            "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
             "dev": true,
             "dependencies": {
-                "through": "~2.3.4"
+                "picomatch": "^2.2.1"
+            },
+            "engines": {
+                "node": ">=8.10.0"
             }
         },
-        "node_modules/side-channel": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
-            "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+        "node_modules/require-directory": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+            "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/resolve-from": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+            "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/resolve-import": {
+            "version": "1.4.5",
+            "resolved": "https://registry.npmjs.org/resolve-import/-/resolve-import-1.4.5.tgz",
+            "integrity": "sha512-HXb4YqODuuXT7Icq1Z++0g2JmhgbUHSs3VT2xR83gqvAPUikYT2Xk+562KHQgiaNkbBOlPddYrDLsC44qQggzw==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.0",
-                "get-intrinsic": "^1.0.2",
-                "object-inspect": "^1.9.0"
+                "glob": "^10.3.3",
+                "walk-up-path": "^3.0.1"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/string.prototype.trim": {
-            "version": "1.2.5",
-            "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.5.tgz",
-            "integrity": "sha512-Lnh17webJVsD6ECeovpVN17RlAKjmz4rF9S+8Y45CkMc/ufVpTkU3vZIyIC7sllQ1FCvObZnnCdNs/HXTUOTlg==",
+        "node_modules/resolve-import/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/resolve-import/node_modules/glob": {
+            "version": "10.3.10",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3",
-                "es-abstract": "^1.19.1"
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^2.3.5",
+                "minimatch": "^9.0.1",
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                "path-scurry": "^1.10.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=16 || 14 >=14.17"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/resolve-import/node_modules/minimatch": {
+            "version": "9.0.3",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "dev": true,
+            "dependencies": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/restore-cursor": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
+            "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==",
+            "dev": true,
+            "dependencies": {
+                "onetime": "^5.1.0",
+                "signal-exit": "^3.0.2"
+            },
+            "engines": {
+                "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/string.prototype.trimend": {
+        "node_modules/restore-cursor/node_modules/signal-exit": {
+            "version": "3.0.7",
+            "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+            "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+            "dev": true
+        },
+        "node_modules/retry": {
+            "version": "0.12.0",
+            "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+            "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+            "dev": true,
+            "engines": {
+                "node": ">= 4"
+            }
+        },
+        "node_modules/reusify": {
             "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz",
-            "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==",
+            "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+            "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+            "engines": {
+                "iojs": ">=1.0.0",
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/rimraf": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+            "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+            "dependencies": {
+                "glob": "^7.1.3"
+            },
+            "bin": {
+                "rimraf": "bin.js"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/run-parallel": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+            "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/feross"
+                },
+                {
+                    "type": "patreon",
+                    "url": "https://www.patreon.com/feross"
+                },
+                {
+                    "type": "consulting",
+                    "url": "https://feross.org/support"
+                }
+            ],
+            "dependencies": {
+                "queue-microtask": "^1.2.2"
+            }
+        },
+        "node_modules/safer-buffer": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+            "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+            "dev": true,
+            "optional": true
+        },
+        "node_modules/scheduler": {
+            "version": "0.23.0",
+            "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
+            "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "loose-envify": "^1.1.0"
+            }
+        },
+        "node_modules/semver": {
+            "version": "7.5.4",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+            "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+            "dev": true,
+            "dependencies": {
+                "lru-cache": "^6.0.0"
+            },
+            "bin": {
+                "semver": "bin/semver.js"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/semver/node_modules/lru-cache": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+            "dev": true,
+            "dependencies": {
+                "yallist": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/shebang-command": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+            "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+            "dependencies": {
+                "shebang-regex": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/shebang-regex": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+            "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/signal-exit": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+            "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+            "dev": true,
+            "engines": {
+                "node": ">=14"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/sigstore": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.1.0.tgz",
+            "integrity": "sha512-kPIj+ZLkyI3QaM0qX8V/nSsweYND3W448pwkDgS6CQ74MfhEkIR8ToK5Iyx46KJYRjseVcD3Rp9zAmUAj6ZjPw==",
+            "dev": true,
+            "dependencies": {
+                "@sigstore/bundle": "^2.1.0",
+                "@sigstore/protobuf-specs": "^0.2.1",
+                "@sigstore/sign": "^2.1.0",
+                "@sigstore/tuf": "^2.1.0"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
             }
         },
-        "node_modules/string.prototype.trimstart": {
+        "node_modules/slice-ansi": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-6.0.0.tgz",
+            "integrity": "sha512-6bn4hRfkTvDfUoEQYkERg0BVF1D0vrX9HEkMl08uDiNWvVvjylLHvZFZWkDo6wjT8tUctbYl1nCOuE66ZTaUtA==",
+            "dev": true,
+            "dependencies": {
+                "ansi-styles": "^6.2.1",
+                "is-fullwidth-code-point": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=14.16"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+            }
+        },
+        "node_modules/slice-ansi/node_modules/ansi-styles": {
+            "version": "6.2.1",
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+            "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            }
+        },
+        "node_modules/smart-buffer": {
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+            "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+            "dev": true,
+            "engines": {
+                "node": ">= 6.0.0",
+                "npm": ">= 3.0.0"
+            }
+        },
+        "node_modules/socks": {
+            "version": "2.7.1",
+            "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz",
+            "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==",
+            "dev": true,
+            "dependencies": {
+                "ip": "^2.0.0",
+                "smart-buffer": "^4.2.0"
+            },
+            "engines": {
+                "node": ">= 10.13.0",
+                "npm": ">= 3.0.0"
+            }
+        },
+        "node_modules/socks-proxy-agent": {
+            "version": "8.0.2",
+            "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz",
+            "integrity": "sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==",
+            "dev": true,
+            "dependencies": {
+                "agent-base": "^7.0.2",
+                "debug": "^4.3.4",
+                "socks": "^2.7.1"
+            },
+            "engines": {
+                "node": ">= 14"
+            }
+        },
+        "node_modules/spdx-correct": {
+            "version": "3.2.0",
+            "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",
+            "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
+            "dev": true,
+            "dependencies": {
+                "spdx-expression-parse": "^3.0.0",
+                "spdx-license-ids": "^3.0.0"
+            }
+        },
+        "node_modules/spdx-exceptions": {
+            "version": "2.3.0",
+            "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+            "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+            "dev": true
+        },
+        "node_modules/spdx-expression-parse": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+            "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+            "dev": true,
+            "dependencies": {
+                "spdx-exceptions": "^2.1.0",
+                "spdx-license-ids": "^3.0.0"
+            }
+        },
+        "node_modules/spdx-license-ids": {
+            "version": "3.0.16",
+            "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz",
+            "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==",
+            "dev": true
+        },
+        "node_modules/ssri": {
+            "version": "10.0.5",
+            "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz",
+            "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==",
+            "dev": true,
+            "dependencies": {
+                "minipass": "^7.0.3"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/stack-utils": {
+            "version": "2.0.6",
+            "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
+            "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
+            "dev": true,
+            "dependencies": {
+                "escape-string-regexp": "^2.0.0"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/stack-utils/node_modules/escape-string-regexp": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+            "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/string-length": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/string-length/-/string-length-6.0.0.tgz",
+            "integrity": "sha512-1U361pxZHEQ+FeSjzqRpV+cu2vTzYeWeafXFLykiFlv4Vc0n3njgU8HrMbyik5uwm77naWMuVG8fhEF+Ovb1Kg==",
+            "dev": true,
+            "dependencies": {
+                "strip-ansi": "^7.1.0"
+            },
+            "engines": {
+                "node": ">=16"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/string-length/node_modules/ansi-regex": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+            "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+            }
+        },
+        "node_modules/string-length/node_modules/strip-ansi": {
+            "version": "7.1.0",
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+            "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+            "dev": true,
+            "dependencies": {
+                "ansi-regex": "^6.0.1"
+            },
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+            }
+        },
+        "node_modules/string-width": {
+            "version": "5.1.2",
+            "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+            "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+            "dev": true,
+            "dependencies": {
+                "eastasianwidth": "^0.2.0",
+                "emoji-regex": "^9.2.2",
+                "strip-ansi": "^7.0.1"
+            },
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/string-width-cjs": {
+            "name": "string-width",
+            "version": "4.2.3",
+            "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+            "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+            "dev": true,
+            "dependencies": {
+                "emoji-regex": "^8.0.0",
+                "is-fullwidth-code-point": "^3.0.0",
+                "strip-ansi": "^6.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/string-width-cjs/node_modules/emoji-regex": {
+            "version": "8.0.0",
+            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+            "dev": true
+        },
+        "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+            "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/string-width/node_modules/ansi-regex": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+            "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+            }
+        },
+        "node_modules/string-width/node_modules/strip-ansi": {
+            "version": "7.1.0",
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+            "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+            "dev": true,
+            "dependencies": {
+                "ansi-regex": "^6.0.1"
+            },
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+            }
+        },
+        "node_modules/strip-ansi": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+            "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+            "dependencies": {
+                "ansi-regex": "^5.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/strip-ansi-cjs": {
+            "name": "strip-ansi",
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+            "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+            "dev": true,
+            "dependencies": {
+                "ansi-regex": "^5.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/strip-json-comments": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+            "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/striptags": {
+            "version": "4.0.0-alpha.4",
+            "resolved": "https://registry.npmjs.org/striptags/-/striptags-4.0.0-alpha.4.tgz",
+            "integrity": "sha512-/0jWyVWhpg9ciRHfjKYBpMHXct/HrFRfsR2HU77nGPbc8SPcVSIHZlZR/0TG3MyPq2C+HiHuwx8BlbcdI/cNbw=="
+        },
+        "node_modules/supports-color": {
+            "version": "7.2.0",
+            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+            "dependencies": {
+                "has-flag": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/sync-content": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/sync-content/-/sync-content-1.0.2.tgz",
+            "integrity": "sha512-znd3rYiiSxU3WteWyS9a6FXkTA/Wjk8WQsOyzHbineeL837dLn3DA4MRhsIX3qGcxDMH6+uuFV4axztssk7wEQ==",
+            "dev": true,
+            "dependencies": {
+                "glob": "^10.2.6",
+                "mkdirp": "^3.0.1",
+                "path-scurry": "^1.9.2",
+                "rimraf": "^5.0.1"
+            },
+            "bin": {
+                "sync-content": "dist/mjs/bin.mjs"
+            },
+            "engines": {
+                "node": ">=14"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/sync-content/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/sync-content/node_modules/glob": {
+            "version": "10.3.10",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "dev": true,
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^2.3.5",
+                "minimatch": "^9.0.1",
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                "path-scurry": "^1.10.1"
+            },
+            "bin": {
+                "glob": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/sync-content/node_modules/minimatch": {
+            "version": "9.0.3",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "dev": true,
+            "dependencies": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/sync-content/node_modules/rimraf": {
+            "version": "5.0.5",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
+            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+            "dev": true,
+            "dependencies": {
+                "glob": "^10.3.7"
+            },
+            "bin": {
+                "rimraf": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=14"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/tap": {
+            "version": "18.6.1",
+            "resolved": "https://registry.npmjs.org/tap/-/tap-18.6.1.tgz",
+            "integrity": "sha512-5cBQhJ1gdbsrTR3tA5kZZTts0HyOML6bcM7pEF7GF8d6y1ajfRMjbInS1Ty7/x2Ip0ko3cY1dYjPJ9JFNPsm7w==",
+            "dev": true,
+            "dependencies": {
+                "@tapjs/after": "1.1.17",
+                "@tapjs/after-each": "1.1.17",
+                "@tapjs/asserts": "1.1.17",
+                "@tapjs/before": "1.1.17",
+                "@tapjs/before-each": "1.1.17",
+                "@tapjs/core": "1.4.6",
+                "@tapjs/filter": "1.2.17",
+                "@tapjs/fixture": "1.2.17",
+                "@tapjs/intercept": "1.2.17",
+                "@tapjs/mock": "1.2.15",
+                "@tapjs/node-serialize": "1.2.6",
+                "@tapjs/run": "1.4.16",
+                "@tapjs/snapshot": "1.2.17",
+                "@tapjs/spawn": "1.1.17",
+                "@tapjs/stdin": "1.1.17",
+                "@tapjs/test": "1.3.17",
+                "@tapjs/typescript": "1.3.6",
+                "@tapjs/worker": "1.1.17",
+                "resolve-import": "^1.4.5"
+            },
+            "bin": {
+                "tap": "dist/esm/run.mjs"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/tap-parser": {
+            "version": "15.3.1",
+            "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-15.3.1.tgz",
+            "integrity": "sha512-hwAtXX5TBGt2MJeYvASc7DjP48PUzA7P8RTbLxQcgKCEH7ICD5IsRco7l5YvkzjHlZbUbeI9wzO8B4hw2sKgnQ==",
+            "dev": true,
+            "dependencies": {
+                "events-to-array": "^2.0.3",
+                "tap-yaml": "2.2.1"
+            },
+            "bin": {
+                "tap-parser": "bin/cmd.cjs"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            }
+        },
+        "node_modules/tap-yaml": {
+            "version": "2.2.1",
+            "resolved": "https://registry.npmjs.org/tap-yaml/-/tap-yaml-2.2.1.tgz",
+            "integrity": "sha512-ovZuUMLAIH59jnFHXKEGJ+WyDYl6Cuduwg9qpvnqkZOUA1nU84q02Sry1HT0KXcdv2uB91bEKKxnIybBgrb6oA==",
+            "dev": true,
+            "dependencies": {
+                "yaml": "^2.3.0",
+                "yaml-types": "^0.3.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            }
+        },
+        "node_modules/tar": {
+            "version": "6.2.0",
+            "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
+            "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==",
+            "dev": true,
+            "dependencies": {
+                "chownr": "^2.0.0",
+                "fs-minipass": "^2.0.0",
+                "minipass": "^5.0.0",
+                "minizlib": "^2.1.1",
+                "mkdirp": "^1.0.3",
+                "yallist": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/tar/node_modules/fs-minipass": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+            "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+            "dev": true,
+            "dependencies": {
+                "minipass": "^3.0.0"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": {
+            "version": "3.3.6",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+            "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+            "dev": true,
+            "dependencies": {
+                "yallist": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tar/node_modules/minipass": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+            "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/tar/node_modules/mkdirp": {
             "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
-            "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
+            "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+            "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+            "dev": true,
+            "bin": {
+                "mkdirp": "bin/cmd.js"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/tcompare": {
+            "version": "6.4.5",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-6.4.5.tgz",
+            "integrity": "sha512-Whuz9xlKKI2XXICKDSDRKjXdBuC6gBNOgmEUtH7UFyQeYzfUMQ19DyjZULarGKDGFhgOg3CJ+IQUEfpkOPg0Uw==",
             "dev": true,
             "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "diff": "^5.1.0",
+                "react-element-to-jsx-string": "^15.0.0"
+            },
+            "engines": {
+                "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20"
+            }
+        },
+        "node_modules/test-exclude": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+            "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+            "dev": true,
+            "dependencies": {
+                "@istanbuljs/schema": "^0.1.2",
+                "glob": "^7.1.4",
+                "minimatch": "^3.0.4"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/text-table": {
+            "version": "0.2.0",
+            "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+            "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="
+        },
+        "node_modules/to-regex-range": {
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+            "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+            "dev": true,
+            "dependencies": {
+                "is-number": "^7.0.0"
+            },
+            "engines": {
+                "node": ">=8.0"
+            }
+        },
+        "node_modules/trivial-deferred": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/trivial-deferred/-/trivial-deferred-2.0.0.tgz",
+            "integrity": "sha512-iGbM7X2slv9ORDVj2y2FFUq3cP/ypbtu2nQ8S38ufjL0glBABvmR9pTdsib1XtS2LUhhLMbelaBUaf/s5J3dSw==",
+            "dev": true,
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/tshy": {
+            "version": "1.8.2",
+            "resolved": "https://registry.npmjs.org/tshy/-/tshy-1.8.2.tgz",
+            "integrity": "sha512-aGlSY+jkZYAv0YDgtdv1U2vvbGTUdlXmhVP4uegujlJ/wuznmJqSu5cUV/6IW7N7a3HFRhofWvIS/FquYN9zgA==",
+            "dev": true,
+            "dependencies": {
+                "chalk": "^5.3.0",
+                "chokidar": "^3.5.3",
+                "foreground-child": "^3.1.1",
+                "mkdirp": "^3.0.1",
+                "resolve-import": "^1.4.4",
+                "rimraf": "^5.0.1",
+                "sync-content": "^1.0.2",
+                "typescript": "5.2",
+                "walk-up-path": "^3.0.1"
+            },
+            "bin": {
+                "tshy": "dist/esm/index.js"
+            },
+            "engines": {
+                "node": "16 >=16.17 || 18 >=18.15.0 || >=20.6.1"
+            }
+        },
+        "node_modules/tshy/node_modules/brace-expansion": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0"
+            }
+        },
+        "node_modules/tshy/node_modules/chalk": {
+            "version": "5.3.0",
+            "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+            "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+            "dev": true,
+            "engines": {
+                "node": "^12.17.0 || ^14.13 || >=16.0.0"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/chalk/chalk?sponsor=1"
             }
         },
-        "node_modules/tape": {
-            "version": "5.4.1",
-            "resolved": "https://registry.npmjs.org/tape/-/tape-5.4.1.tgz",
-            "integrity": "sha512-7bGaJ3WnQ/CX3xOWzlR+9lNptEWoD+11gyREP8k+SYrDu2a20EifKpTmZndXn25ZRxesYHSuNtE7Fb+THcjfGA==",
-            "dev": true,
-            "dependencies": {
-                "array.prototype.every": "^1.1.3",
-                "call-bind": "^1.0.2",
-                "deep-equal": "^2.0.5",
-                "defined": "^1.0.0",
-                "dotignore": "^0.1.2",
-                "for-each": "^0.3.3",
-                "get-package-type": "^0.1.0",
-                "glob": "^7.2.0",
-                "has": "^1.0.3",
-                "has-dynamic-import": "^2.0.1",
-                "inherits": "^2.0.4",
-                "is-regex": "^1.1.4",
-                "minimist": "^1.2.5",
-                "object-inspect": "^1.12.0",
-                "object-is": "^1.1.5",
-                "object-keys": "^1.1.1",
-                "object.assign": "^4.1.2",
-                "resolve": "^2.0.0-next.3",
-                "resumer": "^0.0.0",
-                "string.prototype.trim": "^1.2.5",
-                "through": "^2.3.8"
+        "node_modules/tshy/node_modules/glob": {
+            "version": "10.3.10",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+            "dev": true,
+            "dependencies": {
+                "foreground-child": "^3.1.0",
+                "jackspeak": "^2.3.5",
+                "minimatch": "^9.0.1",
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                "path-scurry": "^1.10.1"
             },
             "bin": {
-                "tape": "bin/tape"
+                "glob": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/tshy/node_modules/minimatch": {
+            "version": "9.0.3",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+            "dev": true,
+            "dependencies": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/tshy/node_modules/rimraf": {
+            "version": "5.0.5",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
+            "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+            "dev": true,
+            "dependencies": {
+                "glob": "^10.3.7"
+            },
+            "bin": {
+                "rimraf": "dist/esm/bin.mjs"
+            },
+            "engines": {
+                "node": ">=14"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/tslib": {
+            "version": "2.6.2",
+            "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+            "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
+        },
+        "node_modules/tuf-js": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.1.0.tgz",
+            "integrity": "sha512-eD7YPPjVlMzdggrOeE8zwoegUaG/rt6Bt3jwoQPunRiNVzgcCE009UDFJKJjG+Gk9wFu6W/Vi+P5d/5QpdD9jA==",
+            "dev": true,
+            "dependencies": {
+                "@tufjs/models": "2.0.0",
+                "debug": "^4.3.4",
+                "make-fetch-happen": "^13.0.0"
+            },
+            "engines": {
+                "node": "^16.14.0 || >=18.0.0"
+            }
+        },
+        "node_modules/type-check": {
+            "version": "0.4.0",
+            "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+            "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+            "dependencies": {
+                "prelude-ls": "^1.2.1"
+            },
+            "engines": {
+                "node": ">= 0.8.0"
+            }
+        },
+        "node_modules/type-fest": {
+            "version": "0.20.2",
+            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+            "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/typescript": {
+            "version": "5.2.2",
+            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
+            "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
+            "dev": true,
+            "bin": {
+                "tsc": "bin/tsc",
+                "tsserver": "bin/tsserver"
+            },
+            "engines": {
+                "node": ">=14.17"
+            }
+        },
+        "node_modules/undici-types": {
+            "version": "5.26.5",
+            "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+            "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+            "dev": true,
+            "peer": true
+        },
+        "node_modules/unique-filename": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz",
+            "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==",
+            "dev": true,
+            "dependencies": {
+                "unique-slug": "^4.0.0"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/unique-slug": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz",
+            "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==",
+            "dev": true,
+            "dependencies": {
+                "imurmurhash": "^0.1.4"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
             }
         },
-        "node_modules/through": {
-            "version": "2.3.8",
-            "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
-            "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
+        "node_modules/uri-js": {
+            "version": "4.4.1",
+            "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+            "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+            "dependencies": {
+                "punycode": "^2.1.0"
+            }
+        },
+        "node_modules/uuid": {
+            "version": "8.3.2",
+            "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+            "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+            "dev": true,
+            "bin": {
+                "uuid": "dist/bin/uuid"
+            }
+        },
+        "node_modules/v8-compile-cache-lib": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+            "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
             "dev": true
         },
-        "node_modules/unbox-primitive": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
-            "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+        "node_modules/v8-to-istanbul": {
+            "version": "9.2.0",
+            "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz",
+            "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==",
+            "dev": true,
+            "dependencies": {
+                "@jridgewell/trace-mapping": "^0.3.12",
+                "@types/istanbul-lib-coverage": "^2.0.1",
+                "convert-source-map": "^2.0.0"
+            },
+            "engines": {
+                "node": ">=10.12.0"
+            }
+        },
+        "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": {
+            "version": "0.3.20",
+            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
+            "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
+            "dev": true,
+            "dependencies": {
+                "@jridgewell/resolve-uri": "^3.1.0",
+                "@jridgewell/sourcemap-codec": "^1.4.14"
+            }
+        },
+        "node_modules/validate-npm-package-license": {
+            "version": "3.0.4",
+            "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+            "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+            "dev": true,
+            "dependencies": {
+                "spdx-correct": "^3.0.0",
+                "spdx-expression-parse": "^3.0.0"
+            }
+        },
+        "node_modules/validate-npm-package-name": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz",
+            "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==",
+            "dev": true,
+            "dependencies": {
+                "builtins": "^5.0.0"
+            },
+            "engines": {
+                "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+            }
+        },
+        "node_modules/walk-up-path": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz",
+            "integrity": "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==",
+            "dev": true
+        },
+        "node_modules/which": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+            "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+            "dependencies": {
+                "isexe": "^2.0.0"
+            },
+            "bin": {
+                "node-which": "bin/node-which"
+            },
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/widest-line": {
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",
+            "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==",
             "dev": true,
             "dependencies": {
-                "function-bind": "^1.1.1",
-                "has-bigints": "^1.0.1",
-                "has-symbols": "^1.0.2",
-                "which-boxed-primitive": "^1.0.2"
+                "string-width": "^5.0.1"
+            },
+            "engines": {
+                "node": ">=12"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/which-boxed-primitive": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
-            "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+        "node_modules/word-wrap": {
+            "version": "1.2.5",
+            "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+            "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/wrap-ansi": {
+            "version": "8.1.0",
+            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+            "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
             "dev": true,
             "dependencies": {
-                "is-bigint": "^1.0.1",
-                "is-boolean-object": "^1.1.0",
-                "is-number-object": "^1.0.4",
-                "is-string": "^1.0.5",
-                "is-symbol": "^1.0.3"
+                "ansi-styles": "^6.1.0",
+                "string-width": "^5.0.1",
+                "strip-ansi": "^7.0.1"
+            },
+            "engines": {
+                "node": ">=12"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
             }
         },
-        "node_modules/which-collection": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz",
-            "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==",
+        "node_modules/wrap-ansi-cjs": {
+            "name": "wrap-ansi",
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+            "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
             "dev": true,
             "dependencies": {
-                "is-map": "^2.0.1",
-                "is-set": "^2.0.1",
-                "is-weakmap": "^2.0.1",
-                "is-weakset": "^2.0.1"
+                "ansi-styles": "^4.0.0",
+                "string-width": "^4.1.0",
+                "strip-ansi": "^6.0.0"
+            },
+            "engines": {
+                "node": ">=10"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+            }
+        },
+        "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+            "version": "8.0.0",
+            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+            "dev": true
+        },
+        "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+            "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
             }
         },
-        "node_modules/which-typed-array": {
-            "version": "1.1.7",
-            "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.7.tgz",
-            "integrity": "sha512-vjxaB4nfDqwKI0ws7wZpxIlde1XrLX5uB0ZjpfshgmapJMD7jJWhZI+yToJTqaFByF0eNBcYxbjmCzoRP7CfEw==",
+        "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+            "version": "4.2.3",
+            "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+            "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
             "dev": true,
             "dependencies": {
-                "available-typed-arrays": "^1.0.5",
-                "call-bind": "^1.0.2",
-                "es-abstract": "^1.18.5",
-                "foreach": "^2.0.5",
-                "has-tostringtag": "^1.0.0",
-                "is-typed-array": "^1.1.7"
+                "emoji-regex": "^8.0.0",
+                "is-fullwidth-code-point": "^3.0.0",
+                "strip-ansi": "^6.0.1"
             },
             "engines": {
-                "node": ">= 0.4"
+                "node": ">=8"
+            }
+        },
+        "node_modules/wrap-ansi/node_modules/ansi-regex": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+            "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
             },
             "funding": {
-                "url": "https://github.com/sponsors/ljharb"
+                "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+            }
+        },
+        "node_modules/wrap-ansi/node_modules/ansi-styles": {
+            "version": "6.2.1",
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+            "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            }
+        },
+        "node_modules/wrap-ansi/node_modules/strip-ansi": {
+            "version": "7.1.0",
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+            "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+            "dev": true,
+            "dependencies": {
+                "ansi-regex": "^6.0.1"
+            },
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/strip-ansi?sponsor=1"
             }
         },
         "node_modules/wrappy": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+        },
+        "node_modules/ws": {
+            "version": "8.16.0",
+            "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
+            "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=10.0.0"
+            },
+            "peerDependencies": {
+                "bufferutil": "^4.0.1",
+                "utf-8-validate": ">=5.0.2"
+            },
+            "peerDependenciesMeta": {
+                "bufferutil": {
+                    "optional": true
+                },
+                "utf-8-validate": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/y18n": {
+            "version": "5.0.8",
+            "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+            "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+            "dev": true,
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/yallist": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+            "dev": true
+        },
+        "node_modules/yaml": {
+            "version": "2.3.4",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",
+            "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==",
+            "dev": true,
+            "engines": {
+                "node": ">= 14"
+            }
+        },
+        "node_modules/yaml-types": {
+            "version": "0.3.0",
+            "resolved": "https://registry.npmjs.org/yaml-types/-/yaml-types-0.3.0.tgz",
+            "integrity": "sha512-i9RxAO/LZBiE0NJUy9pbN5jFz5EasYDImzRkj8Y81kkInTi1laia3P3K/wlMKzOxFQutZip8TejvQP/DwgbU7A==",
+            "dev": true,
+            "engines": {
+                "node": ">= 16",
+                "npm": ">= 7"
+            },
+            "peerDependencies": {
+                "yaml": "^2.3.0"
+            }
+        },
+        "node_modules/yargs": {
+            "version": "17.7.2",
+            "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+            "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+            "dev": true,
+            "dependencies": {
+                "cliui": "^8.0.1",
+                "escalade": "^3.1.1",
+                "get-caller-file": "^2.0.5",
+                "require-directory": "^2.1.1",
+                "string-width": "^4.2.3",
+                "y18n": "^5.0.5",
+                "yargs-parser": "^21.1.1"
+            },
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/yargs-parser": {
+            "version": "21.1.1",
+            "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+            "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+            "dev": true,
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/yargs/node_modules/emoji-regex": {
+            "version": "8.0.0",
+            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+            "dev": true
+        },
+        "node_modules/yargs/node_modules/is-fullwidth-code-point": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+            "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/yargs/node_modules/string-width": {
+            "version": "4.2.3",
+            "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+            "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+            "dev": true,
+            "dependencies": {
+                "emoji-regex": "^8.0.0",
+                "is-fullwidth-code-point": "^3.0.0",
+                "strip-ansi": "^6.0.1"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/yocto-queue": {
+            "version": "0.1.0",
+            "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+            "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/yoga-wasm-web": {
+            "version": "0.3.3",
+            "resolved": "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz",
+            "integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==",
             "dev": true
         }
     },
     "dependencies": {
+        "@alcalzone/ansi-tokenize": {
+            "version": "0.1.3",
+            "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz",
+            "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==",
+            "dev": true,
+            "requires": {
+                "ansi-styles": "^6.2.1",
+                "is-fullwidth-code-point": "^4.0.0"
+            },
+            "dependencies": {
+                "ansi-styles": {
+                    "version": "6.2.1",
+                    "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+                    "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+                    "dev": true
+                }
+            }
+        },
+        "@base2/pretty-print-object": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz",
+            "integrity": "sha512-4iri8i1AqYHJE2DstZYkyEprg6Pq6sKx3xn5FpySk9sNhH7qN2LLlHJCfDTZRILNwQNPD7mATWM0TBui7uC1pA==",
+            "dev": true
+        },
+        "@bcoe/v8-coverage": {
+            "version": "0.2.3",
+            "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
+            "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
+            "dev": true
+        },
+        "@cspotcode/source-map-support": {
+            "version": "0.8.1",
+            "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+            "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+            "dev": true,
+            "requires": {
+                "@jridgewell/trace-mapping": "0.3.9"
+            }
+        },
+        "@eslint-community/eslint-utils": {
+            "version": "4.4.0",
+            "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+            "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+            "requires": {
+                "eslint-visitor-keys": "^3.3.0"
+            }
+        },
+        "@eslint-community/regexpp": {
+            "version": "4.5.0",
+            "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.0.tgz",
+            "integrity": "sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ=="
+        },
+        "@eslint/eslintrc": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.2.tgz",
+            "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==",
+            "requires": {
+                "ajv": "^6.12.4",
+                "debug": "^4.3.2",
+                "espree": "^9.5.1",
+                "globals": "^13.19.0",
+                "ignore": "^5.2.0",
+                "import-fresh": "^3.2.1",
+                "js-yaml": "^4.1.0",
+                "minimatch": "^3.1.2",
+                "strip-json-comments": "^3.1.1"
+            }
+        },
+        "@eslint/js": {
+            "version": "8.37.0",
+            "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.37.0.tgz",
+            "integrity": "sha512-x5vzdtOOGgFVDCUs81QRB2+liax8rFg3+7hqM+QhBG0/G3F1ZsoYl97UrqgHgQ9KKT7G6c4V+aTUCgu/n22v1A=="
+        },
+        "@humanwhocodes/config-array": {
+            "version": "0.11.8",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
+            "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
+            "requires": {
+                "@humanwhocodes/object-schema": "^1.2.1",
+                "debug": "^4.1.1",
+                "minimatch": "^3.0.5"
+            }
+        },
+        "@humanwhocodes/module-importer": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+            "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="
+        },
+        "@humanwhocodes/object-schema": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
+            "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA=="
+        },
+        "@isaacs/cliui": {
+            "version": "8.0.2",
+            "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+            "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+            "dev": true,
+            "requires": {
+                "string-width": "^5.1.2",
+                "string-width-cjs": "npm:string-width@^4.2.0",
+                "strip-ansi": "^7.0.1",
+                "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+                "wrap-ansi": "^8.1.0",
+                "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+            },
+            "dependencies": {
+                "ansi-regex": {
+                    "version": "6.0.1",
+                    "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+                    "dev": true
+                },
+                "strip-ansi": {
+                    "version": "7.1.0",
+                    "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+                    "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+                    "dev": true,
+                    "requires": {
+                        "ansi-regex": "^6.0.1"
+                    }
+                }
+            }
+        },
+        "@isaacs/ts-node-temp-fork-for-pr-2009": {
+            "version": "10.9.5",
+            "resolved": "https://registry.npmjs.org/@isaacs/ts-node-temp-fork-for-pr-2009/-/ts-node-temp-fork-for-pr-2009-10.9.5.tgz",
+            "integrity": "sha512-hEDlwpHhIabtB+Urku8muNMEkGui0LVGlYLS3KoB9QBDf0Pw3r7q0RrfoQmFuk8CvRpGzErO3/vLQd9Ys+/g4g==",
+            "dev": true,
+            "requires": {
+                "@cspotcode/source-map-support": "^0.8.0",
+                "@tsconfig/node14": "*",
+                "@tsconfig/node16": "*",
+                "@tsconfig/node18": "*",
+                "@tsconfig/node20": "*",
+                "acorn": "^8.4.1",
+                "acorn-walk": "^8.1.1",
+                "arg": "^4.1.0",
+                "diff": "^4.0.1",
+                "make-error": "^1.1.1",
+                "v8-compile-cache-lib": "^3.0.1"
+            },
+            "dependencies": {
+                "diff": {
+                    "version": "4.0.2",
+                    "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+                    "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+                    "dev": true
+                }
+            }
+        },
+        "@istanbuljs/schema": {
+            "version": "0.1.3",
+            "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+            "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+            "dev": true
+        },
+        "@jridgewell/resolve-uri": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
+            "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
+            "dev": true
+        },
+        "@jridgewell/sourcemap-codec": {
+            "version": "1.4.15",
+            "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
+            "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
+            "dev": true
+        },
+        "@jridgewell/trace-mapping": {
+            "version": "0.3.9",
+            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+            "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+            "dev": true,
+            "requires": {
+                "@jridgewell/resolve-uri": "^3.0.3",
+                "@jridgewell/sourcemap-codec": "^1.4.10"
+            }
+        },
+        "@js-temporal/polyfill": {
+            "version": "0.4.4",
+            "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.4.4.tgz",
+            "integrity": "sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==",
+            "requires": {
+                "jsbi": "^4.3.0",
+                "tslib": "^2.4.1"
+            }
+        },
+        "@nodelib/fs.scandir": {
+            "version": "2.1.5",
+            "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+            "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+            "requires": {
+                "@nodelib/fs.stat": "2.0.5",
+                "run-parallel": "^1.1.9"
+            }
+        },
+        "@nodelib/fs.stat": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+            "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="
+        },
+        "@nodelib/fs.walk": {
+            "version": "1.2.8",
+            "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+            "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+            "requires": {
+                "@nodelib/fs.scandir": "2.1.5",
+                "fastq": "^1.6.0"
+            }
+        },
+        "@npmcli/agent": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.0.tgz",
+            "integrity": "sha512-2yThA1Es98orMkpSLVqlDZAMPK3jHJhifP2gnNUdk1754uZ8yI5c+ulCoVG+WlntQA6MzhrURMXjSd9Z7dJ2/Q==",
+            "dev": true,
+            "requires": {
+                "agent-base": "^7.1.0",
+                "http-proxy-agent": "^7.0.0",
+                "https-proxy-agent": "^7.0.1",
+                "lru-cache": "^10.0.1",
+                "socks-proxy-agent": "^8.0.1"
+            }
+        },
+        "@npmcli/fs": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz",
+            "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==",
+            "dev": true,
+            "requires": {
+                "semver": "^7.3.5"
+            }
+        },
+        "@npmcli/git": {
+            "version": "5.0.4",
+            "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.4.tgz",
+            "integrity": "sha512-nr6/WezNzuYUppzXRaYu/W4aT5rLxdXqEFupbh6e/ovlYFQ8hpu1UUPV3Ir/YTl+74iXl2ZOMlGzudh9ZPUchQ==",
+            "dev": true,
+            "requires": {
+                "@npmcli/promise-spawn": "^7.0.0",
+                "lru-cache": "^10.0.1",
+                "npm-pick-manifest": "^9.0.0",
+                "proc-log": "^3.0.0",
+                "promise-inflight": "^1.0.1",
+                "promise-retry": "^2.0.1",
+                "semver": "^7.3.5",
+                "which": "^4.0.0"
+            },
+            "dependencies": {
+                "isexe": {
+                    "version": "3.1.1",
+                    "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+                    "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+                    "dev": true
+                },
+                "which": {
+                    "version": "4.0.0",
+                    "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+                    "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+                    "dev": true,
+                    "requires": {
+                        "isexe": "^3.1.1"
+                    }
+                }
+            }
+        },
+        "@npmcli/installed-package-contents": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz",
+            "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==",
+            "dev": true,
+            "requires": {
+                "npm-bundled": "^3.0.0",
+                "npm-normalize-package-bin": "^3.0.0"
+            }
+        },
+        "@npmcli/node-gyp": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz",
+            "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==",
+            "dev": true
+        },
+        "@npmcli/promise-spawn": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz",
+            "integrity": "sha512-P4KkF9jX3y+7yFUxgcUdDtLy+t4OlDGuEBLNs57AZsfSfg+uV6MLndqGpnl4831ggaEdXwR50XFoZP4VFtHolg==",
+            "dev": true,
+            "requires": {
+                "which": "^4.0.0"
+            },
+            "dependencies": {
+                "isexe": {
+                    "version": "3.1.1",
+                    "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+                    "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+                    "dev": true
+                },
+                "which": {
+                    "version": "4.0.0",
+                    "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+                    "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+                    "dev": true,
+                    "requires": {
+                        "isexe": "^3.1.1"
+                    }
+                }
+            }
+        },
+        "@npmcli/run-script": {
+            "version": "7.0.3",
+            "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.3.tgz",
+            "integrity": "sha512-ZMWGLHpzMq3rBGIwPyeaoaleaLMvrBrH8nugHxTi5ACkJZXTxXPtVuEH91ifgtss5hUwJQ2VDnzDBWPmz78rvg==",
+            "dev": true,
+            "requires": {
+                "@npmcli/node-gyp": "^3.0.0",
+                "@npmcli/promise-spawn": "^7.0.0",
+                "node-gyp": "^10.0.0",
+                "read-package-json-fast": "^3.0.0",
+                "which": "^4.0.0"
+            },
+            "dependencies": {
+                "isexe": {
+                    "version": "3.1.1",
+                    "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+                    "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+                    "dev": true
+                },
+                "which": {
+                    "version": "4.0.0",
+                    "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+                    "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+                    "dev": true,
+                    "requires": {
+                        "isexe": "^3.1.1"
+                    }
+                }
+            }
+        },
+        "@pkgjs/parseargs": {
+            "version": "0.11.0",
+            "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+            "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+            "dev": true,
+            "optional": true
+        },
+        "@sigstore/bundle": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz",
+            "integrity": "sha512-89uOo6yh/oxaU8AeOUnVrTdVMcGk9Q1hJa7Hkvalc6G3Z3CupWk4Xe9djSgJm9fMkH69s0P0cVHUoKSOemLdng==",
+            "dev": true,
+            "requires": {
+                "@sigstore/protobuf-specs": "^0.2.1"
+            }
+        },
+        "@sigstore/protobuf-specs": {
+            "version": "0.2.1",
+            "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.2.1.tgz",
+            "integrity": "sha512-XTWVxnWJu+c1oCshMLwnKvz8ZQJJDVOlciMfgpJBQbThVjKTCG8dwyhgLngBD2KN0ap9F/gOV8rFDEx8uh7R2A==",
+            "dev": true
+        },
+        "@sigstore/sign": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.2.0.tgz",
+            "integrity": "sha512-AAbmnEHDQv6CSfrWA5wXslGtzLPtAtHZleKOgxdQYvx/s76Fk6T6ZVt7w2IGV9j1UrFeBocTTQxaXG2oRrDhYA==",
+            "dev": true,
+            "requires": {
+                "@sigstore/bundle": "^2.1.0",
+                "@sigstore/protobuf-specs": "^0.2.1",
+                "make-fetch-happen": "^13.0.0"
+            }
+        },
+        "@sigstore/tuf": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.2.0.tgz",
+            "integrity": "sha512-KKATZ5orWfqd9ZG6MN8PtCIx4eevWSuGRKQvofnWXRpyMyUEpmrzg5M5BrCpjM+NfZ0RbNGOh5tCz/P2uoRqOA==",
+            "dev": true,
+            "requires": {
+                "@sigstore/protobuf-specs": "^0.2.1",
+                "tuf-js": "^2.1.0"
+            }
+        },
+        "@tapjs/after": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/after/-/after-1.1.17.tgz",
+            "integrity": "sha512-14qeP+mHZ8nIMDGtdCwTgvKclLlHxfARMTasb9fw//tmF/8ZDZhTemtCDxAP75wihxy5P7nzVZo/6TpVeOZrwg==",
+            "dev": true,
+            "requires": {
+                "is-actual-promise": "^1.0.0"
+            }
+        },
+        "@tapjs/after-each": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/after-each/-/after-each-1.1.17.tgz",
+            "integrity": "sha512-ia8sr00Wilni+2+wO4MKYCYikeRwUC41HamV8EPN63R2UmiBEOe/cMSf+KYADIh56JvxAiH7Xa0+GSFU+N2FQQ==",
+            "dev": true,
+            "requires": {
+                "function-loop": "^4.0.0"
+            }
+        },
+        "@tapjs/asserts": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/asserts/-/asserts-1.1.17.tgz",
+            "integrity": "sha512-eKmbWBORDXu9bUHtPTu7qFrXNj5UeeH2nABJeP9BGHIn2ydmTgMEWCO3E+ljf7tisHchY5/x672lr99+O/mbTQ==",
+            "dev": true,
+            "requires": {
+                "@tapjs/stack": "1.2.7",
+                "is-actual-promise": "^1.0.0",
+                "tcompare": "6.4.5",
+                "trivial-deferred": "^2.0.0"
+            }
+        },
+        "@tapjs/before": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/before/-/before-1.1.17.tgz",
+            "integrity": "sha512-pAmEAIMIqF9MPNUgEsnuWCM00iD/FJOX0P5eXSsWexWHjuZAkv5tIT/4qpXO9KYj+9c51Lh+7YSY2Xvk1Jjolw==",
+            "dev": true,
+            "requires": {
+                "is-actual-promise": "^1.0.0"
+            }
+        },
+        "@tapjs/before-each": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/before-each/-/before-each-1.1.17.tgz",
+            "integrity": "sha512-d2Um3Y2j0m563QNsSxczh+QeSg5sBngnBFGOelUtQVqmq91oNWU/7mY1pwN6ip8mMIQYD75CIhq5/Z57DGomWQ==",
+            "dev": true,
+            "requires": {
+                "function-loop": "^4.0.0"
+            }
+        },
+        "@tapjs/config": {
+            "version": "2.4.14",
+            "resolved": "https://registry.npmjs.org/@tapjs/config/-/config-2.4.14.tgz",
+            "integrity": "sha512-dkjPVJGbLJC9BxCAxudAGiijnKc6XcQbpBSMAGJ/+VoRSqXlPkMWz0d8Ad3rNt7s+g2GBEWBx1kV7wcKtLlxmw==",
+            "dev": true,
+            "requires": {
+                "@tapjs/core": "1.4.6",
+                "@tapjs/test": "1.3.17",
+                "chalk": "^5.2.0",
+                "jackspeak": "^2.3.6",
+                "polite-json": "^4.0.1",
+                "tap-yaml": "2.2.1",
+                "walk-up-path": "^3.0.1"
+            },
+            "dependencies": {
+                "chalk": {
+                    "version": "5.3.0",
+                    "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+                    "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+                    "dev": true
+                }
+            }
+        },
+        "@tapjs/core": {
+            "version": "1.4.6",
+            "resolved": "https://registry.npmjs.org/@tapjs/core/-/core-1.4.6.tgz",
+            "integrity": "sha512-cAKtdGJslrziwi/RJBU7jF930P/eSsemv295t6yLekNVP0XUCNtLFYirxuS1Xwob0nt0g/k+94xXB7o1wdTQvA==",
+            "dev": true,
+            "requires": {
+                "@tapjs/processinfo": "^3.1.6",
+                "@tapjs/stack": "1.2.7",
+                "@tapjs/test": "1.3.17",
+                "async-hook-domain": "^4.0.1",
+                "diff": "^5.1.0",
+                "is-actual-promise": "^1.0.0",
+                "minipass": "^7.0.3",
+                "signal-exit": "4.1",
+                "tap-parser": "15.3.1",
+                "tap-yaml": "2.2.1",
+                "tcompare": "6.4.5",
+                "trivial-deferred": "^2.0.0"
+            }
+        },
+        "@tapjs/error-serdes": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/@tapjs/error-serdes/-/error-serdes-1.2.1.tgz",
+            "integrity": "sha512-/7eLEcrGo+Qz3eWrjkhDC+VSEOjabkkzr9eRADeU+OLFeZaik8L/GRk0SGhnp4YsQkv0jcNV00A42bEx2HIZcw==",
+            "dev": true,
+            "requires": {
+                "minipass": "^7.0.3"
+            }
+        },
+        "@tapjs/filter": {
+            "version": "1.2.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/filter/-/filter-1.2.17.tgz",
+            "integrity": "sha512-ytsqoPThV92ML1+M+cHlhAS7nOQpDNRBJiPqw20/GmNeoQXsDzVUlWR89DP3WNNUPrr/c1pCVr9XHVhCIeYk0w==",
+            "dev": true,
+            "requires": {}
+        },
+        "@tapjs/fixture": {
+            "version": "1.2.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/fixture/-/fixture-1.2.17.tgz",
+            "integrity": "sha512-eOOQxtsEcQ/sBxaZhpqdF9DCNxXAvLuiE5HgyL6d1eB4eceu57uIUKK7NDtFVv+vlbQH/NoiSTxmN/IBRbKT8w==",
+            "dev": true,
+            "requires": {
+                "mkdirp": "^3.0.0",
+                "rimraf": "^5.0.5"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "glob": {
+                    "version": "10.3.10",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "dev": true,
+                    "requires": {
+                        "foreground-child": "^3.1.0",
+                        "jackspeak": "^2.3.5",
+                        "minimatch": "^9.0.1",
+                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                        "path-scurry": "^1.10.1"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.3",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                },
+                "rimraf": {
+                    "version": "5.0.5",
+                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
+                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+                    "dev": true,
+                    "requires": {
+                        "glob": "^10.3.7"
+                    }
+                }
+            }
+        },
+        "@tapjs/intercept": {
+            "version": "1.2.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/intercept/-/intercept-1.2.17.tgz",
+            "integrity": "sha512-CNuYBxiFBMNALS1PxH3yGI10H8ObxOoD67C2xGWyzXeYrPJ/R4x31Sda9bqaoK3uf/vj28bC9kSECCFjRsNAEg==",
+            "dev": true,
+            "requires": {
+                "@tapjs/after": "1.1.17",
+                "@tapjs/stack": "1.2.7"
+            }
+        },
+        "@tapjs/mock": {
+            "version": "1.2.15",
+            "resolved": "https://registry.npmjs.org/@tapjs/mock/-/mock-1.2.15.tgz",
+            "integrity": "sha512-uXfVNDAMAbCGOu46B9jbryTau2pLSQjCdWnkAm/OUgZh/OtO0i7OORz9HdEPfEF2tuy1tLo9+vsCZm3lPU5F7w==",
+            "dev": true,
+            "requires": {
+                "@tapjs/after": "1.1.17",
+                "@tapjs/stack": "1.2.7",
+                "resolve-import": "^1.4.5",
+                "walk-up-path": "^3.0.1"
+            }
+        },
+        "@tapjs/node-serialize": {
+            "version": "1.2.6",
+            "resolved": "https://registry.npmjs.org/@tapjs/node-serialize/-/node-serialize-1.2.6.tgz",
+            "integrity": "sha512-xj1OJEsdTr0pQFlirfe/apN0dHUCMCx2Nm5H3SoiSOW4D1/FUKS65VZpWgo3mXMPxRyb/2T1DH3xON1eSGq4ww==",
+            "dev": true,
+            "requires": {
+                "@tapjs/error-serdes": "1.2.1",
+                "@tapjs/stack": "1.2.7",
+                "tap-parser": "15.3.1"
+            }
+        },
+        "@tapjs/processinfo": {
+            "version": "3.1.6",
+            "resolved": "https://registry.npmjs.org/@tapjs/processinfo/-/processinfo-3.1.6.tgz",
+            "integrity": "sha512-ktDsaf79wJsLaoG1Pp+stHSRf6a1k/JydoRAaYVG5iJnd3DooL6yewZsciUi2yiN/WQc5tAXCIFTXL4uXGB8LA==",
+            "dev": true,
+            "requires": {
+                "pirates": "^4.0.5",
+                "process-on-spawn": "^1.0.0",
+                "signal-exit": "^4.0.2",
+                "uuid": "^8.3.2"
+            }
+        },
+        "@tapjs/reporter": {
+            "version": "1.3.15",
+            "resolved": "https://registry.npmjs.org/@tapjs/reporter/-/reporter-1.3.15.tgz",
+            "integrity": "sha512-us1vXd6TW1V8wJxxnP2a8DNSP1WFTpODyYukqWg7ym5nCalREYnz2MFsn65rRNu/xJlmqsmv+9P63rupud7Zlg==",
+            "dev": true,
+            "requires": {
+                "@tapjs/config": "2.4.14",
+                "@tapjs/stack": "1.2.7",
+                "chalk": "^5.2.0",
+                "ink": "^4.4.1",
+                "minipass": "^7.0.3",
+                "ms": "^2.1.3",
+                "patch-console": "^2.0.0",
+                "prismjs-terminal": "^1.2.3",
+                "react": "^18.2.0",
+                "string-length": "^6.0.0",
+                "tap-parser": "15.3.1",
+                "tap-yaml": "2.2.1",
+                "tcompare": "6.4.5"
+            },
+            "dependencies": {
+                "chalk": {
+                    "version": "5.3.0",
+                    "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+                    "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+                    "dev": true
+                },
+                "ms": {
+                    "version": "2.1.3",
+                    "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+                    "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+                    "dev": true
+                }
+            }
+        },
+        "@tapjs/run": {
+            "version": "1.4.16",
+            "resolved": "https://registry.npmjs.org/@tapjs/run/-/run-1.4.16.tgz",
+            "integrity": "sha512-ZTESjBDj5SitZgWz2hQdzfBoxgaFs89jQjWzqobcdfro0iF7TVRpSrvpz9GTMdo2Tu9aeFfMNfmaAtwNWnDabw==",
+            "dev": true,
+            "requires": {
+                "@tapjs/after": "1.1.17",
+                "@tapjs/before": "1.1.17",
+                "@tapjs/config": "2.4.14",
+                "@tapjs/processinfo": "^3.1.6",
+                "@tapjs/reporter": "1.3.15",
+                "@tapjs/spawn": "1.1.17",
+                "@tapjs/stdin": "1.1.17",
+                "@tapjs/test": "1.3.17",
+                "c8": "^8.0.1",
+                "chalk": "^5.3.0",
+                "chokidar": "^3.5.3",
+                "foreground-child": "^3.1.1",
+                "glob": "^10.3.10",
+                "minipass": "^7.0.3",
+                "mkdirp": "^3.0.1",
+                "opener": "^1.5.2",
+                "pacote": "^17.0.3",
+                "resolve-import": "^1.4.5",
+                "rimraf": "^5.0.5",
+                "semver": "^7.5.4",
+                "signal-exit": "^4.1.0",
+                "tap-parser": "15.3.1",
+                "tap-yaml": "2.2.1",
+                "tcompare": "6.4.5",
+                "trivial-deferred": "^2.0.0",
+                "which": "^4.0.0"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "chalk": {
+                    "version": "5.3.0",
+                    "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+                    "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+                    "dev": true
+                },
+                "glob": {
+                    "version": "10.3.10",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "dev": true,
+                    "requires": {
+                        "foreground-child": "^3.1.0",
+                        "jackspeak": "^2.3.5",
+                        "minimatch": "^9.0.1",
+                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                        "path-scurry": "^1.10.1"
+                    }
+                },
+                "isexe": {
+                    "version": "3.1.1",
+                    "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+                    "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+                    "dev": true
+                },
+                "minimatch": {
+                    "version": "9.0.3",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                },
+                "rimraf": {
+                    "version": "5.0.5",
+                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
+                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+                    "dev": true,
+                    "requires": {
+                        "glob": "^10.3.7"
+                    }
+                },
+                "which": {
+                    "version": "4.0.0",
+                    "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+                    "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+                    "dev": true,
+                    "requires": {
+                        "isexe": "^3.1.1"
+                    }
+                }
+            }
+        },
+        "@tapjs/snapshot": {
+            "version": "1.2.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/snapshot/-/snapshot-1.2.17.tgz",
+            "integrity": "sha512-xDHys854ZA8s/1uCkE5PgBz4H1vYKChD6a4xjLVkaoRxpBHVp/IJZCD+8d69DRGnyuA4x2MGh0JLClTA9bLGrA==",
+            "dev": true,
+            "requires": {
+                "is-actual-promise": "^1.0.0",
+                "tcompare": "6.4.5",
+                "trivial-deferred": "^2.0.0"
+            }
+        },
+        "@tapjs/spawn": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/spawn/-/spawn-1.1.17.tgz",
+            "integrity": "sha512-Bbyxd91bgXEcglvXYKrRl2MaNHk00RajTZJ1kKe3Scr1ivaYv0maE6ZInAl4UE0a4SJl4Dskec+uKoZY3qGUYQ==",
+            "dev": true,
+            "requires": {}
+        },
+        "@tapjs/stack": {
+            "version": "1.2.7",
+            "resolved": "https://registry.npmjs.org/@tapjs/stack/-/stack-1.2.7.tgz",
+            "integrity": "sha512-7qUDWDmd+y7ZQ0vTrDTvFlWnJ+ND32NemS5HVuT1ZggHtBwJ62PQHIyCx/B5RopETBb6NvFPfUE21yTiex9Jkw==",
+            "dev": true
+        },
+        "@tapjs/stdin": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/stdin/-/stdin-1.1.17.tgz",
+            "integrity": "sha512-mDutFFPDnlVM2oYDAfyYKA+fC+aEiyz5n08D8x6YAbwZNbTIVp+h6ucyp7ygJ04fshd4l3s1HUmCZLSmHb2xEw==",
+            "dev": true,
+            "requires": {}
+        },
+        "@tapjs/test": {
+            "version": "1.3.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/test/-/test-1.3.17.tgz",
+            "integrity": "sha512-yQ4uHC2GaDS+Gr5qwx9uMGxqvpYgnlVY+QexBReSeYZthWIN0KD8HDvnVt4An5Sx/Qhd7UlnNpNMBd6AkvPEew==",
+            "dev": true,
+            "requires": {
+                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.5",
+                "@tapjs/after": "1.1.17",
+                "@tapjs/after-each": "1.1.17",
+                "@tapjs/asserts": "1.1.17",
+                "@tapjs/before": "1.1.17",
+                "@tapjs/before-each": "1.1.17",
+                "@tapjs/filter": "1.2.17",
+                "@tapjs/fixture": "1.2.17",
+                "@tapjs/intercept": "1.2.17",
+                "@tapjs/mock": "1.2.15",
+                "@tapjs/node-serialize": "1.2.6",
+                "@tapjs/snapshot": "1.2.17",
+                "@tapjs/spawn": "1.1.17",
+                "@tapjs/stdin": "1.1.17",
+                "@tapjs/typescript": "1.3.6",
+                "@tapjs/worker": "1.1.17",
+                "glob": "^10.3.10",
+                "jackspeak": "^2.3.6",
+                "mkdirp": "^3.0.0",
+                "resolve-import": "^1.4.5",
+                "rimraf": "^5.0.5",
+                "sync-content": "^1.0.1",
+                "tap-parser": "15.3.1",
+                "tshy": "^1.2.2",
+                "typescript": "5.2"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "glob": {
+                    "version": "10.3.10",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "dev": true,
+                    "requires": {
+                        "foreground-child": "^3.1.0",
+                        "jackspeak": "^2.3.5",
+                        "minimatch": "^9.0.1",
+                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                        "path-scurry": "^1.10.1"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.3",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                },
+                "rimraf": {
+                    "version": "5.0.5",
+                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
+                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+                    "dev": true,
+                    "requires": {
+                        "glob": "^10.3.7"
+                    }
+                }
+            }
+        },
+        "@tapjs/typescript": {
+            "version": "1.3.6",
+            "resolved": "https://registry.npmjs.org/@tapjs/typescript/-/typescript-1.3.6.tgz",
+            "integrity": "sha512-bHqQb06HcD1vFvSwElH0WK4cnCNthvA5OX/KBs5w1TNFHIeRHemp/hsSnGSNDwYwDETuOxD68rDZNTpNbzysBg==",
+            "dev": true,
+            "requires": {
+                "@isaacs/ts-node-temp-fork-for-pr-2009": "^10.9.5"
+            }
+        },
+        "@tapjs/worker": {
+            "version": "1.1.17",
+            "resolved": "https://registry.npmjs.org/@tapjs/worker/-/worker-1.1.17.tgz",
+            "integrity": "sha512-DCRzEBT+OgP518rQqzlX6KawvGTegkeEjPVa/TB6Iifj8WOHJ+XtunkR7riIRGEoCEOMD49DCJXj70c+XP0jNw==",
+            "dev": true,
+            "requires": {}
+        },
+        "@tsconfig/node14": {
+            "version": "14.1.0",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-14.1.0.tgz",
+            "integrity": "sha512-VmsCG04YR58ciHBeJKBDNMWWfYbyP8FekWVuTlpstaUPlat1D0x/tXzkWP7yCMU0eSz9V4OZU0LBWTFJ3xZf6w==",
+            "dev": true
+        },
+        "@tsconfig/node16": {
+            "version": "16.1.1",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-16.1.1.tgz",
+            "integrity": "sha512-+pio93ejHN4nINX4pXqfnR/fPLRtJBaT4ORaa5RH0Oc1zoYmo2B2koG+M328CQhHKn1Wj6FcOxCDFXAot9NhvA==",
+            "dev": true
+        },
+        "@tsconfig/node18": {
+            "version": "18.2.2",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.2.tgz",
+            "integrity": "sha512-d6McJeGsuoRlwWZmVIeE8CUA27lu6jLjvv1JzqmpsytOYYbVi1tHZEnwCNVOXnj4pyLvneZlFlpXUK+X9wBWyw==",
+            "dev": true
+        },
+        "@tsconfig/node20": {
+            "version": "20.1.2",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.2.tgz",
+            "integrity": "sha512-madaWq2k+LYMEhmcp0fs+OGaLFk0OenpHa4gmI4VEmCKX4PJntQ6fnnGADVFrVkBj0wIdAlQnK/MrlYTHsa1gQ==",
+            "dev": true
+        },
+        "@tufjs/canonical-json": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
+            "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==",
+            "dev": true
+        },
+        "@tufjs/models": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.0.tgz",
+            "integrity": "sha512-c8nj8BaOExmZKO2DXhDfegyhSGcG9E/mPN3U13L+/PsoWm1uaGiHHjxqSHQiasDBQwDA3aHuw9+9spYAP1qvvg==",
+            "dev": true,
+            "requires": {
+                "@tufjs/canonical-json": "2.0.0",
+                "minimatch": "^9.0.3"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.3",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                }
+            }
+        },
+        "@types/istanbul-lib-coverage": {
+            "version": "2.0.6",
+            "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
+            "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
+            "dev": true
+        },
+        "@types/node": {
+            "version": "20.10.6",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz",
+            "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==",
+            "dev": true,
+            "peer": true,
+            "requires": {
+                "undici-types": "~5.26.4"
+            }
+        },
+        "abbrev": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
+            "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
+            "dev": true
+        },
+        "acorn": {
+            "version": "8.8.2",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
+            "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw=="
+        },
+        "acorn-jsx": {
+            "version": "5.3.2",
+            "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+            "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+            "requires": {}
+        },
+        "acorn-walk": {
+            "version": "8.3.1",
+            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz",
+            "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==",
+            "dev": true
+        },
+        "agent-base": {
+            "version": "7.1.0",
+            "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
+            "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==",
+            "dev": true,
+            "requires": {
+                "debug": "^4.3.4"
+            }
+        },
+        "aggregate-error": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+            "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+            "dev": true,
+            "requires": {
+                "clean-stack": "^2.0.0",
+                "indent-string": "^4.0.0"
+            },
+            "dependencies": {
+                "indent-string": {
+                    "version": "4.0.0",
+                    "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+                    "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+                    "dev": true
+                }
+            }
+        },
+        "ajv": {
+            "version": "6.12.6",
+            "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+            "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+            "requires": {
+                "fast-deep-equal": "^3.1.1",
+                "fast-json-stable-stringify": "^2.0.0",
+                "json-schema-traverse": "^0.4.1",
+                "uri-js": "^4.2.2"
+            }
+        },
+        "ansi-escapes": {
+            "version": "6.2.0",
+            "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz",
+            "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==",
+            "dev": true,
+            "requires": {
+                "type-fest": "^3.0.0"
+            },
+            "dependencies": {
+                "type-fest": {
+                    "version": "3.13.1",
+                    "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
+                    "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
+                    "dev": true
+                }
+            }
+        },
+        "ansi-regex": {
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+            "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
+        },
+        "ansi-styles": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+            "requires": {
+                "color-convert": "^2.0.1"
+            }
+        },
+        "anymatch": {
+            "version": "3.1.3",
+            "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+            "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+            "dev": true,
+            "requires": {
+                "normalize-path": "^3.0.0",
+                "picomatch": "^2.0.4"
+            }
+        },
+        "arg": {
+            "version": "4.1.3",
+            "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+            "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+            "dev": true
+        },
         "argparse": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
             "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
         },
-        "array.prototype.every": {
-            "version": "1.1.3",
-            "resolved": "https://registry.npmjs.org/array.prototype.every/-/array.prototype.every-1.1.3.tgz",
-            "integrity": "sha512-vWnriJI//SOMOWtXbU/VXhJ/InfnNHPF6BLKn5WfY8xXy+NWql0fUy20GO3sdqBhCAO+qw8S/E5nJiZX+QFdCA==",
-            "dev": true,
-            "requires": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3",
-                "es-abstract": "^1.19.0",
-                "is-string": "^1.0.7"
-            }
+        "async-hook-domain": {
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/async-hook-domain/-/async-hook-domain-4.0.1.tgz",
+            "integrity": "sha512-bSktexGodAjfHWIrSrrqxqWzf1hWBZBpmPNZv+TYUMyWa2eoefFc6q6H1+KtdHYSz35lrhWdmXt/XK9wNEZvww==",
+            "dev": true
         },
-        "available-typed-arrays": {
-            "version": "1.0.5",
-            "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz",
-            "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==",
+        "auto-bind": {
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
+            "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==",
             "dev": true
         },
         "balanced-match": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
-            "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+            "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+        },
+        "binary-extensions": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+            "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
             "dev": true
         },
         "brace-expansion": {
             "version": "1.1.11",
             "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
             "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
-            "dev": true,
             "requires": {
                 "balanced-match": "^1.0.0",
                 "concat-map": "0.0.1"
             }
         },
-        "call-bind": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
-            "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+        "braces": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+            "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
             "dev": true,
             "requires": {
-                "function-bind": "^1.1.1",
-                "get-intrinsic": "^1.0.2"
+                "fill-range": "^7.0.1"
             }
         },
-        "concat-map": {
-            "version": "0.0.1",
-            "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
-            "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
-            "dev": true
+        "builtins": {
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz",
+            "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==",
+            "dev": true,
+            "requires": {
+                "semver": "^7.0.0"
+            }
         },
-        "deep-equal": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.0.5.tgz",
-            "integrity": "sha512-nPiRgmbAtm1a3JsnLCf6/SLfXcjyN5v8L1TXzdCmHrXJ4hx+gW/w1YCcn7z8gJtSiDArZCgYtbao3QqLm/N1Sw==",
+        "c8": {
+            "version": "8.0.1",
+            "resolved": "https://registry.npmjs.org/c8/-/c8-8.0.1.tgz",
+            "integrity": "sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.0",
-                "es-get-iterator": "^1.1.1",
-                "get-intrinsic": "^1.0.1",
-                "is-arguments": "^1.0.4",
-                "is-date-object": "^1.0.2",
-                "is-regex": "^1.1.1",
-                "isarray": "^2.0.5",
-                "object-is": "^1.1.4",
-                "object-keys": "^1.1.1",
-                "object.assign": "^4.1.2",
-                "regexp.prototype.flags": "^1.3.0",
-                "side-channel": "^1.0.3",
-                "which-boxed-primitive": "^1.0.1",
-                "which-collection": "^1.0.1",
-                "which-typed-array": "^1.1.2"
+                "@bcoe/v8-coverage": "^0.2.3",
+                "@istanbuljs/schema": "^0.1.3",
+                "find-up": "^5.0.0",
+                "foreground-child": "^2.0.0",
+                "istanbul-lib-coverage": "^3.2.0",
+                "istanbul-lib-report": "^3.0.1",
+                "istanbul-reports": "^3.1.6",
+                "rimraf": "^3.0.2",
+                "test-exclude": "^6.0.0",
+                "v8-to-istanbul": "^9.0.0",
+                "yargs": "^17.7.2",
+                "yargs-parser": "^21.1.1"
+            },
+            "dependencies": {
+                "foreground-child": {
+                    "version": "2.0.0",
+                    "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz",
+                    "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==",
+                    "dev": true,
+                    "requires": {
+                        "cross-spawn": "^7.0.0",
+                        "signal-exit": "^3.0.2"
+                    }
+                },
+                "signal-exit": {
+                    "version": "3.0.7",
+                    "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+                    "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+                    "dev": true
+                }
             }
         },
-        "define-properties": {
-            "version": "1.1.3",
-            "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
-            "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
+        "cacache": {
+            "version": "18.0.2",
+            "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.2.tgz",
+            "integrity": "sha512-r3NU8h/P+4lVUHfeRw1dtgQYar3DZMm4/cm2bZgOvrFC/su7budSOeqh52VJIC4U4iG1WWwV6vRW0znqBvxNuw==",
             "dev": true,
             "requires": {
-                "object-keys": "^1.0.12"
+                "@npmcli/fs": "^3.1.0",
+                "fs-minipass": "^3.0.0",
+                "glob": "^10.2.2",
+                "lru-cache": "^10.0.1",
+                "minipass": "^7.0.3",
+                "minipass-collect": "^2.0.1",
+                "minipass-flush": "^1.0.5",
+                "minipass-pipeline": "^1.2.4",
+                "p-map": "^4.0.0",
+                "ssri": "^10.0.0",
+                "tar": "^6.1.11",
+                "unique-filename": "^3.0.0"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "glob": {
+                    "version": "10.3.10",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "dev": true,
+                    "requires": {
+                        "foreground-child": "^3.1.0",
+                        "jackspeak": "^2.3.5",
+                        "minimatch": "^9.0.1",
+                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                        "path-scurry": "^1.10.1"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.3",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                }
             }
         },
-        "defined": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
-            "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=",
+        "callsites": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+            "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="
+        },
+        "chalk": {
+            "version": "4.1.2",
+            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+            "requires": {
+                "ansi-styles": "^4.1.0",
+                "supports-color": "^7.1.0"
+            }
+        },
+        "chokidar": {
+            "version": "3.5.3",
+            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+            "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+            "dev": true,
+            "requires": {
+                "anymatch": "~3.1.2",
+                "braces": "~3.0.2",
+                "fsevents": "~2.3.2",
+                "glob-parent": "~5.1.2",
+                "is-binary-path": "~2.1.0",
+                "is-glob": "~4.0.1",
+                "normalize-path": "~3.0.0",
+                "readdirp": "~3.6.0"
+            },
+            "dependencies": {
+                "glob-parent": {
+                    "version": "5.1.2",
+                    "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+                    "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+                    "dev": true,
+                    "requires": {
+                        "is-glob": "^4.0.1"
+                    }
+                }
+            }
+        },
+        "chownr": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+            "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+            "dev": true
+        },
+        "chroma-js": {
+            "version": "2.4.2",
+            "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz",
+            "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A=="
+        },
+        "ci-info": {
+            "version": "3.9.0",
+            "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
+            "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
+            "dev": true
+        },
+        "clean-stack": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+            "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+            "dev": true
+        },
+        "cli-boxes": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
+            "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
             "dev": true
         },
-        "dotignore": {
-            "version": "0.1.2",
-            "resolved": "https://registry.npmjs.org/dotignore/-/dotignore-0.1.2.tgz",
-            "integrity": "sha512-UGGGWfSauusaVJC+8fgV+NVvBXkCTmVv7sk6nojDZZvuOUNGUy0Zk4UpHQD6EDjS0jpBwcACvH4eofvyzBcRDw==",
+        "cli-cursor": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
+            "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==",
             "dev": true,
             "requires": {
-                "minimatch": "^3.0.4"
+                "restore-cursor": "^4.0.0"
             }
         },
-        "es-abstract": {
-            "version": "1.19.1",
-            "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz",
-            "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==",
-            "dev": true,
-            "requires": {
-                "call-bind": "^1.0.2",
-                "es-to-primitive": "^1.2.1",
-                "function-bind": "^1.1.1",
-                "get-intrinsic": "^1.1.1",
-                "get-symbol-description": "^1.0.0",
-                "has": "^1.0.3",
-                "has-symbols": "^1.0.2",
-                "internal-slot": "^1.0.3",
-                "is-callable": "^1.2.4",
-                "is-negative-zero": "^2.0.1",
-                "is-regex": "^1.1.4",
-                "is-shared-array-buffer": "^1.0.1",
-                "is-string": "^1.0.7",
-                "is-weakref": "^1.0.1",
-                "object-inspect": "^1.11.0",
-                "object-keys": "^1.1.1",
-                "object.assign": "^4.1.2",
-                "string.prototype.trimend": "^1.0.4",
-                "string.prototype.trimstart": "^1.0.4",
-                "unbox-primitive": "^1.0.1"
-            }
-        },
-        "es-get-iterator": {
-            "version": "1.1.2",
-            "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.2.tgz",
-            "integrity": "sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ==",
+        "cli-truncate": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz",
+            "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.0",
-                "has-symbols": "^1.0.1",
-                "is-arguments": "^1.1.0",
-                "is-map": "^2.0.2",
-                "is-set": "^2.0.2",
-                "is-string": "^1.0.5",
-                "isarray": "^2.0.5"
+                "slice-ansi": "^5.0.0",
+                "string-width": "^5.0.0"
+            },
+            "dependencies": {
+                "ansi-styles": {
+                    "version": "6.2.1",
+                    "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+                    "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+                    "dev": true
+                },
+                "slice-ansi": {
+                    "version": "5.0.0",
+                    "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz",
+                    "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==",
+                    "dev": true,
+                    "requires": {
+                        "ansi-styles": "^6.0.0",
+                        "is-fullwidth-code-point": "^4.0.0"
+                    }
+                }
             }
         },
-        "es-to-primitive": {
-            "version": "1.2.1",
-            "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
-            "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+        "cliui": {
+            "version": "8.0.1",
+            "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+            "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
             "dev": true,
             "requires": {
-                "is-callable": "^1.1.4",
-                "is-date-object": "^1.0.1",
-                "is-symbol": "^1.0.2"
+                "string-width": "^4.2.0",
+                "strip-ansi": "^6.0.1",
+                "wrap-ansi": "^7.0.0"
+            },
+            "dependencies": {
+                "emoji-regex": {
+                    "version": "8.0.0",
+                    "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+                    "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+                    "dev": true
+                },
+                "is-fullwidth-code-point": {
+                    "version": "3.0.0",
+                    "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+                    "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+                    "dev": true
+                },
+                "string-width": {
+                    "version": "4.2.3",
+                    "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+                    "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+                    "dev": true,
+                    "requires": {
+                        "emoji-regex": "^8.0.0",
+                        "is-fullwidth-code-point": "^3.0.0",
+                        "strip-ansi": "^6.0.1"
+                    }
+                },
+                "wrap-ansi": {
+                    "version": "7.0.0",
+                    "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+                    "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+                    "dev": true,
+                    "requires": {
+                        "ansi-styles": "^4.0.0",
+                        "string-width": "^4.1.0",
+                        "strip-ansi": "^6.0.0"
+                    }
+                }
             }
         },
-        "fix-whitespace": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/fix-whitespace/-/fix-whitespace-1.0.4.tgz",
-            "integrity": "sha512-TYJpw4orIgDpaINRkw1BVJQF8rPTNSUbW/s4mLYSApUt0MquGfI+iripYHibg9l9fe795VauuVCLTpDvy8KFWQ=="
+        "code-excerpt": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
+            "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==",
+            "dev": true,
+            "requires": {
+                "convert-to-spaces": "^2.0.1"
+            }
         },
-        "for-each": {
-            "version": "0.3.3",
-            "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
-            "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
+        "color-convert": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+            "requires": {
+                "color-name": "~1.1.4"
+            }
+        },
+        "color-name": {
+            "version": "1.1.4",
+            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+        },
+        "command-exists": {
+            "version": "1.2.9",
+            "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
+            "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="
+        },
+        "concat-map": {
+            "version": "0.0.1",
+            "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+            "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+        },
+        "convert-source-map": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+            "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+            "dev": true
+        },
+        "convert-to-spaces": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
+            "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==",
+            "dev": true
+        },
+        "cross-spawn": {
+            "version": "7.0.3",
+            "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+            "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+            "requires": {
+                "path-key": "^3.1.0",
+                "shebang-command": "^2.0.0",
+                "which": "^2.0.1"
+            }
+        },
+        "debug": {
+            "version": "4.3.4",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+            "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+            "requires": {
+                "ms": "2.1.2"
+            }
+        },
+        "deep-is": {
+            "version": "0.1.4",
+            "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+            "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
+        },
+        "diff": {
+            "version": "5.1.0",
+            "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
+            "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==",
+            "dev": true
+        },
+        "doctrine": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+            "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+            "requires": {
+                "esutils": "^2.0.2"
+            }
+        },
+        "eastasianwidth": {
+            "version": "0.2.0",
+            "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+            "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+            "dev": true
+        },
+        "emoji-regex": {
+            "version": "9.2.2",
+            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+            "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+            "dev": true
+        },
+        "encoding": {
+            "version": "0.1.13",
+            "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
+            "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
             "dev": true,
+            "optional": true,
             "requires": {
-                "is-callable": "^1.1.3"
+                "iconv-lite": "^0.6.2"
             }
         },
-        "foreach": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz",
-            "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=",
+        "env-paths": {
+            "version": "2.2.1",
+            "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+            "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
             "dev": true
         },
-        "fs.realpath": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
-            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+        "err-code": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
+            "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
             "dev": true
         },
-        "function-bind": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
-            "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+        "escalade": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+            "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
             "dev": true
         },
-        "get-intrinsic": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
-            "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+        "escape-string-regexp": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+            "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
+        },
+        "eslint": {
+            "version": "8.37.0",
+            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.37.0.tgz",
+            "integrity": "sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==",
+            "requires": {
+                "@eslint-community/eslint-utils": "^4.2.0",
+                "@eslint-community/regexpp": "^4.4.0",
+                "@eslint/eslintrc": "^2.0.2",
+                "@eslint/js": "8.37.0",
+                "@humanwhocodes/config-array": "^0.11.8",
+                "@humanwhocodes/module-importer": "^1.0.1",
+                "@nodelib/fs.walk": "^1.2.8",
+                "ajv": "^6.10.0",
+                "chalk": "^4.0.0",
+                "cross-spawn": "^7.0.2",
+                "debug": "^4.3.2",
+                "doctrine": "^3.0.0",
+                "escape-string-regexp": "^4.0.0",
+                "eslint-scope": "^7.1.1",
+                "eslint-visitor-keys": "^3.4.0",
+                "espree": "^9.5.1",
+                "esquery": "^1.4.2",
+                "esutils": "^2.0.2",
+                "fast-deep-equal": "^3.1.3",
+                "file-entry-cache": "^6.0.1",
+                "find-up": "^5.0.0",
+                "glob-parent": "^6.0.2",
+                "globals": "^13.19.0",
+                "grapheme-splitter": "^1.0.4",
+                "ignore": "^5.2.0",
+                "import-fresh": "^3.0.0",
+                "imurmurhash": "^0.1.4",
+                "is-glob": "^4.0.0",
+                "is-path-inside": "^3.0.3",
+                "js-sdsl": "^4.1.4",
+                "js-yaml": "^4.1.0",
+                "json-stable-stringify-without-jsonify": "^1.0.1",
+                "levn": "^0.4.1",
+                "lodash.merge": "^4.6.2",
+                "minimatch": "^3.1.2",
+                "natural-compare": "^1.4.0",
+                "optionator": "^0.9.1",
+                "strip-ansi": "^6.0.1",
+                "strip-json-comments": "^3.1.0",
+                "text-table": "^0.2.0"
+            }
+        },
+        "eslint-scope": {
+            "version": "7.1.1",
+            "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
+            "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
+            "requires": {
+                "esrecurse": "^4.3.0",
+                "estraverse": "^5.2.0"
+            }
+        },
+        "eslint-visitor-keys": {
+            "version": "3.4.0",
+            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz",
+            "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ=="
+        },
+        "espree": {
+            "version": "9.5.1",
+            "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz",
+            "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==",
+            "requires": {
+                "acorn": "^8.8.0",
+                "acorn-jsx": "^5.3.2",
+                "eslint-visitor-keys": "^3.4.0"
+            }
+        },
+        "esquery": {
+            "version": "1.5.0",
+            "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
+            "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+            "requires": {
+                "estraverse": "^5.1.0"
+            }
+        },
+        "esrecurse": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+            "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+            "requires": {
+                "estraverse": "^5.2.0"
+            }
+        },
+        "estraverse": {
+            "version": "5.3.0",
+            "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+            "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="
+        },
+        "esutils": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+            "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
+        },
+        "events-to-array": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/events-to-array/-/events-to-array-2.0.3.tgz",
+            "integrity": "sha512-f/qE2gImHRa4Cp2y1stEOSgw8wTFyUdVJX7G//bMwbaV9JqISFxg99NbmVQeP7YLnDUZ2un851jlaDrlpmGehQ==",
+            "dev": true
+        },
+        "exponential-backoff": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz",
+            "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==",
+            "dev": true
+        },
+        "fast-deep-equal": {
+            "version": "3.1.3",
+            "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+            "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+        },
+        "fast-json-stable-stringify": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+            "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
+        },
+        "fast-levenshtein": {
+            "version": "2.0.6",
+            "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+            "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
+        },
+        "fastq": {
+            "version": "1.15.0",
+            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
+            "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
+            "requires": {
+                "reusify": "^1.0.4"
+            }
+        },
+        "file-entry-cache": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+            "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+            "requires": {
+                "flat-cache": "^3.0.4"
+            }
+        },
+        "fill-range": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+            "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
             "dev": true,
             "requires": {
-                "function-bind": "^1.1.1",
-                "has": "^1.0.3",
-                "has-symbols": "^1.0.1"
+                "to-regex-range": "^5.0.1"
             }
         },
-        "get-package-type": {
-            "version": "0.1.0",
-            "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
-            "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+        "find-up": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+            "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+            "requires": {
+                "locate-path": "^6.0.0",
+                "path-exists": "^4.0.0"
+            }
+        },
+        "flat-cache": {
+            "version": "3.0.4",
+            "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+            "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+            "requires": {
+                "flatted": "^3.1.0",
+                "rimraf": "^3.0.2"
+            }
+        },
+        "flatted": {
+            "version": "3.2.5",
+            "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
+            "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg=="
+        },
+        "foreground-child": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
+            "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
+            "dev": true,
+            "requires": {
+                "cross-spawn": "^7.0.0",
+                "signal-exit": "^4.0.1"
+            }
+        },
+        "fromentries": {
+            "version": "1.3.2",
+            "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz",
+            "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==",
             "dev": true
         },
-        "get-symbol-description": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
-            "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+        "fs-minipass": {
+            "version": "3.0.3",
+            "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz",
+            "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
+                "minipass": "^7.0.3"
             }
         },
-        "glob": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
-            "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
+        "fs.realpath": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+            "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+        },
+        "fsevents": {
+            "version": "2.3.2",
+            "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+            "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
             "dev": true,
+            "optional": true
+        },
+        "function-bind": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+            "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+            "dev": true
+        },
+        "function-loop": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/function-loop/-/function-loop-4.0.0.tgz",
+            "integrity": "sha512-f34iQBedYF3XcI93uewZZOnyscDragxgTK/eTvVB74k3fCD0ZorOi5BV9GS4M8rz/JoNi0Kl3qX5Y9MH3S/CLQ==",
+            "dev": true
+        },
+        "get-caller-file": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+            "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+            "dev": true
+        },
+        "glob": {
+            "version": "7.2.3",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+            "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
             "requires": {
                 "fs.realpath": "^1.0.0",
                 "inflight": "^1.0.4",
                 "inherits": "2",
-                "minimatch": "^3.0.4",
+                "minimatch": "^3.1.1",
                 "once": "^1.3.0",
                 "path-is-absolute": "^1.0.0"
             }
         },
-        "has": {
-            "version": "1.0.3",
-            "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
-            "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
-            "dev": true,
+        "glob-parent": {
+            "version": "6.0.2",
+            "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+            "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
             "requires": {
-                "function-bind": "^1.1.1"
+                "is-glob": "^4.0.3"
             }
         },
-        "has-bigints": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
-            "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==",
-            "dev": true
-        },
-        "has-dynamic-import": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/has-dynamic-import/-/has-dynamic-import-2.0.1.tgz",
-            "integrity": "sha512-X3fbtsZmwb6W7fJGR9o7x65fZoodygCrZ3TVycvghP62yYQfS0t4RS0Qcz+j5tQYUKeSWS09tHkWW6WhFV3XhQ==",
-            "dev": true,
+        "globals": {
+            "version": "13.20.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz",
+            "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==",
             "requires": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
+                "type-fest": "^0.20.2"
             }
         },
-        "has-symbols": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
-            "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
+        "graceful-fs": {
+            "version": "4.2.11",
+            "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+            "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
             "dev": true
         },
-        "has-tostringtag": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
-            "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+        "grapheme-splitter": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
+            "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
+        },
+        "has-flag": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
+        },
+        "hasown": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
+            "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
             "dev": true,
             "requires": {
-                "has-symbols": "^1.0.2"
+                "function-bind": "^1.1.2"
             }
         },
         "he": {
@@ -1235,11 +7104,123 @@
             "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
             "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
         },
+        "hosted-git-info": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz",
+            "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==",
+            "dev": true,
+            "requires": {
+                "lru-cache": "^10.0.1"
+            }
+        },
+        "html-escaper": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+            "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+            "dev": true
+        },
+        "http-cache-semantics": {
+            "version": "4.1.1",
+            "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
+            "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
+            "dev": true
+        },
+        "http-proxy-agent": {
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz",
+            "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==",
+            "dev": true,
+            "requires": {
+                "agent-base": "^7.1.0",
+                "debug": "^4.3.4"
+            }
+        },
+        "https-proxy-agent": {
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz",
+            "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==",
+            "dev": true,
+            "requires": {
+                "agent-base": "^7.0.2",
+                "debug": "4"
+            }
+        },
+        "iconv-lite": {
+            "version": "0.6.3",
+            "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+            "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+            "dev": true,
+            "optional": true,
+            "requires": {
+                "safer-buffer": ">= 2.1.2 < 3.0.0"
+            }
+        },
+        "ignore": {
+            "version": "5.2.4",
+            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
+            "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ=="
+        },
+        "ignore-walk": {
+            "version": "6.0.4",
+            "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.4.tgz",
+            "integrity": "sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw==",
+            "dev": true,
+            "requires": {
+                "minimatch": "^9.0.0"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.3",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                }
+            }
+        },
+        "image-size": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.2.tgz",
+            "integrity": "sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==",
+            "requires": {
+                "queue": "6.0.2"
+            }
+        },
+        "import-fresh": {
+            "version": "3.3.0",
+            "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+            "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+            "requires": {
+                "parent-module": "^1.0.0",
+                "resolve-from": "^4.0.0"
+            }
+        },
+        "imurmurhash": {
+            "version": "0.1.4",
+            "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+            "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="
+        },
+        "indent-string": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
+            "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
+            "dev": true
+        },
         "inflight": {
             "version": "1.0.6",
             "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
             "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
-            "dev": true,
             "requires": {
                 "once": "^1.3.0",
                 "wrappy": "1"
@@ -1248,417 +7229,1947 @@
         "inherits": {
             "version": "2.0.4",
             "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+        },
+        "ink": {
+            "version": "4.4.1",
+            "resolved": "https://registry.npmjs.org/ink/-/ink-4.4.1.tgz",
+            "integrity": "sha512-rXckvqPBB0Krifk5rn/5LvQGmyXwCUpBfmTwbkQNBY9JY8RSl3b8OftBNEYxg4+SWUhEKcPifgope28uL9inlA==",
+            "dev": true,
+            "requires": {
+                "@alcalzone/ansi-tokenize": "^0.1.3",
+                "ansi-escapes": "^6.0.0",
+                "auto-bind": "^5.0.1",
+                "chalk": "^5.2.0",
+                "cli-boxes": "^3.0.0",
+                "cli-cursor": "^4.0.0",
+                "cli-truncate": "^3.1.0",
+                "code-excerpt": "^4.0.0",
+                "indent-string": "^5.0.0",
+                "is-ci": "^3.0.1",
+                "is-lower-case": "^2.0.2",
+                "is-upper-case": "^2.0.2",
+                "lodash": "^4.17.21",
+                "patch-console": "^2.0.0",
+                "react-reconciler": "^0.29.0",
+                "scheduler": "^0.23.0",
+                "signal-exit": "^3.0.7",
+                "slice-ansi": "^6.0.0",
+                "stack-utils": "^2.0.6",
+                "string-width": "^5.1.2",
+                "type-fest": "^0.12.0",
+                "widest-line": "^4.0.1",
+                "wrap-ansi": "^8.1.0",
+                "ws": "^8.12.0",
+                "yoga-wasm-web": "~0.3.3"
+            },
+            "dependencies": {
+                "chalk": {
+                    "version": "5.3.0",
+                    "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+                    "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+                    "dev": true
+                },
+                "signal-exit": {
+                    "version": "3.0.7",
+                    "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+                    "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+                    "dev": true
+                },
+                "type-fest": {
+                    "version": "0.12.0",
+                    "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.12.0.tgz",
+                    "integrity": "sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg==",
+                    "dev": true
+                }
+            }
+        },
+        "ip": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
+            "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
             "dev": true
         },
-        "internal-slot": {
-            "version": "1.0.3",
-            "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
-            "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+        "is-actual-promise": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/is-actual-promise/-/is-actual-promise-1.0.1.tgz",
+            "integrity": "sha512-PlsL4tNv62lx5yN2HSqaRSTgIpUAPW7U6+crVB8HfWm5161rZpeqWbl0ZSqH2MAfRKXWSZVPRNbE/r8qPcb13g==",
             "dev": true,
             "requires": {
-                "get-intrinsic": "^1.1.0",
-                "has": "^1.0.3",
-                "side-channel": "^1.0.4"
+                "tshy": "^1.7.0"
             }
         },
-        "is-arguments": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
-            "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
+        "is-binary-path": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+            "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
+                "binary-extensions": "^2.0.0"
             }
         },
-        "is-bigint": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
-            "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+        "is-ci": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz",
+            "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==",
             "dev": true,
             "requires": {
-                "has-bigints": "^1.0.1"
+                "ci-info": "^3.2.0"
             }
         },
-        "is-boolean-object": {
-            "version": "1.1.2",
-            "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
-            "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
+        "is-core-module": {
+            "version": "2.13.1",
+            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
+            "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
+                "hasown": "^2.0.0"
             }
         },
-        "is-callable": {
-            "version": "1.2.4",
-            "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
-            "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+        "is-extglob": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+            "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
+        },
+        "is-fullwidth-code-point": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
+            "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
             "dev": true
         },
-        "is-core-module": {
-            "version": "2.8.1",
-            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz",
-            "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==",
-            "dev": true,
+        "is-glob": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+            "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
             "requires": {
-                "has": "^1.0.3"
+                "is-extglob": "^2.1.1"
             }
         },
-        "is-date-object": {
-            "version": "1.0.5",
-            "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
-            "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
+        "is-lambda": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz",
+            "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==",
+            "dev": true
+        },
+        "is-lower-case": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz",
+            "integrity": "sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==",
             "dev": true,
             "requires": {
-                "has-tostringtag": "^1.0.0"
+                "tslib": "^2.0.3"
             }
         },
-        "is-map": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz",
-            "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==",
+        "is-number": {
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+            "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+            "dev": true
+        },
+        "is-path-inside": {
+            "version": "3.0.3",
+            "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+            "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="
+        },
+        "is-plain-object": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+            "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
             "dev": true
         },
-        "is-negative-zero": {
+        "is-upper-case": {
             "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
-            "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+            "resolved": "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz",
+            "integrity": "sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==",
+            "dev": true,
+            "requires": {
+                "tslib": "^2.0.3"
+            }
+        },
+        "isexe": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
+        },
+        "istanbul-lib-coverage": {
+            "version": "3.2.2",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+            "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
             "dev": true
         },
-        "is-number-object": {
-            "version": "1.0.6",
-            "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz",
-            "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==",
+        "istanbul-lib-report": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+            "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
             "dev": true,
             "requires": {
-                "has-tostringtag": "^1.0.0"
+                "istanbul-lib-coverage": "^3.0.0",
+                "make-dir": "^4.0.0",
+                "supports-color": "^7.1.0"
             }
         },
-        "is-regex": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
-            "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+        "istanbul-reports": {
+            "version": "3.1.6",
+            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz",
+            "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
+                "html-escaper": "^2.0.0",
+                "istanbul-lib-report": "^3.0.0"
             }
         },
-        "is-set": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz",
-            "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==",
+        "jackspeak": {
+            "version": "2.3.6",
+            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
+            "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
+            "dev": true,
+            "requires": {
+                "@isaacs/cliui": "^8.0.2",
+                "@pkgjs/parseargs": "^0.11.0"
+            }
+        },
+        "js-sdsl": {
+            "version": "4.4.0",
+            "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",
+            "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg=="
+        },
+        "js-tokens": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+            "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
             "dev": true
         },
-        "is-shared-array-buffer": {
+        "js-yaml": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+            "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+            "requires": {
+                "argparse": "^2.0.1"
+            }
+        },
+        "jsbi": {
+            "version": "4.3.0",
+            "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
+            "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
+        },
+        "json-parse-even-better-errors": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz",
+            "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==",
+            "dev": true
+        },
+        "json-schema-traverse": {
+            "version": "0.4.1",
+            "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+            "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
+        },
+        "json-stable-stringify-without-jsonify": {
             "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz",
-            "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==",
+            "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+            "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="
+        },
+        "jsonparse": {
+            "version": "1.3.1",
+            "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
+            "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==",
             "dev": true
         },
-        "is-string": {
-            "version": "1.0.7",
-            "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
-            "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
-            "dev": true,
+        "levn": {
+            "version": "0.4.1",
+            "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+            "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
             "requires": {
-                "has-tostringtag": "^1.0.0"
+                "prelude-ls": "^1.2.1",
+                "type-check": "~0.4.0"
             }
         },
-        "is-symbol": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
-            "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
-            "dev": true,
+        "locate-path": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+            "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
             "requires": {
-                "has-symbols": "^1.0.2"
+                "p-locate": "^5.0.0"
             }
         },
-        "is-typed-array": {
-            "version": "1.1.8",
-            "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.8.tgz",
-            "integrity": "sha512-HqH41TNZq2fgtGT8WHVFVJhBVGuY3AnP3Q36K8JKXUxSxRgk/d+7NjmwG2vo2mYmXK8UYZKu0qH8bVP5gEisjA==",
+        "lodash": {
+            "version": "4.17.21",
+            "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+            "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+            "dev": true
+        },
+        "lodash.merge": {
+            "version": "4.6.2",
+            "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+            "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
+        },
+        "loose-envify": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+            "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
             "dev": true,
             "requires": {
-                "available-typed-arrays": "^1.0.5",
-                "call-bind": "^1.0.2",
-                "es-abstract": "^1.18.5",
-                "foreach": "^2.0.5",
-                "has-tostringtag": "^1.0.0"
+                "js-tokens": "^3.0.0 || ^4.0.0"
             }
         },
-        "is-weakmap": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz",
-            "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==",
+        "lru-cache": {
+            "version": "10.1.0",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz",
+            "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==",
             "dev": true
         },
-        "is-weakref": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
-            "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
+        "make-dir": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+            "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2"
+                "semver": "^7.5.3"
             }
         },
-        "is-weakset": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz",
-            "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==",
+        "make-error": {
+            "version": "1.3.6",
+            "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+            "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+            "dev": true
+        },
+        "make-fetch-happen": {
+            "version": "13.0.0",
+            "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.0.tgz",
+            "integrity": "sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.1.1"
+                "@npmcli/agent": "^2.0.0",
+                "cacache": "^18.0.0",
+                "http-cache-semantics": "^4.1.1",
+                "is-lambda": "^1.0.1",
+                "minipass": "^7.0.2",
+                "minipass-fetch": "^3.0.0",
+                "minipass-flush": "^1.0.5",
+                "minipass-pipeline": "^1.2.4",
+                "negotiator": "^0.6.3",
+                "promise-retry": "^2.0.1",
+                "ssri": "^10.0.0"
             }
         },
-        "isarray": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
-            "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+        "marked": {
+            "version": "10.0.0",
+            "resolved": "https://registry.npmjs.org/marked/-/marked-10.0.0.tgz",
+            "integrity": "sha512-YiGcYcWj50YrwBgNzFoYhQ1hT6GmQbFG8SksnYJX1z4BXTHSOrz1GB5/Jm2yQvMg4nN1FHP4M6r03R10KrVUiA=="
+        },
+        "mimic-fn": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+            "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
             "dev": true
         },
-        "js-yaml": {
-            "version": "4.1.0",
-            "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
-            "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+        "minimatch": {
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+            "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
             "requires": {
-                "argparse": "^2.0.1"
+                "brace-expansion": "^1.1.7"
             }
         },
-        "minimatch": {
+        "minipass": {
+            "version": "7.0.4",
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
+            "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
+            "dev": true
+        },
+        "minipass-collect": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz",
+            "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==",
+            "dev": true,
+            "requires": {
+                "minipass": "^7.0.3"
+            }
+        },
+        "minipass-fetch": {
             "version": "3.0.4",
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
-            "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+            "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz",
+            "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==",
             "dev": true,
             "requires": {
-                "brace-expansion": "^1.1.7"
+                "encoding": "^0.1.13",
+                "minipass": "^7.0.3",
+                "minipass-sized": "^1.0.3",
+                "minizlib": "^2.1.2"
             }
         },
-        "minimist": {
-            "version": "1.2.5",
-            "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
-            "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
+        "minipass-flush": {
+            "version": "1.0.5",
+            "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz",
+            "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==",
+            "dev": true,
+            "requires": {
+                "minipass": "^3.0.0"
+            },
+            "dependencies": {
+                "minipass": {
+                    "version": "3.3.6",
+                    "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+                    "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+                    "dev": true,
+                    "requires": {
+                        "yallist": "^4.0.0"
+                    }
+                }
+            }
+        },
+        "minipass-json-stream": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz",
+            "integrity": "sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg==",
+            "dev": true,
+            "requires": {
+                "jsonparse": "^1.3.1",
+                "minipass": "^3.0.0"
+            },
+            "dependencies": {
+                "minipass": {
+                    "version": "3.3.6",
+                    "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+                    "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+                    "dev": true,
+                    "requires": {
+                        "yallist": "^4.0.0"
+                    }
+                }
+            }
+        },
+        "minipass-pipeline": {
+            "version": "1.2.4",
+            "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
+            "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
+            "dev": true,
+            "requires": {
+                "minipass": "^3.0.0"
+            },
+            "dependencies": {
+                "minipass": {
+                    "version": "3.3.6",
+                    "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+                    "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+                    "dev": true,
+                    "requires": {
+                        "yallist": "^4.0.0"
+                    }
+                }
+            }
+        },
+        "minipass-sized": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz",
+            "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==",
+            "dev": true,
+            "requires": {
+                "minipass": "^3.0.0"
+            },
+            "dependencies": {
+                "minipass": {
+                    "version": "3.3.6",
+                    "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+                    "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+                    "dev": true,
+                    "requires": {
+                        "yallist": "^4.0.0"
+                    }
+                }
+            }
+        },
+        "minizlib": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+            "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+            "dev": true,
+            "requires": {
+                "minipass": "^3.0.0",
+                "yallist": "^4.0.0"
+            },
+            "dependencies": {
+                "minipass": {
+                    "version": "3.3.6",
+                    "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+                    "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+                    "dev": true,
+                    "requires": {
+                        "yallist": "^4.0.0"
+                    }
+                }
+            }
+        },
+        "mkdirp": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
+            "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
             "dev": true
         },
-        "object-inspect": {
-            "version": "1.12.0",
-            "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
-            "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+        "ms": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+        },
+        "natural-compare": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+            "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
+        },
+        "negotiator": {
+            "version": "0.6.3",
+            "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+            "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
             "dev": true
         },
-        "object-is": {
-            "version": "1.1.5",
-            "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
-            "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==",
+        "node-gyp": {
+            "version": "10.0.1",
+            "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.0.1.tgz",
+            "integrity": "sha512-gg3/bHehQfZivQVfqIyy8wTdSymF9yTyP4CJifK73imyNMU8AIGQE2pUa7dNWfmMeG9cDVF2eehiRMv0LC1iAg==",
+            "dev": true,
+            "requires": {
+                "env-paths": "^2.2.0",
+                "exponential-backoff": "^3.1.1",
+                "glob": "^10.3.10",
+                "graceful-fs": "^4.2.6",
+                "make-fetch-happen": "^13.0.0",
+                "nopt": "^7.0.0",
+                "proc-log": "^3.0.0",
+                "semver": "^7.3.5",
+                "tar": "^6.1.2",
+                "which": "^4.0.0"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "glob": {
+                    "version": "10.3.10",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "dev": true,
+                    "requires": {
+                        "foreground-child": "^3.1.0",
+                        "jackspeak": "^2.3.5",
+                        "minimatch": "^9.0.1",
+                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                        "path-scurry": "^1.10.1"
+                    }
+                },
+                "isexe": {
+                    "version": "3.1.1",
+                    "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+                    "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+                    "dev": true
+                },
+                "minimatch": {
+                    "version": "9.0.3",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                },
+                "which": {
+                    "version": "4.0.0",
+                    "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+                    "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
+                    "dev": true,
+                    "requires": {
+                        "isexe": "^3.1.1"
+                    }
+                }
+            }
+        },
+        "nopt": {
+            "version": "7.2.0",
+            "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.0.tgz",
+            "integrity": "sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==",
+            "dev": true,
+            "requires": {
+                "abbrev": "^2.0.0"
+            }
+        },
+        "normalize-package-data": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.0.tgz",
+            "integrity": "sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "hosted-git-info": "^7.0.0",
+                "is-core-module": "^2.8.1",
+                "semver": "^7.3.5",
+                "validate-npm-package-license": "^3.0.4"
             }
         },
-        "object-keys": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
-            "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+        "normalize-path": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+            "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
             "dev": true
         },
-        "object.assign": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
-            "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
+        "npm-bundled": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz",
+            "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==",
+            "dev": true,
+            "requires": {
+                "npm-normalize-package-bin": "^3.0.0"
+            }
+        },
+        "npm-install-checks": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz",
+            "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.0",
-                "define-properties": "^1.1.3",
-                "has-symbols": "^1.0.1",
-                "object-keys": "^1.1.1"
+                "semver": "^7.1.1"
+            }
+        },
+        "npm-normalize-package-bin": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz",
+            "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==",
+            "dev": true
+        },
+        "npm-package-arg": {
+            "version": "11.0.1",
+            "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.1.tgz",
+            "integrity": "sha512-M7s1BD4NxdAvBKUPqqRW957Xwcl/4Zvo8Aj+ANrzvIPzGJZElrH7Z//rSaec2ORcND6FHHLnZeY8qgTpXDMFQQ==",
+            "dev": true,
+            "requires": {
+                "hosted-git-info": "^7.0.0",
+                "proc-log": "^3.0.0",
+                "semver": "^7.3.5",
+                "validate-npm-package-name": "^5.0.0"
+            }
+        },
+        "npm-packlist": {
+            "version": "8.0.1",
+            "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.1.tgz",
+            "integrity": "sha512-MQpL27ZrsJQ2kiAuQPpZb5LtJwydNRnI15QWXsf3WHERu4rzjRj6Zju/My2fov7tLuu3Gle/uoIX/DDZ3u4O4Q==",
+            "dev": true,
+            "requires": {
+                "ignore-walk": "^6.0.4"
+            }
+        },
+        "npm-pick-manifest": {
+            "version": "9.0.0",
+            "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.0.tgz",
+            "integrity": "sha512-VfvRSs/b6n9ol4Qb+bDwNGUXutpy76x6MARw/XssevE0TnctIKcmklJZM5Z7nqs5z5aW+0S63pgCNbpkUNNXBg==",
+            "dev": true,
+            "requires": {
+                "npm-install-checks": "^6.0.0",
+                "npm-normalize-package-bin": "^3.0.0",
+                "npm-package-arg": "^11.0.0",
+                "semver": "^7.3.5"
+            }
+        },
+        "npm-registry-fetch": {
+            "version": "16.1.0",
+            "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.1.0.tgz",
+            "integrity": "sha512-PQCELXKt8Azvxnt5Y85GseQDJJlglTFM9L9U9gkv2y4e9s0k3GVDdOx3YoB6gm2Do0hlkzC39iCGXby+Wve1Bw==",
+            "dev": true,
+            "requires": {
+                "make-fetch-happen": "^13.0.0",
+                "minipass": "^7.0.2",
+                "minipass-fetch": "^3.0.0",
+                "minipass-json-stream": "^1.0.1",
+                "minizlib": "^2.1.2",
+                "npm-package-arg": "^11.0.0",
+                "proc-log": "^3.0.0"
             }
         },
         "once": {
             "version": "1.4.0",
             "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
             "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
-            "dev": true,
             "requires": {
                 "wrappy": "1"
             }
         },
+        "onetime": {
+            "version": "5.1.2",
+            "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+            "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+            "dev": true,
+            "requires": {
+                "mimic-fn": "^2.1.0"
+            }
+        },
+        "opener": {
+            "version": "1.5.2",
+            "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
+            "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
+            "dev": true
+        },
+        "optionator": {
+            "version": "0.9.1",
+            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+            "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+            "requires": {
+                "deep-is": "^0.1.3",
+                "fast-levenshtein": "^2.0.6",
+                "levn": "^0.4.1",
+                "prelude-ls": "^1.2.1",
+                "type-check": "^0.4.0",
+                "word-wrap": "^1.2.3"
+            }
+        },
+        "p-limit": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+            "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+            "requires": {
+                "yocto-queue": "^0.1.0"
+            }
+        },
+        "p-locate": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+            "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+            "requires": {
+                "p-limit": "^3.0.2"
+            }
+        },
+        "p-map": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+            "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+            "dev": true,
+            "requires": {
+                "aggregate-error": "^3.0.0"
+            }
+        },
+        "pacote": {
+            "version": "17.0.5",
+            "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.5.tgz",
+            "integrity": "sha512-TAE0m20zSDMnchPja9vtQjri19X3pZIyRpm2TJVeI+yU42leJBBDTRYhOcWFsPhaMxf+3iwQkFiKz16G9AEeeA==",
+            "dev": true,
+            "requires": {
+                "@npmcli/git": "^5.0.0",
+                "@npmcli/installed-package-contents": "^2.0.1",
+                "@npmcli/promise-spawn": "^7.0.0",
+                "@npmcli/run-script": "^7.0.0",
+                "cacache": "^18.0.0",
+                "fs-minipass": "^3.0.0",
+                "minipass": "^7.0.2",
+                "npm-package-arg": "^11.0.0",
+                "npm-packlist": "^8.0.0",
+                "npm-pick-manifest": "^9.0.0",
+                "npm-registry-fetch": "^16.0.0",
+                "proc-log": "^3.0.0",
+                "promise-retry": "^2.0.1",
+                "read-package-json": "^7.0.0",
+                "read-package-json-fast": "^3.0.0",
+                "sigstore": "^2.0.0",
+                "ssri": "^10.0.0",
+                "tar": "^6.1.11"
+            }
+        },
+        "parent-module": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+            "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+            "requires": {
+                "callsites": "^3.0.0"
+            }
+        },
+        "patch-console": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz",
+            "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==",
+            "dev": true
+        },
+        "path-exists": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+            "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
+        },
         "path-is-absolute": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
-            "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+            "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
+        },
+        "path-key": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+            "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
+        },
+        "path-scurry": {
+            "version": "1.10.1",
+            "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
+            "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
+            "dev": true,
+            "requires": {
+                "lru-cache": "^9.1.1 || ^10.0.0",
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+            }
+        },
+        "picomatch": {
+            "version": "2.3.1",
+            "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+            "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+            "dev": true
+        },
+        "pirates": {
+            "version": "4.0.6",
+            "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
+            "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
+            "dev": true
+        },
+        "polite-json": {
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/polite-json/-/polite-json-4.0.1.tgz",
+            "integrity": "sha512-8LI5ZeCPBEb4uBbcYKNVwk4jgqNx1yHReWoW4H4uUihWlSqZsUDfSITrRhjliuPgxsNPFhNSudGO2Zu4cbWinQ==",
             "dev": true
         },
-        "path-parse": {
-            "version": "1.0.7",
-            "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
-            "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+        "prelude-ls": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+            "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="
+        },
+        "prismjs": {
+            "version": "1.29.0",
+            "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
+            "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
             "dev": true
         },
-        "regexp.prototype.flags": {
-            "version": "1.4.1",
-            "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.1.tgz",
-            "integrity": "sha512-pMR7hBVUUGI7PMA37m2ofIdQCsomVnas+Jn5UPGAHQ+/LlwKm/aTLJHdasmHRzlfeZwHiAOaRSo2rbBDm3nNUQ==",
+        "prismjs-terminal": {
+            "version": "1.2.3",
+            "resolved": "https://registry.npmjs.org/prismjs-terminal/-/prismjs-terminal-1.2.3.tgz",
+            "integrity": "sha512-xc0zuJ5FMqvW+DpiRkvxURlz98DdfDsZcFHdO699+oL+ykbFfgI7O4VDEgUyc07BSL2NHl3zdb8m/tZ/aaqUrw==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "chalk": "^5.2.0",
+                "prismjs": "^1.29.0",
+                "string-length": "^6.0.0"
+            },
+            "dependencies": {
+                "chalk": {
+                    "version": "5.3.0",
+                    "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+                    "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+                    "dev": true
+                }
             }
         },
-        "resolve": {
-            "version": "2.0.0-next.3",
-            "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.3.tgz",
-            "integrity": "sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q==",
+        "proc-log": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz",
+            "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==",
+            "dev": true
+        },
+        "process-on-spawn": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz",
+            "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==",
             "dev": true,
             "requires": {
-                "is-core-module": "^2.2.0",
-                "path-parse": "^1.0.6"
+                "fromentries": "^1.2.0"
             }
         },
-        "resumer": {
-            "version": "0.0.0",
-            "resolved": "https://registry.npmjs.org/resumer/-/resumer-0.0.0.tgz",
-            "integrity": "sha1-8ej0YeQGS6Oegq883CqMiT0HZ1k=",
+        "promise-inflight": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
+            "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==",
+            "dev": true
+        },
+        "promise-retry": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
+            "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
             "dev": true,
             "requires": {
-                "through": "~2.3.4"
+                "err-code": "^2.0.2",
+                "retry": "^0.12.0"
             }
         },
-        "side-channel": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
-            "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+        "punycode": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+            "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
+        },
+        "queue": {
+            "version": "6.0.2",
+            "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
+            "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
+            "requires": {
+                "inherits": "~2.0.3"
+            }
+        },
+        "queue-microtask": {
+            "version": "1.2.3",
+            "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+            "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
+        },
+        "react": {
+            "version": "18.2.0",
+            "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
+            "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.0",
-                "get-intrinsic": "^1.0.2",
-                "object-inspect": "^1.9.0"
+                "loose-envify": "^1.1.0"
             }
         },
-        "string.prototype.trim": {
-            "version": "1.2.5",
-            "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.5.tgz",
-            "integrity": "sha512-Lnh17webJVsD6ECeovpVN17RlAKjmz4rF9S+8Y45CkMc/ufVpTkU3vZIyIC7sllQ1FCvObZnnCdNs/HXTUOTlg==",
+        "react-dom": {
+            "version": "18.2.0",
+            "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
+            "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
             "dev": true,
+            "peer": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3",
-                "es-abstract": "^1.19.1"
+                "loose-envify": "^1.1.0",
+                "scheduler": "^0.23.0"
             }
         },
-        "string.prototype.trimend": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz",
-            "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==",
+        "react-element-to-jsx-string": {
+            "version": "15.0.0",
+            "resolved": "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-15.0.0.tgz",
+            "integrity": "sha512-UDg4lXB6BzlobN60P8fHWVPX3Kyw8ORrTeBtClmIlGdkOOE+GYQSFvmEU5iLLpwp/6v42DINwNcwOhOLfQ//FQ==",
+            "dev": true,
+            "requires": {
+                "@base2/pretty-print-object": "1.0.1",
+                "is-plain-object": "5.0.0",
+                "react-is": "18.1.0"
+            }
+        },
+        "react-is": {
+            "version": "18.1.0",
+            "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.1.0.tgz",
+            "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==",
+            "dev": true
+        },
+        "react-reconciler": {
+            "version": "0.29.0",
+            "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.0.tgz",
+            "integrity": "sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==",
+            "dev": true,
+            "requires": {
+                "loose-envify": "^1.1.0",
+                "scheduler": "^0.23.0"
+            }
+        },
+        "read-package-json": {
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.0.tgz",
+            "integrity": "sha512-uL4Z10OKV4p6vbdvIXB+OzhInYtIozl/VxUBPgNkBuUi2DeRonnuspmaVAMcrkmfjKGNmRndyQAbE7/AmzGwFg==",
+            "dev": true,
+            "requires": {
+                "glob": "^10.2.2",
+                "json-parse-even-better-errors": "^3.0.0",
+                "normalize-package-data": "^6.0.0",
+                "npm-normalize-package-bin": "^3.0.0"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "glob": {
+                    "version": "10.3.10",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "dev": true,
+                    "requires": {
+                        "foreground-child": "^3.1.0",
+                        "jackspeak": "^2.3.5",
+                        "minimatch": "^9.0.1",
+                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                        "path-scurry": "^1.10.1"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.3",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                }
+            }
+        },
+        "read-package-json-fast": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz",
+            "integrity": "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==",
+            "dev": true,
+            "requires": {
+                "json-parse-even-better-errors": "^3.0.0",
+                "npm-normalize-package-bin": "^3.0.0"
+            }
+        },
+        "readdirp": {
+            "version": "3.6.0",
+            "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+            "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+            "dev": true,
+            "requires": {
+                "picomatch": "^2.2.1"
+            }
+        },
+        "require-directory": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+            "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+            "dev": true
+        },
+        "resolve-from": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+            "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
+        },
+        "resolve-import": {
+            "version": "1.4.5",
+            "resolved": "https://registry.npmjs.org/resolve-import/-/resolve-import-1.4.5.tgz",
+            "integrity": "sha512-HXb4YqODuuXT7Icq1Z++0g2JmhgbUHSs3VT2xR83gqvAPUikYT2Xk+562KHQgiaNkbBOlPddYrDLsC44qQggzw==",
+            "dev": true,
+            "requires": {
+                "glob": "^10.3.3",
+                "walk-up-path": "^3.0.1"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "glob": {
+                    "version": "10.3.10",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "dev": true,
+                    "requires": {
+                        "foreground-child": "^3.1.0",
+                        "jackspeak": "^2.3.5",
+                        "minimatch": "^9.0.1",
+                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                        "path-scurry": "^1.10.1"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.3",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                }
+            }
+        },
+        "restore-cursor": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
+            "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==",
             "dev": true,
             "requires": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
+                "onetime": "^5.1.0",
+                "signal-exit": "^3.0.2"
+            },
+            "dependencies": {
+                "signal-exit": {
+                    "version": "3.0.7",
+                    "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+                    "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+                    "dev": true
+                }
             }
         },
-        "string.prototype.trimstart": {
+        "retry": {
+            "version": "0.12.0",
+            "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+            "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+            "dev": true
+        },
+        "reusify": {
             "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
-            "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
-            "dev": true,
-            "requires": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.1.3"
-            }
-        },
-        "tape": {
-            "version": "5.4.1",
-            "resolved": "https://registry.npmjs.org/tape/-/tape-5.4.1.tgz",
-            "integrity": "sha512-7bGaJ3WnQ/CX3xOWzlR+9lNptEWoD+11gyREP8k+SYrDu2a20EifKpTmZndXn25ZRxesYHSuNtE7Fb+THcjfGA==",
-            "dev": true,
-            "requires": {
-                "array.prototype.every": "^1.1.3",
-                "call-bind": "^1.0.2",
-                "deep-equal": "^2.0.5",
-                "defined": "^1.0.0",
-                "dotignore": "^0.1.2",
-                "for-each": "^0.3.3",
-                "get-package-type": "^0.1.0",
-                "glob": "^7.2.0",
-                "has": "^1.0.3",
-                "has-dynamic-import": "^2.0.1",
-                "inherits": "^2.0.4",
-                "is-regex": "^1.1.4",
-                "minimist": "^1.2.5",
-                "object-inspect": "^1.12.0",
-                "object-is": "^1.1.5",
-                "object-keys": "^1.1.1",
-                "object.assign": "^4.1.2",
-                "resolve": "^2.0.0-next.3",
-                "resumer": "^0.0.0",
-                "string.prototype.trim": "^1.2.5",
-                "through": "^2.3.8"
-            }
-        },
-        "through": {
-            "version": "2.3.8",
-            "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
-            "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
-            "dev": true
-        },
-        "unbox-primitive": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
-            "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+            "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+            "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="
+        },
+        "rimraf": {
+            "version": "3.0.2",
+            "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+            "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+            "requires": {
+                "glob": "^7.1.3"
+            }
+        },
+        "run-parallel": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+            "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+            "requires": {
+                "queue-microtask": "^1.2.2"
+            }
+        },
+        "safer-buffer": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+            "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+            "dev": true,
+            "optional": true
+        },
+        "scheduler": {
+            "version": "0.23.0",
+            "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
+            "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+            "dev": true,
+            "requires": {
+                "loose-envify": "^1.1.0"
+            }
+        },
+        "semver": {
+            "version": "7.5.4",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+            "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+            "dev": true,
+            "requires": {
+                "lru-cache": "^6.0.0"
+            },
+            "dependencies": {
+                "lru-cache": {
+                    "version": "6.0.0",
+                    "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+                    "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+                    "dev": true,
+                    "requires": {
+                        "yallist": "^4.0.0"
+                    }
+                }
+            }
+        },
+        "shebang-command": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+            "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+            "requires": {
+                "shebang-regex": "^3.0.0"
+            }
+        },
+        "shebang-regex": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+            "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
+        },
+        "signal-exit": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+            "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+            "dev": true
+        },
+        "sigstore": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.1.0.tgz",
+            "integrity": "sha512-kPIj+ZLkyI3QaM0qX8V/nSsweYND3W448pwkDgS6CQ74MfhEkIR8ToK5Iyx46KJYRjseVcD3Rp9zAmUAj6ZjPw==",
             "dev": true,
             "requires": {
-                "function-bind": "^1.1.1",
-                "has-bigints": "^1.0.1",
-                "has-symbols": "^1.0.2",
-                "which-boxed-primitive": "^1.0.2"
+                "@sigstore/bundle": "^2.1.0",
+                "@sigstore/protobuf-specs": "^0.2.1",
+                "@sigstore/sign": "^2.1.0",
+                "@sigstore/tuf": "^2.1.0"
             }
         },
-        "which-boxed-primitive": {
+        "slice-ansi": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-6.0.0.tgz",
+            "integrity": "sha512-6bn4hRfkTvDfUoEQYkERg0BVF1D0vrX9HEkMl08uDiNWvVvjylLHvZFZWkDo6wjT8tUctbYl1nCOuE66ZTaUtA==",
+            "dev": true,
+            "requires": {
+                "ansi-styles": "^6.2.1",
+                "is-fullwidth-code-point": "^4.0.0"
+            },
+            "dependencies": {
+                "ansi-styles": {
+                    "version": "6.2.1",
+                    "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+                    "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+                    "dev": true
+                }
+            }
+        },
+        "smart-buffer": {
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+            "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+            "dev": true
+        },
+        "socks": {
+            "version": "2.7.1",
+            "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz",
+            "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==",
+            "dev": true,
+            "requires": {
+                "ip": "^2.0.0",
+                "smart-buffer": "^4.2.0"
+            }
+        },
+        "socks-proxy-agent": {
+            "version": "8.0.2",
+            "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz",
+            "integrity": "sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==",
+            "dev": true,
+            "requires": {
+                "agent-base": "^7.0.2",
+                "debug": "^4.3.4",
+                "socks": "^2.7.1"
+            }
+        },
+        "spdx-correct": {
+            "version": "3.2.0",
+            "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",
+            "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
+            "dev": true,
+            "requires": {
+                "spdx-expression-parse": "^3.0.0",
+                "spdx-license-ids": "^3.0.0"
+            }
+        },
+        "spdx-exceptions": {
+            "version": "2.3.0",
+            "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+            "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+            "dev": true
+        },
+        "spdx-expression-parse": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+            "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+            "dev": true,
+            "requires": {
+                "spdx-exceptions": "^2.1.0",
+                "spdx-license-ids": "^3.0.0"
+            }
+        },
+        "spdx-license-ids": {
+            "version": "3.0.16",
+            "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz",
+            "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==",
+            "dev": true
+        },
+        "ssri": {
+            "version": "10.0.5",
+            "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz",
+            "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==",
+            "dev": true,
+            "requires": {
+                "minipass": "^7.0.3"
+            }
+        },
+        "stack-utils": {
+            "version": "2.0.6",
+            "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
+            "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
+            "dev": true,
+            "requires": {
+                "escape-string-regexp": "^2.0.0"
+            },
+            "dependencies": {
+                "escape-string-regexp": {
+                    "version": "2.0.0",
+                    "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+                    "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+                    "dev": true
+                }
+            }
+        },
+        "string-length": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/string-length/-/string-length-6.0.0.tgz",
+            "integrity": "sha512-1U361pxZHEQ+FeSjzqRpV+cu2vTzYeWeafXFLykiFlv4Vc0n3njgU8HrMbyik5uwm77naWMuVG8fhEF+Ovb1Kg==",
+            "dev": true,
+            "requires": {
+                "strip-ansi": "^7.1.0"
+            },
+            "dependencies": {
+                "ansi-regex": {
+                    "version": "6.0.1",
+                    "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+                    "dev": true
+                },
+                "strip-ansi": {
+                    "version": "7.1.0",
+                    "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+                    "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+                    "dev": true,
+                    "requires": {
+                        "ansi-regex": "^6.0.1"
+                    }
+                }
+            }
+        },
+        "string-width": {
+            "version": "5.1.2",
+            "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+            "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+            "dev": true,
+            "requires": {
+                "eastasianwidth": "^0.2.0",
+                "emoji-regex": "^9.2.2",
+                "strip-ansi": "^7.0.1"
+            },
+            "dependencies": {
+                "ansi-regex": {
+                    "version": "6.0.1",
+                    "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+                    "dev": true
+                },
+                "strip-ansi": {
+                    "version": "7.1.0",
+                    "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+                    "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+                    "dev": true,
+                    "requires": {
+                        "ansi-regex": "^6.0.1"
+                    }
+                }
+            }
+        },
+        "string-width-cjs": {
+            "version": "npm:string-width@4.2.3",
+            "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+            "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+            "dev": true,
+            "requires": {
+                "emoji-regex": "^8.0.0",
+                "is-fullwidth-code-point": "^3.0.0",
+                "strip-ansi": "^6.0.1"
+            },
+            "dependencies": {
+                "emoji-regex": {
+                    "version": "8.0.0",
+                    "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+                    "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+                    "dev": true
+                },
+                "is-fullwidth-code-point": {
+                    "version": "3.0.0",
+                    "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+                    "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+                    "dev": true
+                }
+            }
+        },
+        "strip-ansi": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+            "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+            "requires": {
+                "ansi-regex": "^5.0.1"
+            }
+        },
+        "strip-ansi-cjs": {
+            "version": "npm:strip-ansi@6.0.1",
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+            "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+            "dev": true,
+            "requires": {
+                "ansi-regex": "^5.0.1"
+            }
+        },
+        "strip-json-comments": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+            "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="
+        },
+        "striptags": {
+            "version": "4.0.0-alpha.4",
+            "resolved": "https://registry.npmjs.org/striptags/-/striptags-4.0.0-alpha.4.tgz",
+            "integrity": "sha512-/0jWyVWhpg9ciRHfjKYBpMHXct/HrFRfsR2HU77nGPbc8SPcVSIHZlZR/0TG3MyPq2C+HiHuwx8BlbcdI/cNbw=="
+        },
+        "supports-color": {
+            "version": "7.2.0",
+            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+            "requires": {
+                "has-flag": "^4.0.0"
+            }
+        },
+        "sync-content": {
             "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
-            "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+            "resolved": "https://registry.npmjs.org/sync-content/-/sync-content-1.0.2.tgz",
+            "integrity": "sha512-znd3rYiiSxU3WteWyS9a6FXkTA/Wjk8WQsOyzHbineeL837dLn3DA4MRhsIX3qGcxDMH6+uuFV4axztssk7wEQ==",
             "dev": true,
             "requires": {
-                "is-bigint": "^1.0.1",
-                "is-boolean-object": "^1.1.0",
-                "is-number-object": "^1.0.4",
-                "is-string": "^1.0.5",
-                "is-symbol": "^1.0.3"
+                "glob": "^10.2.6",
+                "mkdirp": "^3.0.1",
+                "path-scurry": "^1.9.2",
+                "rimraf": "^5.0.1"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "glob": {
+                    "version": "10.3.10",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "dev": true,
+                    "requires": {
+                        "foreground-child": "^3.1.0",
+                        "jackspeak": "^2.3.5",
+                        "minimatch": "^9.0.1",
+                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                        "path-scurry": "^1.10.1"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.3",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                },
+                "rimraf": {
+                    "version": "5.0.5",
+                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
+                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+                    "dev": true,
+                    "requires": {
+                        "glob": "^10.3.7"
+                    }
+                }
             }
         },
-        "which-collection": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz",
-            "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==",
+        "tap": {
+            "version": "18.6.1",
+            "resolved": "https://registry.npmjs.org/tap/-/tap-18.6.1.tgz",
+            "integrity": "sha512-5cBQhJ1gdbsrTR3tA5kZZTts0HyOML6bcM7pEF7GF8d6y1ajfRMjbInS1Ty7/x2Ip0ko3cY1dYjPJ9JFNPsm7w==",
+            "dev": true,
+            "requires": {
+                "@tapjs/after": "1.1.17",
+                "@tapjs/after-each": "1.1.17",
+                "@tapjs/asserts": "1.1.17",
+                "@tapjs/before": "1.1.17",
+                "@tapjs/before-each": "1.1.17",
+                "@tapjs/core": "1.4.6",
+                "@tapjs/filter": "1.2.17",
+                "@tapjs/fixture": "1.2.17",
+                "@tapjs/intercept": "1.2.17",
+                "@tapjs/mock": "1.2.15",
+                "@tapjs/node-serialize": "1.2.6",
+                "@tapjs/run": "1.4.16",
+                "@tapjs/snapshot": "1.2.17",
+                "@tapjs/spawn": "1.1.17",
+                "@tapjs/stdin": "1.1.17",
+                "@tapjs/test": "1.3.17",
+                "@tapjs/typescript": "1.3.6",
+                "@tapjs/worker": "1.1.17",
+                "resolve-import": "^1.4.5"
+            }
+        },
+        "tap-parser": {
+            "version": "15.3.1",
+            "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-15.3.1.tgz",
+            "integrity": "sha512-hwAtXX5TBGt2MJeYvASc7DjP48PUzA7P8RTbLxQcgKCEH7ICD5IsRco7l5YvkzjHlZbUbeI9wzO8B4hw2sKgnQ==",
+            "dev": true,
+            "requires": {
+                "events-to-array": "^2.0.3",
+                "tap-yaml": "2.2.1"
+            }
+        },
+        "tap-yaml": {
+            "version": "2.2.1",
+            "resolved": "https://registry.npmjs.org/tap-yaml/-/tap-yaml-2.2.1.tgz",
+            "integrity": "sha512-ovZuUMLAIH59jnFHXKEGJ+WyDYl6Cuduwg9qpvnqkZOUA1nU84q02Sry1HT0KXcdv2uB91bEKKxnIybBgrb6oA==",
+            "dev": true,
+            "requires": {
+                "yaml": "^2.3.0",
+                "yaml-types": "^0.3.0"
+            }
+        },
+        "tar": {
+            "version": "6.2.0",
+            "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
+            "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==",
+            "dev": true,
+            "requires": {
+                "chownr": "^2.0.0",
+                "fs-minipass": "^2.0.0",
+                "minipass": "^5.0.0",
+                "minizlib": "^2.1.1",
+                "mkdirp": "^1.0.3",
+                "yallist": "^4.0.0"
+            },
+            "dependencies": {
+                "fs-minipass": {
+                    "version": "2.1.0",
+                    "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+                    "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+                    "dev": true,
+                    "requires": {
+                        "minipass": "^3.0.0"
+                    },
+                    "dependencies": {
+                        "minipass": {
+                            "version": "3.3.6",
+                            "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+                            "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+                            "dev": true,
+                            "requires": {
+                                "yallist": "^4.0.0"
+                            }
+                        }
+                    }
+                },
+                "minipass": {
+                    "version": "5.0.0",
+                    "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+                    "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+                    "dev": true
+                },
+                "mkdirp": {
+                    "version": "1.0.4",
+                    "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+                    "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+                    "dev": true
+                }
+            }
+        },
+        "tcompare": {
+            "version": "6.4.5",
+            "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-6.4.5.tgz",
+            "integrity": "sha512-Whuz9xlKKI2XXICKDSDRKjXdBuC6gBNOgmEUtH7UFyQeYzfUMQ19DyjZULarGKDGFhgOg3CJ+IQUEfpkOPg0Uw==",
+            "dev": true,
+            "requires": {
+                "diff": "^5.1.0",
+                "react-element-to-jsx-string": "^15.0.0"
+            }
+        },
+        "test-exclude": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+            "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
             "dev": true,
             "requires": {
-                "is-map": "^2.0.1",
-                "is-set": "^2.0.1",
-                "is-weakmap": "^2.0.1",
-                "is-weakset": "^2.0.1"
+                "@istanbuljs/schema": "^0.1.2",
+                "glob": "^7.1.4",
+                "minimatch": "^3.0.4"
             }
         },
-        "which-typed-array": {
-            "version": "1.1.7",
-            "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.7.tgz",
-            "integrity": "sha512-vjxaB4nfDqwKI0ws7wZpxIlde1XrLX5uB0ZjpfshgmapJMD7jJWhZI+yToJTqaFByF0eNBcYxbjmCzoRP7CfEw==",
+        "text-table": {
+            "version": "0.2.0",
+            "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+            "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="
+        },
+        "to-regex-range": {
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+            "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
             "dev": true,
             "requires": {
-                "available-typed-arrays": "^1.0.5",
-                "call-bind": "^1.0.2",
-                "es-abstract": "^1.18.5",
-                "foreach": "^2.0.5",
-                "has-tostringtag": "^1.0.0",
-                "is-typed-array": "^1.1.7"
+                "is-number": "^7.0.0"
+            }
+        },
+        "trivial-deferred": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/trivial-deferred/-/trivial-deferred-2.0.0.tgz",
+            "integrity": "sha512-iGbM7X2slv9ORDVj2y2FFUq3cP/ypbtu2nQ8S38ufjL0glBABvmR9pTdsib1XtS2LUhhLMbelaBUaf/s5J3dSw==",
+            "dev": true
+        },
+        "tshy": {
+            "version": "1.8.2",
+            "resolved": "https://registry.npmjs.org/tshy/-/tshy-1.8.2.tgz",
+            "integrity": "sha512-aGlSY+jkZYAv0YDgtdv1U2vvbGTUdlXmhVP4uegujlJ/wuznmJqSu5cUV/6IW7N7a3HFRhofWvIS/FquYN9zgA==",
+            "dev": true,
+            "requires": {
+                "chalk": "^5.3.0",
+                "chokidar": "^3.5.3",
+                "foreground-child": "^3.1.1",
+                "mkdirp": "^3.0.1",
+                "resolve-import": "^1.4.4",
+                "rimraf": "^5.0.1",
+                "sync-content": "^1.0.2",
+                "typescript": "5.2",
+                "walk-up-path": "^3.0.1"
+            },
+            "dependencies": {
+                "brace-expansion": {
+                    "version": "2.0.1",
+                    "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+                    "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+                    "dev": true,
+                    "requires": {
+                        "balanced-match": "^1.0.0"
+                    }
+                },
+                "chalk": {
+                    "version": "5.3.0",
+                    "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+                    "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+                    "dev": true
+                },
+                "glob": {
+                    "version": "10.3.10",
+                    "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+                    "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+                    "dev": true,
+                    "requires": {
+                        "foreground-child": "^3.1.0",
+                        "jackspeak": "^2.3.5",
+                        "minimatch": "^9.0.1",
+                        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+                        "path-scurry": "^1.10.1"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.3",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+                    "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                },
+                "rimraf": {
+                    "version": "5.0.5",
+                    "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
+                    "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+                    "dev": true,
+                    "requires": {
+                        "glob": "^10.3.7"
+                    }
+                }
+            }
+        },
+        "tslib": {
+            "version": "2.6.2",
+            "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+            "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
+        },
+        "tuf-js": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.1.0.tgz",
+            "integrity": "sha512-eD7YPPjVlMzdggrOeE8zwoegUaG/rt6Bt3jwoQPunRiNVzgcCE009UDFJKJjG+Gk9wFu6W/Vi+P5d/5QpdD9jA==",
+            "dev": true,
+            "requires": {
+                "@tufjs/models": "2.0.0",
+                "debug": "^4.3.4",
+                "make-fetch-happen": "^13.0.0"
+            }
+        },
+        "type-check": {
+            "version": "0.4.0",
+            "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+            "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+            "requires": {
+                "prelude-ls": "^1.2.1"
+            }
+        },
+        "type-fest": {
+            "version": "0.20.2",
+            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+            "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="
+        },
+        "typescript": {
+            "version": "5.2.2",
+            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
+            "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
+            "dev": true
+        },
+        "undici-types": {
+            "version": "5.26.5",
+            "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+            "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+            "dev": true,
+            "peer": true
+        },
+        "unique-filename": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz",
+            "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==",
+            "dev": true,
+            "requires": {
+                "unique-slug": "^4.0.0"
+            }
+        },
+        "unique-slug": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz",
+            "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==",
+            "dev": true,
+            "requires": {
+                "imurmurhash": "^0.1.4"
+            }
+        },
+        "uri-js": {
+            "version": "4.4.1",
+            "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+            "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+            "requires": {
+                "punycode": "^2.1.0"
+            }
+        },
+        "uuid": {
+            "version": "8.3.2",
+            "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+            "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+            "dev": true
+        },
+        "v8-compile-cache-lib": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+            "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
+            "dev": true
+        },
+        "v8-to-istanbul": {
+            "version": "9.2.0",
+            "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz",
+            "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==",
+            "dev": true,
+            "requires": {
+                "@jridgewell/trace-mapping": "^0.3.12",
+                "@types/istanbul-lib-coverage": "^2.0.1",
+                "convert-source-map": "^2.0.0"
+            },
+            "dependencies": {
+                "@jridgewell/trace-mapping": {
+                    "version": "0.3.20",
+                    "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
+                    "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
+                    "dev": true,
+                    "requires": {
+                        "@jridgewell/resolve-uri": "^3.1.0",
+                        "@jridgewell/sourcemap-codec": "^1.4.14"
+                    }
+                }
+            }
+        },
+        "validate-npm-package-license": {
+            "version": "3.0.4",
+            "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+            "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+            "dev": true,
+            "requires": {
+                "spdx-correct": "^3.0.0",
+                "spdx-expression-parse": "^3.0.0"
+            }
+        },
+        "validate-npm-package-name": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz",
+            "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==",
+            "dev": true,
+            "requires": {
+                "builtins": "^5.0.0"
+            }
+        },
+        "walk-up-path": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz",
+            "integrity": "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==",
+            "dev": true
+        },
+        "which": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+            "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+            "requires": {
+                "isexe": "^2.0.0"
+            }
+        },
+        "widest-line": {
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",
+            "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==",
+            "dev": true,
+            "requires": {
+                "string-width": "^5.0.1"
+            }
+        },
+        "word-wrap": {
+            "version": "1.2.5",
+            "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+            "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="
+        },
+        "wrap-ansi": {
+            "version": "8.1.0",
+            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+            "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+            "dev": true,
+            "requires": {
+                "ansi-styles": "^6.1.0",
+                "string-width": "^5.0.1",
+                "strip-ansi": "^7.0.1"
+            },
+            "dependencies": {
+                "ansi-regex": {
+                    "version": "6.0.1",
+                    "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+                    "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+                    "dev": true
+                },
+                "ansi-styles": {
+                    "version": "6.2.1",
+                    "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+                    "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+                    "dev": true
+                },
+                "strip-ansi": {
+                    "version": "7.1.0",
+                    "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+                    "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+                    "dev": true,
+                    "requires": {
+                        "ansi-regex": "^6.0.1"
+                    }
+                }
+            }
+        },
+        "wrap-ansi-cjs": {
+            "version": "npm:wrap-ansi@7.0.0",
+            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+            "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+            "dev": true,
+            "requires": {
+                "ansi-styles": "^4.0.0",
+                "string-width": "^4.1.0",
+                "strip-ansi": "^6.0.0"
+            },
+            "dependencies": {
+                "emoji-regex": {
+                    "version": "8.0.0",
+                    "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+                    "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+                    "dev": true
+                },
+                "is-fullwidth-code-point": {
+                    "version": "3.0.0",
+                    "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+                    "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+                    "dev": true
+                },
+                "string-width": {
+                    "version": "4.2.3",
+                    "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+                    "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+                    "dev": true,
+                    "requires": {
+                        "emoji-regex": "^8.0.0",
+                        "is-fullwidth-code-point": "^3.0.0",
+                        "strip-ansi": "^6.0.1"
+                    }
+                }
             }
         },
         "wrappy": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+            "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+        },
+        "ws": {
+            "version": "8.16.0",
+            "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
+            "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
+            "dev": true,
+            "requires": {}
+        },
+        "y18n": {
+            "version": "5.0.8",
+            "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+            "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+            "dev": true
+        },
+        "yallist": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+            "dev": true
+        },
+        "yaml": {
+            "version": "2.3.4",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",
+            "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==",
+            "dev": true
+        },
+        "yaml-types": {
+            "version": "0.3.0",
+            "resolved": "https://registry.npmjs.org/yaml-types/-/yaml-types-0.3.0.tgz",
+            "integrity": "sha512-i9RxAO/LZBiE0NJUy9pbN5jFz5EasYDImzRkj8Y81kkInTi1laia3P3K/wlMKzOxFQutZip8TejvQP/DwgbU7A==",
+            "dev": true,
+            "requires": {}
+        },
+        "yargs": {
+            "version": "17.7.2",
+            "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+            "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+            "dev": true,
+            "requires": {
+                "cliui": "^8.0.1",
+                "escalade": "^3.1.1",
+                "get-caller-file": "^2.0.5",
+                "require-directory": "^2.1.1",
+                "string-width": "^4.2.3",
+                "y18n": "^5.0.5",
+                "yargs-parser": "^21.1.1"
+            },
+            "dependencies": {
+                "emoji-regex": {
+                    "version": "8.0.0",
+                    "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+                    "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+                    "dev": true
+                },
+                "is-fullwidth-code-point": {
+                    "version": "3.0.0",
+                    "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+                    "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+                    "dev": true
+                },
+                "string-width": {
+                    "version": "4.2.3",
+                    "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+                    "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+                    "dev": true,
+                    "requires": {
+                        "emoji-regex": "^8.0.0",
+                        "is-fullwidth-code-point": "^3.0.0",
+                        "strip-ansi": "^6.0.1"
+                    }
+                }
+            }
+        },
+        "yargs-parser": {
+            "version": "21.1.1",
+            "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+            "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+            "dev": true
+        },
+        "yocto-queue": {
+            "version": "0.1.0",
+            "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+            "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
+        },
+        "yoga-wasm-web": {
+            "version": "0.3.3",
+            "resolved": "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz",
+            "integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==",
             "dev": true
         }
     }
diff --git a/package.json b/package.json
index c48d868..fc755f4 100644
--- a/package.json
+++ b/package.json
@@ -3,20 +3,74 @@
     "version": "0.1.0",
     "description": "static wiki software cataloguing collaborative creation",
     "type": "module",
-    "main": "upd8.js",
+    "main": "src/upd8.js",
     "bin": {
         "hsmusic": "./src/upd8.js"
     },
     "scripts": {
-        "test": "tape test/**/*.js"
+        "test": "tap",
+        "dev": "eslint src && node src/upd8.js"
+    },
+    "imports": {
+        "#aggregate": "./src/util/aggregate.js",
+        "#cacheable-object": "./src/data/cacheable-object.js",
+        "#colors": "./src/util/colors.js",
+        "#composite": "./src/data/composite.js",
+        "#composite/control-flow": "./src/data/composite/control-flow/index.js",
+        "#composite/data": "./src/data/composite/data/index.js",
+        "#composite/wiki-data": "./src/data/composite/wiki-data/index.js",
+        "#composite/wiki-properties": "./src/data/composite/wiki-properties/index.js",
+        "#composite/things/album": "./src/data/composite/things/album/index.js",
+        "#composite/things/flash": "./src/data/composite/things/flash/index.js",
+        "#composite/things/flash-act": "./src/data/composite/things/flash-act/index.js",
+        "#composite/things/track": "./src/data/composite/things/track/index.js",
+        "#content-dependencies": "./src/content/dependencies/index.js",
+        "#content-function": "./src/content-function.js",
+        "#cli": "./src/util/cli.js",
+        "#data-checks": "./src/data/checks.js",
+        "#external-links": "./src/util/external-links.js",
+        "#find": "./src/find.js",
+        "#html": "./src/util/html.js",
+        "#language": "./src/data/language.js",
+        "#page-specs": "./src/page/index.js",
+        "#node-utils": "./src/util/node-utils.js",
+        "#repl": "./src/write/build-modes/repl.js",
+        "#replacer": "./src/util/replacer.js",
+        "#serialize": "./src/data/serialize.js",
+        "#sugar": "./src/util/sugar.js",
+        "#sort": "./src/util/sort.js",
+        "#test-lib": "./test/lib/index.js",
+        "#thing": "./src/data/thing.js",
+        "#things": "./src/data/things/index.js",
+        "#thumbs": "./src/gen-thumbs.js",
+        "#urls": "./src/util/urls.js",
+        "#validators": "./src/data/validators.js",
+        "#wiki-data": "./src/util/wiki-data.js",
+        "#yaml": "./src/data/yaml.js"
+    },
+    "engines": {
+        "node": ">= 20.9.0"
     },
     "dependencies": {
-        "fix-whitespace": "^1.0.4",
+        "@js-temporal/polyfill": "^0.4.4",
+        "chroma-js": "^2.4.2",
+        "command-exists": "^1.2.9",
+        "eslint": "^8.37.0",
         "he": "^1.2.0",
-        "js-yaml": "^4.1.0"
+        "image-size": "^1.0.2",
+        "js-yaml": "^4.1.0",
+        "marked": "^10.0.0",
+        "striptags": "^4.0.0-alpha.4",
+        "word-wrap": "^1.2.3"
     },
     "license": "GPL-3.0",
     "devDependencies": {
-        "tape": "^5.4.1"
+        "chokidar": "^3.5.3",
+        "tap": "^18.4.0",
+        "tcompare": "^6.0.0"
+    },
+    "tap": {
+        "coverage": false,
+        "coverage-report": false
     }
 }
diff --git a/src/content-function.js b/src/content-function.js
new file mode 100644
index 0000000..44f8b84
--- /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 0000000..930b6f1
--- /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 0000000..f504cf8
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -0,0 +1,24 @@
+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'},
+      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 0000000..5804115
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesListChunk.js
@@ -0,0 +1,53 @@
+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}) {
+    const summary =
+      html.tag('summary',
+        html.tag('span',
+          language.$('releaseInfo.additionalFiles.entry', {
+            title:
+              html.tag('span', {class: 'group-name'},
+                slots.title),
+          })));
+
+    const description =
+      html.tag('li', {class: 'entry-description'},
+        {[html.onlyIfContent]: true},
+        slots.description);
+
+    const items =
+      (html.isBlank(slots.items)
+        ? html.tag('li',
+            language.$('releaseInfo.additionalFiles.entry.noFilesAvailable'))
+        : slots.items);
+
+    const content =
+      html.tag('ul', [description, items]);
+
+    const details =
+      html.tag('details',
+        html.isBlank(slots.items) &&
+          {open: true},
+
+        [summary, content]);
+
+    return html.tag('li', details);
+  },
+};
diff --git a/src/content/dependencies/generateAdditionalFilesListChunkItem.js b/src/content/dependencies/generateAdditionalFilesListChunkItem.js
new file mode 100644
index 0000000..c37d6bb
--- /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/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js
new file mode 100644
index 0000000..9e119bc
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalFilesShortcut.js
@@ -0,0 +1,27 @@
+import {empty} from '#sugar';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  data(additionalFiles) {
+    return {
+      titles: additionalFiles.map(fileGroup => fileGroup.title),
+    };
+  },
+
+  generate(data, {html, language}) {
+    if (empty(data.titles)) {
+      return html.blank();
+    }
+
+    return language.$('releaseInfo.additionalFiles.shortcut', {
+      anchorLink:
+        html.tag('a',
+          {href: '#additional-files'},
+          language.$('releaseInfo.additionalFiles.shortcut.anchorLink')),
+
+      titles:
+        language.formatUnitList(data.titles),
+    });
+  },
+}
diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js
new file mode 100644
index 0000000..63427c5
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalNamesBox.js
@@ -0,0 +1,20 @@
+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'}, [
+      html.tag('p',
+        language.$('misc.additionalNames.title')),
+
+      html.tag('ul',
+        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 0000000..7515b5b
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalNamesBoxItem.js
@@ -0,0 +1,71 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkTrack', 'transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    nameContent:
+      relation('transformContent', entry.name),
+
+    annotationContent:
+      (entry.annotation
+        ? relation('transformContent', entry.annotation)
+        : null),
+
+    trackLinks:
+      (entry.from
+        ? entry.from.map(track => relation('linkTrack', track))
+        : null),
+  }),
+
+  data: (entry) => ({
+    albumNames:
+      (entry.from
+        ? entry.from.map(track => track.album.name)
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) => {
+    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.slot('mode', 'inline');
+    }
+
+    if (relations.trackLinks) {
+      accentParts.push('withAlbums');
+      accentOptions.albums =
+        language.formatConjunctionList(
+          stitchArrays({
+            trackLink: relations.trackLinks,
+            albumName: data.albumNames,
+          }).map(({trackLink, albumName}) =>
+              trackLink.slot('content',
+                language.sanitize(albumName))));
+    }
+
+    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 0000000..9818a43
--- /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: ['getSizeOfAdditionalFile', '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, {getSizeOfAdditionalFile, 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
+                      ? getSizeOfAdditionalFile(
+                          urls
+                            .from('media.root')
+                            .to('media.albumAdditionalFile', data.albumDirectory, location))
+                      : 0),
+                }))),
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumBanner.js b/src/content/dependencies/generateAlbumBanner.js
new file mode 100644
index 0000000..3cc141b
--- /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 0000000..751a0c9
--- /dev/null
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -0,0 +1,274 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumCoverArtwork',
+    'generateAlbumNavAccent',
+    'generateAlbumSidebarTrackSection',
+    'generateAlbumStyleRules',
+    'generateCommentaryEntry',
+    'generateContentHeading',
+    'generateTrackCoverArtwork',
+    'generatePageLayout',
+    'generatePageSidebar',
+    'linkAlbum',
+    'linkExternal',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.sidebar =
+      relation('generatePageSidebar');
+
+    relations.albumStyleRules =
+      relation('generateAlbumStyleRules', album, null);
+
+    relations.albumLink =
+      relation('linkAlbum', album);
+
+    relations.albumNavAccent =
+      relation('generateAlbumNavAccent', album, null);
+
+    if (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('generateAlbumCoverArtwork', album);
+      }
+
+      relations.albumCommentaryEntries =
+        album.commentary
+          .map(entry => relation('generateCommentaryEntry', entry));
+    }
+
+    const tracksWithCommentary =
+      album.tracks
+        .filter(({commentary}) => commentary);
+
+    relations.trackCommentaryHeadings =
+      tracksWithCommentary
+        .map(() => relation('generateContentHeading'));
+
+    relations.trackCommentaryLinks =
+      tracksWithCommentary
+        .map(track => relation('linkTrack', track));
+
+    relations.trackCommentaryListeningLinks =
+      tracksWithCommentary
+        .map(track =>
+          track.urls.map(url => relation('linkExternal', url)));
+
+    relations.trackCommentaryCovers =
+      tracksWithCommentary
+        .map(track =>
+          (track.hasUniqueCoverArt
+            ? relation('generateTrackCoverArtwork', track)
+            : null));
+
+    relations.trackCommentaryEntries =
+      tracksWithCommentary
+        .map(track =>
+          track.commentary
+            .map(entry => relation('generateCommentaryEntry', entry)));
+
+    relations.sidebarAlbumLink =
+      relation('linkAlbum', album);
+
+    relations.sidebarTrackSections =
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection', album, null, trackSection));
+
+    return relations;
+  },
+
+  data(album) {
+    const data = {};
+
+    data.name = album.name;
+    data.color = album.color;
+
+    const tracksWithCommentary =
+      album.tracks
+        .filter(({commentary}) => commentary);
+
+    const thingsWithCommentary =
+      (album.commentary
+        ? [album, ...tracksWithCommentary]
+        : tracksWithCommentary);
+
+    data.entryCount =
+      thingsWithCommentary
+        .flatMap(({commentary}) => commentary)
+        .length;
+
+    data.wordCount =
+      thingsWithCommentary
+        .flatMap(({commentary}) => commentary)
+        .map(({body}) => body)
+        .join(' ')
+        .split(' ')
+        .length;
+
+    data.trackCommentaryDirectories =
+      tracksWithCommentary
+        .map(track => track.directory);
+
+    data.trackCommentaryColors =
+      tracksWithCommentary
+        .map(track =>
+          (track.color === album.color
+            ? null
+            : track.color));
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.layout
+      .slots({
+        title:
+          language.$('albumCommentaryPage.title', {
+            album: data.name,
+          }),
+
+        headingMode: 'sticky',
+
+        color: data.color,
+        styleRules: [relations.albumStyleRules],
+
+        mainClasses: ['long-content'],
+        mainContent: [
+          html.tag('p',
+            language.$('albumCommentaryPage.infoLine', {
+              words:
+                html.tag('b',
+                  language.formatWordCount(data.wordCount, {unit: true})),
+
+              entries:
+                html.tag('b',
+                  language.countCommentaryEntries(data.entryCount, {unit: true})),
+            })),
+
+          relations.albumCommentaryEntries && [
+            relations.albumCommentaryHeading.slots({
+              tag: 'h3',
+              color: data.color,
+
+              title:
+                language.$('albumCommentaryPage.entry.title.albumCommentary', {
+                  album: relations.albumCommentaryLink,
+                }),
+
+              accent:
+                !empty(relations.albumCommentaryListeningLinks) &&
+                  language.$('albumCommentaryPage.entry.title.albumCommentary.accent', {
+                    listeningLinks:
+                      language.formatUnitList(
+                        relations.albumCommentaryListeningLinks
+                          .map(link => link.slots({
+                            context: 'album',
+                            tab: 'separate',
+                          }))),
+                  }),
+            }),
+
+            relations.albumCommentaryCover
+              ?.slots({mode: 'commentary'}),
+
+            relations.albumCommentaryEntries,
+          ],
+
+          stitchArrays({
+            heading: relations.trackCommentaryHeadings,
+            link: relations.trackCommentaryLinks,
+            listeningLinks: relations.trackCommentaryListeningLinks,
+            directory: data.trackCommentaryDirectories,
+            cover: relations.trackCommentaryCovers,
+            entries: relations.trackCommentaryEntries,
+            color: data.trackCommentaryColors,
+          }).map(({
+              heading,
+              link,
+              listeningLinks,
+              directory,
+              cover,
+              entries,
+              color,
+            }) => [
+              heading.slots({
+                tag: 'h3',
+                id: directory,
+                color,
+
+                title:
+                  language.$('albumCommentaryPage.entry.title.trackCommentary', {
+                    track: link,
+                  }),
+
+                accent:
+                  !empty(listeningLinks) &&
+                    language.$('albumCommentaryPage.entry.title.trackCommentary.accent', {
+                      listeningLinks:
+                        language.formatUnitList(
+                          listeningLinks.map(link =>
+                            link.slot('tab', 'separate'))),
+                    }),
+              }),
+
+              cover?.slots({mode: 'commentary'}),
+
+              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',
+              }),
+          },
+        ],
+
+        leftSidebar:
+          relations.sidebar.slots({
+            attributes: {class: 'commentary-track-list-sidebar-box'},
+
+            stickyMode: 'column',
+
+            content: [
+              html.tag('h1', relations.sidebarAlbumLink),
+              relations.sidebarTrackSections.map(section =>
+                section.slots({
+                  anchor: true,
+                  open: true,
+                  mode: 'commentary',
+                })),
+            ],
+          }),
+      });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js
new file mode 100644
index 0000000..dbb22fe
--- /dev/null
+++ b/src/content/dependencies/generateAlbumCoverArtwork.js
@@ -0,0 +1,26 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations: (relation, album) => ({
+    coverArtwork:
+      relation('generateCoverArtwork', album.artTags),
+  }),
+
+  data: (album) => ({
+    path:
+      ['media.albumCover', album.directory, album.coverArtFileExtension],
+
+    color:
+      album.color,
+
+    dimensions:
+      album.coverArtDimensions,
+  }),
+
+  generate: (data, relations) =>
+    relations.coverArtwork.slots({
+      path: data.path,
+      color: data.color,
+      dimensions: data.dimensions,
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js b/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js
new file mode 100644
index 0000000..7dcdf6d
--- /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 0000000..ad99cb8
--- /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 0000000..b4f9268
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -0,0 +1,228 @@
+import {compareArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumGalleryCoverArtistsLine',
+    'generateAlbumGalleryNoTrackArtworksLine',
+    'generateAlbumGalleryStatsLine',
+    'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
+    'generateAlbumStyleRules',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album) {
+    const query = {};
+
+    const tracksWithUniqueCoverArt =
+      album.tracks
+        .filter(track => track.hasUniqueCoverArt);
+
+    // Don't display "all artwork by..." for albums where there's
+    // only one unique artwork in the first place.
+    if (tracksWithUniqueCoverArt.length > 1) {
+      const allCoverArtistArrays =
+        tracksWithUniqueCoverArt
+          .map(track => track.coverArtistContribs)
+          .map(contribs => contribs.map(contrib => contrib.who));
+
+      const allSameCoverArtists =
+        allCoverArtistArrays
+          .slice(1)
+          .every(artists => compareArrays(artists, allCoverArtistArrays[0]));
+
+      if (allSameCoverArtists) {
+        query.coverArtistsForAllTracks =
+          allCoverArtistArrays[0];
+      }
+    }
+
+    return query;
+  },
+
+  relations(relation, query, album) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.albumStyleRules =
+      relation('generateAlbumStyleRules', album, null);
+
+    relations.albumLink =
+      relation('linkAlbum', album);
+
+    relations.albumNavAccent =
+      relation('generateAlbumNavAccent', album, null);
+
+    relations.secondaryNav =
+      relation('generateAlbumSecondaryNav', album);
+
+    relations.statsLine =
+      relation('generateAlbumGalleryStatsLine', album);
+
+    if (album.tracks.every(track => !track.hasUniqueCoverArt)) {
+      relations.noTrackArtworksLine =
+        relation('generateAlbumGalleryNoTrackArtworksLine');
+    }
+
+    if (query.coverArtistsForAllTracks) {
+      relations.coverArtistsLine =
+        relation('generateAlbumGalleryCoverArtistsLine', query.coverArtistsForAllTracks);
+    }
+
+    relations.coverGrid =
+      relation('generateCoverGrid');
+
+    relations.links = [
+      relation('linkAlbum', album),
+
+      ...
+        album.tracks
+          .map(track => relation('linkTrack', track)),
+    ];
+
+    relations.images = [
+      (album.hasCoverArt
+        ? relation('image', album.artTags)
+        : relation('image')),
+
+      ...
+        album.tracks.map(track =>
+          (track.hasUniqueCoverArt
+            ? relation('image', track.artTags)
+            : relation('image'))),
+    ];
+
+    return relations;
+  },
+
+  data(query, album) {
+    const data = {};
+
+    data.name = album.name;
+    data.color = album.color;
+
+    data.names = [
+      album.name,
+      ...album.tracks.map(track => track.name),
+    ];
+
+    data.coverArtists = [
+      (album.hasCoverArt
+        ? album.coverArtistContribs.map(({who: artist}) => artist.name)
+        : null),
+
+      ...
+        album.tracks.map(track => {
+          if (query.coverArtistsForAllTracks) {
+            return null;
+          }
+
+          if (track.hasUniqueCoverArt) {
+            return track.coverArtistContribs.map(({who: artist}) => artist.name);
+          }
+
+          return null;
+        }),
+    ];
+
+    data.paths = [
+      (album.hasCoverArt
+        ? ['media.albumCover', album.directory, album.coverArtFileExtension]
+        : null),
+
+      ...
+        album.tracks.map(track =>
+          (track.hasUniqueCoverArt
+            ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
+            : null)),
+    ];
+
+    data.dimensions = [
+      (album.hasCoverArt
+        ? album.coverArtDimensions
+        : null),
+
+      ...
+        album.tracks.map(track =>
+          (track.hasUniqueCoverArt
+            ? track.coverArtDimensions
+            : null)),
+    ];
+
+    return data;
+  },
+
+  generate(data, relations, {language}) {
+    return relations.layout
+      .slots({
+        title:
+          language.$('albumGalleryPage.title', {
+            album: data.name,
+          }),
+
+        headingMode: 'static',
+
+        color: data.color,
+        styleRules: [relations.albumStyleRules],
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          relations.statsLine,
+          relations.coverArtistsLine,
+          relations.noTrackArtworksLine,
+
+          relations.coverGrid
+            .slots({
+              links: relations.links,
+              names: data.names,
+              images:
+                stitchArrays({
+                  image: relations.images,
+                  path: data.paths,
+                  dimensions: data.dimensions,
+                  name: data.names,
+                }).map(({image, path, dimensions, name}) =>
+                    image.slots({
+                      path,
+                      dimensions,
+                      missingSourceContent:
+                        language.$('misc.albumGalleryGrid.noCoverArt', {name}),
+                    })),
+              info:
+                data.coverArtists.map(names =>
+                  (names === null
+                    ? null
+                    : language.$('misc.albumGrid.details.coverArtists', {
+                        artists: language.formatUnitList(names),
+                      }))),
+            }),
+        ],
+
+        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 0000000..75bffb3
--- /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/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
new file mode 100644
index 0000000..e0f23bd
--- /dev/null
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -0,0 +1,273 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import {empty} from '#sugar';
+
+import getChronologyRelations from '../util/getChronologyRelations.js';
+
+export default {
+  contentDependencies: [
+    'generateAdditionalFilesShortcut',
+    'generateAlbumAdditionalFilesList',
+    'generateAlbumBanner',
+    'generateAlbumCoverArtwork',
+    'generateAlbumNavAccent',
+    'generateAlbumReleaseInfo',
+    'generateAlbumSecondaryNav',
+    'generateAlbumSidebar',
+    'generateAlbumSocialEmbed',
+    'generateAlbumStyleRules',
+    'generateAlbumTrackList',
+    'generateChronologyLinks',
+    'generateCommentarySection',
+    'generateContentHeading',
+    'generatePageLayout',
+    'linkAlbum',
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+    'linkArtist',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album) {
+    const relations = {};
+    const sections = relations.sections = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.albumStyleRules =
+      relation('generateAlbumStyleRules', album, null);
+
+    relations.socialEmbed =
+      relation('generateAlbumSocialEmbed', album);
+
+    relations.coverArtistChronologyContributions =
+      getChronologyRelations(album, {
+        contributions: album.coverArtistContribs ?? [],
+
+        linkArtist: artist => relation('linkArtist', artist),
+
+        linkThing: trackOrAlbum =>
+          (trackOrAlbum.album
+            ? relation('linkTrack', trackOrAlbum)
+            : relation('linkAlbum', trackOrAlbum)),
+
+        getThings(artist) {
+          const getDate = thing => thing.coverArtDate ?? thing.date;
+
+          const things = [
+            ...artist.albumsAsCoverArtist,
+            ...artist.tracksAsCoverArtist,
+          ].filter(getDate);
+
+          return sortAlbumsTracksChronologically(things, {getDate});
+        },
+      });
+
+    relations.albumNavAccent =
+      relation('generateAlbumNavAccent', album, null);
+
+    relations.chronologyLinks =
+      relation('generateChronologyLinks');
+
+    relations.secondaryNav =
+      relation('generateAlbumSecondaryNav', album);
+
+    relations.sidebar =
+      relation('generateAlbumSidebar', album, null);
+
+    if (album.hasCoverArt) {
+      relations.cover =
+        relation('generateAlbumCoverArtwork', album);
+    }
+
+    if (album.hasBannerArt) {
+      relations.banner =
+        relation('generateAlbumBanner', album);
+    }
+
+    // Section: Release info
+
+    relations.releaseInfo =
+      relation('generateAlbumReleaseInfo', album);
+
+    // Section: Extra links
+
+    const extra = sections.extra = {};
+
+    if (album.tracks.some(t => t.hasUniqueCoverArt)) {
+      extra.galleryLink =
+        relation('linkAlbumGallery', album);
+    }
+
+    if (album.commentary || album.tracks.some(t => t.commentary)) {
+      extra.commentaryLink =
+        relation('linkAlbumCommentary', album);
+    }
+
+    if (!empty(album.additionalFiles)) {
+      extra.additionalFilesShortcut =
+        relation('generateAdditionalFilesShortcut', album.additionalFiles);
+    }
+
+    // Section: Track list
+
+    relations.trackList =
+      relation('generateAlbumTrackList', album);
+
+    // Section: Additional files
+
+    if (!empty(album.additionalFiles)) {
+      const additionalFiles = sections.additionalFiles = {};
+
+      additionalFiles.heading =
+        relation('generateContentHeading');
+
+      additionalFiles.additionalFilesList =
+        relation('generateAlbumAdditionalFilesList', album, album.additionalFiles);
+    }
+
+    // Section: Artist commentary
+
+    if (album.commentary) {
+      sections.artistCommentary =
+        relation('generateCommentarySection', album.commentary);
+    }
+
+    return relations;
+  },
+
+  data(album) {
+    const data = {};
+
+    data.name = album.name;
+    data.color = album.color;
+
+    if (!empty(album.additionalFiles)) {
+      data.numAdditionalFiles = album.additionalFiles.length;
+    }
+
+    data.dateAddedToWiki = album.dateAddedToWiki;
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    const {sections: sec} = relations;
+
+    return relations.layout
+      .slots({
+        title: language.$('albumPage.title', {album: data.name}),
+        headingMode: 'sticky',
+
+        color: data.color,
+        styleRules: [relations.albumStyleRules],
+
+        cover:
+          relations.cover
+            ?.slots({
+              alt: language.$('misc.alt.albumCover'),
+            })
+            ?? null,
+
+        mainContent: [
+          relations.releaseInfo,
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            [
+              sec.extra.additionalFilesShortcut,
+
+              sec.extra.galleryLink && sec.extra.commentaryLink &&
+                language.$('releaseInfo.viewGalleryOrCommentary', {
+                  gallery:
+                    sec.extra.galleryLink
+                      .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.gallery')),
+                  commentary:
+                    sec.extra.commentaryLink
+                      .slot('content', language.$('releaseInfo.viewGalleryOrCommentary.commentary')),
+                }),
+
+              sec.extra.galleryLink && !sec.extra.commentaryLink &&
+                language.$('releaseInfo.viewGallery', {
+                  link:
+                    sec.extra.galleryLink
+                      .slot('content', language.$('releaseInfo.viewGallery.link')),
+                }),
+
+              !sec.extra.galleryLink && sec.extra.commentaryLink &&
+                language.$('releaseInfo.viewCommentary', {
+                  link:
+                    sec.extra.commentaryLink
+                      .slot('content', language.$('releaseInfo.viewCommentary.link')),
+                }),
+            ]),
+
+          relations.trackList,
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            [
+              data.dateAddedToWiki &&
+                language.$('releaseInfo.addedToWiki', {
+                  date: language.formatDate(data.dateAddedToWiki),
+                }),
+            ]),
+
+          sec.additionalFiles && [
+            sec.additionalFiles.heading
+              .slots({
+                id: 'additional-files',
+                title:
+                  language.$('releaseInfo.additionalFiles.heading', {
+                    additionalFiles:
+                      language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
+                  }),
+              }),
+
+            sec.additionalFiles.additionalFilesList,
+          ],
+
+          sec.artistCommentary,
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {
+            auto: 'current',
+            accent:
+              relations.albumNavAccent.slots({
+                showTrackNavigation: true,
+                showExtraLinks: true,
+              }),
+          },
+        ],
+
+        navContent:
+          relations.chronologyLinks.slots({
+            chronologyInfoSets: [
+              {
+                headingString: 'misc.chronology.heading.coverArt',
+                contributions: relations.coverArtistChronologyContributions,
+              },
+            ],
+          }),
+
+        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 0000000..121af43
--- /dev/null
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -0,0 +1,112 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePreviousNextLinks',
+    'linkTrack',
+    'linkAlbumCommentary',
+    'linkAlbumGallery',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, album, track) {
+    const relations = {};
+
+    relations.previousNextLinks =
+      relation('generatePreviousNextLinks');
+
+    relations.previousTrackLink = null;
+    relations.nextTrackLink = null;
+
+    if (track) {
+      const index = album.tracks.indexOf(track);
+
+      if (index > 0) {
+        relations.previousTrackLink =
+          relation('linkTrack', album.tracks[index - 1]);
+      }
+
+      if (index < album.tracks.length - 1) {
+        relations.nextTrackLink =
+          relation('linkTrack', album.tracks[index + 1]);
+      }
+    }
+
+    relations.albumGalleryLink =
+      relation('linkAlbumGallery', album);
+
+    if (album.commentary || album.tracks.some(t => t.commentary)) {
+      relations.albumCommentaryLink =
+        relation('linkAlbumCommentary', album);
+    }
+
+    return relations;
+  },
+
+  data(album, track) {
+    return {
+      hasMultipleTracks: album.tracks.length > 1,
+      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 {content: extraLinks = []} =
+      slots.showExtraLinks &&
+        {content: [
+          (!data.galleryIsStub || slots.currentExtra === 'gallery') &&
+            relations.albumGalleryLink?.slots({
+              attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+              content: language.$('albumPage.nav.gallery'),
+            }),
+
+          relations.albumCommentaryLink?.slots({
+            attributes: {class: slots.currentExtra === 'commentary' && 'current'},
+            content: language.$('albumPage.nav.commentary'),
+          }),
+        ]};
+
+    const {content: previousNextLinks = []} =
+      slots.showTrackNavigation &&
+      data.isTrackPage &&
+      data.hasMultipleTracks &&
+        relations.previousNextLinks.slots({
+          previousLink: relations.previousTrackLink,
+          nextLink: relations.nextTrackLink,
+        });
+
+    const randomLink =
+      slots.showTrackNavigation &&
+      data.hasMultipleTracks &&
+        html.tag('a',
+          {id: 'random-button'},
+          {href: '#', 'data-random': 'track-in-sidebar'},
+
+          (data.isTrackPage
+            ? language.$('trackPage.nav.random')
+            : language.$('albumPage.nav.randomTrack')));
+
+    const allLinks = [
+      ...previousNextLinks,
+      ...extraLinks,
+      randomLink,
+    ].filter(Boolean);
+
+    if (empty(allLinks)) {
+      return html.blank();
+    }
+
+    return `(${language.formatUnitList(allLinks)})`;
+  },
+};
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
new file mode 100644
index 0000000..6fc1375
--- /dev/null
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -0,0 +1,110 @@
+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.coverArtistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.coverArtistContribs);
+
+    relations.wallpaperArtistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs);
+
+    relations.bannerArtistContributionsLine =
+      relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
+
+    if (!empty(album.urls)) {
+      relations.externalLinks =
+        album.urls.map(url =>
+          relation('linkExternal', url));
+    }
+
+    return relations;
+  },
+
+  data(album) {
+    const data = {};
+
+    if (album.date) {
+      data.date = album.date;
+    }
+
+    if (album.coverArtDate && +album.coverArtDate !== +album.date) {
+      data.coverArtDate = album.coverArtDate;
+    }
+
+    data.duration = accumulateSum(album.tracks, track => track.duration);
+    data.durationApproximate = album.tracks.length > 1;
+
+    data.numTracks = album.tracks.length;
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    return html.tags([
+      html.tag('p',
+        {[html.onlyIfContent]: true},
+        {[html.joinChildren]: html.tag('br')},
+
+        [
+          relations.artistContributionsLine
+            .slots({stringKey: 'releaseInfo.by'}),
+
+          relations.coverArtistContributionsLine
+            .slots({stringKey: 'releaseInfo.coverArtBy'}),
+
+          relations.wallpaperArtistContributionsLine
+            .slots({stringKey: 'releaseInfo.wallpaperArtBy'}),
+
+          relations.bannerArtistContributionsLine
+            .slots({stringKey: 'releaseInfo.bannerArtBy'}),
+
+          data.date &&
+            language.$('releaseInfo.released', {
+              date: language.formatDate(data.date),
+            }),
+
+          data.coverArtDate &&
+            language.$('releaseInfo.artReleased', {
+              date: language.formatDate(data.coverArtDate),
+            }),
+
+          data.duration &&
+            language.$('releaseInfo.duration', {
+              duration:
+                language.formatDuration(data.duration, {
+                  approximate: data.durationApproximate,
+                }),
+            }),
+        ]),
+
+      relations.externalLinks &&
+        html.tag('p',
+          language.$('releaseInfo.listenOn', {
+            links:
+              language.formatDisjunctionList(
+                relations.externalLinks
+                  .map(link =>
+                    link.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 0000000..400420b
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSecondaryNav.js
@@ -0,0 +1,168 @@
+import {sortChronologically} from '#sort';
+import {atOffset, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generatePreviousNextLinks',
+    'generateSecondaryNav',
+    'linkAlbumDynamically',
+    'linkGroup',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album) {
+    const query = {};
+
+    query.groups =
+      album.groups;
+
+    if (album.date) {
+      // Sort by latest first. This matches the sorting order used on group
+      // gallery pages, ensuring that previous/next matches moving up/down
+      // the gallery. Note that this makes the index offsets "backwards"
+      // compared to how latest-last chronological lists are accessed.
+      const groupAlbums =
+        query.groups.map(group =>
+          sortChronologically(
+            group.albums.filter(album => album.date),
+            {latestFirst: true}));
+
+      const groupCurrentIndex =
+        groupAlbums.map(albums =>
+          albums.indexOf(album));
+
+      query.groupPreviousAlbum =
+        stitchArrays({
+          albums: groupAlbums,
+          index: groupCurrentIndex,
+        }).map(({albums, index}) =>
+            atOffset(albums, index, +1));
+
+      query.groupNextAlbum =
+        stitchArrays({
+          albums: groupAlbums,
+          index: groupCurrentIndex,
+        }).map(({albums, index}) =>
+            atOffset(albums, index, -1));
+    }
+
+    return query;
+  },
+
+  relations(relation, query, album) {
+    const relations = {};
+
+    relations.secondaryNav =
+      relation('generateSecondaryNav');
+
+    relations.groupLinks =
+      album.groups
+        .map(group => relation('linkGroup', group));
+
+    relations.colorStyles =
+      album.groups
+        .map(group => relation('generateColorStyleAttribute', group.color));
+
+    if (album.date) {
+      relations.previousNextLinks =
+        stitchArrays({
+          previousAlbum: query.groupPreviousAlbum,
+          nextAlbum: query.groupNextAlbum
+        }).map(({previousAlbum, nextAlbum}) =>
+            (previousAlbum || nextAlbum
+              ? relation('generatePreviousNextLinks')
+              : null));
+
+      relations.previousAlbumLinks =
+        query.groupPreviousAlbum.map(previousAlbum =>
+          (previousAlbum
+            ? relation('linkAlbumDynamically', previousAlbum)
+            : null));
+
+      relations.nextAlbumLinks =
+        query.groupNextAlbum.map(nextAlbum =>
+          (nextAlbum
+            ? relation('linkAlbumDynamically', nextAlbum)
+            : null));
+    }
+
+    return relations;
+  },
+
+  slots: {
+    mode: {
+      validate: v => v.is('album', 'track'),
+      default: 'album',
+    },
+  },
+
+  generate(relations, slots, {html, language}) {
+    const navLinksShouldShowPreviousNext =
+      (slots.mode === 'track'
+        ? Array.from(relations.previousNextLinks, () => false)
+        : stitchArrays({
+            previousAlbumLink: relations.previousAlbumLinks ?? null,
+            nextAlbumLink: relations.nextAlbumLinks ?? null,
+          }).map(({previousAlbumLink, nextAlbumLink}) =>
+              previousAlbumLink ||
+              nextAlbumLink));
+
+    const navLinkPreviousNextLinks =
+      stitchArrays({
+        showPreviousNext: navLinksShouldShowPreviousNext,
+        previousNextLinks: relations.previousNextLinks ?? null,
+        previousAlbumLink: relations.previousAlbumLinks ?? null,
+        nextAlbumLink: relations.nextAlbumLinks ?? null,
+      }).map(({
+          showPreviousNext,
+          previousNextLinks,
+          previousAlbumLink,
+          nextAlbumLink,
+        }) =>
+          (showPreviousNext
+            ? previousNextLinks.slots({
+                previousLink: previousAlbumLink,
+                nextLink: nextAlbumLink,
+                id: false,
+              })
+            : null));
+
+    for (const groupLink of relations.groupLinks) {
+      groupLink.setSlot('color', false);
+    }
+
+    const navLinkContents =
+      stitchArrays({
+        groupLink: relations.groupLinks,
+        previousNextLinks: navLinkPreviousNextLinks,
+      }).map(({groupLink, previousNextLinks}) => [
+          language.$('albumSidebar.groupBox.title', {
+            group: groupLink,
+          }),
+
+          previousNextLinks &&
+            `(${language.formatUnitList(previousNextLinks.content)})`,
+        ]);
+
+    const navLinks =
+      stitchArrays({
+        content: navLinkContents,
+        colorStyle: relations.colorStyles,
+      }).map(({content, colorStyle}, index) =>
+          html.tag('span', {class: 'nav-link'},
+            index > 0 &&
+              {class: 'has-divider'},
+
+            colorStyle.slot('context', 'primary-only'),
+
+            content));
+
+    return relations.secondaryNav.slots({
+      class: 'nav-links-groups',
+      content: navLinks,
+    });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
new file mode 100644
index 0000000..355a9a9
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -0,0 +1,47 @@
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarGroupBox',
+    'generateAlbumSidebarTrackListBox',
+    'generatePageSidebar',
+    'generatePageSidebarConjoinedBox',
+  ],
+
+  relations: (relation, album, track) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    conjoinedBox:
+      relation('generatePageSidebarConjoinedBox'),
+
+    trackListBox:
+      relation('generateAlbumSidebarTrackListBox', album, track),
+
+    groupBoxes:
+      album.groups.map(group =>
+        relation('generateAlbumSidebarGroupBox', album, group)),
+  }),
+
+  data: (album, track) => ({
+    isAlbumPage: !track,
+  }),
+
+  generate: (data, relations) =>
+    relations.sidebar.slots({
+      boxes: [
+        data.isAlbumPage &&
+          relations.groupBoxes
+            .map(box => box.slot('mode', 'album')),
+
+        relations.trackListBox,
+
+        !data.isAlbumPage &&
+          relations.conjoinedBox.slots({
+            attributes: {class: 'conjoined-group-sidebar-box'},
+            boxes:
+              relations.groupBoxes
+                .map(box => box.slot('mode', 'track'))
+                .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 0000000..00a96c3
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -0,0 +1,116 @@
+import {sortChronologically} from '#sort';
+import {atOffset, empty} 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}) =>
+    relations.box.slots({
+      attributes: {class: 'individual-group-sidebar-box'},
+      content: [
+        html.tag('h1',
+          language.$('albumSidebar.groupBox.title', {
+            group: relations.groupLink,
+          })),
+
+        slots.mode === 'album' &&
+          relations.description
+            ?.slot('mode', 'multiline'),
+
+        !empty(relations.externalLinks) &&
+          html.tag('p',
+            language.$('releaseInfo.visitOn', {
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'group'))),
+            })),
+
+        slots.mode === 'album' &&
+        relations.nextAlbumLink &&
+          html.tag('p', {class: 'group-chronology-link'},
+            language.$('albumSidebar.groupBox.next', {
+              album: relations.nextAlbumLink,
+            })),
+
+        slots.mode === 'album' &&
+        relations.previousAlbumLink &&
+          html.tag('p', {class: 'group-chronology-link'},
+            language.$('albumSidebar.groupBox.previous', {
+              album: relations.previousAlbumLink,
+            })),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateAlbumSidebarTrackListBox.js b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
new file mode 100644
index 0000000..3a244e3
--- /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 0000000..aa5c723
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -0,0 +1,136 @@
+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;
+    data.isTrackPage = !!track;
+
+    data.name = trackSection.name;
+    data.color = trackSection.color;
+    data.isDefaultTrackSection = trackSection.isDefaultTrackSection;
+
+    data.firstTrackNumber = trackSection.startIndex + 1;
+    data.lastTrackNumber = trackSection.startIndex + trackSection.tracks.length;
+
+    if (track) {
+      const index = trackSection.tracks.indexOf(track);
+      if (index !== -1) {
+        data.includesCurrentTrack = true;
+        data.currentTrackIndex = index;
+      }
+    }
+
+    data.trackDirectories =
+      trackSection.tracks
+        .map(track => track.directory);
+
+    data.tracksAreMissingCommentary =
+      trackSection.tracks
+        .map(track => !track.commentary);
+
+    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 sectionName =
+      html.tag('span', {class: 'group-name'},
+        (data.isDefaultTrackSection
+          ? language.$('albumSidebar.trackList.fallbackSectionName')
+          : data.name));
+
+    let colorStyle;
+    if (data.color) {
+      const {primary} = getColors(data.color);
+      colorStyle = {style: `--primary-color: ${primary}`};
+    }
+
+    const trackListItems =
+      relations.trackLinks.map((trackLink, index) =>
+        html.tag('li',
+          data.includesCurrentTrack &&
+          index === data.currentTrackIndex &&
+            {class: 'current'},
+
+          slots.mode === 'commentary' &&
+          data.tracksAreMissingCommentary[index] &&
+            {class: 'no-commentary'},
+
+          language.$('albumSidebar.trackList.item', {
+            track:
+              (slots.mode === 'commentary' && data.tracksAreMissingCommentary[index]
+                ? trackLink.slots({
+                    linkless: true,
+                  })
+             : slots.anchor
+                ? trackLink.slots({
+                    anchor: true,
+                    hash: data.trackDirectories[index],
+                  })
+                : trackLink),
+          })));
+
+    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',
+            (data.hasTrackNumbers
+              ? language.$('albumSidebar.trackList.group.withRange', {
+                  group: sectionName,
+                  range: `${data.firstTrackNumber}–${data.lastTrackNumber}`
+                })
+              : language.$('albumSidebar.trackList.group', {
+                  group: sectionName,
+                })))),
+
+        (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 0000000..c8b123f
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSocialEmbed.js
@@ -0,0 +1,74 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateSocialEmbed',
+    'generateAlbumSocialEmbedDescription',
+  ],
+
+  extraDependencies: ['absoluteTo', 'language', 'urls'],
+
+  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.directory;
+      data.headingGroupDirectory = firstGroup.directory;
+    }
+
+    data.hasImage = album.hasCoverArt;
+
+    if (data.hasImage) {
+      data.coverArtDirectory = album.directory;
+      data.coverArtFileExtension = album.coverArtFileExtension;
+    }
+
+    data.albumName = album.name;
+
+    return data;
+  },
+
+  generate(data, relations, {absoluteTo, language, urls}) {
+    return relations.socialEmbed.slots({
+      title:
+        language.$('albumPage.socialEmbed.title', {
+          album: data.albumName,
+        }),
+
+      description: relations.description,
+
+      headingContent:
+        (data.hasHeading
+          ? language.$('albumPage.socialEmbed.heading', {
+              group: data.headingGroupName,
+            })
+          : null),
+
+      headingLink:
+        (data.hasHeading
+          ? absoluteTo('localized.groupGallery', data.headingGroupDirectory)
+          : null),
+
+      imagePath:
+        (data.hasImage
+          ? '/' +
+            urls
+              .from('shared.root')
+              .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension)
+          : null),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateAlbumSocialEmbedDescription.js b/src/content/dependencies/generateAlbumSocialEmbedDescription.js
new file mode 100644
index 0000000..7099616
--- /dev/null
+++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js
@@ -0,0 +1,48 @@
+import {accumulateSum} from '#sugar';
+
+export default {
+  extraDependencies: ['language'],
+
+  data(album) {
+    const data = {};
+
+    const duration = accumulateSum(album.tracks, track => track.duration);
+
+    data.hasDuration = duration > 0;
+    data.hasTracks = album.tracks.length > 0;
+    data.hasDate = !!album.date;
+    data.hasAny = (data.hasDuration || data.hasTracks || data.hasDuration);
+
+    if (!data.hasAny)
+      return data;
+
+    if (data.hasDuration)
+      data.duration = duration;
+
+    if (data.hasTracks)
+      data.tracks = album.tracks.length;
+
+    if (data.hasDate)
+      data.date = album.date;
+
+    return data;
+  },
+
+  generate(data, {language}) {
+    return language.formatString(
+      'albumPage.socialEmbed.body' + [
+        data.hasDuration && '.withDuration',
+        data.hasTracks && '.withTracks',
+        data.hasDate && '.withReleaseDate',
+      ].filter(Boolean).join(''),
+
+      Object.fromEntries([
+        data.hasDuration &&
+          ['duration', language.formatDuration(data.duration)],
+        data.hasTracks &&
+          ['tracks', language.countTracks(data.tracks, {unit: true})],
+        data.hasDate &&
+          ['date', language.formatDate(data.date)],
+      ].filter(Boolean)));
+  },
+};
diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js
new file mode 100644
index 0000000..c5acf37
--- /dev/null
+++ b/src/content/dependencies/generateAlbumStyleRules.js
@@ -0,0 +1,72 @@
+import {empty} from '#sugar';
+
+export default {
+  extraDependencies: ['to'],
+
+  data(album, track) {
+    const data = {};
+
+    data.hasWallpaper = !empty(album.wallpaperArtistContribs);
+    data.hasBanner = !empty(album.bannerArtistContribs);
+
+    if (data.hasWallpaper) {
+      data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension];
+      data.wallpaperStyle = album.wallpaperStyle;
+    }
+
+    if (data.hasBanner) {
+      data.hasBannerStyle = !!album.bannerStyle;
+      data.bannerStyle = album.bannerStyle;
+    }
+
+    data.albumDirectory = album.directory;
+
+    if (track) {
+      data.trackDirectory = track.directory;
+    }
+
+    return data;
+  },
+
+  generate(data, {to}) {
+    const indent = parts =>
+      (parts ?? [])
+        .filter(Boolean)
+        .join('\n')
+        .split('\n')
+        .map(line => ' '.repeat(4) + line)
+        .join('\n');
+
+    const rule = (selector, parts) =>
+      (!empty(parts.filter(Boolean))
+        ? [`${selector} {`, indent(parts), `}`]
+        : []);
+
+    const wallpaperRule =
+      data.hasWallpaper &&
+        rule(`body::before`, [
+          `background-image: url("${to(...data.wallpaperPath)}");`,
+          data.wallpaperStyle,
+        ]);
+
+    const bannerRule =
+      data.hasBanner &&
+        rule(`#banner img`, [
+          data.bannerStyle,
+        ]);
+
+    const dataRule =
+      rule(`:root`, [
+        data.albumDirectory &&
+          `--album-directory: ${data.albumDirectory};`,
+        data.trackDirectory &&
+          `--track-directory: ${data.trackDirectory};`,
+      ]);
+
+    return (
+      [wallpaperRule, bannerRule, dataRule]
+        .filter(Boolean)
+        .flat()
+        .join('\n'));
+  },
+};
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
new file mode 100644
index 0000000..ee06b9e
--- /dev/null
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -0,0 +1,181 @@
+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'],
+  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.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.trackSectionStartIndices =
+            album.trackSections
+              .map(section => section.startIndex);
+        } else {
+          data.trackSectionStartIndices =
+            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,
+            items: relations.trackSectionItems,
+
+            name: data.trackSectionNames,
+            duration: data.trackSectionDurations,
+            durationApproximate: data.trackSectionDurationsApproximate,
+            startIndex: data.trackSectionStartIndices,
+          }).map(({
+              heading,
+              items,
+
+              name,
+              duration,
+              durationApproximate,
+              startIndex,
+            }) => [
+              heading.slots({
+                tag: 'dt',
+                title:
+                  (duration === 0
+                    ? language.$('trackList.section', {
+                        section: name,
+                      })
+                    : language.$('trackList.section.withDuration', {
+                        section: name,
+                        duration:
+                          language.formatDuration(duration, {
+                            approximate: durationApproximate,
+                          }),
+                      })),
+              }),
+
+              html.tag('dd',
+                html.tag(listTag,
+                  data.hasTrackNumbers &&
+                    {start: startIndex + 1},
+
+                  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 0000000..1898074
--- /dev/null
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -0,0 +1,133 @@
+import {compareArrays, empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumTrackListMissingDuration',
+    'linkContribution',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['getColors', 'html', 'language'],
+
+  query(track, album) {
+    const query = {};
+
+    query.duration = track.duration ?? 0;
+
+    query.trackHasDuration = !!track.duration;
+
+    query.sectionHasDuration =
+      !album.trackSections
+        .some(section =>
+          section.tracks.every(track => !track.duration) &&
+          section.tracks.includes(track));
+
+    query.albumHasDuration =
+      album.tracks.some(track => track.duration);
+
+    return query;
+  },
+
+  relations(relation, query, track) {
+    const relations = {};
+
+    if (!empty(track.artistContribs)) {
+      relations.contributionLinks =
+        track.artistContribs
+          .map(contrib => relation('linkContribution', contrib));
+    }
+
+    relations.trackLink =
+      relation('linkTrack', track);
+
+    if (!query.trackHasDuration) {
+      relations.missingDuration =
+        relation('generateAlbumTrackListMissingDuration');
+    }
+
+    return relations;
+  },
+
+  data(query, track, album) {
+    const data = {};
+
+    data.duration = query.duration;
+    data.trackHasDuration = query.trackHasDuration;
+    data.sectionHasDuration = query.sectionHasDuration;
+    data.albumHasDuration = query.albumHasDuration;
+
+    if (track.color !== album.color) {
+      data.color = track.color;
+    }
+
+    data.showArtists =
+      !empty(track.artistContribs) &&
+       (empty(album.artistContribs) ||
+        !compareArrays(
+          track.artistContribs.map(c => c.who),
+          album.artistContribs.map(c => c.who),
+          {checkOrder: false}));
+
+    return data;
+  },
+
+  slots: {
+    collapseDurationScope: {
+      validate: v =>
+        v.is('never', 'track', 'section', 'album'),
+
+      default: 'album',
+    },
+  },
+
+  generate(data, relations, slots, {getColors, html, language}) {
+    let colorStyle;
+    if (data.color) {
+      const {primary} = getColors(data.color);
+      colorStyle = {style: `--primary-color: ${primary}`};
+    }
+
+    const parts = ['trackList.item'];
+    const options = {};
+
+    options.track =
+      relations.trackLink
+        .slot('color', false);
+
+    const collapseDuration =
+      (slots.collapseDurationScope === 'track'
+        ? !data.trackHasDuration
+     : slots.collapseDurationScope === 'section'
+        ? !data.sectionHasDuration
+     : slots.collapseDurationScope === 'album'
+        ? !data.albumHasDuration
+        : false);
+
+    if (!collapseDuration) {
+      parts.push('withDuration');
+
+      options.duration =
+        (data.trackHasDuration
+          ? language.$('trackList.item.withDuration.duration', {
+              duration:
+                language.formatDuration(data.duration),
+            })
+          : relations.missingDuration);
+    }
+
+    if (data.showArtists) {
+      parts.push('withArtists');
+      options.by =
+        html.tag('span', {class: 'by'},
+          html.metatag('chunkwrap', {split: ','},
+            html.resolve(
+              language.$('trackList.item.withArtists.by', {
+                artists: language.formatConjunctionList(relations.contributionLinks),
+              }))));
+    }
+
+    return html.tag('li',
+      colorStyle,
+      language.formatString(...parts, options));
+  },
+};
diff --git a/src/content/dependencies/generateAlbumTrackListMissingDuration.js b/src/content/dependencies/generateAlbumTrackListMissingDuration.js
new file mode 100644
index 0000000..6d4a6ec
--- /dev/null
+++ b/src/content/dependencies/generateAlbumTrackListMissingDuration.js
@@ -0,0 +1,33 @@
+export default {
+  contentDependencies: ['generateTextWithTooltip', 'generateTooltip'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    textWithTooltip:
+      relation('generateTextWithTooltip'),
+
+    tooltip:
+      relation('generateTooltip'),
+  }),
+
+  generate: (relations, {html, language}) =>
+    relations.textWithTooltip.slots({
+      attributes: {class: 'missing-duration'},
+      customInteractionCue: true,
+
+      text:
+        language.$('trackList.item.withDuration.duration', {
+          duration:
+            html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
+              language.$('trackList.item.withDuration.duration.missing')),
+        }),
+
+      tooltip:
+        relations.tooltip.slots({
+          attributes: {class: 'missing-duration-tooltip'},
+
+          content:
+            language.$('trackList.item.withDuration.duration.missing.info'),
+        }),
+    }),
+};
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
new file mode 100644
index 0000000..338d18f
--- /dev/null
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -0,0 +1,153 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkAlbum',
+    'linkArtTag',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      enableListings: wikiInfo.enableListings,
+    };
+  },
+
+  query(sprawl, tag) {
+    const things = tag.taggedInThings.slice();
+
+    sortAlbumsTracksChronologically(things, {
+      getDate: thing => thing.coverArtDate ?? thing.date,
+      latestFirst: true,
+    });
+
+    return {things};
+  },
+
+  relations(relation, query, sprawl, tag) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.artTagMainLink =
+      relation('linkArtTag', tag);
+
+    relations.coverGrid =
+      relation('generateCoverGrid');
+
+    relations.links =
+      query.things.map(thing =>
+        (thing.album
+          ? relation('linkTrack', thing)
+          : relation('linkAlbum', thing)));
+
+    relations.images =
+      query.things.map(thing =>
+        relation('image', thing.artTags));
+
+    return relations;
+  },
+
+  data(query, sprawl, tag) {
+    const data = {};
+
+    data.enableListings = sprawl.enableListings;
+
+    data.name = tag.name;
+    data.color = tag.color;
+
+    data.numArtworks = query.things.length;
+
+    data.names =
+      query.things.map(thing => thing.name);
+
+    data.paths =
+      query.things.map(thing =>
+        (thing.album
+          ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
+          : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
+
+    data.dimensions =
+      query.things.map(thing => thing.coverArtDimensions);
+
+    data.coverArtists =
+      query.things.map(thing =>
+        thing.coverArtistContribs
+          .map(({who: artist}) => artist.name));
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.layout
+      .slots({
+        title:
+          language.$('tagPage.title', {
+            tag: data.name,
+          }),
+
+        headingMode: 'static',
+
+        color: data.color,
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          html.tag('p', {class: 'quick-info'},
+            language.$('tagPage.infoLine', {
+              coverArts: language.countCoverArts(data.numArtworks, {
+                unit: true,
+              }),
+            })),
+
+          relations.coverGrid
+            .slots({
+              links: relations.links,
+              names: data.names,
+              images:
+                stitchArrays({
+                  image: relations.images,
+                  path: data.paths,
+                  dimensions: data.dimensions,
+                }).map(({image, path, dimensions}) =>
+                    image.slots({
+                      path,
+                      dimensions,
+                    })),
+
+              info:
+                data.coverArtists.map(names =>
+                  (names === null
+                    ? null
+                    : language.$('misc.albumGrid.details.coverArtists', {
+                        artists: language.formatUnitList(names),
+                      }))),
+            }),
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+
+          data.enableListings &&
+            {
+              path: ['localized.listingIndex'],
+              title: language.$('listingIndex.title'),
+            },
+
+          {
+            html:
+              language.$('tagPage.nav.tag', {
+                tag: relations.artTagMainLink,
+              }),
+          },
+        ],
+      });
+  },
+};
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
new file mode 100644
index 0000000..36343c1
--- /dev/null
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -0,0 +1,140 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistNavLinks',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'image',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(artist) {
+    const things = [
+      ...artist.albumsAsCoverArtist,
+      ...artist.tracksAsCoverArtist,
+    ];
+
+    sortAlbumsTracksChronologically(things, {
+      latestFirst: true,
+      getDate: thing => thing.coverArtDate ?? thing.date,
+    });
+
+    return {things};
+  },
+
+  relations(relation, query, artist) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.artistNavLinks =
+      relation('generateArtistNavLinks', artist);
+
+    relations.coverGrid =
+      relation('generateCoverGrid');
+
+    relations.links =
+      query.things.map(thing =>
+        (thing.album
+          ? relation('linkTrack', thing)
+          : relation('linkAlbum', thing)));
+
+    relations.images =
+      query.things.map(thing =>
+        relation('image', thing.artTags));
+
+    return relations;
+  },
+
+  data(query, artist) {
+    const data = {};
+
+    data.name = artist.name;
+
+    data.numArtworks = query.things.length;
+
+    data.names =
+      query.things.map(thing => thing.name);
+
+    data.paths =
+      query.things.map(thing =>
+        (thing.album
+          ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
+          : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
+
+    data.dimensions =
+      query.things.map(thing => thing.coverArtDimensions);
+
+    data.otherCoverArtists =
+      query.things.map(thing =>
+        (thing.coverArtistContribs.length > 1
+          ? thing.coverArtistContribs
+              .filter(({who}) => who !== artist)
+              .map(({who}) => who.name)
+          : null));
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.layout
+      .slots({
+        title:
+          language.$('artistGalleryPage.title', {
+            artist: data.name,
+          }),
+
+        headingMode: 'static',
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          html.tag('p', {class: 'quick-info'},
+            language.$('artistGalleryPage.infoLine', {
+              coverArts: language.countCoverArts(data.numArtworks, {
+                unit: true,
+              }),
+            })),
+
+          relations.coverGrid
+            .slots({
+              links: relations.links,
+              names: data.names,
+
+              images:
+                stitchArrays({
+                  image: relations.images,
+                  path: data.paths,
+                  dimensions: data.dimensions,
+                }).map(({image, path, dimensions}) =>
+                    image.slots({
+                      path,
+                      dimensions,
+                    })),
+
+              info:
+                data.otherCoverArtists.map(names =>
+                  (names === null
+                    ? null
+                    : language.$('misc.albumGrid.details.otherCoverArtists', {
+                        artists: language.formatUnitList(names),
+                      }))),
+            }),
+        ],
+
+        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 0000000..1725d4b
--- /dev/null
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -0,0 +1,224 @@
+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.originalReleaseTrack === 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}) {
+    if (slots.sort === 'count' && empty(relations.groupLinksSortedByCount)) {
+      return html.blank();
+    } else if (slots.sort === 'duration' && empty(relations.groupLinksSortedByDuration)) {
+      return html.blank();
+    }
+
+    const getCounts = counts =>
+      counts.map(count => {
+        switch (slots.countUnit) {
+          case 'tracks': return language.countTracks(count, {unit: true});
+          case 'artworks': return language.countArtworks(count, {unit: true});
+        }
+      });
+
+    // We aren't displaying the "~" approximate symbol here for now.
+    // The general notion that these sums aren't going to be 100% accurate
+    // is made clear by the "XYZ has contributed ~1:23:45 hours of music..."
+    // line that's always displayed above this table.
+    const getDurations = (durations, approximate) =>
+      stitchArrays({
+        duration: durations,
+        approximate: approximate,
+      }).map(({duration}) => language.formatDuration(duration));
+
+    const topLevelClasses = [
+      'group-contributions-sorted-by-' + slots.sort,
+      slots.visible && 'visible',
+    ];
+
+    return html.tags([
+      html.tag('dt', {class: topLevelClasses},
+        (slots.showSortButton
+          ? language.$('artistPage.groupContributions.title.withSortButton', {
+              title: slots.title,
+              sort:
+                html.tag('a', {class: 'group-contributions-sort-button'},
+                  {href: '#'},
+
+                  (slots.sort === 'count'
+                    ? language.$('artistPage.groupContributions.title.sorting.count')
+                    : language.$('artistPage.groupContributions.title.sorting.duration'))),
+            })
+          : slots.title)),
+
+      html.tag('dd', {class: topLevelClasses},
+        html.tag('ul', {class: 'group-contributions-table'},
+          {role: 'list'},
+
+          (slots.sort === 'count'
+            ? stitchArrays({
+                group: relations.groupLinksSortedByCount,
+                count: getCounts(data.groupCountsSortedByCount),
+                duration:
+                  getDurations(
+                    data.groupDurationsSortedByCount,
+                    data.groupDurationsApproximateSortedByCount),
+              }).map(({group, count, duration}) =>
+                  html.tag('li',
+                    html.tag('div', {class: 'group-contributions-row'}, [
+                      group,
+                      html.tag('span', {class: 'group-contributions-metrics'},
+                        // When sorting by count, duration details aren't necessarily
+                        // available for all items.
+                        (slots.showBothColumns && duration
+                          ? language.$('artistPage.groupContributions.item.countDurationAccent', {count, duration})
+                          : language.$('artistPage.groupContributions.item.countAccent', {count}))),
+                    ])))
+
+            : stitchArrays({
+                group: relations.groupLinksSortedByDuration,
+                count: getCounts(data.groupCountsSortedByDuration),
+                duration:
+                  getDurations(
+                    data.groupDurationsSortedByDuration,
+                    data.groupDurationsApproximateSortedByDuration),
+              }).map(({group, count, duration}) =>
+                  html.tag('li',
+                    html.tag('div', {class: 'group-contributions-row'}, [
+                      group,
+                      html.tag('span', {class: 'group-contributions-metrics'},
+                        // Count details are always available, since they're just the
+                        // number of contributions directly. And duration details are
+                        // guaranteed for every item when sorting by duration.
+                        (slots.showBothColumns
+                          ? language.$('artistPage.groupContributions.item.durationCountAccent', {duration, count})
+                          : language.$('artistPage.groupContributions.item.durationAccent', {duration}))),
+                    ])))))),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
new file mode 100644
index 0000000..ac9209a
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -0,0 +1,311 @@
+import {empty, unique} from '#sugar';
+import {getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateArtistGroupContributionsInfo',
+    'generateArtistInfoPageArtworksChunkedList',
+    'generateArtistInfoPageCommentaryChunkedList',
+    'generateArtistInfoPageFlashesChunkedList',
+    'generateArtistInfoPageTracksChunkedList',
+    'generateArtistNavLinks',
+    'generateContentHeading',
+    'generateCoverArtwork',
+    'generatePageLayout',
+    'linkAlbum',
+    'linkArtistGallery',
+    'linkExternal',
+    'linkGroup',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
+    };
+  },
+
+  query(sprawl, artist) {
+    return {
+      // Even if an artist has served as both "artist" (compositional) and
+      // "contributor" (instruments, production, etc) on the same track, that
+      // track only counts as one unique contribution.
+      allTracks:
+        unique([...artist.tracksAsArtist, ...artist.tracksAsContributor]),
+
+      // Artworks are different, though. We intentionally duplicate album data
+      // objects when the artist has contributed some combination of cover art,
+      // wallpaper, and banner - these each count as a unique contribution.
+      allArtworks: [
+        ...artist.albumsAsCoverArtist,
+        ...artist.albumsAsWallpaperArtist,
+        ...artist.albumsAsBannerArtist,
+        ...artist.tracksAsCoverArtist,
+      ],
+
+      // Banners and wallpapers don't show up in the artist gallery page, only
+      // cover art.
+      hasGallery:
+        !empty(artist.albumsAsCoverArtist) ||
+        !empty(artist.tracksAsCoverArtist),
+    };
+  },
+
+  relations(relation, query, sprawl, artist) {
+    const relations = {};
+    const sections = relations.sections = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.artistNavLinks =
+      relation('generateArtistNavLinks', artist);
+
+    if (artist.hasAvatar) {
+      relations.cover =
+        relation('generateCoverArtwork', []);
+    }
+
+    if (artist.contextNotes) {
+      const contextNotes = sections.contextNotes = {};
+      contextNotes.content = relation('transformContent', artist.contextNotes);
+    }
+
+    if (!empty(artist.urls)) {
+      const visit = sections.visit = {};
+      visit.externalLinks =
+        artist.urls.map(url =>
+          relation('linkExternal', url));
+    }
+
+    if (!empty(query.allTracks)) {
+      const tracks = sections.tracks = {};
+      tracks.heading = relation('generateContentHeading');
+      tracks.list = relation('generateArtistInfoPageTracksChunkedList', artist);
+      tracks.groupInfo = relation('generateArtistGroupContributionsInfo', query.allTracks);
+    }
+
+    if (!empty(query.allArtworks)) {
+      const artworks = sections.artworks = {};
+      artworks.heading = relation('generateContentHeading');
+      artworks.list = relation('generateArtistInfoPageArtworksChunkedList', artist);
+      artworks.groupInfo =
+        relation('generateArtistGroupContributionsInfo', query.allArtworks);
+
+      if (query.hasGallery) {
+        artworks.artistGalleryLink =
+          relation('linkArtistGallery', artist);
+      }
+    }
+
+    if (sprawl.enableFlashesAndGames && !empty(artist.flashesAsContributor)) {
+      const flashes = sections.flashes = {};
+      flashes.heading = relation('generateContentHeading');
+      flashes.list = relation('generateArtistInfoPageFlashesChunkedList', artist);
+    }
+
+    if (!empty(artist.albumsAsCommentator) || !empty(artist.tracksAsCommentator)) {
+      const commentary = sections.commentary = {};
+      commentary.heading = relation('generateContentHeading');
+      commentary.list = relation('generateArtistInfoPageCommentaryChunkedList', artist);
+    }
+
+    return relations;
+  },
+
+  data(query, sprawl, artist) {
+    const data = {};
+
+    data.name = artist.name;
+    data.directory = artist.directory;
+
+    if (artist.hasAvatar) {
+      data.avatarFileExtension = artist.avatarFileExtension;
+    }
+
+    data.totalTrackCount = query.allTracks.length;
+    data.totalDuration = getTotalDuration(query.allTracks, {originalReleasesOnly: true});
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    const {sections: sec} = relations;
+
+    return relations.layout
+      .slots({
+        title: data.name,
+        headingMode: 'sticky',
+
+        cover:
+          (relations.cover
+            ? relations.cover.slots({
+                path: [
+                  'media.artistAvatar',
+                  data.directory,
+                  data.avatarFileExtension,
+                ],
+              })
+            : null),
+
+        mainContent: [
+          sec.contextNotes && [
+            html.tag('p', language.$('releaseInfo.note')),
+            html.tag('blockquote',
+              sec.contextNotes.content),
+          ],
+
+          sec.visit &&
+            html.tag('p',
+              language.$('releaseInfo.visitOn', {
+                links:
+                  language.formatDisjunctionList(
+                    sec.visit.externalLinks
+                      .map(link => link.slot('context', 'artist'))),
+              })),
+
+          sec.artworks?.artistGalleryLink &&
+            html.tag('p',
+              language.$('artistPage.viewArtGallery', {
+                link: sec.artworks.artistGalleryLink.slots({
+                  content: language.$('artistPage.viewArtGallery.link'),
+                }),
+              })),
+
+          (sec.tracks || sec.artworsk || sec.flashes || sec.commentary) &&
+            html.tag('p',
+              language.$('misc.jumpTo.withLinks', {
+                links: language.formatUnitList(
+                  [
+                    sec.tracks &&
+                      html.tag('a',
+                        {href: '#tracks'},
+                        language.$('artistPage.trackList.title')),
+
+                    sec.artworks &&
+                      html.tag('a',
+                        {href: '#art'},
+                        language.$('artistPage.artList.title')),
+
+                    sec.flashes &&
+                      html.tag('a',
+                        {href: '#flashes'},
+                        language.$('artistPage.flashList.title')),
+
+                    sec.commentary &&
+                      html.tag('a',
+                        {href: '#commentary'},
+                        language.$('artistPage.commentaryList.title')),
+                  ].filter(Boolean)),
+              })),
+
+          sec.tracks && [
+            sec.tracks.heading
+              .slots({
+                tag: 'h2',
+                id: 'tracks',
+                title: language.$('artistPage.trackList.title'),
+              }),
+
+            data.totalDuration > 0 &&
+              html.tag('p',
+                language.$('artistPage.contributedDurationLine', {
+                  artist: data.name,
+                  duration:
+                    language.formatDuration(data.totalDuration, {
+                      approximate: data.totalTrackCount > 1,
+                      unit: true,
+                    }),
+                })),
+
+            sec.tracks.list
+              .slots({
+                groupInfo: [
+                  sec.tracks.groupInfo
+                    .clone()
+                    .slots({
+                      title: language.$('artistPage.groupContributions.title.music'),
+                      showSortButton: true,
+                      sort: 'count',
+                      countUnit: 'tracks',
+                      visible: true,
+                    }),
+
+                  sec.tracks.groupInfo
+                    .clone()
+                    .slots({
+                      title: language.$('artistPage.groupContributions.title.music'),
+                      showSortButton: true,
+                      sort: 'duration',
+                      countUnit: 'tracks',
+                      visible: false,
+                    }),
+                ],
+              }),
+          ],
+
+          sec.artworks && [
+            sec.artworks.heading
+              .slots({
+                tag: 'h2',
+                id: 'art',
+                title: language.$('artistPage.artList.title'),
+              }),
+
+            sec.artworks.artistGalleryLink &&
+              html.tag('p',
+                language.$('artistPage.viewArtGallery.orBrowseList', {
+                  link: sec.artworks.artistGalleryLink.slots({
+                    content: language.$('artistPage.viewArtGallery.link'),
+                  }),
+                })),
+
+            sec.artworks.list
+              .slots({
+                groupInfo:
+                  sec.artworks.groupInfo
+                    .slots({
+                      title: language.$('artistPage.groupContributions.title.artworks'),
+                      showBothColumns: false,
+                      sort: 'count',
+                      countUnit: 'artworks',
+                    }),
+              }),
+          ],
+
+          sec.flashes && [
+            sec.flashes.heading
+              .slots({
+                tag: 'h2',
+                id: 'flashes',
+                title: language.$('artistPage.flashList.title'),
+              }),
+
+            sec.flashes.list,
+          ],
+
+          sec.commentary && [
+            sec.commentary.heading
+              .slots({
+                tag: 'h2',
+                id: 'commentary',
+                title: language.$('artistPage.commentaryList.title'),
+              }),
+
+            sec.commentary.list,
+          ],
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks:
+          relations.artistNavLinks
+            .slots({
+              showExtraLinks: true,
+            })
+            .content,
+      });
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
new file mode 100644
index 0000000..0beeb27
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -0,0 +1,241 @@
+import {sortAlbumsTracksChronologically, sortEntryThingPairs} from '#sort';
+import {chunkByProperties, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageChunkedList',
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageOtherArtistLinks',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(artist) {
+    // TODO: Add and integrate wallpaper and banner date fields (#90)
+    // This will probably only happen once all artworks follow a standard
+    // shape (#70) and get their own sorting function. Read for more info:
+    // https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607422961
+
+    const processEntry = ({thing, type, track, album, contribs}) => ({
+      thing: thing,
+      entry: {
+        type: type,
+        track: track,
+        album: album,
+        contribs: contribs,
+        date: thing.coverArtDate ?? thing.date,
+      },
+    });
+
+    const processAlbumEntry = ({type, album, contribs}) =>
+      processEntry({
+        thing: album,
+        type: type,
+        track: null,
+        album: album,
+        contribs: contribs,
+      });
+
+    const processTrackEntry = ({type, track, contribs}) =>
+      processEntry({
+        thing: track,
+        type: type,
+        track: track,
+        album: track.album,
+        contribs: contribs,
+      });
+
+    const processAlbumEntries = ({type, albums, contribs}) =>
+      stitchArrays({
+        album: albums,
+        contribs: contribs,
+      }).map(entry =>
+          processAlbumEntry({type, ...entry}));
+
+    const processTrackEntries = ({type, tracks, contribs}) =>
+      stitchArrays({
+        track: tracks,
+        contribs: contribs,
+      }).map(entry =>
+          processTrackEntry({type, ...entry}));
+
+    const {
+      albumsAsCoverArtist,
+      albumsAsWallpaperArtist,
+      albumsAsBannerArtist,
+      tracksAsCoverArtist,
+    } = artist;
+
+    const albumsAsCoverArtistContribs =
+      albumsAsCoverArtist
+        .map(album => album.coverArtistContribs);
+
+    const albumsAsWallpaperArtistContribs =
+      albumsAsWallpaperArtist
+        .map(album => album.wallpaperArtistContribs);
+
+    const albumsAsBannerArtistContribs =
+      albumsAsBannerArtist
+        .map(album => album.bannerArtistContribs);
+
+    const tracksAsCoverArtistContribs =
+      tracksAsCoverArtist
+        .map(track => track.coverArtistContribs);
+
+    const albumsAsCoverArtistEntries =
+      processAlbumEntries({
+        type: 'albumCover',
+        albums: albumsAsCoverArtist,
+        contribs: albumsAsCoverArtistContribs,
+      });
+
+    const albumsAsWallpaperArtistEntries =
+      processAlbumEntries({
+        type: 'albumWallpaper',
+        albums: albumsAsWallpaperArtist,
+        contribs: albumsAsWallpaperArtistContribs,
+      });
+
+    const albumsAsBannerArtistEntries =
+      processAlbumEntries({
+        type: 'albumBanner',
+        albums: albumsAsBannerArtist,
+        contribs: albumsAsBannerArtistContribs,
+      });
+
+    const tracksAsCoverArtistEntries =
+      processTrackEntries({
+        type: 'trackCover',
+        tracks: tracksAsCoverArtist,
+        contribs: tracksAsCoverArtistContribs,
+      });
+
+    const entries = [
+      ...albumsAsCoverArtistEntries,
+      ...albumsAsWallpaperArtistEntries,
+      ...albumsAsBannerArtistEntries,
+      ...tracksAsCoverArtistEntries,
+    ];
+
+    sortEntryThingPairs(entries,
+      things => sortAlbumsTracksChronologically(things, {
+        getDate: thing => thing.coverArtDate ?? thing.date,
+      }));
+
+    const chunks =
+      chunkByProperties(
+        entries.map(({entry}) => entry),
+        ['album', 'date']);
+
+    return {chunks};
+  },
+
+  relations(relation, query, artist) {
+    return {
+      chunkedList:
+        relation('generateArtistInfoPageChunkedList'),
+
+      chunks:
+        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
+
+      albumLinks:
+        query.chunks.map(({album}) => relation('linkAlbum', album)),
+
+      items:
+        query.chunks.map(({chunk}) =>
+          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+
+      itemTrackLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({track}) => track ? relation('linkTrack', track) : null)),
+
+      itemOtherArtistLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))),
+    };
+  },
+
+  data(query, artist) {
+    return {
+      chunkDates:
+        query.chunks.map(({date}) => date),
+
+      itemTypes:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({type}) => type)),
+
+      itemContributions:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({contribs}) =>
+            contribs
+              .find(({who}) => who === artist)
+              .what)),
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.chunkedList.slots({
+      chunks:
+        stitchArrays({
+          chunk: relations.chunks,
+          albumLink: relations.albumLinks,
+          date: data.chunkDates,
+
+          items: relations.items,
+          itemTrackLinks: relations.itemTrackLinks,
+          itemOtherArtistLinks: relations.itemOtherArtistLinks,
+          itemTypes: data.itemTypes,
+          itemContributions: data.itemContributions,
+        }).map(({
+            chunk,
+            albumLink,
+            date,
+
+            items,
+            itemTrackLinks,
+            itemOtherArtistLinks,
+            itemTypes,
+            itemContributions,
+          }) =>
+            chunk.slots({
+              mode: 'album',
+              albumLink,
+              date,
+
+              items:
+                stitchArrays({
+                  item: items,
+                  trackLink: itemTrackLinks,
+                  otherArtistLinks: itemOtherArtistLinks,
+                  type: itemTypes,
+                  contribution: itemContributions,
+                }).map(({
+                    item,
+                    trackLink,
+                    otherArtistLinks,
+                    type,
+                    contribution,
+                  }) =>
+                    item.slots({
+                      otherArtistLinks,
+                      annotation: contribution,
+
+                      content:
+                        (type === 'trackCover'
+                          ? language.$('artistPage.creditList.entry.track', {
+                              track: trackLink,
+                            })
+                          : html.tag('i',
+                              language.$('artistPage.creditList.entry.album.' + {
+                                albumWallpaper: 'wallpaperArt',
+                                albumBanner: 'bannerArt',
+                                albumCover: 'coverArt',
+                              }[type]))),
+                    })),
+            })),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js
new file mode 100644
index 0000000..4094391
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageChunk.js
@@ -0,0 +1,91 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    mode: {
+      validate: v => v.is('flash', 'album'),
+    },
+
+    albumLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    flashActLink: {
+      type: 'html',
+      mutable: false,
+    },
+
+    items: {
+      type: 'html',
+      mutable: false,
+    },
+
+    date: {validate: v => v.isDate},
+    dateRangeStart: {validate: v => v.isDate},
+    dateRangeEnd: {validate: v => v.isDate},
+
+    duration: {validate: v => v.isDuration},
+    durationApproximate: {type: 'boolean'},
+  },
+
+  generate(slots, {html, language}) {
+    let accentedLink;
+
+    accent: {
+      switch (slots.mode) {
+        case 'album': {
+          accentedLink = slots.albumLink;
+
+          const options = {album: accentedLink};
+          const parts = ['artistPage.creditList.album'];
+
+          if (slots.date) {
+            parts.push('withDate');
+            options.date = language.formatDate(slots.date);
+          }
+
+          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 (
+            slots.dateRangeStart &&
+            slots.dateRangeEnd &&
+            slots.dateRangeStart !== slots.dateRangeEnd
+          ) {
+            parts.push('withDateRange');
+            options.dateRange = language.formatDateRange(slots.dateRangeStart, slots.dateRangeEnd);
+          } else if (slots.dateRangeStart || slots.date) {
+            parts.push('withDate');
+            options.date = language.formatDate(slots.dateRangeStart ?? slots.date);
+          }
+
+          accentedLink = language.formatString(...parts, options);
+          break;
+        }
+      }
+    }
+
+    return html.tags([
+      html.tag('dt', accentedLink),
+      html.tag('dd',
+        html.tag('ul',
+          slots.items)),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js
new file mode 100644
index 0000000..b6f4072
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js
@@ -0,0 +1,60 @@
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    annotation: {
+      type: 'html',
+      mutable: false,
+    },
+
+    otherArtistLinks: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    rerelease: {type: 'boolean'},
+  },
+
+  generate(slots, {html, language}) {
+    let accentedContent = slots.content;
+
+    accent: {
+      if (slots.rerelease) {
+        accentedContent =
+          language.$('artistPage.creditList.entry.rerelease', {
+            entry: accentedContent,
+          });
+
+        break accent;
+      }
+
+      const parts = ['artistPage.creditList.entry'];
+      const options = {entry: accentedContent};
+
+      if (slots.otherArtistLinks) {
+        parts.push('withArtists');
+        options.artists = language.formatConjunctionList(slots.otherArtistLinks);
+      }
+
+      if (!html.isBlank(slots.annotation)) {
+        parts.push('withAnnotation');
+        options.annotation = slots.annotation;
+      }
+
+      if (parts.length === 1) {
+        break accent;
+      }
+
+      accentedContent = language.formatString(...parts, options);
+    }
+
+    return (
+      html.tag('li',
+        slots.rerelease && {class: 'rerelease'},
+        accentedContent));
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageChunkedList.js b/src/content/dependencies/generateArtistInfoPageChunkedList.js
new file mode 100644
index 0000000..8503d01
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageChunkedList.js
@@ -0,0 +1,23 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    groupInfo: {
+      type: 'html',
+      mutable: false,
+    },
+
+    chunks: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate(slots, {html}) {
+    return (
+      html.tag('dl', [
+        slots.groupInfo,
+        slots.chunks,
+      ]));
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
new file mode 100644
index 0000000..133095e
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
@@ -0,0 +1,269 @@
+import {chunkByProperties, stitchArrays} from '#sugar';
+
+import {
+  sortAlbumsTracksChronologically,
+  sortByDate,
+  sortEntryThingPairs,
+} from '#sort';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageOtherArtistLinks',
+    'linkAlbum',
+    'linkFlash',
+    'linkFlashAct',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(artist) {
+    const processEntry = ({
+      thing,
+      entry,
+
+      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))
+            .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) => ({
+    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}) =>
+            (annotation
+              ? relation('transformContent', annotation)
+              : null))),
+  }),
+
+  data: (query) => ({
+    chunkTypes:
+      query.chunks
+        .map(({chunkType}) => chunkType),
+
+    itemTypes:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(({itemType}) => itemType)),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    html.tag('dl',
+      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,
+        }) =>
+          (chunkType === 'album'
+            ? chunk.slots({
+                mode: 'album',
+                albumLink: chunkLink,
+                items:
+                  stitchArrays({
+                    item: items,
+                    link: itemLinks,
+                    annotation: itemAnnotations,
+                    type: itemTypes,
+                  }).map(({item, link, annotation, type}) =>
+                    item.slots({
+                      annotation:
+                        (annotation
+                          ? annotation.slot('mode', 'inline')
+                          : null),
+
+                      content:
+                        (type === 'album'
+                          ? html.tag('i',
+                              language.$('artistPage.creditList.entry.album.commentary'))
+                          : language.$('artistPage.creditList.entry.track', {
+                              track: link,
+                            })),
+                    })),
+              })
+         : chunkType === 'flash-act'
+            ? chunk.slots({
+                mode: 'flash',
+                flashActLink: chunkLink,
+                items:
+                  stitchArrays({
+                    item: items,
+                    link: itemLinks,
+                    annotation: itemAnnotations,
+                  }).map(({item, link, annotation}) =>
+                    item.slots({
+                      annotation:
+                        (annotation
+                          ? annotation.slot('mode', 'inline')
+                          : null),
+
+                      content:
+                        language.$('artistPage.creditList.entry.flash', {
+                          flash: link,
+                        }),
+                    })),
+              })
+            : null))),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
new file mode 100644
index 0000000..88a97af
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
@@ -0,0 +1,149 @@
+import {sortEntryThingPairs, sortFlashesChronologically} from '#sort';
+import {chunkByProperties, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageChunkItem',
+    'linkFlash',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(artist) {
+    const processFlashEntry = ({flash, contribs}) => ({
+      thing: flash,
+      entry: {
+        flash: flash,
+        act: flash.act,
+        contribs: contribs,
+      },
+    });
+
+    const processFlashEntries = ({flashes, contribs}) =>
+      stitchArrays({
+        flash: flashes,
+        contribs: contribs,
+      }).map(processFlashEntry);
+
+    const {flashesAsContributor} = artist;
+
+    const flashesAsContributorContribs =
+      flashesAsContributor
+        .map(flash => flash.contributorContribs);
+
+    const flashesAsContributorEntries =
+      processFlashEntries({
+        flashes: flashesAsContributor,
+        contribs: flashesAsContributorContribs,
+      });
+
+    const entries = [
+      ...flashesAsContributorEntries,
+    ];
+
+    sortEntryThingPairs(entries, sortFlashesChronologically);
+
+    const chunks =
+      chunkByProperties(
+        entries.map(({entry}) => entry),
+        ['act']);
+
+    return {chunks};
+  },
+
+  relations(relation, query) {
+    // Flashes and games can list multiple contributors as collaborative
+    // credits, but we don't display these on the artist page, since they
+    // usually involve many artists crediting a larger team where collaboration
+    // isn't as relevant (without more particular details that aren't tracked
+    // on the wiki).
+
+    return {
+      chunks:
+        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
+
+      actLinks:
+        query.chunks.map(({chunk}) =>
+          relation('linkFlash', chunk[0].flash)),
+
+      items:
+        query.chunks.map(({chunk}) =>
+          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+
+      itemFlashLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({flash}) => relation('linkFlash', flash))),
+    };
+  },
+
+  data(query, artist) {
+    return {
+      actNames:
+        query.chunks.map(({act}) => act.name),
+
+      firstDates:
+        query.chunks.map(({chunk}) => chunk[0].flash.date ?? null),
+
+      lastDates:
+        query.chunks.map(({chunk}) => chunk.at(-1).flash.date ?? null),
+
+      itemContributions:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({contribs}) =>
+            contribs
+              .find(({who}) => who === artist)
+              .what)),
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    return html.tag('dl',
+      stitchArrays({
+        chunk: relations.chunks,
+        actLink: relations.actLinks,
+        actName: data.actNames,
+        firstDate: data.firstDates,
+        lastDate: data.lastDates,
+
+        items: relations.items,
+        itemFlashLinks: relations.itemFlashLinks,
+        itemContributions: data.itemContributions,
+      }).map(({
+          chunk,
+          actLink,
+          actName,
+          firstDate,
+          lastDate,
+
+          items,
+          itemFlashLinks,
+          itemContributions,
+        }) =>
+          chunk.slots({
+            mode: 'flash',
+            flashActLink: actLink.slot('content', actName),
+            dateRangeStart: firstDate,
+            dateRangeEnd: lastDate,
+
+            items:
+              stitchArrays({
+                item: items,
+                flashLink: itemFlashLinks,
+                contribution: itemContributions,
+              }).map(({
+                  item,
+                  flashLink,
+                  contribution,
+                }) =>
+                  item.slots({
+                    annotation: contribution,
+
+                    content:
+                      language.$('artistPage.creditList.entry.flash', {
+                        flash: flashLink,
+                      }),
+                  })),
+          })));
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
new file mode 100644
index 0000000..dea7742
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
@@ -0,0 +1,23 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['linkArtist'],
+
+  relations(relation, contribs, artist) {
+    const otherArtistContribs = contribs.filter(({who}) => who !== artist);
+
+    if (empty(otherArtistContribs)) {
+      return {};
+    }
+
+    const otherArtistLinks =
+      otherArtistContribs
+        .map(({who}) => relation('linkArtist', who));
+
+    return {otherArtistLinks};
+  },
+
+  generate(relations) {
+    return relations.otherArtistLinks ?? null;
+  },
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
new file mode 100644
index 0000000..f003779
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -0,0 +1,293 @@
+import {sortAlbumsTracksChronologically, sortEntryThingPairs} from '#sort';
+import {accumulateSum, chunkByProperties, empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateArtistInfoPageChunk',
+    'generateArtistInfoPageChunkedList',
+    'generateArtistInfoPageChunkItem',
+    'generateArtistInfoPageOtherArtistLinks',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(artist) {
+    const processTrackEntry = ({track, contribs}) => ({
+      thing: track,
+      entry: {
+        track: track,
+        album: track.album,
+        date: track.date,
+        contribs: contribs,
+      },
+    });
+
+    const processTrackEntries = ({tracks, contribs}) =>
+      stitchArrays({
+        track: tracks,
+        contribs: contribs,
+      }).map(processTrackEntry);
+
+    const {tracksAsArtist, tracksAsContributor} = artist;
+
+    const tracksAsArtistAndContributor =
+      tracksAsArtist
+        .filter(track => tracksAsContributor.includes(track));
+
+    const tracksAsArtistOnly =
+      tracksAsArtist
+        .filter(track => !tracksAsContributor.includes(track));
+
+    const tracksAsContributorOnly =
+      tracksAsContributor
+        .filter(track => !tracksAsArtist.includes(track));
+
+    const tracksAsArtistAndContributorContribs =
+      tracksAsArtistAndContributor
+        .map(track => [
+          ...
+            track.artistContribs
+              .map(contrib => ({...contrib, kind: 'artist'})),
+          ...
+            track.contributorContribs
+              .map(contrib => ({...contrib, kind: 'contributor'})),
+        ]);
+
+    const tracksAsArtistOnlyContribs =
+      tracksAsArtistOnly
+        .map(track => track.artistContribs
+          .map(contrib => ({...contrib, kind: 'artist'})));
+
+    const tracksAsContributorOnlyContribs =
+      tracksAsContributorOnly
+        .map(track => track.contributorContribs
+          .map(contrib => ({...contrib, kind: 'contributor'})));
+
+    const tracksAsArtistAndContributorEntries =
+      processTrackEntries({
+        tracks: tracksAsArtistAndContributor,
+        contribs: tracksAsArtistAndContributorContribs,
+      });
+
+    const tracksAsArtistOnlyEntries =
+      processTrackEntries({
+        tracks: tracksAsArtistOnly,
+        contribs: tracksAsArtistOnlyContribs,
+      });
+
+    const tracksAsContributorOnlyEntries =
+      processTrackEntries({
+        tracks: tracksAsContributorOnly,
+        contribs: tracksAsContributorOnlyContribs,
+      });
+
+    const entries = [
+      ...tracksAsArtistAndContributorEntries,
+      ...tracksAsArtistOnlyEntries,
+      ...tracksAsContributorOnlyEntries,
+    ];
+
+    sortEntryThingPairs(entries, sortAlbumsTracksChronologically);
+
+    const chunks =
+      chunkByProperties(
+        entries.map(({entry}) => entry),
+        ['album', 'date']);
+
+    return {chunks};
+  },
+
+  relations(relation, query, artist) {
+    return {
+      chunkedList:
+        relation('generateArtistInfoPageChunkedList'),
+
+      chunks:
+        query.chunks.map(() => relation('generateArtistInfoPageChunk')),
+
+      albumLinks:
+        query.chunks.map(({album}) => relation('linkAlbum', album)),
+
+      items:
+        query.chunks.map(({chunk}) =>
+          chunk.map(() => relation('generateArtistInfoPageChunkItem'))),
+
+      trackLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({track}) => relation('linkTrack', track))),
+
+      trackOtherArtistLinks:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({contribs}) => relation('generateArtistInfoPageOtherArtistLinks', contribs, artist))),
+    };
+  },
+
+  data(query, artist) {
+    return {
+      chunkDates:
+        query.chunks.map(({date}) => date),
+
+      chunkDurations:
+        query.chunks.map(({chunk}) =>
+          accumulateSum(
+            chunk
+              .filter(({track}) => track.duration && track.originalReleaseTrack === null)
+              .map(({track}) => track.duration))),
+
+      chunkDurationsApproximate:
+        query.chunks.map(({chunk}) =>
+          chunk
+            .filter(({track}) => track.duration && track.originalReleaseTrack === null)
+            .length > 1),
+
+      trackDurations:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({track}) => track.duration)),
+
+      trackContributions:
+        query.chunks.map(({chunk}) =>
+          chunk
+            .map(({contribs}) =>
+              contribs.filter(({who}) => who === artist))
+            .map(ownContribs => ({
+              creditedAsArtist:
+                ownContribs
+                  .some(({kind}) => kind === 'artist'),
+
+              creditedAsContributor:
+                ownContribs
+                  .some(({kind}) => kind === 'contributor'),
+
+              annotatedContribs:
+                ownContribs
+                  .filter(({what}) => what),
+            }))
+            .map(({annotatedContribs, ...rest}) => ({
+              ...rest,
+
+              annotatedArtistContribs:
+                annotatedContribs
+                  .filter(({kind}) => kind === 'artist'),
+
+              annotatedContributorContribs:
+                annotatedContribs
+                  .filter(({kind}) => kind === 'contributor'),
+            }))
+            .map(({
+              creditedAsArtist,
+              creditedAsContributor,
+              annotatedArtistContribs,
+              annotatedContributorContribs,
+            }) => {
+              // Don't display annotations associated with crediting in the
+              // Contributors field if the artist is also credited as an Artist
+              // *and* the Artist-field contribution is non-annotated. This is
+              // so that we don't misrepresent the artist - the contributor
+              // annotation tends to be for "secondary" and performance roles.
+              // For example, this avoids crediting Marcy Nabors on Renewed
+              // Return seemingly only for "bass clarinet" when they're also
+              // the one who composed and arranged Renewed Return!
+              if (
+                creditedAsArtist &&
+                creditedAsContributor &&
+                empty(annotatedArtistContribs)
+              ) {
+                return [];
+              }
+
+              return [
+                ...annotatedArtistContribs,
+                ...annotatedContributorContribs,
+              ];
+            })
+            .map(contribs =>
+              contribs.map(({what}) => what))
+            .map(contributions =>
+              (empty(contributions)
+                ? null
+                : contributions))),
+
+      trackRereleases:
+        query.chunks.map(({chunk}) =>
+          chunk.map(({track}) => track.originalReleaseTrack !== null)),
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.chunkedList.slots({
+      chunks:
+        stitchArrays({
+          chunk: relations.chunks,
+          albumLink: relations.albumLinks,
+          date: data.chunkDates,
+          duration: data.chunkDurations,
+          durationApproximate: data.chunkDurationsApproximate,
+
+          items: relations.items,
+          trackLinks: relations.trackLinks,
+          trackOtherArtistLinks: relations.trackOtherArtistLinks,
+          trackDurations: data.trackDurations,
+          trackContributions: data.trackContributions,
+          trackRereleases: data.trackRereleases,
+        }).map(({
+            chunk,
+            albumLink,
+            date,
+            duration,
+            durationApproximate,
+
+            items,
+            trackLinks,
+            trackOtherArtistLinks,
+            trackDurations,
+            trackContributions,
+            trackRereleases,
+          }) =>
+            chunk.slots({
+              mode: 'album',
+              albumLink,
+              date,
+              duration,
+              durationApproximate,
+
+              items:
+                stitchArrays({
+                  item: items,
+                  trackLink: trackLinks,
+                  otherArtistLinks: trackOtherArtistLinks,
+                  duration: trackDurations,
+                  contribution: trackContributions,
+                  rerelease: trackRereleases,
+                }).map(({
+                    item,
+                    trackLink,
+                    otherArtistLinks,
+                    duration,
+                    contribution,
+                    rerelease,
+                  }) =>
+                    item.slots({
+                      otherArtistLinks,
+                      rerelease,
+
+                      annotation:
+                        (contribution
+                          ? language.formatUnitList(contribution)
+                          : html.blank()),
+
+                      content:
+                        (duration
+                          ? language.$('artistPage.creditList.entry.track.withDuration', {
+                              track: trackLink,
+                              duration: language.formatDuration(duration),
+                            })
+                          : language.$('artistPage.creditList.entry.track', {
+                              track: trackLink,
+                            })),
+                    })),
+            })),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js
new file mode 100644
index 0000000..aa95dba
--- /dev/null
+++ b/src/content/dependencies/generateArtistNavLinks.js
@@ -0,0 +1,100 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'linkArtist',
+    'linkArtistGallery',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      enableListings: wikiInfo.enableListings,
+    };
+  },
+
+  relations(relation, sprawl, artist) {
+    const relations = {};
+
+    relations.artistMainLink =
+      relation('linkArtist', artist);
+
+    relations.artistInfoLink =
+      relation('linkArtist', artist);
+
+    if (
+      !empty(artist.albumsAsCoverArtist) ||
+      !empty(artist.tracksAsCoverArtist)
+    ) {
+      relations.artistGalleryLink =
+        relation('linkArtistGallery', artist);
+    }
+
+    return relations;
+  },
+
+  data(sprawl) {
+    return {
+      enableListings: sprawl.enableListings,
+    };
+  },
+
+  slots: {
+    showExtraLinks: {type: 'boolean', default: false},
+
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const infoLink =
+      relations.artistInfoLink?.slots({
+        attributes: {class: slots.currentExtra === null && 'current'},
+        content: language.$('misc.nav.info'),
+      });
+
+    const {content: extraLinks = []} =
+      slots.showExtraLinks &&
+        {content: [
+          relations.artistGalleryLink?.slots({
+            attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+            content: language.$('misc.nav.gallery'),
+          }),
+        ]};
+
+    const mostAccentLinks = [
+      ...extraLinks,
+    ].filter(Boolean);
+
+    // Don't show the info accent link all on its own.
+    const allAccentLinks =
+      (empty(mostAccentLinks)
+        ? []
+        : [infoLink, ...mostAccentLinks]);
+
+    const accent =
+      (empty(allAccentLinks)
+        ? html.blank()
+        : `(${language.formatUnitList(allAccentLinks)})`);
+
+    return [
+      {auto: 'home'},
+
+      data.enableListings &&
+        {
+          path: ['localized.listingIndex'],
+          title: language.$('listingIndex.title'),
+        },
+
+      {
+        accent,
+        html:
+          language.$('artistPage.nav.artist', {
+            artist: relations.artistMainLink,
+          }),
+      },
+    ];
+  },
+};
diff --git a/src/content/dependencies/generateBanner.js b/src/content/dependencies/generateBanner.js
new file mode 100644
index 0000000..15eb08e
--- /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/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js
new file mode 100644
index 0000000..8ec6ee0
--- /dev/null
+++ b/src/content/dependencies/generateChronologyLinks.js
@@ -0,0 +1,82 @@
+import {accumulateSum, empty} from '#sugar';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    chronologyInfoSets: {
+      validate: v =>
+        v.strictArrayOf(
+          v.validateProperties({
+            headingString: v.isString,
+            contributions: v.strictArrayOf(v.validateProperties({
+              index: v.isCountingNumber,
+              artistLink: v.isHTML,
+              previousLink: v.isHTML,
+              nextLink: v.isHTML,
+            })),
+          })),
+    }
+  },
+
+  generate(slots, {html, language}) {
+    if (empty(slots.chronologyInfoSets)) {
+      return html.blank();
+    }
+
+    const totalContributionCount =
+      accumulateSum(
+        slots.chronologyInfoSets,
+        ({contributions}) => contributions.length);
+
+    if (totalContributionCount === 0) {
+      return html.blank();
+    }
+
+    if (totalContributionCount > 8) {
+      return html.tag('div', {class: 'chronology'},
+        language.$('misc.chronology.seeArtistPages'));
+    }
+
+    return html.tags(
+      slots.chronologyInfoSets.map(({
+        headingString,
+        contributions,
+      }) =>
+        contributions.map(({
+          index,
+          artistLink,
+          previousLink,
+          nextLink,
+        }) => {
+          const heading =
+            html.tag('span', {class: 'heading'},
+              language.$(headingString, {
+                index: language.formatIndex(index),
+                artist: artistLink,
+              }));
+
+          const navigation =
+            (previousLink || nextLink) &&
+              html.tag('span', {class: 'buttons'},
+                language.formatUnitList([
+                  previousLink?.slots({
+                    tooltipStyle: 'browser',
+                    color: false,
+                    content: language.$('misc.nav.previous'),
+                  }),
+
+                  nextLink?.slots({
+                    tooltipStyle: 'browser',
+                    color: false,
+                    content: language.$('misc.nav.next'),
+                  }),
+                ].filter(Boolean)));
+
+          return html.tag('div', {class: 'chronology'},
+            (navigation
+              ? language.$('misc.chronology.withNavigation', {heading, navigation})
+              : heading));
+        })));
+  },
+};
diff --git a/src/content/dependencies/generateColorStyleAttribute.js b/src/content/dependencies/generateColorStyleAttribute.js
new file mode 100644
index 0000000..03d95ac
--- /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 0000000..c412b8f
--- /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 0000000..069d85d
--- /dev/null
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -0,0 +1,89 @@
+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,
+      bg,
+      bgBlack,
+      shadow,
+    } = getColors(slots.color);
+
+    let anyContent = [
+      `--primary-color: ${primary}`,
+      `--dark-color: ${dark}`,
+      `--dim-color: ${dim}`,
+      `--deep-color: ${deep}`,
+      `--deep-ghost-color: ${deepGhost}`,
+      `--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 0000000..522a028
--- /dev/null
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -0,0 +1,98 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    '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'),
+  }),
+
+  data: (entry) => ({
+    date: entry.date,
+  }),
+
+  slots: {
+    color: {validate: v => v.isColor},
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const artistsSpan =
+      html.tag('span', {class: 'commentary-entry-artists'},
+        (relations.artistsContent
+          ? relations.artistsContent.slot('mode', 'inline')
+       : relations.artistLinks
+          ? language.formatConjunctionList(relations.artistLinks)
+          : language.$('misc.artistCommentary.entry.title.noArtists')));
+
+    const accentParts = ['misc.artistCommentary.entry.title.accent'];
+    const accentOptions = {};
+
+    if (relations.annotationContent) {
+      accentParts.push('withAnnotation');
+      accentOptions.annotation =
+        relations.annotationContent.slot('mode', 'inline');
+    }
+
+    if (data.date) {
+      accentParts.push('withDate');
+      accentOptions.date =
+        language.formatDate(data.date);
+    }
+
+    const accent =
+      (accentParts.length > 1
+        ? html.tag('span', {class: 'commentary-entry-accent'},
+            language.$(...accentParts, accentOptions))
+        : null);
+
+    const titleParts = ['misc.artistCommentary.entry.title'];
+    const titleOptions = {artists: artistsSpan};
+
+    if (accent) {
+      titleParts.push('withAccent');
+      titleOptions.accent = accent;
+    }
+
+    const style =
+      slots.color &&
+        relations.colorStyle.slot('color', slots.color);
+
+    return html.tags([
+      html.tag('p', {class: 'commentary-entry-heading'},
+        style,
+        language.$(...titleParts, titleOptions)),
+
+      html.tag('blockquote', {class: 'commentary-entry-body'},
+        style,
+        relations.bodyContent.slot('mode', 'multiline')),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js
new file mode 100644
index 0000000..3c3504d
--- /dev/null
+++ b/src/content/dependencies/generateCommentaryIndexPage.js
@@ -0,0 +1,102 @@
+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}) {
+    return relations.layout.slots({
+      title: language.$('commentaryIndex.title'),
+
+      headingMode: 'static',
+
+      mainClasses: ['long-content'],
+      mainContent: [
+        html.tag('p', language.$('commentaryIndex.infoLine', {
+          words:
+            html.tag('b',
+              language.formatWordCount(data.totalWordCount, {unit: true})),
+
+          entries:
+            html.tag('b',
+                language.countCommentaryEntries(data.totalEntryCount, {unit: true})),
+        })),
+
+        html.tag('p',
+          language.$('commentaryIndex.albumList.title')),
+
+        html.tag('ul',
+          stitchArrays({
+            albumLink: relations.albumLinks,
+            wordCount: data.wordCounts,
+            entryCount: data.entryCounts,
+          }).map(({albumLink, wordCount, entryCount}) =>
+            html.tag('li',
+              language.$('commentaryIndex.albumList.item', {
+                album: albumLink,
+                words: language.formatWordCount(wordCount, {unit: true}),
+                entries: language.countCommentaryEntries(entryCount, {unit: true}),
+              })))),
+      ],
+
+      navLinkStyle: 'hierarchical',
+      navLinks: [
+        {auto: 'home'},
+        {auto: 'current'},
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js
new file mode 100644
index 0000000..8ae1b2d
--- /dev/null
+++ b/src/content/dependencies/generateCommentarySection.js
@@ -0,0 +1,29 @@
+export default {
+  contentDependencies: [
+    'transformContent',
+    'generateCommentaryEntry',
+    'generateContentHeading',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entries) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    entries:
+      entries.map(entry =>
+        relation('generateCommentaryEntry', entry)),
+  }),
+
+  generate: (relations, {html, language}) =>
+    html.tags([
+      relations.heading
+        .slots({
+          id: 'artist-commentary',
+          title: language.$('misc.artistCommentary')
+        }),
+
+      relations.entries,
+    ]),
+};
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
new file mode 100644
index 0000000..469db87
--- /dev/null
+++ b/src/content/dependencies/generateContentHeading.js
@@ -0,0 +1,45 @@
+export default {
+  extraDependencies: ['html'],
+  contentDependencies: ['generateColorStyleAttribute'],
+
+  relations: (relation) => ({
+    colorStyle: relation('generateColorStyleAttribute'),
+  }),
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    accent: {
+      type: 'html',
+      mutable: false,
+    },
+
+    color: {validate: v => v.isColor},
+
+    id: {type: 'string'},
+    tag: {type: 'string', default: 'p'},
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag(slots.tag, {class: 'content-heading'},
+      {tabindex: '0'},
+
+      slots.id &&
+        {id: slots.id},
+
+      slots.color &&
+        relations.colorStyle.slot('color', slots.color),
+
+      [
+        html.tag('span', {class: 'content-heading-main-title'},
+          {[html.onlyIfContent]: true},
+          slots.title),
+
+        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 0000000..6401e65
--- /dev/null
+++ b/src/content/dependencies/generateContributionList.js
@@ -0,0 +1,21 @@
+export default {
+  contentDependencies: ['linkContribution'],
+  extraDependencies: ['html'],
+
+  relations: (relation, contributions) =>
+    ({contributionLinks:
+        contributions
+          .map(contrib => relation('linkContribution', contrib))}),
+
+  generate: (relations, {html}) =>
+    html.tag('ul',
+      relations.contributionLinks.map(contributionLink =>
+        html.tag('li',
+          contributionLink
+            .slots({
+              showIcons: true,
+              showContribution: true,
+              preventWrapping: false,
+              iconMode: 'tooltip',
+            })))),
+};
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
new file mode 100644
index 0000000..90c9db9
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -0,0 +1,132 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['image', 'linkArtTag'],
+  extraDependencies: ['html'],
+
+  query: (artTags) => ({
+    linkableArtTags:
+      (artTags
+        ? artTags.filter(tag => !tag.isContentWarning)
+        : []),
+  }),
+
+  relations: (relation, query, artTags) => ({
+    image:
+      relation('image', artTags),
+
+    tagLinks:
+      query.linkableArtTags
+        .filter(tag => !tag.isContentWarning)
+        .map(tag => relation('linkArtTag', tag)),
+  }),
+
+  data: (query) => {
+    const data = {};
+
+    const seenShortNames = new Set();
+    const duplicateShortNames = new Set();
+
+    for (const {nameShort: shortName} of query.linkableArtTags) {
+      if (seenShortNames.has(shortName)) {
+        duplicateShortNames.add(shortName);
+      } else {
+        seenShortNames.add(shortName);
+      }
+    }
+
+    data.preferShortName =
+      query.linkableArtTags
+        .map(artTag => !duplicateShortNames.has(artTag.nameShort));
+
+    return data;
+  },
+
+  slots: {
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
+    },
+
+    alt: {
+      type: 'string',
+    },
+
+    color: {
+      validate: v => v.isColor,
+    },
+
+    mode: {
+      validate: v => v.is('primary', 'thumbnail', 'commentary'),
+      default: 'primary',
+    },
+
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
+  },
+
+  generate(data, relations, slots, {html}) {
+    const square =
+      (slots.dimensions
+        ? slots.dimensions[0] === slots.dimensions[1]
+        : true);
+
+    const sizeSlots =
+      (square
+        ? {square: true}
+        : {dimensions: slots.dimensions});
+
+    switch (slots.mode) {
+      case 'primary':
+        return html.tags([
+          relations.image.slots({
+            path: slots.path,
+            alt: slots.alt,
+            color: slots.color,
+            thumb: 'medium',
+            reveal: true,
+            link: true,
+            ...sizeSlots,
+          }),
+
+          !empty(relations.tagLinks) &&
+            html.tag('ul', {class: 'image-details'},
+              stitchArrays({
+                tagLink: relations.tagLinks,
+                preferShortName: data.preferShortName,
+              }).map(({tagLink, preferShortName}) =>
+                  html.tag('li',
+                    tagLink.slot('preferShortName', preferShortName)))),
+        ]);
+
+      case 'thumbnail':
+        return relations.image.slots({
+          path: slots.path,
+          alt: slots.alt,
+          color: slots.color,
+          thumb: 'small',
+          reveal: false,
+          link: false,
+          ...sizeSlots,
+        });
+
+      case 'commentary':
+        return relations.image.slots({
+          path: slots.path,
+          alt: slots.alt,
+          color: slots.color,
+          thumb: 'medium',
+          reveal: true,
+          link: true,
+          lazy: true,
+          ...sizeSlots,
+
+          attributes:
+            {class: 'commentary-art'},
+        });
+
+      default:
+        return html.blank();
+    }
+  },
+};
diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js
new file mode 100644
index 0000000..69220da
--- /dev/null
+++ b/src/content/dependencies/generateCoverCarousel.js
@@ -0,0 +1,66 @@
+import {empty, repeat, stitchArrays} from '#sugar';
+import {getCarouselLayoutForNumberOfItems} from '#wiki-data';
+
+export default {
+  contentDependencies: ['generateGridActionLinks'],
+  extraDependencies: ['html'],
+
+  relations(relation) {
+    return {
+      actionLinks: relation('generateGridActionLinks'),
+    };
+  },
+
+  slots: {
+    images: {validate: v => v.strictArrayOf(v.isHTML)},
+    links: {validate: v => v.strictArrayOf(v.isHTML)},
+
+    lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)},
+    actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
+  },
+
+  generate(relations, slots, {html}) {
+    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),
+                    }),
+                })))),
+        ])),
+
+      relations.actionLinks
+        .slot('actionLinks', slots.actionLinks),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
new file mode 100644
index 0000000..0433aaf
--- /dev/null
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -0,0 +1,59 @@
+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)},
+
+    lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)},
+    actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
+  },
+
+  generate(relations, slots, {html, language}) {
+    return (
+      html.tag('div', {class: 'grid-listing'}, [
+        stitchArrays({
+          image: slots.images,
+          link: slots.links,
+          name: slots.names,
+          info: slots.info,
+        }).map(({image, link, name, info}, index) =>
+            link.slots({
+              attributes: {class: ['grid-item', 'box']},
+              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.sanitize(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 0000000..d9ed036
--- /dev/null
+++ b/src/content/dependencies/generateDatetimestampTemplate.js
@@ -0,0 +1,38 @@
+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:
+        slots.tooltip?.slots({
+          attributes: [{class: 'datetimestamp-tooltip'}],
+        }),
+    }),
+};
diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js
new file mode 100644
index 0000000..1707812
--- /dev/null
+++ b/src/content/dependencies/generateFlashActGalleryPage.js
@@ -0,0 +1,91 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCoverGrid',
+    'generateFlashActNavAccent',
+    'generateFlashActSidebar',
+    'generatePageLayout',
+    'image',
+    'linkFlash',
+    'linkFlashIndex',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, act) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    flashIndexLink:
+      relation('linkFlashIndex'),
+
+    flashActNavAccent:
+      relation('generateFlashActNavAccent', act),
+
+    sidebar:
+      relation('generateFlashActSidebar', act, null),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    coverGridImages:
+      act.flashes
+        .map(_flash => relation('image')),
+
+    flashLinks:
+      act.flashes
+        .map(flash => relation('linkFlash', flash)),
+  }),
+
+  data: (act) => ({
+    name: act.name,
+    color: act.color,
+
+    flashNames:
+      act.flashes.map(flash => flash.name),
+
+    flashCoverPaths:
+      act.flashes.map(flash =>
+        ['media.flashArt', flash.directory, flash.coverArtFileExtension])
+  }),
+
+  generate(data, relations, {html, language}) {
+    return relations.layout.slots({
+      title:
+        language.$('flashPage.title', {
+          flash: new html.Tag(null, null, data.name),
+        }),
+
+      color: data.color,
+      headingMode: 'static',
+
+      mainClasses: ['flash-index'],
+      mainContent: [
+        relations.coverGrid.slots({
+          links: relations.flashLinks,
+          names: data.flashNames,
+          lazy: 6,
+
+          images:
+            stitchArrays({
+              image: relations.coverGridImages,
+              path: data.flashCoverPaths,
+            }).map(({image, path}) =>
+                image.slot('path', path)),
+        }),
+      ],
+
+      navLinkStyle: 'hierarchical',
+      navLinks: [
+        {auto: 'home'},
+        {html: relations.flashIndexLink},
+        {auto: 'current'},
+      ],
+
+      navBottomRowContent: relations.flashActNavAccent,
+
+      leftSidebar: relations.sidebar,
+    });
+  },
+};
diff --git a/src/content/dependencies/generateFlashActNavAccent.js b/src/content/dependencies/generateFlashActNavAccent.js
new file mode 100644
index 0000000..424948f
--- /dev/null
+++ b/src/content/dependencies/generateFlashActNavAccent.js
@@ -0,0 +1,71 @@
+import {atOffset, empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePreviousNextLinks',
+    'linkFlashAct',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({flashActData}) {
+    return {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) {
+    const relations = {};
+
+    if (query.previousFlashAct || query.nextFlashAct) {
+      relations.previousNextLinks =
+        relation('generatePreviousNextLinks');
+
+      relations.previousFlashActLink =
+        (query.previousFlashAct
+          ? relation('linkFlashAct', query.previousFlashAct)
+          : null);
+
+      relations.nextFlashActLink =
+        (query.nextFlashAct
+          ? relation('linkFlashAct', query.nextFlashAct)
+          : null);
+    }
+
+    return relations;
+  },
+
+  generate(relations, {html, language}) {
+    const {content: previousNextLinks = []} =
+      relations.previousNextLinks &&
+        relations.previousNextLinks.slots({
+          previousLink: relations.previousFlashActLink,
+          nextLink: relations.nextFlashActLink,
+        });
+
+    const allLinks = [
+      ...previousNextLinks,
+    ].filter(Boolean);
+
+    if (empty(allLinks)) {
+      return html.blank();
+    }
+
+    return `(${language.formatUnitList(allLinks)})`;
+  },
+};
diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js
new file mode 100644
index 0000000..1421dde
--- /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 0000000..c5426a4
--- /dev/null
+++ b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js
@@ -0,0 +1,63 @@
+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', {class: 'group-name'},
+                (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 0000000..3d261ec
--- /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', {class: 'group-name'},
+                    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/generateFlashCoverArtwork.js b/src/content/dependencies/generateFlashCoverArtwork.js
new file mode 100644
index 0000000..374fa3f
--- /dev/null
+++ b/src/content/dependencies/generateFlashCoverArtwork.js
@@ -0,0 +1,12 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations: (relation) =>
+    ({coverArtwork: relation('generateCoverArtwork')}),
+
+  data: (flash) =>
+    ({path: ['media.flashArt', flash.directory, flash.coverArtFileExtension]}),
+
+  generate: (data, relations) =>
+    relations.coverArtwork.slot('path', data.path),
+};
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
new file mode 100644
index 0000000..36bfaba
--- /dev/null
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -0,0 +1,154 @@
+import {empty, 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(() => relation('image'))),
+  }),
+
+  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)),
+
+    actCoverGridPaths:
+      query.flashActs
+        .map(act => act.flashes
+          .map(flash => ['media.flashArt', flash.directory, flash.coverArtFileExtension])),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.layout.slots({
+      title: language.$('flashIndex.title'),
+      headingMode: 'static',
+
+      mainClasses: ['flash-index'],
+      mainContent: [
+        !empty(data.jumpLinkLabels) && [
+          html.tag('p', {class: 'quick-info'},
+            language.$('misc.jumpTo')),
+
+          html.tag('ul', {class: 'quick-info'},
+            stitchArrays({
+              colorStyle: relations.jumpLinkColorStyles,
+              anchor: data.jumpLinkAnchors,
+              label: data.jumpLinkLabels,
+            }).map(({colorStyle, anchor, label}) =>
+                html.tag('li',
+                  html.tag('a',
+                    {href: '#' + anchor},
+                    colorStyle,
+                    label)))),
+        ],
+
+        stitchArrays({
+          colorStyle: relations.actColorStyles,
+          actLink: relations.actLinks,
+          anchor: data.actAnchors,
+
+          coverGrid: relations.actCoverGrids,
+          coverGridImages: relations.actCoverGridImages,
+          coverGridLinks: relations.actCoverGridLinks,
+          coverGridNames: data.actCoverGridNames,
+          coverGridPaths: data.actCoverGridPaths,
+        }).map(({
+            colorStyle,
+            actLink,
+            anchor,
+
+            coverGrid,
+            coverGridImages,
+            coverGridLinks,
+            coverGridNames,
+            coverGridPaths,
+          }, index) => [
+            html.tag('h2',
+              {id: anchor},
+              colorStyle,
+              actLink),
+
+            coverGrid.slots({
+              links: coverGridLinks,
+              names: coverGridNames,
+              lazy: index === 0 ? 4 : true,
+
+              images:
+                stitchArrays({
+                  image: coverGridImages,
+                  path: coverGridPaths,
+                }).map(({image, path}) =>
+                    image.slot('path', path)),
+            }),
+          ]),
+      ],
+
+      navLinkStyle: 'hierarchical',
+      navLinks: [
+        {auto: 'home'},
+        {auto: 'current'},
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
new file mode 100644
index 0000000..0596493
--- /dev/null
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -0,0 +1,198 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCommentarySection',
+    'generateContentHeading',
+    'generateContributionList',
+    'generateFlashActSidebar',
+    'generateFlashCoverArtwork',
+    'generateFlashNavAccent',
+    'generatePageLayout',
+    'generateTrackList',
+    'linkExternal',
+    'linkFlashAct',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(flash) {
+    const query = {};
+
+    if (flash.page || !empty(flash.urls)) {
+      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) {
+    const relations = {};
+    const sections = relations.sections = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.sidebar =
+      relation('generateFlashActSidebar', flash.act, flash);
+
+    if (query.urls) {
+      relations.externalLinks =
+        query.urls.map(url => relation('linkExternal', url));
+    }
+
+    // TODO: Flashes always have cover art (#175)
+    /* eslint-disable-next-line no-constant-condition */
+    if (true) {
+      relations.cover =
+        relation('generateFlashCoverArtwork', flash);
+    }
+
+    // Section: navigation bar
+
+    const nav = sections.nav = {};
+
+    nav.flashActLink =
+      relation('linkFlashAct', flash.act);
+
+    nav.flashNavAccent =
+      relation('generateFlashNavAccent', flash);
+
+    // Section: Featured tracks
+
+    if (!empty(flash.featuredTracks)) {
+      const featuredTracks = sections.featuredTracks = {};
+
+      featuredTracks.heading =
+        relation('generateContentHeading');
+
+      featuredTracks.list =
+        relation('generateTrackList', flash.featuredTracks);
+    }
+
+    // Section: Contributors
+
+    if (!empty(flash.contributorContribs)) {
+      const contributors = sections.contributors = {};
+
+      contributors.heading =
+        relation('generateContentHeading');
+
+      contributors.list =
+        relation('generateContributionList', flash.contributorContribs);
+    }
+
+    // Section: Artist commentary
+
+    if (flash.commentary) {
+      sections.artistCommentary =
+        relation('generateCommentarySection', flash.commentary);
+    }
+
+    return relations;
+  },
+
+  data(query, flash) {
+    const data = {};
+
+    data.name = flash.name;
+    data.color = flash.color;
+    data.date = flash.date;
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    const {sections: sec} = relations;
+
+    return relations.layout.slots({
+      title:
+        language.$('flashPage.title', {
+          flash: data.name,
+        }),
+
+      color: data.color,
+      headingMode: 'sticky',
+
+      cover:
+        (relations.cover
+          ? relations.cover.slots({
+              alt: language.$('misc.alt.flashArt'),
+            })
+          : null),
+
+      mainContent: [
+        html.tag('p',
+          language.$('releaseInfo.released', {
+            date: language.formatDate(data.date),
+          })),
+
+        relations.externalLinks &&
+          html.tag('p',
+            language.$('releaseInfo.playOn', {
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'flash'))),
+            })),
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            sec.artistCommentary &&
+              language.$('releaseInfo.readCommentary', {
+                link: html.tag('a',
+                  {href: '#artist-commentary'},
+                  language.$('releaseInfo.readCommentary.link')),
+              }),
+          ]),
+
+        sec.featuredTracks && [
+          sec.featuredTracks.heading
+            .slots({
+              id: 'features',
+              title:
+                language.$('releaseInfo.tracksFeatured', {
+                  flash: html.tag('i', data.name),
+                }),
+            }),
+
+          sec.featuredTracks.list,
+        ],
+
+        sec.contributors && [
+          sec.contributors.heading
+            .slots({
+              id: 'contributors',
+              title: language.$('releaseInfo.contributors'),
+            }),
+
+          sec.contributors.list,
+        ],
+
+        sec.artistCommentary,
+      ],
+
+      navLinkStyle: 'hierarchical',
+      navLinks: [
+        {auto: 'home'},
+        {html: sec.nav.flashActLink.slot('color', false)},
+        {auto: 'current'},
+      ],
+
+      navBottomRowContent: sec.nav.flashNavAccent,
+
+      leftSidebar: relations.sidebar,
+    });
+  },
+};
diff --git a/src/content/dependencies/generateFlashNavAccent.js b/src/content/dependencies/generateFlashNavAccent.js
new file mode 100644
index 0000000..55e056d
--- /dev/null
+++ b/src/content/dependencies/generateFlashNavAccent.js
@@ -0,0 +1,73 @@
+import {atOffset, empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePreviousNextLinks',
+    'linkFlash',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({flashActData}) {
+    return {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) {
+    const relations = {};
+
+    if (query.previousFlash || query.nextFlash) {
+      relations.previousNextLinks =
+        relation('generatePreviousNextLinks');
+
+      relations.previousFlashLink =
+        (query.previousFlash
+          ? relation('linkFlash', query.previousFlash)
+          : null);
+
+      relations.nextFlashLink =
+        (query.nextFlash
+          ? relation('linkFlash', query.nextFlash)
+          : null);
+    }
+
+    return relations;
+  },
+
+  generate(relations, {html, language}) {
+    const {content: previousNextLinks = []} =
+      relations.previousNextLinks &&
+        relations.previousNextLinks.slots({
+          previousLink: relations.previousFlashLink,
+          nextLink: relations.nextFlashLink,
+        });
+
+    const allLinks = [
+      ...previousNextLinks,
+    ].filter(Boolean);
+
+    if (empty(allLinks)) {
+      return html.blank();
+    }
+
+    return `(${language.formatUnitList(allLinks)})`;
+  },
+};
diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js
new file mode 100644
index 0000000..dfd83ae
--- /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 0000000..f5b1aaa
--- /dev/null
+++ b/src/content/dependencies/generateGridActionLinks.js
@@ -0,0 +1,22 @@
+import {empty} from '#sugar';
+
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
+  },
+
+  generate(slots, {html}) {
+    if (empty(slots.actionLinks)) {
+      return html.blank();
+    }
+
+    return (
+      html.tag('div', {class: 'grid-actions'},
+        slots.actionLinks
+          .filter(Boolean)
+          .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 0000000..d07847c
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -0,0 +1,198 @@
+import {sortChronologically} from '#sort';
+import {empty, stitchArrays} from '#sugar';
+import {filterItemsForCarousel, getTotalDuration} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateCoverCarousel',
+    'generateCoverGrid',
+    'generateGroupNavLinks',
+    'generateGroupSecondaryNav',
+    'generateGroupSidebar',
+    'generatePageLayout',
+    '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.artTags));
+    }
+
+    relations.coverGrid =
+      relation('generateCoverGrid');
+
+    relations.gridLinks =
+      albums
+        .map(album => relation('linkAlbum', album));
+
+    relations.gridImages =
+      albums.map(album =>
+        (album.hasCoverArt
+          ? relation('image', album.artTags)
+          : 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, {originalReleasesOnly: true});
+
+    data.gridNames = albums.map(album => album.name);
+    data.gridDurations = albums.map(album => getTotalDuration(album.tracks));
+    data.gridNumTracks = albums.map(album => album.tracks.length);
+
+    data.gridPaths =
+      albums.map(album =>
+        (album.hasCoverArt
+          ? ['media.albumCover', album.directory, album.coverArtFileExtension]
+          : null));
+
+    const carouselAlbums = filterItemsForCarousel(group.featuredAlbums);
+
+    if (!empty(group.featuredAlbums)) {
+      data.carouselPaths =
+        carouselAlbums.map(album =>
+          (album.hasCoverArt
+            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
+            : null));
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.layout
+      .slots({
+        title: language.$('groupGalleryPage.title', {group: data.name}),
+        headingMode: 'static',
+
+        color: data.color,
+
+        mainClasses: ['top-index'],
+        mainContent: [
+          relations.coverCarousel
+            ?.slots({
+              links: relations.carouselLinks,
+              images:
+                stitchArrays({
+                  image: relations.carouselImages,
+                  path: data.carouselPaths,
+                }).map(({image, path}) =>
+                    image.slot('path', path)),
+            }),
+
+          html.tag('p', {class: 'quick-info'},
+            language.$('groupGalleryPage.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,
+                  path: data.gridPaths,
+                  name: data.gridNames,
+                }).map(({image, path, name}) =>
+                    image.slots({
+                      path,
+                      missingSourceContent:
+                        language.$('misc.albumGrid.noCoverArt', {
+                          album: name,
+                        }),
+                    })),
+              info:
+                stitchArrays({
+                  numTracks: data.gridNumTracks,
+                  duration: data.gridDurations,
+                }).map(({numTracks, duration}) =>
+                    language.$('misc.albumGrid.details', {
+                      tracks: language.countTracks(numTracks, {unit: true}),
+                      time: language.formatDuration(duration),
+                    })),
+            }),
+        ],
+
+        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 0000000..b5b456a
--- /dev/null
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -0,0 +1,222 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateColorStyleAttribute',
+    'generateContentHeading',
+    'generateGroupNavLinks',
+    'generateGroupSecondaryNav',
+    'generateGroupSidebar',
+    'generatePageLayout',
+    'linkAlbum',
+    'linkExternal',
+    'linkGroupGallery',
+    'linkGroup',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      enableGroupUI: wikiInfo.enableGroupUI,
+    };
+  },
+
+  query(sprawl, group) {
+    const albums =
+      group.albums;
+
+    const albumGroups =
+      albums
+        .map(album => album.groups);
+
+    const albumOtherCategory =
+      albumGroups
+        .map(groups => groups
+          .map(group => group.category)
+          .find(category => category !== group.category));
+
+    const albumOtherGroups =
+      stitchArrays({
+        groups: albumGroups,
+        category: albumOtherCategory,
+      }).map(({groups, category}) =>
+          groups
+            .filter(group => group.category === category));
+
+    return {albums, albumOtherGroups};
+  },
+
+  relations(relation, query, sprawl, group) {
+    const relations = {};
+    const sec = relations.sections = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.navLinks =
+      relation('generateGroupNavLinks', group);
+
+    if (sprawl.enableGroupUI) {
+      relations.secondaryNav =
+        relation('generateGroupSecondaryNav', group);
+
+      relations.sidebar =
+        relation('generateGroupSidebar', group);
+    }
+
+    sec.info = {};
+
+    if (!empty(group.urls)) {
+      sec.info.visitLinks =
+        group.urls
+          .map(url => relation('linkExternal', url));
+    }
+
+    if (group.description) {
+      sec.info.description =
+        relation('transformContent', group.description);
+    }
+
+    if (!empty(query.albums)) {
+      sec.albums = {};
+
+      sec.albums.heading =
+        relation('generateContentHeading');
+
+      sec.albums.galleryLink =
+        relation('linkGroupGallery', group);
+
+      sec.albums.albumColorStyles =
+        query.albums
+          .map(album => relation('generateColorStyleAttribute', album.color));
+
+      sec.albums.albumLinks =
+        query.albums
+          .map(album => relation('linkAlbum', album));
+
+      sec.albums.otherGroupLinks =
+        query.albumOtherGroups
+          .map(groups => groups
+            .map(group => relation('linkGroup', group)));
+
+      sec.albums.datetimestamps =
+        group.albums.map(album =>
+          (album.date
+            ? relation('generateAbsoluteDatetimestamp', album.date)
+            : null));
+    }
+
+    return relations;
+  },
+
+  data(query, sprawl, group) {
+    const data = {};
+
+    data.name = group.name;
+    data.color = group.color;
+
+    return data;
+  },
+
+  generate(data, relations, {html, language}) {
+    const {sections: sec} = relations;
+
+    return relations.layout
+      .slots({
+        title: language.$('groupInfoPage.title', {group: data.name}),
+        headingMode: 'sticky',
+        color: data.color,
+
+        mainContent: [
+          sec.info.visitLinks &&
+            html.tag('p',
+              language.$('releaseInfo.visitOn', {
+                links:
+                  language.formatDisjunctionList(
+                    sec.info.visitLinks
+                      .map(link => link.slot('context', 'group'))),
+              })),
+
+          html.tag('blockquote',
+            {[html.onlyIfContent]: true},
+            sec.info.description
+              ?.slot('mode', 'multiline')),
+
+          sec.albums && [
+            sec.albums.heading
+              .slots({
+                tag: 'h2',
+                title: language.$('groupInfoPage.albumList.title'),
+              }),
+
+            html.tag('p',
+              language.$('groupInfoPage.viewAlbumGallery', {
+                link:
+                  sec.albums.galleryLink
+                    .slot('content', language.$('groupInfoPage.viewAlbumGallery.link')),
+              })),
+
+            html.tag('ul',
+              stitchArrays({
+                albumLink: sec.albums.albumLinks,
+                otherGroupLinks: sec.albums.otherGroupLinks,
+                datetimestamp: sec.albums.datetimestamps,
+                albumColorStyle: sec.albums.albumColorStyles,
+              }).map(({
+                  albumLink,
+                  otherGroupLinks,
+                  datetimestamp,
+                  albumColorStyle,
+                }) => {
+                  const prefix = 'groupInfoPage.albumList.item';
+                  const parts = [prefix];
+                  const options = {};
+
+                  options.album =
+                    albumLink.slot('color', false);
+
+                  if (datetimestamp) {
+                    parts.push('withYear');
+                    options.yearAccent =
+                      language.$(prefix, 'yearAccent', {
+                        year:
+                          datetimestamp.slots({style: 'year', tooltip: true}),
+                      });
+                  }
+
+                  if (!empty(otherGroupLinks)) {
+                    parts.push('withOtherGroup');
+                    options.otherGroupAccent =
+                      html.tag('span', {class: 'other-group-accent'},
+                        language.$(prefix, 'otherGroupAccent', {
+                          groups:
+                            language.formatConjunctionList(
+                              otherGroupLinks.map(groupLink =>
+                                groupLink.slot('color', false))),
+                        }));
+                  }
+
+                  return (
+                    html.tag('li',
+                      albumColorStyle,
+                      language.$(...parts, options)));
+                })),
+          ],
+        ],
+
+        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/generateGroupNavLinks.js b/src/content/dependencies/generateGroupNavLinks.js
new file mode 100644
index 0000000..5cde2ab
--- /dev/null
+++ b/src/content/dependencies/generateGroupNavLinks.js
@@ -0,0 +1,104 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'linkGroup',
+    'linkGroupGallery',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({groupCategoryData, wikiInfo}) {
+    return {
+      groupCategoryData,
+      enableGroupUI: wikiInfo.enableGroupUI,
+      enableListings: wikiInfo.enableListings,
+    };
+  },
+
+  relations(relation, sprawl, group) {
+    if (!sprawl.enableGroupUI) {
+      return {};
+    }
+
+    const relations = {};
+
+    relations.mainLink =
+      relation('linkGroup', group);
+
+    relations.infoLink =
+      relation('linkGroup', group);
+
+    if (!empty(group.albums)) {
+      relations.galleryLink =
+        relation('linkGroupGallery', group);
+    }
+
+    return relations;
+  },
+
+  data(sprawl) {
+    return {
+      enableGroupUI: sprawl.enableGroupUI,
+      enableListings: sprawl.enableListings,
+    };
+  },
+
+  slots: {
+    showExtraLinks: {type: 'boolean', default: false},
+
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate(data, relations, slots, {language}) {
+    if (!data.enableGroupUI) {
+      return [
+        {auto: 'home'},
+        {auto: 'current'},
+      ];
+    }
+
+    const infoLink =
+      relations.infoLink.slots({
+        attributes: {class: slots.currentExtra === null && 'current'},
+        content: language.$('misc.nav.info'),
+      });
+
+    const extraLinks = [
+      relations.galleryLink?.slots({
+        attributes: {class: slots.currentExtra === 'gallery' && 'current'},
+        content: language.$('misc.nav.gallery'),
+      }),
+    ];
+
+    const extrasPart =
+      (empty(extraLinks)
+        ? ''
+        : language.formatUnitList([infoLink, ...extraLinks]));
+
+    const accent =
+      (extrasPart
+        ? `(${extrasPart})`
+        : null);
+
+    return [
+      {auto: 'home'},
+
+      data.enableListings &&
+        {
+          path: ['localized.listingIndex'],
+          title: language.$('listingIndex.title'),
+        },
+
+      {
+        accent,
+        html:
+          language.$('groupPage.nav.group', {
+            group: relations.mainLink,
+          }),
+      },
+    ].filter(Boolean);
+  },
+};
diff --git a/src/content/dependencies/generateGroupSecondaryNav.js b/src/content/dependencies/generateGroupSecondaryNav.js
new file mode 100644
index 0000000..17eb508
--- /dev/null
+++ b/src/content/dependencies/generateGroupSecondaryNav.js
@@ -0,0 +1,100 @@
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleAttribute',
+    'generatePreviousNextLinks',
+    'generateSecondaryNav',
+    'linkGroupDynamically',
+    'linkListing',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({listingSpec, wikiInfo}) => ({
+    groupsByCategoryListing:
+      (wikiInfo.enableListings
+        ? listingSpec
+            .find(l => l.directory === 'groups/by-category')
+        : null),
+  }),
+
+  query(sprawl, group) {
+    const groups = group.category.groups;
+    const index = groups.indexOf(group);
+
+    return {
+      previousGroup:
+        atOffset(groups, index, -1),
+
+      nextGroup:
+        atOffset(groups, index, +1),
+    };
+  },
+
+  relations(relation, query, sprawl, group) {
+    const relations = {};
+
+    relations.secondaryNav =
+      relation('generateSecondaryNav');
+
+    if (sprawl.groupsByCategoryListing) {
+      relations.categoryLink =
+        relation('linkListing', sprawl.groupsByCategoryListing);
+    }
+
+    relations.colorStyle =
+      relation('generateColorStyleAttribute', group.category.color);
+
+    if (query.previousGroup || query.nextGroup) {
+      relations.previousNextLinks =
+        relation('generatePreviousNextLinks');
+    }
+
+    relations.previousGroupLink =
+      (query.previousGroup
+        ? relation('linkGroupDynamically', query.previousGroup)
+        : null);
+
+    relations.nextGroupLink =
+      (query.nextGroup
+        ? relation('linkGroupDynamically', query.nextGroup)
+        : null);
+
+    return relations;
+  },
+
+  data: (query, sprawl, group) => ({
+    categoryName: group.category.name,
+  }),
+
+  generate(data, relations, {html, language}) {
+    const {content: previousNextPart} =
+      relations.previousNextLinks.slots({
+        previousLink: relations.previousGroupLink,
+        nextLink: relations.nextGroupLink,
+        id: true,
+      });
+
+    const {categoryLink} = relations;
+
+    categoryLink?.setSlot('content', data.categoryName);
+
+    return relations.secondaryNav.slots({
+      class: 'nav-links-groups',
+      content:
+        (relations.previousGroupLink || relations.nextGroupLink
+          ? html.tag('span', {class: 'nav-link'},
+              relations.colorStyle.slot('context', 'primary-only'),
+
+              [
+                categoryLink?.slot('color', false),
+                `(${language.formatUnitList(previousNextPart)})`,
+              ])
+       : categoryLink
+          ? html.tag('span', {class: 'nav-link'},
+              categoryLink)
+          : html.blank()),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js
new file mode 100644
index 0000000..3abb339
--- /dev/null
+++ b/src/content/dependencies/generateGroupSidebar.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: [
+    'generateGroupSidebarCategoryDetails',
+    'generatePageSidebar',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({groupCategoryData}) => ({groupCategoryData}),
+
+  relations: (relation, sprawl, group) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    categoryDetails:
+      sprawl.groupCategoryData.map(category =>
+        relation('generateGroupSidebarCategoryDetails', category, group)),
+  }),
+
+  slots: {
+    currentExtra: {
+      validate: v => v.is('gallery'),
+    },
+  },
+
+  generate: (relations, slots, {html, language}) =>
+    relations.sidebar.slots({
+      attributes: {class: 'category-map-sidebar-box'},
+
+      content: [
+        html.tag('h1',
+          language.$('groupSidebar.title')),
+
+        relations.categoryDetails
+          .map(details =>
+            details.slot('currentExtra', slots.currentExtra)),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateGroupSidebarCategoryDetails.js b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
new file mode 100644
index 0000000..69de373
--- /dev/null
+++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js
@@ -0,0 +1,82 @@
+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}) {
+    return html.tag('details',
+      data.isCurrentCategory &&
+        {class: 'current', open: true},
+
+      [
+        html.tag('summary',
+          relations.colorStyle,
+
+          html.tag('span',
+            language.$('groupSidebar.groupList.category', {
+              category:
+                html.tag('span', {class: 'group-name'},
+                  data.name),
+            }))),
+
+        html.tag('ul',
+          stitchArrays(({
+            infoLink: relations.groupInfoLinks,
+            galleryLink: relations.groupGalleryLinks,
+          })).map(({infoLink, galleryLink}, index) =>
+                html.tag('li',
+                  index === data.currentGroupIndex &&
+                    {class: 'current'},
+
+                  language.$('groupSidebar.groupList.item', {
+                    group:
+                      (slots.currentExtra === 'gallery'
+                        ? galleryLink ?? infoLink
+                        : infoLink),
+                  })))),
+      ]);
+  },
+};
diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
new file mode 100644
index 0000000..43a78cb
--- /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,
+            }) =>
+              (additionalFileLinks.length === 1
+                ? html.tag('li',
+                    additionalFileLinks[0].slots({
+                      content:
+                        language.$('listingPage', slots.stringsKey, 'file', {
+                          title: additionalFileTitle,
+                        }),
+                    }))
+
+             : additionalFileLinks.length === 0
+                ? html.tag('li',
+                    language.$('listingPage', slots.stringsKey, 'file.withNoFiles', {
+                      title: additionalFileTitle,
+                    }))
+
+                : html.tag('li', {class: 'has-details'},
+                    html.tag('details', [
+                      html.tag('summary',
+                        html.tag('span',
+                          language.$('listingPage', slots.stringsKey, 'file.withMultipleFiles', {
+                            title:
+                              html.tag('span', {class: 'group-name'},
+                                additionalFileTitle),
+
+                            files:
+                              language.countAdditionalFiles(
+                                additionalFileLinks.length,
+                                {unit: true}),
+                          }))),
+
+                      html.tag('ul',
+                        stitchArrays({
+                          additionalFileLink: additionalFileLinks,
+                          additionalFileFile: additionalFileFiles,
+                        }).map(({additionalFileLink, additionalFileFile}) =>
+                            html.tag('li',
+                              additionalFileLink.slots({
+                                content:
+                                  language.$('listingPage', slots.stringsKey, 'file', {
+                                    title: additionalFileFile,
+                                  }),
+                              })))),
+                    ])))))),
+    ]);
+  },
+};
diff --git a/src/content/dependencies/generateListRandomPageLinksAlbumLink.js b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js
new file mode 100644
index 0000000..b3560ac
--- /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 0000000..ed15365
--- /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', {class: 'group-name'},
+                      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 0000000..23377af
--- /dev/null
+++ b/src/content/dependencies/generateListingPage.js
@@ -0,0 +1,282 @@
+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));
+    }
+
+    if (!empty(listing.seeAlso)) {
+      relations.seeAlsoLinks =
+        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: [
+        relations.sameTargetListingLinks &&
+          html.tag('p',
+            language.$('listingPage.listingsFor', {
+              target:
+                language.$('listingPage.target', data.targetStringsKey),
+
+              listings:
+                language.formatUnitList(
+                  stitchArrays({
+                    link: relations.sameTargetListingLinks,
+                    stringsKey: data.sameTargetListingStringsKeys,
+                  }).map(({link, stringsKey}, index) =>
+                      html.tag('span',
+                        index === data.sameTargetListingsCurrentIndex &&
+                          {class: 'current'},
+
+                        link.slots({
+                          attributes: {class: 'nowrap'},
+                          content: language.$('listingPage', stringsKey, 'title.short'),
+                        })))),
+            })),
+
+        relations.seeAlsoLinks &&
+          html.tag('p',
+            language.$('listingPage.seeAlso', {
+              listings: language.formatUnitList(relations.seeAlsoLinks),
+            })),
+
+        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',
+                    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 0000000..1e5c8bf
--- /dev/null
+++ b/src/content/dependencies/generateListingSidebar.js
@@ -0,0 +1,29 @@
+export default {
+  contentDependencies: [
+    'generateListingIndexList',
+    'generatePageSidebar',
+    'linkListingIndex',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, currentListing) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    listingIndexLink:
+      relation('linkListingIndex'),
+
+    listingIndexList:
+      relation('generateListingIndexList', currentListing),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.sidebar.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 0000000..b57ebe1
--- /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/generateNewsEntryPage.js b/src/content/dependencies/generateNewsEntryPage.js
new file mode 100644
index 0000000..bcba719
--- /dev/null
+++ b/src/content/dependencies/generateNewsEntryPage.js
@@ -0,0 +1,131 @@
+import {sortChronologically} from '#sort';
+import {atOffset} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateNewsEntryReadAnotherLinks',
+    'generatePageLayout',
+    'generatePreviousNextLinks',
+    'linkNewsEntry',
+    '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) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.content =
+      relation('transformContent', newsEntry.content);
+
+    relations.newsIndexLink =
+      relation('linkNewsIndex');
+
+    relations.currentEntryLink =
+      relation('linkNewsEntry', newsEntry);
+
+    if (query.previousEntry || query.nextEntry) {
+      relations.previousNextLinks =
+        relation('generatePreviousNextLinks');
+
+      relations.readAnotherLinks =
+        relation('generateNewsEntryReadAnotherLinks',
+          newsEntry,
+          query.previousEntry,
+          query.nextEntry);
+
+      if (query.previousEntry) {
+        relations.previousEntryNavLink =
+          relation('linkNewsEntry', query.previousEntry);
+      }
+
+      if (query.nextEntry) {
+        relations.nextEntryNavLink =
+          relation('linkNewsEntry', query.nextEntry);
+      }
+    }
+
+    return relations;
+  },
+
+  data(query, sprawl, newsEntry) {
+    return {
+      name: newsEntry.name,
+      date: newsEntry.date,
+
+      daysSincePreviousEntry:
+        query.previousEntry &&
+          Math.round((newsEntry.date - query.previousEntry.date) / 86400000),
+
+      daysUntilNextEntry:
+        query.nextEntry &&
+          Math.round((query.nextEntry.date - newsEntry.date) / 86400000),
+
+      previousEntryDate:
+        query.previousEntry?.date,
+
+      nextEntryDate:
+        query.nextEntry?.date,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    return relations.layout.slots({
+      title:
+        language.$('newsEntryPage.title', {
+          entry: data.name,
+        }),
+
+      headingMode: 'sticky',
+
+      mainClasses: ['long-content'],
+      mainContent: [
+        html.tag('p',
+          language.$('newsEntryPage.published', {
+            date: language.formatDate(data.date),
+          })),
+
+        relations.content,
+        relations.readAnotherLinks,
+      ],
+
+      navLinkStyle: 'hierarchical',
+      navLinks: [
+        {auto: 'home'},
+        {html: relations.newsIndexLink},
+        {
+          auto: 'current',
+          accent:
+            (relations.previousNextLinks
+              ? `(${language.formatUnitList(relations.previousNextLinks.slots({
+                  previousLink: relations.previousEntryNavLink ?? null,
+                  nextLink: relations.nextEntryNavLink ?? null,
+                }).content)})`
+              : null),
+        },
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateNewsEntryReadAnotherLinks.js b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js
new file mode 100644
index 0000000..d978b0e
--- /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 0000000..539af80
--- /dev/null
+++ b/src/content/dependencies/generateNewsIndexPage.js
@@ -0,0 +1,93 @@
+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}) {
+    return relations.layout.slots({
+      title: language.$('newsIndex.title'),
+      headingMode: 'sticky',
+
+      mainClasses: ['long-content', 'news-index'],
+      mainContent:
+        stitchArrays({
+          entryLink: relations.entryLinks,
+          viewRestLink: relations.viewRestLinks,
+          content: relations.entryContents,
+          date: data.entryDates,
+          directory: data.entryDirectories,
+        }).map(({entryLink, viewRestLink, content, date, directory}) =>
+            html.tag('article', {id: directory}, [
+              html.tag('h2', [
+                html.tag('time', language.formatDate(date)),
+                entryLink,
+              ]),
+
+              content,
+
+              viewRestLink
+                ?.slot('content', language.$('newsIndex.entry.viewRest')),
+            ])),
+
+      navLinkStyle: 'hierarchical',
+      navLinks: [
+        {auto: 'home'},
+        {auto: 'current'},
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
new file mode 100644
index 0000000..cbfc905
--- /dev/null
+++ b/src/content/dependencies/generatePageLayout.js
@@ -0,0 +1,672 @@
+import {openAggregate} from '#aggregate';
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateColorStyleRules',
+    'generateFooterLocalizationLinks',
+    'generateStickyHeadingContainer',
+    'transformContent',
+  ],
+
+  extraDependencies: [
+    'cachebust',
+    'getColors',
+    'html',
+    'language',
+    'pagePath',
+    'to',
+    'wikiData',
+  ],
+
+  sprawl({wikiInfo}) {
+    return {
+      footerContent: wikiInfo.footerContent,
+      wikiColor: wikiInfo.color,
+      wikiName: wikiInfo.nameShort,
+    };
+  },
+
+  data({wikiColor, wikiName}) {
+    return {
+      wikiColor,
+      wikiName,
+    };
+  },
+
+  relations(relation, sprawl) {
+    const relations = {};
+
+    relations.footerLocalizationLinks =
+      relation('generateFooterLocalizationLinks');
+
+    relations.stickyHeadingContainer =
+      relation('generateStickyHeadingContainer');
+
+    if (sprawl.footerContent) {
+      relations.defaultFooterContent =
+        relation('transformContent', sprawl.footerContent);
+    }
+
+    relations.colorStyleRules =
+      relation('generateColorStyleRules');
+
+    return relations;
+  },
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    showWikiNameInTitle: {
+      type: 'boolean',
+      default: true,
+    },
+
+    additionalNames: {
+      type: 'html',
+      mutable: false,
+    },
+
+    cover: {
+      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, {
+    cachebust,
+    getColors,
+    html,
+    language,
+    pagePath,
+    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 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: slots.cover,
+          })
+        : html.tag('h1', titleContentsHTML));
+
+    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},
+
+        [
+          titleHTML,
+
+          html.tag('div', {id: 'cover-art-container'},
+            {[html.onlyIfContent]: true},
+            slots.cover),
+
+          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) => {
+                let content;
+
+                if (cur.html) {
+                  content = cur.html;
+                } else {
+                  let title;
+                  let href;
+
+                  switch (cur.auto) {
+                    case 'home':
+                      title = data.wikiName;
+                      href = to('localized.home');
+                      break;
+                    case 'current':
+                      title = slots.title;
+                      href = '';
+                      break;
+                    case null:
+                    case undefined:
+                      title = cur.title;
+                      href = to(...cur.path);
+                      break;
+                  }
+
+                  content = html.tag('a',
+                    {href},
+                    title);
+                }
+
+                const showAsCurrent =
+                  cur.current ||
+                  cur.auto === 'current' ||
+                  (slots.navLinkStyle === 'hierarchical' &&
+                    i === slots.navLinks.length - 1);
+
+                return (
+                  html.tag('span', {class: 'nav-link'},
+                    showAsCurrent &&
+                      {class: 'current'},
+
+                    i > 0 &&
+                      {class: 'has-divider'},
+
+                    [
+                      html.tag('span', {class: 'nav-link-content'},
+                        // Use inline-block styling on the content span,
+                        // rather than wrapping the whole nav-link in a proper
+                        // blockwrap, so that if the content spans multiple
+                        // lines, it'll kick the accent down beneath it.
+                        i > 0 &&
+                          {class: 'blockwrap'},
+
+                        content),
+
+                      html.tag('span', {class: 'nav-link-accent'},
+                        {[html.onlyIfContent]: true},
+                        cur.accent),
+                    ]));
+              })),
+
+          html.tag('div', {class: 'nav-bottom-row'},
+            {[html.onlyIfContent]: true},
+            slots.navBottomRowContent),
+
+          html.tag('div', {class: 'nav-content'},
+            {[html.onlyIfContent]: true},
+            slots.navContent),
+        ]);
+
+    const getSidebar = (side, id) =>
+      (html.isBlank(slots[side])
+        ? html.blank()
+        : slots[side].slots({
+            attributes:
+              slots[side]
+                .getSlotValue('attributes')
+                .with({id}),
+          }));
+
+    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left');
+    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right');
+
+    const hasSidebarLeft = !html.isBlank(html.resolve(leftSidebar));
+    const hasSidebarRight = !html.isBlank(html.resolve(rightSidebar));
+
+    const collapseSidebars =
+      (hasSidebarLeft
+        ? leftSidebar.getSlotValue('collapse')
+        : true) &&
+      (hasSidebarRight
+        ? rightSidebar.getSlotValue('collapse')
+        : true);
+
+    const processSkippers = skipperList =>
+      skipperList
+        .filter(({condition, id}) =>
+          (condition === undefined
+            ? hasID(id)
+            : condition))
+        .map(({id, string}) =>
+          html.tag('span', {class: 'skipper'},
+            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'},
+            ])),
+        ]);
+
+    const imageOverlayHTML = html.tag('div', {id: 'image-overlay-container'},
+      html.tag('div', {id: 'image-overlay-content-container'}, [
+        html.tag('a', {id: 'image-overlay-image-container'}, [
+          html.tag('img', {id: 'image-overlay-image'}),
+          html.tag('img', {id: 'image-overlay-image-thumb'}),
+        ]),
+        html.tag('div', {id: 'image-overlay-action-container'}, [
+          html.tag('div', {id: 'image-overlay-action-content-without-size'},
+            language.$('releaseInfo.viewOriginalFile', {
+              link: html.tag('a', {class: 'image-overlay-view-original'},
+                language.$('releaseInfo.viewOriginalFile.link')),
+            })),
+
+          html.tag('div', {id: 'image-overlay-action-content-with-size'}, [
+            language.$('releaseInfo.viewOriginalFile.withSize', {
+              link:
+                html.tag('a', {class: 'image-overlay-view-original'},
+                  language.$('releaseInfo.viewOriginalFile.link')),
+
+              size:
+                html.tag('span',
+                  {[html.joinChildren]: ''},
+                  [
+                    html.tag('span', {id: 'image-overlay-file-size-kilobytes'},
+                      language.$('count.fileSize.kilobytes', {
+                        kilobytes:
+                          html.tag('span', {class: 'image-overlay-file-size-count'}),
+                      })),
+
+                    html.tag('span', {id: 'image-overlay-file-size-megabytes'},
+                      language.$('count.fileSize.megabytes', {
+                        megabytes:
+                          html.tag('span', {class: 'image-overlay-file-size-count'}),
+                      })),
+                  ]),
+            }),
+
+            html.tag('span', {id: 'image-overlay-file-size-warning'},
+              language.$('releaseInfo.viewOriginalFile.sizeWarning')),
+          ]),
+        ]),
+      ]));
+
+    const layoutHTML = [
+      navHTML,
+
+      slots.bannerPosition === 'top' &&
+        slots.banner,
+
+      slots.secondaryNav,
+
+      html.tag('div', {class: 'layout-columns'},
+        !collapseSidebars &&
+          {class: 'vertical-when-thin'},
+
+        [
+          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-data': to('data.root')},
+
+        [
+          // developersComment,
+
+          html.tag('head', [
+            html.tag('title',
+              (slots.showWikiNameInTitle
+                ? language.formatString('misc.pageTitle.withWikiName', {
+                    title: slots.title,
+                    wikiName: data.wikiName,
+                  })
+                : language.formatString('misc.pageTitle', {
+                    title: slots.title,
+                  }))),
+
+            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}))),
+
+            canonical &&
+              html.tag('link', {
+                rel: 'canonical',
+                href: canonical,
+              }),
+
+            ...(
+              localizedCanonical
+                .map(({lang, href}) => html.tag('link', {
+                  rel: 'alternate',
+                  hreflang: lang,
+                  href,
+                }))),
+
+            */
+
+            hasSocialEmbed &&
+              slots.socialEmbed
+                .clone()
+                .slot('mode', 'html'),
+
+            html.tag('link', {
+              rel: 'stylesheet',
+              href: to('shared.staticFile', 'site6.css', cachebust),
+            }),
+
+            html.tag('style', [
+              relations.colorStyleRules
+                .slot('color', slots.color ?? data.wikiColor),
+              slots.styleRules,
+            ]),
+
+            html.tag('script', {
+              src: to('shared.staticFile', 'lazy-loading.js', cachebust),
+            }),
+          ]),
+
+          html.tag('body',
+            [
+              html.tag('div', {id: 'page-container'},
+                (hasSidebarLeft || hasSidebarRight
+                  ? {class: 'has-one-sidebar'}
+                  : {class: 'has-zero-sidebars'}),
+
+                hasSidebarLeft && hasSidebarRight &&
+                  {class: 'has-two-sidebars'},
+
+                hasSidebarLeft &&
+                  {class: 'has-sidebar-left'},
+
+                hasSidebarRight &&
+                  {class: 'has-sidebar-right'},
+
+                [
+                  skippersHTML,
+                  layoutHTML,
+                ]),
+
+              // infoCardHTML,
+              imageOverlayHTML,
+
+              html.tag('script', {
+                type: 'module',
+                src: to('shared.staticFile', 'client3.js', cachebust),
+              }),
+            ]),
+        ])
+    ]).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 0000000..a7da3d1
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebar.js
@@ -0,0 +1,103 @@
+export default {
+  contentDependencies: ['generatePageSidebarBox'],
+  extraDependencies: ['html'],
+
+  relations: (relation) => ({
+    box:
+      relation('generatePageSidebarBox'),
+  }),
+
+  slots: {
+    // Content is a flat HTML array. It'll all be placed into one sidebar box
+    // if specified.
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    // Attributes to apply to the whole sidebar. If specifying multiple
+    // sections, this be added to the containing sidebar-column, arr - specify
+    // attributes on each section if that's more suitable.
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    // Chunks of content to be split into separate boxes 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.
+    //
+    // 'last' - last or only sidebar box is sticky
+    // 'column' - entire column, incl. multiple boxes from top, is sticky
+    // 'static' - sidebar not sticky at all, stays at top of page
+    //
+    // Note: This doesn't affect the content of any sidebar section, only
+    // the whole section's containing box (or the sidebar column as a whole).
+    stickyMode: {
+      validate: v => v.is('last', 'column', 'static'),
+      default: 'static',
+    },
+
+    // Collapsing sidebars disappear when the viewport is sufficiently
+    // thin. (This is the default.) Override as false to make the sidebar
+    // stay visible in thinner viewports, where the page layout will be
+    // reflowed so the sidebar is as wide as the screen and appears below
+    // nav, above the main content.
+    collapse: {
+      type: 'boolean',
+      default: true,
+    },
+
+    // Wide sidebars generally take up more horizontal space in the normal
+    // page layout, and should be used if the content of the sidebar has
+    // a greater than typical focus compared to main content.
+    wide: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate(relations, slots, {html}) {
+    const attributes =
+      html.attributes({class: [
+        'sidebar-column',
+        'sidebar-multiple',
+      ]});
+
+    attributes.add(slots.attributes);
+
+    if (slots.class) {
+      attributes.add('class', slots.class);
+    }
+
+    if (slots.wide) {
+      attributes.add('class', 'wide');
+    }
+
+    if (!slots.collapse) {
+      attributes.add('class', 'no-hide');
+    }
+
+    if (slots.stickyMode !== 'static') {
+      attributes.add('class', `sticky-${slots.stickyMode}`);
+    }
+
+    const boxes =
+      (!html.isBlank(slots.boxes)
+        ? slots.boxes
+     : !html.isBlank(slots.content)
+        ? relations.box.slot('content', slots.content)
+        : html.blank());
+
+    if (html.isBlank(boxes)) {
+      return html.blank();
+    } else {
+      return html.tag('div', attributes, boxes);
+    }
+  },
+};
diff --git a/src/content/dependencies/generatePageSidebarBox.js b/src/content/dependencies/generatePageSidebarBox.js
new file mode 100644
index 0000000..5183545
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebarBox.js
@@ -0,0 +1,20 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('div', {class: 'sidebar'},
+      slots.attributes,
+      slots.content),
+};
diff --git a/src/content/dependencies/generatePageSidebarConjoinedBox.js b/src/content/dependencies/generatePageSidebarConjoinedBox.js
new file mode 100644
index 0000000..05b1d46
--- /dev/null
+++ b/src/content/dependencies/generatePageSidebarConjoinedBox.js
@@ -0,0 +1,42 @@
+// 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', {
+                style:
+                  `border-color: var(--primary-color); ` +
+                  `border-style: none none dotted none`,
+              }),
+          ]),
+    }),
+};
diff --git a/src/content/dependencies/generatePreviousNextLinks.js b/src/content/dependencies/generatePreviousNextLinks.js
new file mode 100644
index 0000000..9771de3
--- /dev/null
+++ b/src/content/dependencies/generatePreviousNextLinks.js
@@ -0,0 +1,50 @@
+export default {
+  // Returns an array with the slotted previous and next links, prepared
+  // for inclusion in a page's navigation bar. Include with other links
+  // in the nav bar and then join them all as a unit list, for example.
+
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    previousLink: {
+      type: 'html',
+      mutable: true,
+    },
+
+    nextLink: {
+      type: 'html',
+      mutable: true,
+    },
+
+    id: {
+      type: 'boolean',
+      default: true,
+    },
+  },
+
+  generate(slots, {html, language}) {
+    const previousNext = [];
+
+    if (!html.isBlank(slots.previousLink)) {
+      previousNext.push(
+        slots.previousLink.slots({
+          tooltipStyle: 'browser',
+          color: false,
+          attributes: {id: slots.id && 'previous-button'},
+          content: language.$('misc.nav.previous'),
+        }));
+    }
+
+    if (!html.isBlank(slots.nextLink)) {
+      previousNext.push(
+        slots.nextLink.slots({
+          tooltipStyle: 'browser',
+          color: false,
+          attributes: {id: slots.id && 'next-button'},
+          content: language.$('misc.nav.next'),
+        }));
+    }
+
+    return previousNext;
+  },
+};
diff --git a/src/content/dependencies/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js
new file mode 100644
index 0000000..a997de0
--- /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 0000000..2e6c470
--- /dev/null
+++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js
@@ -0,0 +1,42 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['linkContribution'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, contributions) {
+    if (empty(contributions)) {
+      return {};
+    }
+
+    return {
+      contributionLinks:
+        contributions
+          .map(contrib => relation('linkContribution', contrib)),
+    };
+  },
+
+  slots: {
+    stringKey: {type: 'string'},
+
+    showContribution: {type: 'boolean', default: true},
+    showIcons: {type: 'boolean', default: true},
+  },
+
+  generate(relations, slots, {html, language}) {
+    if (!relations.contributionLinks) {
+      return html.blank();
+    }
+
+    return language.$(slots.stringKey, {
+      artists:
+        language.formatConjunctionList(
+          relations.contributionLinks.map(link =>
+            link.slots({
+              showContribution: slots.showContribution,
+              showIcons: slots.showIcons,
+              iconMode: 'tooltip',
+            }))),
+    });
+  },
+};
diff --git a/src/content/dependencies/generateSecondaryNav.js b/src/content/dependencies/generateSecondaryNav.js
new file mode 100644
index 0000000..e9aef66
--- /dev/null
+++ b/src/content/dependencies/generateSecondaryNav.js
@@ -0,0 +1,20 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+
+    class: {
+      validate: v => v.anyOf(v.isString, v.sparseArrayOf(v.isString)),
+    },
+  },
+
+  generate: (slots, {html}) =>
+    html.tag('nav', {id: 'secondary-nav'},
+      {[html.onlyIfContent]: true},
+      {class: slots.class},
+      slots.content),
+};
diff --git a/src/content/dependencies/generateSocialEmbed.js b/src/content/dependencies/generateSocialEmbed.js
new file mode 100644
index 0000000..0144c7f
--- /dev/null
+++ b/src/content/dependencies/generateSocialEmbed.js
@@ -0,0 +1,65 @@
+export default {
+  extraDependencies: ['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: {type: 'string'},
+  },
+
+  generate(data, slots, {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: slots.imagePath}),
+        ]);
+
+      case 'json':
+        return JSON.stringify({
+          author_name:
+            (slots.headingContent
+              ? language.$('misc.socialEmbed.heading', {
+                  wikiName: data.shortWikiName,
+                  heading: slots.headingContent,
+                })
+              : 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 0000000..226152c
--- /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 0000000..9becfb2
--- /dev/null
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -0,0 +1,34 @@
+export default {
+  extraDependencies: ['html'],
+
+  slots: {
+    title: {
+      type: 'html',
+      mutable: false,
+    },
+
+    cover: {
+      type: 'html',
+      mutable: true,
+    },
+  },
+
+  generate: (slots, {html}) =>
+    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', slots.title),
+
+          !html.isBlank(slots.cover) &&
+            html.tag('div', {class: 'content-sticky-heading-cover-container'},
+              html.tag('div', {class: 'content-sticky-heading-cover'},
+                slots.cover.slot('mode', 'thumbnail'))),
+        ]),
+
+        html.tag('div', {class: 'content-sticky-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 0000000..462557d
--- /dev/null
+++ b/src/content/dependencies/generateTextWithTooltip.js
@@ -0,0 +1,62 @@
+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.joinChildren]: '',
+        [html.noEdgeWhitespace]: true,
+        class: 'text-with-tooltip',
+      });
+    }
+
+    const textPart =
+      (hasTooltip && slots.customInteractionCue
+        ? html.tag('span', {class: 'hoverable'},
+            slots.text)
+     : hasTooltip
+        ? html.tag('span', {class: 'hoverable'},
+            html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
+              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 0000000..81f74ae
--- /dev/null
+++ b/src/content/dependencies/generateTooltip.js
@@ -0,0 +1,30 @@
+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},
+      slots.attributes,
+
+      html.tag('span', {class: 'tooltip-content'},
+        {[html.noEdgeWhitespace]: true},
+        slots.contentAttributes,
+        slots.content)),
+};
diff --git a/src/content/dependencies/generateTrackAdditionalNamesBox.js b/src/content/dependencies/generateTrackAdditionalNamesBox.js
new file mode 100644
index 0000000..bad04b7
--- /dev/null
+++ b/src/content/dependencies/generateTrackAdditionalNamesBox.js
@@ -0,0 +1,53 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['generateAdditionalNamesBox'],
+  extraDependencies: ['html'],
+
+  query: (track) => {
+    const {
+      additionalNames: own,
+      sharedAdditionalNames: shared,
+      inferredAdditionalNames: inferred,
+    } = track;
+
+    if (empty(own) && empty(shared) && empty(inferred)) {
+      return {combinedList: []};
+    }
+
+    const firstFilter =
+      (empty(own)
+        ? new Set()
+        : new Set(own.map(({name}) => name)));
+
+    const sharedFiltered =
+      shared.filter(({name}) => !firstFilter.has(name))
+
+    const secondFilter =
+      new Set([
+        ...firstFilter,
+        ...sharedFiltered.map(({name}) => name),
+      ]);
+
+    const inferredFiltered =
+      inferred.filter(({name}) => !secondFilter.has(name));
+
+    return {
+      combinedList: [
+        ...own,
+        ...sharedFiltered,
+        ...inferredFiltered,
+      ],
+    };
+  },
+
+  relations: (relation, query) => ({
+    box:
+      (empty(query.combinedList)
+        ? null
+        : relation('generateAdditionalNamesBox', query.combinedList)),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.box ?? html.blank(),
+};
diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js
new file mode 100644
index 0000000..a241eaf
--- /dev/null
+++ b/src/content/dependencies/generateTrackCoverArtwork.js
@@ -0,0 +1,34 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations: (relation, track) => ({
+    coverArtwork:
+      relation('generateCoverArtwork',
+        (track.hasUniqueCoverArt
+          ? track.artTags
+          : track.album.artTags)),
+  }),
+
+  data: (track) => ({
+    path:
+      (track.hasUniqueCoverArt
+        ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
+        : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension]),
+
+    color:
+      track.color,
+
+    dimensions:
+      (track.hasUniqueCoverArt
+        ? track.coverArtDimensions
+        : track.album.coverArtDimensions),
+  }),
+
+  generate: (data, relations) =>
+    relations.coverArtwork.slots({
+      path: data.path,
+      color: data.color,
+      dimensions: data.dimensions,
+    }),
+};
+
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
new file mode 100644
index 0000000..1b5fbbf
--- /dev/null
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -0,0 +1,652 @@
+import {sortAlbumsTracksChronologically, sortFlashesChronologically}
+  from '#sort';
+import {empty, stitchArrays} from '#sugar';
+
+import getChronologyRelations from '../util/getChronologyRelations.js';
+
+export default {
+  contentDependencies: [
+    'generateAbsoluteDatetimestamp',
+    'generateAdditionalFilesShortcut',
+    'generateAlbumAdditionalFilesList',
+    'generateAlbumNavAccent',
+    'generateAlbumSidebar',
+    'generateAlbumStyleRules',
+    'generateChronologyLinks',
+    'generateColorStyleAttribute',
+    'generateCommentarySection',
+    'generateContentHeading',
+    'generateContributionList',
+    'generatePageLayout',
+    'generateRelativeDatetimestamp',
+    'generateTrackAdditionalNamesBox',
+    'generateTrackCoverArtwork',
+    'generateTrackList',
+    'generateTrackListDividedByGroups',
+    'generateTrackReleaseInfo',
+    'generateTrackSocialEmbed',
+    'linkAlbum',
+    'linkArtist',
+    'linkFlash',
+    'linkTrack',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      divideTrackListsByGroups: wikiInfo.divideTrackListsByGroups,
+      enableFlashesAndGames: wikiInfo.enableFlashesAndGames,
+    };
+  },
+
+  relations(relation, sprawl, track) {
+    const relations = {};
+    const sections = relations.sections = {};
+    const {album} = track;
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.albumStyleRules =
+      relation('generateAlbumStyleRules', track.album, track);
+
+    relations.socialEmbed =
+      relation('generateTrackSocialEmbed', track);
+
+    relations.artistChronologyContributions =
+      getChronologyRelations(track, {
+        contributions: [
+          ...track.artistContribs ?? [],
+          ...track.contributorContribs ?? [],
+        ],
+
+        linkArtist: artist => relation('linkArtist', artist),
+        linkThing: track => relation('linkTrack', track),
+
+        getThings(artist) {
+          const getDate = thing => thing.date;
+
+          const things = [
+            ...artist.tracksAsArtist,
+            ...artist.tracksAsContributor,
+          ].filter(getDate);
+
+          return sortAlbumsTracksChronologically(things, {getDate});
+        },
+      });
+
+    relations.coverArtistChronologyContributions =
+      getChronologyRelations(track, {
+        contributions: track.coverArtistContribs ?? [],
+
+        linkArtist: artist => relation('linkArtist', artist),
+
+        linkThing: trackOrAlbum =>
+          (trackOrAlbum.album
+            ? relation('linkTrack', trackOrAlbum)
+            : relation('linkAlbum', trackOrAlbum)),
+
+        getThings(artist) {
+          const getDate = thing => thing.coverArtDate ?? thing.date;
+
+          const things = [
+            ...artist.albumsAsCoverArtist,
+            ...artist.tracksAsCoverArtist,
+          ].filter(getDate);
+
+          return sortAlbumsTracksChronologically(things, {getDate});
+        },
+      }),
+
+    relations.albumLink =
+      relation('linkAlbum', track.album);
+
+    relations.trackLink =
+      relation('linkTrack', track);
+
+    relations.albumNavAccent =
+      relation('generateAlbumNavAccent', track.album, track);
+
+    relations.chronologyLinks =
+      relation('generateChronologyLinks');
+
+    relations.sidebar =
+      relation('generateAlbumSidebar', track.album, track);
+
+    const additionalFilesSection = additionalFiles => ({
+      heading: relation('generateContentHeading'),
+      list: relation('generateAlbumAdditionalFilesList', album, additionalFiles),
+    });
+
+    // This'll take care of itself being blank if there's nothing to show here.
+    relations.additionalNamesBox =
+      relation('generateTrackAdditionalNamesBox', track);
+
+    if (track.hasUniqueCoverArt || album.hasCoverArt) {
+      relations.cover =
+        relation('generateTrackCoverArtwork', track);
+    }
+
+    // Section: Release info
+
+    relations.releaseInfo =
+      relation('generateTrackReleaseInfo', track);
+
+    // Section: Extra links
+
+    const extra = sections.extra = {};
+
+    if (!empty(track.additionalFiles)) {
+      extra.additionalFilesShortcut =
+        relation('generateAdditionalFilesShortcut', track.additionalFiles);
+    }
+
+    // Section: Other releases
+
+    if (!empty(track.otherReleases)) {
+      const otherReleases = sections.otherReleases = {};
+
+      otherReleases.heading =
+        relation('generateContentHeading');
+
+      otherReleases.colorStyles =
+        track.otherReleases
+          .map(track => relation('generateColorStyleAttribute', track.color));
+
+      otherReleases.trackLinks =
+        track.otherReleases
+          .map(track => relation('linkTrack', track));
+
+      otherReleases.albumLinks =
+        track.otherReleases
+          .map(track => relation('linkAlbum', track.album));
+
+      otherReleases.datetimestamps =
+        track.otherReleases.map(track2 =>
+          (track2.date
+            ? (track.date
+                ? relation('generateRelativeDatetimestamp',
+                    track2.date,
+                    track.date)
+                : relation('generateAbsoluteDatetimestamp',
+                    track2.date))
+            : null));
+
+      otherReleases.items =
+        track.otherReleases.map(track => ({
+          trackLink: relation('linkTrack', track),
+          albumLink: relation('linkAlbum', track.album),
+        }));
+    }
+
+    // Section: Contributors
+
+    if (!empty(track.contributorContribs)) {
+      const contributors = sections.contributors = {};
+
+      contributors.heading =
+        relation('generateContentHeading');
+
+      contributors.list =
+        relation('generateContributionList', track.contributorContribs);
+    }
+
+    // Section: Referenced tracks
+
+    if (!empty(track.referencedTracks)) {
+      const references = sections.references = {};
+
+      references.heading =
+        relation('generateContentHeading');
+
+      references.list =
+        relation('generateTrackList', track.referencedTracks);
+    }
+
+    // Section: Sampled tracks
+
+    if (!empty(track.sampledTracks)) {
+      const samples = sections.samples = {};
+
+      samples.heading =
+        relation('generateContentHeading');
+
+      samples.list =
+        relation('generateTrackList', track.sampledTracks);
+    }
+
+    // Section: Tracks that reference
+
+    if (!empty(track.referencedByTracks)) {
+      const referencedBy = sections.referencedBy = {};
+
+      referencedBy.heading =
+        relation('generateContentHeading');
+
+      referencedBy.list =
+        relation('generateTrackListDividedByGroups',
+          track.referencedByTracks,
+          sprawl.divideTrackListsByGroups);
+    }
+
+    // Section: Tracks that sample
+
+    if (!empty(track.sampledByTracks)) {
+      const sampledBy = sections.sampledBy = {};
+
+      sampledBy.heading =
+        relation('generateContentHeading');
+
+      sampledBy.list =
+        relation('generateTrackListDividedByGroups',
+          track.sampledByTracks,
+          sprawl.divideTrackListsByGroups);
+    }
+
+    // Section: Flashes that feature
+
+    if (sprawl.enableFlashesAndGames) {
+      const sortedFeatures =
+        sortFlashesChronologically(
+          [track, ...track.otherReleases].flatMap(track =>
+            track.featuredInFlashes.map(flash => ({
+              // These aren't going to be exposed directly, they're processed
+              // into the appropriate relations after this sort.
+              flash, track,
+
+              // These properties are only used for the sort.
+              act: flash.act,
+              date: flash.date,
+            }))));
+
+      if (!empty(sortedFeatures)) {
+        const flashesThatFeature = sections.flashesThatFeature = {};
+
+        flashesThatFeature.heading =
+          relation('generateContentHeading');
+
+        flashesThatFeature.entries =
+          sortedFeatures.map(({flash, track: directlyFeaturedTrack}) =>
+            (directlyFeaturedTrack === track
+              ? {
+                  flashLink: relation('linkFlash', flash),
+                }
+              : {
+                  flashLink: relation('linkFlash', flash),
+                  trackLink: relation('linkTrack', directlyFeaturedTrack),
+                }));
+      }
+    }
+
+    // Section: Lyrics
+
+    if (track.lyrics) {
+      const lyrics = sections.lyrics = {};
+
+      lyrics.heading =
+        relation('generateContentHeading');
+
+      lyrics.content =
+        relation('transformContent', track.lyrics);
+    }
+
+    // Sections: Sheet music files, MIDI/proejct files, additional files
+
+    if (!empty(track.sheetMusicFiles)) {
+      sections.sheetMusicFiles = additionalFilesSection(track.sheetMusicFiles);
+    }
+
+    if (!empty(track.midiProjectFiles)) {
+      sections.midiProjectFiles = additionalFilesSection(track.midiProjectFiles);
+    }
+
+    if (!empty(track.additionalFiles)) {
+      sections.additionalFiles = additionalFilesSection(track.additionalFiles);
+    }
+
+    // Section: Artist commentary
+
+    if (track.commentary) {
+      sections.artistCommentary =
+        relation('generateCommentarySection', track.commentary);
+    }
+
+    return relations;
+  },
+
+  data(sprawl, track) {
+    return {
+      name: track.name,
+      color: track.color,
+
+      hasTrackNumbers: track.album.hasTrackNumbers,
+      trackNumber: track.album.tracks.indexOf(track) + 1,
+
+      numAdditionalFiles: track.additionalFiles.length,
+    };
+  },
+
+  generate(data, relations, {html, language}) {
+    const {sections: sec} = relations;
+
+    return relations.layout
+      .slots({
+        title: language.$('trackPage.title', {track: data.name}),
+        headingMode: 'sticky',
+
+        additionalNames: relations.additionalNamesBox,
+
+        color: data.color,
+        styleRules: [relations.albumStyleRules],
+
+        cover:
+          (relations.cover
+            ? relations.cover.slots({
+                alt: language.$('misc.alt.trackCover'),
+              })
+            : null),
+
+        mainContent: [
+          relations.releaseInfo,
+
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            {[html.joinChildren]: html.tag('br')},
+
+            [
+              sec.sheetMusicFiles &&
+                language.$('releaseInfo.sheetMusicFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#sheet-music-files'},
+                    language.$('releaseInfo.sheetMusicFiles.shortcut.link')),
+                }),
+
+              sec.midiProjectFiles &&
+                language.$('releaseInfo.midiProjectFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#midi-project-files'},
+                    language.$('releaseInfo.midiProjectFiles.shortcut.link')),
+                }),
+
+              sec.additionalFiles &&
+                sec.extra.additionalFilesShortcut,
+
+              sec.artistCommentary &&
+                language.$('releaseInfo.readCommentary', {
+                  link: html.tag('a',
+                    {href: '#artist-commentary'},
+                    language.$('releaseInfo.readCommentary.link')),
+                }),
+            ]),
+
+          sec.otherReleases && [
+            sec.otherReleases.heading
+              .slots({
+                id: 'also-released-as',
+                title: language.$('releaseInfo.alsoReleasedAs'),
+              }),
+
+            html.tag('ul',
+              stitchArrays({
+                trackLink: sec.otherReleases.trackLinks,
+                albumLink: sec.otherReleases.albumLinks,
+                datetimestamp: sec.otherReleases.datetimestamps,
+                colorStyle: sec.otherReleases.colorStyles,
+              }).map(({
+                  trackLink,
+                  albumLink,
+                  datetimestamp,
+                  colorStyle,
+                }) => {
+                  const parts = ['releaseInfo.alsoReleasedAs.item'];
+                  const options = {};
+
+                  options.track = trackLink.slot('color', false);
+                  options.album = albumLink;
+
+                  if (datetimestamp) {
+                    parts.push('withYear');
+                    options.year =
+                      datetimestamp.slots({
+                        style: 'year',
+                        tooltip: true,
+                      });
+                  }
+
+                  return (
+                    html.tag('li',
+                      colorStyle,
+                      language.$(...parts, options)));
+                })),
+          ],
+
+          sec.contributors && [
+            sec.contributors.heading
+              .slots({
+                id: 'contributors',
+                title: language.$('releaseInfo.contributors'),
+              }),
+
+            sec.contributors.list,
+          ],
+
+          sec.references && [
+            sec.references.heading
+              .slots({
+                id: 'references',
+                title:
+                  language.$('releaseInfo.tracksReferenced', {
+                    track: html.tag('i', data.name),
+                  }),
+              }),
+
+            sec.references.list,
+          ],
+
+          sec.samples && [
+            sec.samples.heading
+              .slots({
+                id: 'samples',
+                title:
+                  language.$('releaseInfo.tracksSampled', {
+                    track: html.tag('i', data.name),
+                  }),
+              }),
+
+            sec.samples.list,
+          ],
+
+          sec.referencedBy && [
+            sec.referencedBy.heading
+              .slots({
+                id: 'referenced-by',
+                title:
+                  language.$('releaseInfo.tracksThatReference', {
+                    track: html.tag('i', data.name),
+                  }),
+              }),
+
+            sec.referencedBy.list,
+          ],
+
+          sec.sampledBy && [
+            sec.sampledBy.heading
+              .slots({
+                id: 'referenced-by',
+                title:
+                  language.$('releaseInfo.tracksThatSample', {
+                    track: html.tag('i', data.name),
+                  }),
+              }),
+
+            sec.sampledBy.list,
+          ],
+
+          sec.flashesThatFeature && [
+            sec.flashesThatFeature.heading
+              .slots({
+                id: 'featured-in',
+                title:
+                  language.$('releaseInfo.flashesThatFeature', {
+                    track: html.tag('i', data.name),
+                  }),
+              }),
+
+            html.tag('ul', sec.flashesThatFeature.entries.map(({flashLink, trackLink}) =>
+              (trackLink
+                ? html.tag('li', {class: 'rerelease'},
+                    language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
+                      flash: flashLink,
+                      track: trackLink,
+                    }))
+                : html.tag('li',
+                    language.$('releaseInfo.flashesThatFeature.item', {
+                      flash: flashLink,
+                    }))))),
+          ],
+
+          sec.lyrics && [
+            sec.lyrics.heading
+              .slots({
+                id: 'lyrics',
+                title: language.$('releaseInfo.lyrics'),
+              }),
+
+            html.tag('blockquote',
+              sec.lyrics.content
+                .slot('mode', 'lyrics')),
+          ],
+
+          sec.sheetMusicFiles && [
+            sec.sheetMusicFiles.heading
+              .slots({
+                id: 'sheet-music-files',
+                title: language.$('releaseInfo.sheetMusicFiles.heading'),
+              }),
+
+            sec.sheetMusicFiles.list,
+          ],
+
+          sec.midiProjectFiles && [
+            sec.midiProjectFiles.heading
+              .slots({
+                id: 'midi-project-files',
+                title: language.$('releaseInfo.midiProjectFiles.heading'),
+              }),
+
+            sec.midiProjectFiles.list,
+          ],
+
+          sec.additionalFiles && [
+            sec.additionalFiles.heading
+              .slots({
+                id: 'additional-files',
+                title:
+                  language.$('releaseInfo.additionalFiles.heading', {
+                    additionalFiles:
+                      language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
+                  }),
+              }),
+
+            sec.additionalFiles.list,
+          ],
+
+          sec.artistCommentary,
+        ],
+
+        navLinkStyle: 'hierarchical',
+        navLinks: [
+          {auto: 'home'},
+          {html: relations.albumLink.slot('color', false)},
+          {
+            html:
+              (data.hasTrackNumbers
+                ? language.$('trackPage.nav.track.withNumber', {
+                    number: data.trackNumber,
+                    track: relations.trackLink
+                      .slot('attributes', {class: 'current'}),
+                  })
+                : language.$('trackPage.nav.track', {
+                    track: relations.trackLink
+                      .slot('attributes', {class: 'current'}),
+                  })),
+          },
+        ],
+
+        navBottomRowContent:
+          relations.albumNavAccent.slots({
+            showTrackNavigation: true,
+            showExtraLinks: false,
+          }),
+
+        navContent:
+          relations.chronologyLinks.slots({
+            chronologyInfoSets: [
+              {
+                headingString: 'misc.chronology.heading.track',
+                contributions: relations.artistChronologyContributions,
+              },
+              {
+                headingString: 'misc.chronology.heading.coverArt',
+                contributions: relations.coverArtistChronologyContributions,
+              },
+            ],
+          }),
+
+        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/generateTrackList.js b/src/content/dependencies/generateTrackList.js
new file mode 100644
index 0000000..3c36d24
--- /dev/null
+++ b/src/content/dependencies/generateTrackList.js
@@ -0,0 +1,59 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkTrack', 'linkContribution'],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, tracks) {
+    if (empty(tracks)) {
+      return {};
+    }
+
+    return {
+      trackLinks:
+        tracks
+          .map(track => relation('linkTrack', track)),
+
+      contributionLinks:
+        tracks
+          .map(track =>
+            (empty(track.artistContribs)
+              ? null
+              : track.artistContribs
+                  .map(contrib => relation('linkContribution', contrib)))),
+    };
+  },
+
+  slots: {
+    showContribution: {type: 'boolean', default: false},
+    showIcons: {type: 'boolean', default: false},
+  },
+
+  generate(relations, slots, {html, language}) {
+    return (
+      html.tag('ul',
+        stitchArrays({
+          trackLink: relations.trackLinks,
+          contributionLinks: relations.contributionLinks,
+        }).map(({trackLink, contributionLinks}) =>
+            html.tag('li',
+              (empty(contributionLinks)
+                ? trackLink
+                : language.$('trackList.item.withArtists', {
+                    track: trackLink,
+                    by:
+                      html.tag('span', {class: 'by'},
+                        html.metatag('chunkwrap', {split: ','},
+                          language.$('trackList.item.withArtists.by', {
+                            artists:
+                              language.formatConjunctionList(
+                                contributionLinks.map(link =>
+                                  link.slots({
+                                    showContribution: slots.showContribution,
+                                    showIcons: slots.showIcons,
+                                  }))),
+                          }))),
+                  }))))));
+  },
+};
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
new file mode 100644
index 0000000..e070ac3
--- /dev/null
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -0,0 +1,53 @@
+import {empty} from '#sugar';
+
+import groupTracksByGroup from '../util/groupTracksByGroup.js';
+
+export default {
+  contentDependencies: ['generateTrackList', 'linkGroup'],
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, tracks, groups) {
+    if (empty(tracks)) {
+      return {};
+    }
+
+    if (empty(groups)) {
+      return {
+        flatList:
+          relation('generateTrackList', tracks),
+      };
+    }
+
+    const lists = groupTracksByGroup(tracks, groups);
+
+    return {
+      groupedLists:
+        Array.from(lists.entries()).map(([groupOrOther, tracks]) => ({
+          ...(groupOrOther === 'other'
+                ? {other: true}
+                : {groupLink: relation('linkGroup', groupOrOther)}),
+
+          list:
+            relation('generateTrackList', tracks),
+        })),
+    };
+  },
+
+  generate(relations, {html, language}) {
+    if (relations.flatList) {
+      return relations.flatList;
+    }
+
+    return html.tag('dl',
+      relations.groupedLists.map(({other, groupLink, list}) => [
+        html.tag('dt',
+          (other
+            ? language.$('trackList.group.fromOther')
+            : language.$('trackList.group', {
+                group: groupLink
+              }))),
+
+        html.tag('dd', list),
+      ]));
+  },
+};
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
new file mode 100644
index 0000000..3bdeaa4
--- /dev/null
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -0,0 +1,90 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateReleaseInfoContributionsLine',
+    'linkExternal',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, track) {
+    const relations = {};
+
+    relations.artistContributionLinks =
+      relation('generateReleaseInfoContributionsLine', track.artistContribs);
+
+    if (track.hasUniqueCoverArt) {
+      relations.coverArtistContributionsLine =
+        relation('generateReleaseInfoContributionsLine', track.coverArtistContribs);
+    }
+
+    if (!empty(track.urls)) {
+      relations.externalLinks =
+        track.urls.map(url =>
+          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.coverArtDate !== +track.date
+    ) {
+      data.coverArtDate = track.coverArtDate;
+    }
+
+    return data;
+  },
+
+  generate: (data, relations, {html, language}) =>
+    html.tags([
+      html.tag('p',
+        {[html.onlyIfContent]: true},
+        {[html.joinChildren]: html.tag('br')},
+
+        [
+          relations.artistContributionLinks
+            .slots({stringKey: 'releaseInfo.by'}),
+
+          relations.coverArtistContributionsLine
+            ?.slots({stringKey: 'releaseInfo.coverArtBy'}),
+
+          data.date &&
+            language.$('releaseInfo.released', {
+              date: language.formatDate(data.date),
+            }),
+
+          data.coverArtDate &&
+            language.$('releaseInfo.artReleased', {
+              date: language.formatDate(data.coverArtDate),
+            }),
+
+          data.duration &&
+            language.$('releaseInfo.duration', {
+              duration: language.formatDuration(data.duration),
+            }),
+        ]),
+
+      html.tag('p',
+        (relations.externalLinks
+          ? language.$('releaseInfo.listenOn', {
+              links:
+                language.formatDisjunctionList(
+                  relations.externalLinks
+                    .map(link => link.slot('context', 'track'))),
+            })
+          : language.$('releaseInfo.listenOn.noLinks', {
+              name: html.tag('i', data.name),
+            }))),
+    ]),
+};
diff --git a/src/content/dependencies/generateTrackSocialEmbed.js b/src/content/dependencies/generateTrackSocialEmbed.js
new file mode 100644
index 0000000..0337fc4
--- /dev/null
+++ b/src/content/dependencies/generateTrackSocialEmbed.js
@@ -0,0 +1,86 @@
+export default {
+  contentDependencies: [
+    'generateSocialEmbed',
+    'generateTrackSocialEmbedDescription',
+  ],
+
+  extraDependencies: ['absoluteTo', 'language', 'urls'],
+
+  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, urls}) {
+    return relations.socialEmbed.slots({
+      title:
+        language.$('trackPage.socialEmbed.title', {
+          track: data.trackName,
+        }),
+
+      headingContent:
+        language.$('trackPage.socialEmbed.heading', {
+          album: data.albumName,
+        }),
+
+      headingLink:
+        absoluteTo('localized.album', data.albumDirectory),
+
+      imagePath:
+        (data.imageSource === 'album'
+          ? '/' +
+            urls
+              .from('shared.root')
+              .to('media.albumCover', data.albumDirectory, data.coverArtFileExtension)
+       : data.imageSource === 'track'
+          ? '/' +
+            urls
+              .from('shared.root')
+              .to('media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension)
+          : null),
+    });
+  },
+};
+
+/*
+        socialEmbed: {
+          heading: language.$('trackPage.socialEmbed.heading', {
+            album: track.album.name,
+          }),
+          headingLink: absoluteTo('localized.album', album.directory),
+          title: language.$('trackPage.socialEmbed.title', {
+            track: track.name,
+          }),
+          description: getSocialEmbedDescription({getArtistString, language}),
+          image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}),
+          color: track.color,
+        },
+*/
diff --git a/src/content/dependencies/generateTrackSocialEmbedDescription.js b/src/content/dependencies/generateTrackSocialEmbedDescription.js
new file mode 100644
index 0000000..cf21ead
--- /dev/null
+++ b/src/content/dependencies/generateTrackSocialEmbedDescription.js
@@ -0,0 +1,38 @@
+export default {
+  generate() {
+  },
+};
+
+/*
+  const getSocialEmbedDescription = ({
+    getArtistString: _getArtistString,
+    language,
+  }) => {
+    const hasArtists = !empty(track.artistContribs);
+    const hasCoverArtists = !empty(track.coverArtistContribs);
+    const getArtistString = (contribs) =>
+      _getArtistString(contribs, {
+        // We don't want to put actual HTML tags in social embeds (sadly
+        // they don't get parsed and displayed, generally speaking), so
+        // override the link argument so that artist "links" just show
+        // their names.
+        link: {artist: (artist) => artist.name},
+      });
+    if (!hasArtists && !hasCoverArtists) return '';
+    return language.formatString(
+      'trackPage.socialEmbed.body' +
+        [hasArtists && '.withArtists', hasCoverArtists && '.withCoverArtists']
+          .filter(Boolean)
+          .join(''),
+      Object.fromEntries(
+        [
+          hasArtists && ['artists', getArtistString(track.artistContribs)],
+          hasCoverArtists && [
+            'coverArtists',
+            getArtistString(track.coverArtistContribs),
+          ],
+        ].filter(Boolean)
+      )
+    );
+  };
+*/
diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js
new file mode 100644
index 0000000..a19f104
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js
@@ -0,0 +1,150 @@
+import {empty, stitchArrays} from '#sugar';
+import {getNewAdditions, getNewReleases} from '#wiki-data';
+
+export default {
+  contentDependencies: [
+    'generateWikiHomeContentRow',
+    'generateCoverCarousel',
+    'generateCoverGrid',
+    'image',
+    'linkAlbum',
+    'transformContent',
+  ],
+
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({albumData}, row) {
+    const sprawl = {};
+
+    switch (row.sourceGroup) {
+      case 'new-releases':
+        sprawl.albums = getNewReleases(row.countAlbumsFromGroup, {albumData});
+        break;
+
+      case 'new-additions':
+        sprawl.albums = getNewAdditions(row.countAlbumsFromGroup, {albumData});
+        break;
+
+      default:
+        sprawl.albums =
+          (row.sourceGroup
+            ? row.sourceGroup.albums
+                .slice()
+                .reverse()
+                .filter(album => album.isListedOnHomepage)
+                .slice(0, row.countAlbumsFromGroup)
+            : []);
+    }
+
+    if (!empty(row.sourceAlbums)) {
+      sprawl.albums.push(...row.sourceAlbums);
+    }
+
+    return sprawl;
+  },
+
+  relations(relation, sprawl, row) {
+    const relations = {};
+
+    relations.contentRow =
+      relation('generateWikiHomeContentRow', row);
+
+    if (row.displayStyle === 'grid') {
+      relations.coverGrid =
+        relation('generateCoverGrid');
+    }
+
+    if (row.displayStyle === 'carousel') {
+      relations.coverCarousel =
+        relation('generateCoverCarousel');
+    }
+
+    relations.links =
+      sprawl.albums
+        .map(album => relation('linkAlbum', album));
+
+    relations.images =
+      sprawl.albums
+        .map(album => relation('image', album.artTags));
+
+    if (row.actionLinks) {
+      relations.actionLinks =
+        row.actionLinks
+          .map(content => relation('transformContent', content));
+    }
+
+    return relations;
+  },
+
+  data(sprawl, row) {
+    const data = {};
+
+    data.displayStyle = row.displayStyle;
+
+    if (row.displayStyle === 'grid') {
+      data.names =
+        sprawl.albums
+          .map(album => album.name);
+    }
+
+    data.paths =
+      sprawl.albums
+        .map(album =>
+          (album.hasCoverArt
+            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
+            : null));
+
+    return data;
+  },
+
+  generate(data, relations, {language}) {
+    // Grids and carousels share some slots! Very convenient.
+    const commonSlots = {};
+
+    commonSlots.links =
+      relations.links;
+
+    commonSlots.images =
+      stitchArrays({
+        image: relations.images,
+        path: data.paths,
+        name: data.names ?? data.paths.slice().fill(null),
+      }).map(({image, path, name}) =>
+          image.slots({
+            path,
+            missingSourceContent:
+              name &&
+                language.$('misc.albumGrid.noCoverArt', {
+                  album: name,
+                }),
+            }));
+
+    commonSlots.actionLinks =
+      (relations.actionLinks
+        ? relations.actionLinks
+            .map(contents =>
+              contents
+                .slot('mode', 'single-link')
+                .content)
+        : null);
+
+    let content;
+
+    switch (data.displayStyle) {
+      case 'grid':
+        content =
+          relations.coverGrid.slots({
+            ...commonSlots,
+            names: data.names,
+          });
+        break;
+
+      case 'carousel':
+        content =
+          relations.coverCarousel.slots(commonSlots);
+        break;
+    }
+
+    return relations.contentRow.slots({content});
+  },
+};
diff --git a/src/content/dependencies/generateWikiHomeContentRow.js b/src/content/dependencies/generateWikiHomeContentRow.js
new file mode 100644
index 0000000..27b12e5
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomeContentRow.js
@@ -0,0 +1,28 @@
+export default {
+  contentDependencies: ['generateColorStyleAttribute'],
+  extraDependencies: ['html'],
+
+  relations: (relation, row) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute', row.color),
+  }),
+
+  data: (row) =>
+    ({name: row.name}),
+
+  slots: {
+    content: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate: (data, relations, slots, {html}) =>
+    html.tag('section', {class: 'row'},
+      relations.colorStyle,
+
+      [
+        html.tag('h2', data.name),
+        slots.content,
+      ]),
+};
diff --git a/src/content/dependencies/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js
new file mode 100644
index 0000000..e054edd
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomeNewsBox.js
@@ -0,0 +1,85 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generatePageSidebarBox',
+    'linkNewsEntry',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({newsData}) => ({
+    entries:
+      newsData.slice(0, 3),
+  }),
+
+  relations: (relation, sprawl) => ({
+    box:
+      relation('generatePageSidebarBox'),
+
+    entryContents:
+      sprawl.entries
+        .map(entry => relation('transformContent', entry.contentShort)),
+
+    entryMainLinks:
+      sprawl.entries
+        .map(entry => relation('linkNewsEntry', entry)),
+
+    entryReadMoreLinks:
+      sprawl.entries
+        .map(entry =>
+          entry.contentShort !== entry.content &&
+            relation('linkNewsEntry', entry)),
+  }),
+
+  data: (sprawl) => ({
+    entryDates:
+      sprawl.entries
+        .map(entry => entry.date),
+  }),
+
+  generate(data, relations, {html, language}) {
+    if (empty(relations.entryContents)) {
+      return html.blank();
+    }
+
+    return relations.box.slots({
+      attributes: {class: 'latest-news-sidebar-box'},
+      content: [
+        html.tag('h1', language.$('homepage.news.title')),
+
+        stitchArrays({
+          date: data.entryDates,
+          content: relations.entryContents,
+          mainLink: relations.entryMainLinks,
+          readMoreLink: relations.entryReadMoreLinks,
+        }).map(({
+            date,
+            content,
+            mainLink,
+            readMoreLink,
+          }, index) =>
+            html.tag('article', {class: 'news-entry'},
+              index === 0 &&
+                {class: 'first-news-entry'},
+
+              [
+                html.tag('h2', [
+                  html.tag('time', language.formatDate(date)),
+                  mainLink,
+                ]),
+
+                content.slot('thumb', 'medium'),
+
+                html.tag('p',
+                  {[html.onlyIfContent]: true},
+                  readMoreLink
+                    ?.slots({
+                      content: language.$('homepage.news.entry.viewRest'),
+                    })),
+              ])),
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/generateWikiHomePage.js b/src/content/dependencies/generateWikiHomePage.js
new file mode 100644
index 0000000..35461d0
--- /dev/null
+++ b/src/content/dependencies/generateWikiHomePage.js
@@ -0,0 +1,115 @@
+export default {
+  contentDependencies: [
+    'generatePageLayout',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'generateWikiHomeAlbumsRow',
+    'generateWikiHomeNewsBox',
+    'transformContent',
+  ],
+
+  extraDependencies: ['wikiData'],
+
+  sprawl({wikiInfo}) {
+    return {
+      wikiName: wikiInfo.name,
+
+      enableNews: wikiInfo.enableNews,
+    };
+  },
+
+  relations(relation, sprawl, homepageLayout) {
+    const relations = {};
+
+    relations.layout =
+      relation('generatePageLayout');
+
+    relations.sidebar =
+      relation('generatePageSidebar');
+
+    if (homepageLayout.sidebarContent) {
+      relations.customSidebarBox =
+        relation('generatePageSidebarBox');
+
+      relations.customSidebarContent =
+        relation('transformContent', homepageLayout.sidebarContent);
+    }
+
+    if (sprawl.enableNews) {
+      relations.newsSidebarBox =
+        relation('generateWikiHomeNewsBox');
+    }
+
+    if (homepageLayout.navbarLinks) {
+      relations.customNavLinkContents =
+        homepageLayout.navbarLinks
+          .map(content => relation('transformContent', content));
+    }
+
+    relations.contentRows =
+      homepageLayout.rows.map(row => {
+        switch (row.type) {
+          case 'albums':
+            return relation('generateWikiHomeAlbumsRow', row);
+          default:
+            return null;
+        }
+      });
+
+    return relations;
+  },
+
+  data(sprawl) {
+    return {
+      wikiName: sprawl.wikiName,
+    };
+  },
+
+  generate(data, relations) {
+    return relations.layout.slots({
+      title: data.wikiName,
+      showWikiNameInTitle: false,
+
+      mainClasses: ['top-index'],
+      headingMode: 'static',
+
+      mainContent: [
+        relations.contentRows,
+      ],
+
+      leftSidebar:
+        relations.sidebar.slots({
+          collapse: false,
+          wide: true,
+
+          boxes: [
+            relations.customSidebarContent &&
+              relations.customSidebarBox.slots({
+                attributes: {class: 'custom-content-sidebar-box'},
+                content:
+                  relations.customSidebarContent
+                    .slot('mode', 'multiline'),
+              }),
+
+            relations.newsSidebarBox,
+          ],
+        }),
+
+      navLinkStyle: 'index',
+      navLinks: [
+        {auto: 'home', current: true},
+
+        ...(
+          relations.customNavLinkContents
+            ?.map(content => ({
+              html:
+                content.slots({
+                  mode: 'single-link',
+                  preferShortLinkNames: true,
+                }),
+            }))
+          ?? []),
+      ],
+    });
+  },
+};
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
new file mode 100644
index 0000000..6b24f38
--- /dev/null
+++ b/src/content/dependencies/image.js
@@ -0,0 +1,383 @@
+import {logInfo, logWarn} from '#cli';
+import {empty} from '#sugar';
+
+export default {
+  extraDependencies: [
+    'cachebust',
+    'checkIfImagePathHasCachedThumbnails',
+    'getDimensionsOfImagePath',
+    'getSizeOfImagePath',
+    'getThumbnailEqualOrSmaller',
+    'getThumbnailsAvailableForDimensions',
+    'html',
+    'language',
+    'missingImagePaths',
+    'to',
+  ],
+
+  contentDependencies: ['generateColorStyleAttribute'],
+
+  relations: (relation) => ({
+    colorStyle:
+      relation('generateColorStyleAttribute'),
+  }),
+
+  data(artTags) {
+    const data = {};
+
+    if (artTags) {
+      data.contentWarnings =
+        artTags
+          .filter(tag => tag.isContentWarning)
+          .map(tag => tag.name);
+    } else {
+      data.contentWarnings = null;
+    }
+
+    return data;
+  },
+
+  slots: {
+    src: {type: 'string'},
+
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
+    },
+
+    thumb: {type: 'string'},
+
+    link: {
+      validate: v => v.anyOf(v.isBoolean, v.isString),
+      default: false,
+    },
+
+    color: {
+      validate: v => v.isColor,
+    },
+
+    warnings: {
+      validate: v => v.looseArrayOf(v.isString),
+    },
+
+    reveal: {type: 'boolean', default: true},
+    lazy: {type: 'boolean', default: false},
+
+    square: {type: 'boolean', default: false},
+
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
+
+    alt: {type: 'string'},
+
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    missingSourceContent: {
+      type: 'html',
+      mutable: false,
+    },
+  },
+
+  generate(data, relations, slots, {
+    cachebust,
+    checkIfImagePathHasCachedThumbnails,
+    getDimensionsOfImagePath,
+    getSizeOfImagePath,
+    getThumbnailEqualOrSmaller,
+    getThumbnailsAvailableForDimensions,
+    html,
+    language,
+    missingImagePaths,
+    to,
+  }) {
+    let originalSrc;
+
+    if (slots.src) {
+      originalSrc = slots.src;
+    } else if (!empty(slots.path)) {
+      originalSrc = to(...slots.path);
+    } else {
+      originalSrc = '';
+    }
+
+    // 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);
+
+    if (isMissingImageFile) {
+      logInfo`No image file for ${mediaSrc} - build again for list of missing images.`;
+    }
+
+    const willLink =
+      !isMissingImageFile &&
+      (typeof slots.link === 'string' || slots.link);
+
+    const contentWarnings =
+      slots.warnings ??
+      data.contentWarnings;
+
+    const willReveal =
+      slots.reveal &&
+      originalSrc &&
+      !isMissingImageFile &&
+      !empty(contentWarnings);
+
+    const hasBothDimensions =
+      !!(slots.dimensions &&
+         slots.dimensions[0] !== null &&
+         slots.dimensions[1] !== null);
+
+    const willSquare =
+      (hasBothDimensions
+        ? slots.dimensions[0] === slots.dimensions[1]
+        : slots.square);
+
+    const imgAttributes = html.attributes([
+      {class: 'image'},
+
+      slots.alt && {alt: slots.alt},
+
+      slots.dimensions?.[0] &&
+        {width: slots.dimensions[0]},
+
+      slots.dimensions?.[1] &&
+        {width: slots.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('shared.staticFile', 'warning.svg', cachebust)}),
+
+        html.tag('br'),
+
+        html.tag('span', {class: 'reveal-warnings'},
+          language.$('misc.contentWarnings.warnings', {
+            warnings: language.formatUnitList(contentWarnings),
+          })),
+
+        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 originalLength = Math.max(originalDimensions[0], originalDimensions[1]);
+
+      const fileSize =
+        (willLink && mediaSrc
+          ? getSizeOfImagePath(mediaSrc)
+          : null);
+
+      imgAttributes.add([
+        fileSize &&
+          {'data-original-size': fileSize},
+
+        originalLength &&
+          {'data-original-length': originalLength},
+
+        !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'},
+          willSquare &&
+            {class: 'square-content'},
+
+          wrapped);
+
+      wrapped =
+        html.tag('div', {class: 'image-container'},
+          willSquare &&
+            {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 0000000..a500980
--- /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 0000000..36b0d13
--- /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 0000000..39e7111
--- /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 0000000..ab519fd
--- /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 0000000..3adc64d
--- /dev/null
+++ b/src/content/dependencies/linkAlbumDynamically.js
@@ -0,0 +1,14 @@
+export default {
+  contentDependencies: ['linkAlbumGallery', 'linkAlbum'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, album) => ({
+    galleryLink: relation('linkAlbumGallery', album),
+    infoLink: relation('linkAlbum', album),
+  }),
+
+  generate: (relations, {pagePath}) =>
+    (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 0000000..e3f30a2
--- /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/linkArtTag.js b/src/content/dependencies/linkArtTag.js
new file mode 100644
index 0000000..7ddb778
--- /dev/null
+++ b/src/content/dependencies/linkArtTag.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artTag) =>
+    ({link: relation('linkThing', 'localized.tag', artTag)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtist.js b/src/content/dependencies/linkArtist.js
new file mode 100644
index 0000000..718ee6f
--- /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 0000000..66dc172
--- /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/linkCommentaryIndex.js b/src/content/dependencies/linkCommentaryIndex.js
new file mode 100644
index 0000000..5568ff8
--- /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 0000000..41ce114
--- /dev/null
+++ b/src/content/dependencies/linkContribution.js
@@ -0,0 +1,145 @@
+import {empty, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateTextWithTooltip',
+    'generateTooltip',
+    'linkArtist',
+    'linkExternalAsIcon',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations(relation, contribution) {
+    const relations = {};
+
+    relations.artistLink =
+      relation('linkArtist', contribution.who);
+
+    relations.textWithTooltip =
+      relation('generateTextWithTooltip');
+
+    relations.tooltip =
+      relation('generateTooltip');
+
+    if (!empty(contribution.who.urls)) {
+      relations.artistIcons =
+        contribution.who.urls
+          .map(url => relation('linkExternalAsIcon', url));
+    }
+
+    return relations;
+  },
+
+  data(contribution) {
+    return {
+      what: contribution.what,
+      urls: contribution.who.urls,
+    };
+  },
+
+  slots: {
+    showContribution: {type: 'boolean', default: false},
+    showIcons: {type: 'boolean', default: false},
+    preventWrapping: {type: 'boolean', default: true},
+
+    iconMode: {
+      validate: v => v.is('inline', 'tooltip'),
+      default: 'inline'
+    },
+  },
+
+  generate(data, relations, slots, {html, language}) {
+    const hasContribution = !!(slots.showContribution && data.what);
+    const hasExternalIcons = !!(slots.showIcons && relations.artistIcons);
+
+    const parts = ['misc.artistLink'];
+    const options = {};
+
+    options.artist =
+      (hasExternalIcons && slots.iconMode === 'tooltip'
+        ? relations.textWithTooltip.slots({
+            customInteractionCue: true,
+
+            text:
+              relations.artistLink.slots({
+                attributes: {class: 'text-with-tooltip-interaction-cue'},
+              }),
+
+            tooltip:
+              relations.tooltip.slots({
+                attributes:
+                  {class: ['icons', 'icons-tooltip']},
+
+                contentAttributes:
+                  {[html.joinChildren]: ''},
+
+                content:
+                  stitchArrays({
+                    icon: relations.artistIcons,
+                    url: data.urls,
+                  }).map(({icon, url}) => {
+                      icon.setSlots({
+                        context: 'artist',
+                        withText: true,
+                      });
+
+                      let platformText =
+                        language.formatExternalLink(url, {
+                          context: 'artist',
+                          style: 'platform',
+                        });
+
+                      // This is a pretty ridiculous hack, but we currently
+                      // don't have a way of telling formatExternalLink to *not*
+                      // use the fallback string, which just formats the URL as
+                      // its host/domain... so is technically detectable.
+                      if (platformText.toString() === (new URL(url)).host) {
+                        platformText =
+                          language.$('misc.artistLink.noExternalLinkPlatformName');
+                      }
+
+                      const platformSpan =
+                        html.tag('span', {class: 'icon-platform'},
+                          platformText);
+
+                      return [icon, platformSpan];
+                    }),
+              }),
+          })
+        : relations.artistLink);
+
+    if (hasContribution) {
+      parts.push('withContribution');
+      options.contrib = data.what;
+    }
+
+    if (hasExternalIcons && slots.iconMode === 'inline') {
+      parts.push('withExternalLinks');
+      options.links =
+        html.tag('span', {class: ['icons', 'icons-inline']},
+          {[html.noEdgeWhitespace]: true},
+          language.formatUnitList(
+            relations.artistIcons
+              .slice(0, 4)
+              .map(icon => icon.slot('context', 'artist'))));
+    }
+
+    const contributionPart =
+      language.formatString(...parts, options);
+
+    if (!hasContribution && !hasExternalIcons) {
+      return contributionPart;
+    }
+
+    return (
+      html.tag('span', {class: 'contribution'},
+        {[html.noEdgeWhitespace]: true},
+
+        parts.length > 1 &&
+        slots.preventWrapping &&
+          {class: 'nowrap'},
+
+        contributionPart));
+  },
+};
diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js
new file mode 100644
index 0000000..f6b47db
--- /dev/null
+++ b/src/content/dependencies/linkExternal.js
@@ -0,0 +1,136 @@
+import {isExternalLinkContext, isExternalLinkStyle} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    content: {
+      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');
+    }
+
+    return html.tag('a', linkAttributes, linkContent);
+  },
+};
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
new file mode 100644
index 0000000..6f37529
--- /dev/null
+++ b/src/content/dependencies/linkExternalAsIcon.js
@@ -0,0 +1,51 @@
+import {isExternalLinkContext} from '#external-links';
+
+export default {
+  extraDependencies: ['html', 'language', 'to'],
+
+  data: (url) => ({url}),
+
+  slots: {
+    context: {
+      // This awkward syntax is because the slot descriptor validator can't
+      // differentiate between a function that returns a validator (the usual
+      // syntax) and a function that is itself a validator.
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+
+    withText: {type: 'boolean'},
+  },
+
+  generate(data, slots, {html, language, to}) {
+    const format = style =>
+      language.formatExternalLink(data.url, {style, context: slots.context});
+
+    const platformText = format('platform');
+    const handleText = format('handle');
+    const iconId = format('icon-id');
+
+    return html.tag('a', {class: 'icon'},
+      {href: data.url},
+
+      slots.withText &&
+        {class: 'has-text'},
+
+      [
+        html.tag('svg', [
+          !slots.withText &&
+            html.tag('title', platformText),
+
+          html.tag('use', {
+            href: to('shared.staticIcon', iconId),
+          }),
+        ]),
+
+        slots.withText &&
+          html.tag('span', {class: 'icon-text'},
+            (html.isBlank(handleText)
+              ? platformText
+              : handleText)),
+      ]);
+  },
+};
diff --git a/src/content/dependencies/linkFlash.js b/src/content/dependencies/linkFlash.js
new file mode 100644
index 0000000..93dd5a2
--- /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 0000000..fbb819e
--- /dev/null
+++ b/src/content/dependencies/linkFlashAct.js
@@ -0,0 +1,14 @@
+export default {
+  contentDependencies: ['linkThing'],
+  extraDependencies: ['html'],
+
+  relations: (relation, flashAct) =>
+    ({link: relation('linkThing', 'localized.flashActGallery', flashAct)}),
+
+  data: (flashAct) =>
+    ({name: flashAct.name}),
+
+  generate: (data, relations, {html}) =>
+    relations.link
+      .slot('content', new html.Tag(null, null, data.name)),
+};
diff --git a/src/content/dependencies/linkFlashIndex.js b/src/content/dependencies/linkFlashIndex.js
new file mode 100644
index 0000000..6dd0710
--- /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/linkGroup.js b/src/content/dependencies/linkGroup.js
new file mode 100644
index 0000000..ebab1b5
--- /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 0000000..90303ed
--- /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 0000000..bc3c058
--- /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 0000000..86c4a0f
--- /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 0000000..ac66919
--- /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 0000000..1bfaf46
--- /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 0000000..1fb32dd
--- /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 0000000..e911a38
--- /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/linkPathFromMedia.js b/src/content/dependencies/linkPathFromMedia.js
new file mode 100644
index 0000000..34a2b85
--- /dev/null
+++ b/src/content/dependencies/linkPathFromMedia.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['linkTemplate'],
+
+  relations: (relation) =>
+    ({link: relation('linkTemplate')}),
+
+  data: (path) =>
+    ({path}),
+
+  generate: (data, relations) =>
+    relations.link
+      .slot('path', ['media.path', data.path]),
+};
diff --git a/src/content/dependencies/linkPathFromRoot.js b/src/content/dependencies/linkPathFromRoot.js
new file mode 100644
index 0000000..dab3ac1
--- /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 0000000..6467646
--- /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/linkStaticPage.js b/src/content/dependencies/linkStaticPage.js
new file mode 100644
index 0000000..032af6c
--- /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 0000000..d5506e6
--- /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 0000000..63cc82e
--- /dev/null
+++ b/src/content/dependencies/linkTemplate.js
@@ -0,0 +1,73 @@
+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,
+    },
+  },
+
+  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 content =
+      (html.isBlank(slots.content)
+        ? language.$('misc.missingLinkContent')
+        : striptags(html.resolve(slots.content, {normalize: 'string'}), {
+            disallowedTags: new Set(['a']),
+          }));
+
+    return html.tag('a', attributes, content);
+  },
+}
diff --git a/src/content/dependencies/linkThing.js b/src/content/dependencies/linkThing.js
new file mode 100644
index 0000000..3902f38
--- /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 0000000..d5d9672
--- /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 0000000..242cd4c
--- /dev/null
+++ b/src/content/dependencies/linkTrackDynamically.js
@@ -0,0 +1,34 @@
+export default {
+  contentDependencies: ['linkTrack'],
+  extraDependencies: ['pagePath'],
+
+  relations: (relation, track) => ({
+    infoLink: relation('linkTrack', track),
+  }),
+
+  data: (track) => ({
+    trackDirectory:
+      track.directory,
+
+    albumDirectory:
+      track.album.directory,
+
+    trackHasCommentary:
+      !!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/linkWikiHome.js b/src/content/dependencies/linkWikiHome.js
new file mode 100644
index 0000000..d8d3d0a
--- /dev/null
+++ b/src/content/dependencies/linkWikiHome.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 0000000..c83ffc9
--- /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 0000000..d462ad4
--- /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 0000000..c60685a
--- /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 0000000..2141953
--- /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 0000000..798e6c2
--- /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 0000000..a6e34b9
--- /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 0000000..e33ad7b
--- /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 0000000..31a70ef
--- /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 0000000..166b206
--- /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 0000000..b3a5474
--- /dev/null
+++ b/src/content/dependencies/listArtTagNetwork.js
@@ -0,0 +1 @@
+export default {generate() {}};
diff --git a/src/content/dependencies/listArtistsByCommentaryEntries.js b/src/content/dependencies/listArtistsByCommentaryEntries.js
new file mode 100644
index 0000000..eff2dba
--- /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 0000000..0af586c
--- /dev/null
+++ b/src/content/dependencies/listArtistsByContributions.js
@@ -0,0 +1,160 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {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.tracksAsContributor,
+          ...artist.tracksAsArtist,
+        ]).length);
+
+    queryContributionInfo(
+      'artistsByArtworkContributions',
+      'countsByArtworkContributions',
+      artist =>
+        artist.tracksAsCoverArtist.length +
+        artist.albumsAsCoverArtist.length +
+        artist.albumsAsWallpaperArtist.length +
+        artist.albumsAsBannerArtist.length);
+
+    if (sprawl.enableFlashesAndGames) {
+      queryContributionInfo(
+        'artistsByFlashContributions',
+        'countsByFlashContributions',
+        artist =>
+          artist.flashesAsContributor.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 0000000..f677d82
--- /dev/null
+++ b/src/content/dependencies/listArtistsByDuration.js
@@ -0,0 +1,60 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+import {getTotalDuration} from '#wiki-data';
+
+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 =>
+        getTotalDuration([
+          ...(artist.tracksAsArtist ?? []),
+          ...(artist.tracksAsContributor ?? []),
+        ], {originalReleasesOnly: true}));
+
+    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 0000000..30884d2
--- /dev/null
+++ b/src/content/dependencies/listArtistsByGroup.js
@@ -0,0 +1,133 @@
+import {sortAlphabetically} from '#sort';
+import {empty, filterMultipleArrays, stitchArrays, unique} from '#sugar';
+import {getArtistNumContributions} from '#wiki-data';
+
+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 groups =
+      sprawl.wikiInfo.divideTrackListsByGroups;
+
+    if (empty(groups)) {
+      return {spec, artists};
+    }
+
+    const artistGroups =
+      artists.map(artist =>
+        unique(
+          unique([
+            ...artist.albumsAsAny,
+            ...artist.tracksAsAny.map(track => track.album),
+          ]).flatMap(album => album.groups)))
+
+    const artistsByGroup =
+      groups.map(group =>
+        artists.filter((artist, index) => artistGroups[index].includes(group)));
+
+    filterMultipleArrays(groups, artistsByGroup,
+      (group, artists) => !empty(artists));
+
+    return {spec, groups, artistsByGroup};
+  },
+
+  relations(relation, query) {
+    const relations = {};
+
+    relations.page =
+      relation('generateListingPage', query.spec);
+
+    if (query.artists) {
+      relations.artistLinks =
+        query.artists
+          .map(artist => relation('linkArtist', artist));
+    }
+
+    if (query.artistsByGroup) {
+      relations.groupLinks =
+        query.groups
+          .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.artists) {
+      data.counts =
+        query.artists
+          .map(artist => getArtistNumContributions(artist));
+    }
+
+    if (query.artistsByGroup) {
+      data.groupDirectories =
+        query.groups
+          .map(group => group.directory);
+
+      data.countsByGroup =
+        query.artistsByGroup
+          .map(artists => artists
+            .map(artist => getArtistNumContributions(artist)));
+    }
+
+    return data;
+  },
+
+  generate(data, relations, {language}) {
+    return (
+      (relations.artistLinksByGroup
+        ? relations.page.slots({
+            type: 'chunks',
+
+            showSkipToSection: true,
+            chunkIDs:
+              data.groupDirectories
+                .map(directory => `contributed-to-${directory}`),
+
+            chunkTitles:
+              relations.groupLinks.map(groupLink => ({
+                group: groupLink,
+              })),
+
+            chunkRows:
+              stitchArrays({
+                artistLinks: relations.artistLinksByGroup,
+                counts: data.countsByGroup,
+              }).map(({artistLinks, counts}) =>
+                  stitchArrays({
+                    link: artistLinks,
+                    count: counts,
+                  }).map(({link, count}) => ({
+                      artist: link,
+                      contributions: language.countContributions(count, {unit: true}),
+                    }))),
+          })
+        : relations.page.slots({
+            type: 'rows',
+            rows:
+              stitchArrays({
+                link: relations.artistLinks,
+                count: data.counts,
+              }).map(({link, count}) => ({
+                  artist: link,
+                  contributions: language.countContributions(count, {unit: true}),
+                })),
+          })));
+  },
+};
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
new file mode 100644
index 0000000..0f70957
--- /dev/null
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -0,0 +1,319 @@
+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(({who}) => who);
+
+    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');
+      }
+    }
+
+    for (const track of tracksLatestFirst) {
+      for (const artist of getArtists(track, 'coverArtistContribs')) {
+        // No special effect if artist already has 'artwork' for the same album and date.
+        considerDate(artist, track.coverArtDate ?? track.date, track.album, 'artwork');
+      }
+
+      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 0000000..9321849
--- /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 0000000..4adfb6d
--- /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 0000000..43919be
--- /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 0000000..da2f26d
--- /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),
+          {originalReleasesOnly: 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 0000000..4831931
--- /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 0000000..696a49b
--- /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 0000000..0b5e4e9
--- /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 0000000..ab2eca9
--- /dev/null
+++ b/src/content/dependencies/listRandomPageLinks.js
@@ -0,0 +1,193 @@
+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 miscellaneousChunkRows = [
+      {
+        stringsKey: 'randomArtist',
+
+        mainLink:
+          html.tag('a',
+            {href: '#', 'data-random': 'artist'},
+            language.$('listingPage.other.randomPages.chunk.item.randomArtist.mainLink')),
+
+        atLeastTwoContributions:
+          html.tag('a',
+            {href: '#', 'data-random': 'artist-more-than-one-contrib'},
+            language.$('listingPage.other.randomPages.chunk.item.randomArtist.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.$('listingPage.other.randomPages.chooseLinkLine', {
+            fromPart:
+              (relations.groupLinks
+                ? language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups')
+                : language.$('listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups')),
+
+            browserSupportPart:
+              language.$('listingPage.other.randomPages.chooseLinkLine.browserSupportPart'),
+          })),
+
+        html.tag('p', {id: 'data-loading-line'},
+          language.$('listingPage.other.randomPages.dataLoadingLine')),
+
+        html.tag('p', {id: 'data-loaded-line'},
+          language.$('listingPage.other.randomPages.dataLoadedLine')),
+
+        html.tag('p', {id: 'data-error-line'},
+          language.$('listingPage.other.randomPages.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(() => ({
+                randomAlbum:
+                  html.tag('a',
+                    {href: '#', 'data-random': 'album-in-group-dl'},
+                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomAlbum')),
+
+                randomTrack:
+                  html.tag('a',
+                    {href: '#', 'data-random': 'track-in-group-dl'},
+                    language.$('listingPage.other.randomPages.chunk.title.fromGroup.accent.randomTrack')),
+              }))
+            : [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/listTagsByName.js b/src/content/dependencies/listTagsByName.js
new file mode 100644
index 0000000..d7022a5
--- /dev/null
+++ b/src/content/dependencies/listTagsByName.js
@@ -0,0 +1,54 @@
+import {sortAlphabetically} from '#sort';
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtTag'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artTagData}) {
+    return {artTagData};
+  },
+
+  query({artTagData}, spec) {
+    return {
+      spec,
+
+      artTags:
+        sortAlphabetically(
+          artTagData
+            .filter(tag => !tag.isContentWarning)),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      artTagLinks:
+        query.artTags
+          .map(tag => relation('linkArtTag', tag)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts:
+        query.artTags
+          .map(tag => tag.taggedInThings.length),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artTagLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            tag: link,
+            timesUsed: language.countTimesUsed(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTagsByUses.js b/src/content/dependencies/listTagsByUses.js
new file mode 100644
index 0000000..00c700a
--- /dev/null
+++ b/src/content/dependencies/listTagsByUses.js
@@ -0,0 +1,59 @@
+import {sortAlphabetically, sortByCount} from '#sort';
+import {filterByCount, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkArtTag'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({artTagData}) {
+    return {artTagData};
+  },
+
+  query({artTagData}, spec) {
+    const artTags =
+      sortAlphabetically(
+        artTagData
+          .filter(tag => !tag.isContentWarning));
+
+    const counts =
+      artTags
+        .map(tag => tag.taggedInThings.length);
+
+    filterByCount(artTags, counts);
+    sortByCount(artTags, counts, {greatestFirst: true});
+
+    return {spec, artTags, counts};
+  },
+
+  relations(relation, query) {
+    return {
+      page: relation('generateListingPage', query.spec),
+
+      artTagLinks:
+        query.artTags
+          .map(tag => relation('linkArtTag', tag)),
+    };
+  },
+
+  data(query) {
+    return {
+      counts:
+        query.artTags
+          .map(tag => tag.taggedInThings.length),
+    };
+  },
+
+  generate(data, relations, {language}) {
+    return relations.page.slots({
+      type: 'rows',
+      rows:
+        stitchArrays({
+          link: relations.artTagLinks,
+          count: data.counts,
+        }).map(({link, count}) => ({
+            tag: link,
+            timesUsed: language.countTimesUsed(count, {unit: true}),
+          })),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByAlbum.js b/src/content/dependencies/listTracksByAlbum.js
new file mode 100644
index 0000000..b240503
--- /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 0000000..01ce4e2
--- /dev/null
+++ b/src/content/dependencies/listTracksByDate.js
@@ -0,0 +1,85 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import {chunkByProperties, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'],
+  extraDependencies: ['language', 'wikiData'],
+
+  sprawl({trackData}) {
+    return {trackData};
+  },
+
+  query({trackData}, spec) {
+    return {
+      spec,
+
+      chunks:
+        chunkByProperties(
+          sortAlbumsTracksChronologically(trackData.slice()),
+          ['album', 'date']),
+    };
+  },
+
+  relations(relation, query) {
+    return {
+      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) {
+    return {
+      dates:
+        query.chunks
+          .map(({date}) => date),
+
+      rereleases:
+        query.chunks.map(({chunk}) =>
+          chunk.map(track =>
+            track.originalReleaseTrack !== null)),
+    };
+  },
+
+  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'}
+              : null))),
+    });
+  },
+};
diff --git a/src/content/dependencies/listTracksByDuration.js b/src/content/dependencies/listTracksByDuration.js
new file mode 100644
index 0000000..64feb4f
--- /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 0000000..c1ea32a
--- /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 0000000..773b047
--- /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 0000000..5838ded
--- /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 0000000..8ca0d99
--- /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 0000000..6ab954e
--- /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 0000000..c7f42f9
--- /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 0000000..a13a76f
--- /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 0000000..418af4c
--- /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 0000000..0c6761e
--- /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 0000000..0904cde
--- /dev/null
+++ b/src/content/dependencies/transformContent.js
@@ -0,0 +1,595 @@
+import {bindFind} from '#find';
+import {replacerSpec, parseInput} from '#replacer';
+
+import {Marked} from 'marked';
+
+const commonMarkedOptions = {
+  headerIds: false,
+  mangle: false,
+};
+
+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,
+            }
+          : 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,
+    },
+
+    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;
+
+    const contentFromNodes =
+      data.nodes.map(node => {
+        switch (node.type) {
+          case 'text':
+            return {type: 'text', data: node.data};
+
+          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},
+
+                  pixelate &&
+                    {class: 'pixelate'});
+
+              if (link) {
+                content =
+                  html.tag('a',
+                    {href: link},
+                    {target: '_blank'},
+
+                    {title:
+                      language.$('misc.external.opensInNewTab', {
+                        link:
+                          language.formatExternalLink(link, {
+                            style: 'platform',
+                          }),
+
+                        annotation:
+                          language.$('misc.external.opensInNewTab.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 'internal-link': {
+            const nodeFromRelations = relations.internalLinks[internalLinkIndex++];
+            if (nodeFromRelations.type === 'text') {
+              return {type: 'text', data: nodeFromRelations.data};
+            }
+
+            const {link, label, hash} = nodeFromRelations;
+
+            // 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');
+            }
+
+            return {type: 'processed-internal-link', data: link};
+          }
+
+          case 'external-link': {
+            const {label} = node.data;
+            const externalLink = relations.externalLinks[externalLinkIndex++];
+
+            externalLink.setSlots({
+              content: label,
+              fromContent: true,
+            });
+
+            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 contents =
+              (htmlFn
+                ? htmlFn(value, {html, language})
+                : value);
+
+            return {type: 'text', data: contents.toString()};
+          }
+
+          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 that were all on their own line need to be removed from
+        // the surrounding <p> tag that marked generates. The HTML parser
+        // treats a <div> that starts inside a <p> as a Crocker-class
+        // misgiving, and will treat you very badly if you feed it that.
+        if (attributes.get('data-type') === 'processed-image') {
+          if (!attributes.get('data-inline')) {
+            tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, '');
+            deleteParagraph = true;
+          }
+        }
+
+        const nonTextNodeIndex = match[2];
+        tags.push(contentFromNodes[nonTextNodeIndex].data);
+      }
+
+      if (parseFrom !== markedOutput.length) {
+        addText(markedOutput.slice(parseFrom));
+      }
+
+      return html.tags(tags, {[html.joinChildren]: ''});
+    };
+
+    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(/(?<!^ *-.*|^>.*|^  .*\n*|  $|<br>$)\n+(?!  |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
+          // Expand line breaks which are at the end of a list.
+          .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n')
+          // 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, index) {
+            // First, replace line breaks that follow text content with
+            // <br> tags.
+            let content = node.data.replace(/(?!^)\n/gm, '<br>\n');
+
+            // Scrap line breaks that are at the end of a verse.
+            content = content.replace(/<br>$(?=\n\n)/gm, '');
+
+            // If the node started with a line break, and it's not the
+            // very first node, then whatever came before it was inline.
+            // (This is an assumption based on text links being basically
+            // the only tag that shows up in lyrics.) Since this text is
+            // following content that was already inline, restore that
+            // initial line break.
+            if (node.data[0] === '\n' && index !== 0) {
+              content = '<br>' + content;
+            }
+
+            return content;
+          },
+        });
+
+      const markedOutput =
+        lyricsMarked.parse(markedInput);
+
+      return reinsertNonTextNodes(markedOutput);
+    }
+  },
+}
diff --git a/src/content/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js
new file mode 100644
index 0000000..67d6d5f
--- /dev/null
+++ b/src/content/util/getChronologyRelations.js
@@ -0,0 +1,55 @@
+export default function getChronologyRelations(thing, {
+  contributions,
+  linkArtist,
+  linkThing,
+  getThings,
+}) {
+  // One call to getChronologyRelations is considered "lumping" together all
+  // contributions as carrying equivalent meaning (for example, "artist"
+  // contributions and "contributor" contributions are bunched together in
+  // one call to getChronologyRelations, while "cover artist" contributions
+  // are a separate call). getChronologyRelations prevents duplicates that
+  // carry the same meaning by only using the first instance of each artist
+  // in the contributions array passed to it. It's expected that the string
+  // identifying which kind of contribution ("track" or "cover art") is
+  // shared and applied to all contributions, as providing them together
+  // in one call to getChronologyRelations implies they carry the same
+  // meaning.
+
+  const artistsSoFar = new Set();
+
+  contributions = contributions.filter(({who}) => {
+    if (artistsSoFar.has(who)) {
+      return false;
+    } else {
+      artistsSoFar.add(who);
+      return true;
+    }
+  });
+
+  return contributions.map(({who}) => {
+    const things = Array.from(new Set(getThings(who)));
+
+    // Don't show a line if this contribution isn't part of the artist's
+    // chronology at all (usually because this thing isn't dated).
+    const index = things.indexOf(thing);
+    if (index === -1) {
+      return;
+    }
+
+    // Don't show a line if this contribution is the *only* item in the
+    // artist's chronology (since there's nothing to navigate there).
+    const previous = things[index - 1];
+    const next = things[index + 1];
+    if (!previous && !next) {
+      return;
+    }
+
+    return {
+      index: index + 1,
+      artistLink: linkArtist(who),
+      previousLink: previous ? linkThing(previous) : null,
+      nextLink: next ? linkThing(next) : null,
+    };
+  }).filter(Boolean);
+}
diff --git a/src/content/util/groupTracksByGroup.js b/src/content/util/groupTracksByGroup.js
new file mode 100644
index 0000000..4e18900
--- /dev/null
+++ b/src/content/util/groupTracksByGroup.js
@@ -0,0 +1,23 @@
+import {empty} from '#sugar';
+
+export default function groupTracksByGroup(tracks, groups) {
+  const lists = new Map(groups.map(group => [group, []]));
+  lists.set('other', []);
+
+  for (const track of tracks) {
+    const group = groups.find(group => group.albums.includes(track.album));
+    if (group) {
+      lists.get(group).push(track);
+    } else {
+      lists.get('other').push(track);
+    }
+  }
+
+  for (const [key, tracks] of lists.entries()) {
+    if (empty(tracks)) {
+      lists.delete(key);
+    }
+  }
+
+  return lists;
+}
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
index 4afb036..1e7c7aa 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -74,236 +74,296 @@
 //      function, which provides a mapping of exposed property names to whether
 //      or not their dependencies are yet met.
 
-import { color, ENABLE_COLOR } from '../util/cli.js';
+import {inspect as nodeInspect} from 'node:util';
 
-import { inspect as nodeInspect } from 'util';
+import {colors, ENABLE_COLOR} from '#cli';
 
 function inspect(value) {
-    return nodeInspect(value, {colors: ENABLE_COLOR});
+  return nodeInspect(value, {colors: ENABLE_COLOR});
 }
 
 export default class CacheableObject {
-    static instance = Symbol('CacheableObject `this` instance');
-
-    #propertyUpdateValues = Object.create(null);
-    #propertyUpdateCacheInvalidators = Object.create(null);
-
-    /*
-    // Note the constructor doesn't take an initial data source. Due to a quirk
-    // of JavaScript, private members can't be accessed before the superclass's
-    // constructor is finished processing - so if we call the overridden
-    // update() function from inside this constructor, it will error when
-    // writing to private members. Pretty bad!
-    //
-    // That means initial data must be provided by following up with update()
-    // after constructing the new instance of the Thing (sub)class.
-    */
-
-    constructor() {
-        this.#defineProperties();
-        this.#initializeUpdatingPropertyValues();
-
-        if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
-            return new Proxy(this, {
-                get: (obj, key) => {
-                    if (!Object.hasOwn(obj, key)) {
-                        if (key !== 'constructor') {
-                            CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`);
-                        }
-                    }
-                    return obj[key];
-                }
-            });
-        }
+  #propertyUpdateValues = Object.create(null);
+  #propertyUpdateCacheInvalidators = Object.create(null);
+
+  // Note the constructor doesn't take an initial data source. Due to a quirk
+  // of JavaScript, private members can't be accessed before the superclass's
+  // constructor is finished processing - so if we call the overridden
+  // update() function from inside this constructor, it will error when
+  // writing to private members. Pretty bad!
+  //
+  // That means initial data must be provided by following up with update()
+  // after constructing the new instance of the Thing (sub)class.
+
+  constructor() {
+    this.#defineProperties();
+    this.#initializeUpdatingPropertyValues();
+
+    if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
+      return new Proxy(this, {
+        get: (obj, key) => {
+          if (!Object.hasOwn(obj, key)) {
+            if (key !== 'constructor') {
+              CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`);
+            }
+          }
+          return obj[key];
+        },
+      });
     }
+  }
 
-    #initializeUpdatingPropertyValues() {
-        for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) {
-            const { flags, update } = descriptor;
+  #initializeUpdatingPropertyValues() {
+    for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) {
+      const {flags, update} = descriptor;
 
-            if (!flags.update) {
-                continue;
-            }
+      if (!flags.update) {
+        continue;
+      }
 
-            if (update?.default) {
-                this[property] = update?.default;
-            } else {
-                this[property] = null;
-            }
-        }
+      if (update?.default) {
+        this[property] = update?.default;
+      } else {
+        this[property] = null;
+      }
     }
+  }
 
-    #defineProperties() {
-        if (!this.constructor.propertyDescriptors) {
-            throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`);
-        }
+  #defineProperties() {
+    if (!this.constructor.propertyDescriptors) {
+      throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`);
+    }
 
-        for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) {
-            const { flags } = descriptor;
+    for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) {
+      const {flags} = descriptor;
 
-            const definition = {
-                configurable: false,
-                enumerable: true
-            };
+      const definition = {
+        configurable: false,
+        enumerable: flags.expose,
+      };
 
-            if (flags.update) {
-                definition.set = this.#getUpdateObjectDefinitionSetterFunction(property);
-            }
+      if (flags.update) {
+        definition.set = this.#getUpdateObjectDefinitionSetterFunction(property);
+      }
 
-            if (flags.expose) {
-                definition.get = this.#getExposeObjectDefinitionGetterFunction(property);
-            }
+      if (flags.expose) {
+        definition.get = this.#getExposeObjectDefinitionGetterFunction(property);
+      }
+
+      Object.defineProperty(this, property, definition);
+    }
 
-            Object.defineProperty(this, property, definition);
+    Object.seal(this);
+  }
+
+  #getUpdateObjectDefinitionSetterFunction(property) {
+    const {update} = this.#getPropertyDescriptor(property);
+    const validate = update?.validate;
+
+    return (newValue) => {
+      const oldValue = this.#propertyUpdateValues[property];
+
+      if (newValue === undefined) {
+        throw new TypeError(`Properties cannot be set to undefined`);
+      }
+
+      if (newValue === oldValue) {
+        return;
+      }
+
+      if (newValue !== null && validate) {
+        try {
+          const result = validate(newValue);
+          if (result === undefined) {
+            throw new TypeError(`Validate function returned undefined`);
+          } else if (result !== true) {
+            throw new TypeError(`Validation failed for value ${newValue}`);
+          }
+        } catch (caughtError) {
+          throw new CacheableObjectPropertyValueError(
+            property, oldValue, newValue, {cause: caughtError});
         }
+      }
 
-        Object.seal(this);
-    }
+      this.#propertyUpdateValues[property] = newValue;
+      this.#invalidateCachesDependentUpon(property);
+    };
+  }
 
-    #getUpdateObjectDefinitionSetterFunction(property) {
-        const { update } = this.#getPropertyDescriptor(property);
-        const validate = update?.validate;
-        const allowNull = update?.allowNull;
+  #getPropertyDescriptor(property) {
+    return this.constructor.propertyDescriptors[property];
+  }
 
-        return (newValue) => {
-            const oldValue = this.#propertyUpdateValues[property];
+  #invalidateCachesDependentUpon(property) {
+    const invalidators = this.#propertyUpdateCacheInvalidators[property];
+    if (!invalidators) {
+      return;
+    }
 
-            if (newValue === undefined) {
-                throw new TypeError(`Properties cannot be set to undefined`);
-            }
+    for (const invalidate of invalidators) {
+      invalidate();
+    }
+  }
+
+  #getExposeObjectDefinitionGetterFunction(property) {
+    const {flags} = this.#getPropertyDescriptor(property);
+    const compute = this.#getExposeComputeFunction(property);
+
+    if (compute) {
+      let cachedValue;
+      const checkCacheValid = this.#getExposeCheckCacheValidFunction(property);
+      return () => {
+        if (checkCacheValid()) {
+          return cachedValue;
+        } else {
+          return (cachedValue = compute());
+        }
+      };
+    } else if (!flags.update && !compute) {
+      throw new Error(`Exposed property ${property} does not update and is missing compute function`);
+    } else {
+      return () => this.#propertyUpdateValues[property];
+    }
+  }
 
-            if (newValue === oldValue) {
-                return;
-            }
+  #getExposeComputeFunction(property) {
+    const {flags, expose} = this.#getPropertyDescriptor(property);
 
-            if (newValue !== null && validate) {
-                try {
-                    const result = validate(newValue);
-                    if (result === undefined) {
-                        throw new TypeError(`Validate function returned undefined`);
-                    } else if (result !== true) {
-                        throw new TypeError(`Validation failed for value ${newValue}`);
-                    }
-                } catch (error) {
-                    error.message = `Property ${color.green(property)} (${inspect(this[property])} -> ${inspect(newValue)}): ${error.message}`;
-                    throw error;
-                }
-            }
+    const compute = expose?.compute;
+    const transform = expose?.transform;
 
-            this.#propertyUpdateValues[property] = newValue;
-            this.#invalidateCachesDependentUpon(property);
-        };
+    if (flags.update && !transform) {
+      return null;
+    } else if (flags.update && compute) {
+      throw new Error(`Updating property ${property} has compute function, should be formatted as transform`);
+    } else if (!flags.update && !compute) {
+      throw new Error(`Exposed property ${property} does not update and is missing compute function`);
     }
 
-    #getUpdatePropertyValidateFunction(property) {
-        const descriptor = this.#getPropertyDescriptor(property);
-    }
+    let getAllDependencies;
 
-    #getPropertyDescriptor(property) {
-        return this.constructor.propertyDescriptors[property];
-    }
+    if (expose.dependencies?.length > 0) {
+      const dependencyKeys = expose.dependencies.slice();
+      const shouldReflect = dependencyKeys.includes('this');
+
+      getAllDependencies = () => {
+        const dependencies = Object.create(null);
 
-    #invalidateCachesDependentUpon(property) {
-        for (const invalidate of this.#propertyUpdateCacheInvalidators[property] || []) {
-            invalidate();
+        for (const key of dependencyKeys) {
+          dependencies[key] = this.#propertyUpdateValues[key];
         }
-    }
 
-    #getExposeObjectDefinitionGetterFunction(property) {
-        const { flags } = this.#getPropertyDescriptor(property);
-        const compute = this.#getExposeComputeFunction(property);
-
-        if (compute) {
-            let cachedValue;
-            const checkCacheValid = this.#getExposeCheckCacheValidFunction(property);
-            return () => {
-                if (checkCacheValid()) {
-                    return cachedValue;
-                } else {
-                    return (cachedValue = compute());
-                }
-            };
-        } else if (!flags.update && !compute) {
-            throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-        } else {
-            return () => this.#propertyUpdateValues[property];
+        if (shouldReflect) {
+          dependencies.this = this;
         }
+
+        return dependencies;
+      };
+    } else {
+      const dependencies = Object.create(null);
+      Object.freeze(dependencies);
+      getAllDependencies = () => dependencies;
     }
 
-    #getExposeComputeFunction(property) {
-        const { flags, expose } = this.#getPropertyDescriptor(property);
+    if (flags.update) {
+      return () => transform(this.#propertyUpdateValues[property], getAllDependencies());
+    } else {
+      return () => compute(getAllDependencies());
+    }
+  }
 
-        const compute = expose?.compute;
-        const transform = expose?.transform;
+  #getExposeCheckCacheValidFunction(property) {
+    const {flags, expose} = this.#getPropertyDescriptor(property);
 
-        if (flags.update && !transform) {
-            return null;
-        } else if (flags.update && compute) {
-            throw new Error(`Updating property ${property} has compute function, should be formatted as transform`);
-        } else if (!flags.update && !compute) {
-            throw new Error(`Exposed property ${property} does not update and is missing compute function`);
-        }
+    let valid = false;
 
-        const dependencyKeys = expose.dependencies || [];
-        const dependencyGetters = dependencyKeys.map(key => () => [key, this.#propertyUpdateValues[key]]);
-        const getAllDependencies = () => Object.fromEntries(dependencyGetters.map(f => f())
-            .concat([[this.constructor.instance, this]]));
+    const invalidate = () => {
+      valid = false;
+    };
 
-        if (flags.update) {
-            return () => transform(this.#propertyUpdateValues[property], getAllDependencies());
-        } else {
-            return () => compute(getAllDependencies());
-        }
+    const dependencyKeys = new Set(expose?.dependencies);
+
+    if (flags.update) {
+      dependencyKeys.add(property);
     }
 
-    #getExposeCheckCacheValidFunction(property) {
-        const { flags, expose } = this.#getPropertyDescriptor(property);
+    for (const key of dependencyKeys) {
+      if (this.#propertyUpdateCacheInvalidators[key]) {
+        this.#propertyUpdateCacheInvalidators[key].push(invalidate);
+      } else {
+        this.#propertyUpdateCacheInvalidators[key] = [invalidate];
+      }
+    }
 
-        let valid = false;
+    return () => {
+      if (!valid) {
+        valid = true;
+        return false;
+      } else {
+        return true;
+      }
+    };
+  }
+
+  static cacheAllExposedProperties(obj) {
+    if (!(obj instanceof CacheableObject)) {
+      console.warn('Not a CacheableObject:', obj);
+      return;
+    }
 
-        const invalidate = () => {
-            valid = false;
-        };
+    const {propertyDescriptors} = obj.constructor;
 
-        const dependencyKeys = new Set(expose?.dependencies);
+    if (!propertyDescriptors) {
+      console.warn('Missing property descriptors:', obj);
+      return;
+    }
 
-        if (flags.update) {
-            dependencyKeys.add(property);
-        }
+    for (const [property, descriptor] of Object.entries(propertyDescriptors)) {
+      const {flags} = descriptor;
 
-        for (const key of dependencyKeys) {
-            if (this.#propertyUpdateCacheInvalidators[key]) {
-                this.#propertyUpdateCacheInvalidators[key].push(invalidate);
-            } else {
-                this.#propertyUpdateCacheInvalidators[key] = [invalidate];
-            }
-        }
+      if (!flags.expose) {
+        continue;
+      }
 
-        return () => {
-            if (!valid) {
-                valid = true;
-                return false;
-            } else {
-                return true;
-            }
-        };
+      obj[property];
     }
+  }
 
-    static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false;
-    static _invalidAccesses = new Set();
+  static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false;
+  static _invalidAccesses = new Set();
 
-    static showInvalidAccesses() {
-        if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
-            return;
-        }
+  static showInvalidAccesses() {
+    if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) {
+      return;
+    }
 
-        if (!this._invalidAccesses.size) {
-            return;
-        }
+    if (!this._invalidAccesses.size) {
+      return;
+    }
 
-        console.log(`${this._invalidAccesses.size} unique invalid accesses:`);
-        for (const line of this._invalidAccesses) {
-            console.log(` - ${line}`);
-        }
+    console.log(`${this._invalidAccesses.size} unique invalid accesses:`);
+    for (const line of this._invalidAccesses) {
+      console.log(` - ${line}`);
     }
+  }
+
+  static getUpdateValue(object, key) {
+    if (!Object.hasOwn(object, key)) {
+      return undefined;
+    }
+
+    return object.#propertyUpdateValues[key] ?? null;
+  }
+}
+
+export class CacheableObjectPropertyValueError extends Error {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+  constructor(property, oldValue, newValue, options) {
+    super(
+      `Error setting ${colors.green(property)} (${inspect(oldValue)} -> ${inspect(newValue)})`,
+      options);
+
+    this.property = property;
+  }
 }
diff --git a/src/data/checks.js b/src/data/checks.js
new file mode 100644
index 0000000..44f3efd
--- /dev/null
+++ b/src/data/checks.js
@@ -0,0 +1,704 @@
+// 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 {commentaryRegexCaseSensitive} from '#wiki-data';
+
+import {
+  annotateErrorWithIndex,
+  conditionallySuppressError,
+  decorateErrorWithIndex,
+  filterAggregate,
+  openAggregate,
+  withAggregate,
+} from '#aggregate';
+
+function inspect(value, opts = {}) {
+  return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
+}
+
+// Warn about directories which are reused across more than one of the same type
+// of Thing. Directories are the unique identifier for most data objects across
+// the wiki, so we have to make sure they aren't duplicated!
+export function reportDuplicateDirectories(wikiData, {
+  getAllFindSpecs,
+}) {
+  const duplicateSets = [];
+
+  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];
+
+    for (const thing of thingData) {
+      if (findSpec.include && !findSpec.include(thing)) {
+        continue;
+      }
+
+      const directories =
+        (findSpec.getMatchableDirectories
+          ? findSpec.getMatchableDirectories(thing)
+          : [thing.directory]);
+
+      for (const directory of directories) {
+        if (directory in directoryPlaces) {
+          directoryPlaces[directory].push(thing);
+          duplicateDirectories.add(directory);
+        } else {
+          directoryPlaces[directory] = [thing];
+        }
+      }
+    }
+
+    if (empty(duplicateDirectories)) continue;
+
+    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});
+    }
+  }
+
+  if (empty(duplicateSets)) return;
+
+  // Multiple find functions may effectively have duplicates across the same
+  // things. These only need to be reported once, because resolving one of them
+  // will resolve the rest, so cut out duplicate sets before reporting.
+
+  const seenDuplicateSets = new Map();
+  const deduplicateDuplicateSets = [];
+
+  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;
+        }
+      }
+
+      placeLists.push(set.places);
+    } else {
+      seenDuplicateSets.set(set.directory, [set.places]);
+    }
+
+    deduplicateDuplicateSets.push(set);
+  }
+
+  withAggregate({message: `Duplicate directories found`}, ({push}) => {
+    for (const {directory, places} of deduplicateDuplicateSets) {
+      push(new Error(
+        `Duplicate directory ${colors.green(`"${directory}"`)}:\n` +
+        places.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, {
+  bindFind,
+}) {
+  const referenceSpec = [
+    ['albumData', {
+      artistContribs: '_contrib',
+      coverArtistContribs: '_contrib',
+      trackCoverArtistContribs: '_contrib',
+      wallpaperArtistContribs: '_contrib',
+      bannerArtistContribs: '_contrib',
+      groups: 'group',
+      artTags: '_artTag',
+      commentary: '_commentary',
+    }],
+
+    ['flashData', {
+      commentary: '_commentary',
+    }],
+
+    ['groupCategoryData', {
+      groups: 'group',
+    }],
+
+    ['homepageLayout.rows', {
+      sourceGroup: '_homepageSourceGroup',
+      sourceAlbums: 'album',
+    }],
+
+    ['flashData', {
+      contributorContribs: '_contrib',
+      featuredTracks: 'track',
+    }],
+
+    ['flashActData', {
+      flashes: 'flash',
+    }],
+
+    ['trackData', {
+      artistContribs: '_contrib',
+      contributorContribs: '_contrib',
+      coverArtistContribs: '_contrib',
+      referencedTracks: '_trackNotRerelease',
+      sampledTracks: '_trackNotRerelease',
+      artTags: '_artTag',
+      originalReleaseTrack: '_trackNotRerelease',
+      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 : [thingData];
+    aggregate.nest({message: `Reference errors in ${colors.green('wikiData.' + thingDataProp)}`}, ({nest}) => {
+      for (const thing of things) {
+        nest({message: `Reference errors in ${inspect(thing)}`}, ({nest, push, filter}) => {
+          for (const [property, findFnKey] of Object.entries(propSpec)) {
+            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;
+            }
+
+            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 '_artTag':
+                findFn = boundFind.artTag;
+                break;
+
+              case '_commentary':
+                findFn = findArtistOrAlias;
+                break;
+
+              case '_contrib':
+                findFn = contribRef => findArtistOrAlias(contribRef.who);
+                break;
+
+              case '_homepageSourceGroup':
+                findFn = groupRef => {
+                  if (groupRef === 'new-additions' || groupRef === 'new-releases') {
+                    return true;
+                  }
+
+                  return boundFind.group(groupRef);
+                };
+                break;
+
+              case '_trackNotRerelease':
+                findFn = trackRef => {
+                  const track = boundFind.track(trackRef);
+                  const originalRef = track && CacheableObject.getUpdateValue(track, 'originalReleaseTrack');
+
+                  if (originalRef) {
+                    // It's possible for the original to not actually exist, in this case.
+                    // It should still be reported since the 'Originally Released As' field
+                    // was present.
+                    const original = boundFind.track(originalRef, {mode: 'quiet'});
+
+                    // Prefer references by name, but only if it's unambiguous.
+                    const originalByName =
+                      (original
+                        ? boundFind.track(original.name, {mode: 'quiet'})
+                        : null);
+
+                    const shouldBeMessage =
+                      (originalByName
+                        ? colors.green(original.name)
+                     : original
+                        ? colors.green('track:' + original.directory)
+                        : colors.green(originalRef));
+
+                    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 => {
+              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 contentTextSpec = [
+    ['albumData', {
+      additionalFiles: additionalFileShape,
+      commentary: commentaryShape,
+    }],
+
+    ['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,
+      lyrics: '_content',
+      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 (const [property, shape] of Object.entries(propSpec)) {
+              const 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;
+              }
+
+              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),
+                      });
+                    }
+                  }
+                });
+              }
+            }
+          });
+        }
+      });
+    }
+  });
+}
diff --git a/src/data/composite.js b/src/data/composite.js
new file mode 100644
index 0000000..7a98c42
--- /dev/null
+++ b/src/data/composite.js
@@ -0,0 +1,1297 @@
+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.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(inputOptions) {
+  const metadata = {};
+
+  for (const [name, token] of Object.entries(inputOptions)) {
+    if (typeof token === 'string') {
+      metadata[input.staticDependency(name)] = token;
+      metadata[input.staticValue(name)] = null;
+    } else if (isInputToken(token)) {
+      const tokenShape = getInputTokenShape(token);
+      const tokenValue = getInputTokenValue(token);
+
+      metadata[input.staticDependency(name)] =
+        (tokenShape === 'input.dependency'
+          ? tokenValue
+          : null);
+
+      metadata[input.staticValue(name)] =
+        (tokenShape === 'input.value'
+          ? tokenValue
+          : null);
+    } else {
+      metadata[input.staticDependency(name)] = null;
+      metadata[input.staticValue(name)] = null;
+    }
+  }
+
+  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.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 inputMetadata = getStaticInputMetadata(inputOptions);
+
+    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) {
+          const inputMapping = {};
+
+          for (const [name, token] of Object.entries(description.inputs)) {
+            const tokenValue = getInputTokenValue(token);
+            if (name in inputOptions) {
+              if (typeof inputOptions[name] === 'string') {
+                inputMapping[name] = input.dependency(inputOptions[name]);
+              } else {
+                inputMapping[name] = inputOptions[name];
+              }
+            } else if (tokenValue.defaultValue) {
+              inputMapping[name] = input.value(tokenValue.defaultValue);
+            } else if (tokenValue.defaultDependency) {
+              inputMapping[name] = input.dependency(tokenValue.defaultDependency);
+            } else {
+              inputMapping[name] = input.value(null);
+            }
+          }
+
+          finalDescription.inputMapping = inputMapping;
+          finalDescription.inputDescriptions = description.inputs;
+        }
+
+        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 = 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';
+          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';
+            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 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 !== compositionNests) {
+        return push(new TypeError(
+          (compositionNests
+            ? `Base must compose, this composition is nestable`
+            : `Base must not compose, this composition isn't nestable`)));
+      } else if (!isBase && !stepComposes) {
+        return push(new TypeError(
+          (compositionNests
+            ? `All steps must compose`
+            : `All steps (except 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':
+              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 (let i = 0; i < steps.length; i++) {
+      const step = steps[i];
+      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?.['this'] ?? 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.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;
+
+        if (isBase && !compositionNests) {
+          args =
+            argsLayout.filter(arg => arg !== continuationSymbol);
+        } else {
+          let continuation;
+
+          ({continuation, continuationStorage} =
+            _prepareContinuation(callingTransformForThisStep));
+
+          args =
+            argsLayout.map(arg =>
+              (arg === continuationSymbol
+                ? continuation
+                : arg));
+        }
+
+        return expose[name](...args);
+      }
+
+      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]);
+
+        if (compositionNests) {
+          throw new TypeError(`Inferred early-exit is disallowed in nested compositions`);
+        }
+
+        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;
+        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 0000000..c660a7e
--- /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 0000000..244b323
--- /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 0000000..e76699c
--- /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 0000000..3aa3d03
--- /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 0000000..0f7f223
--- /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 0000000..1f94b33
--- /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/index.js b/src/data/composite/control-flow/index.js
new file mode 100644
index 0000000..7fad88b
--- /dev/null
+++ b/src/data/composite/control-flow/index.js
@@ -0,0 +1,14 @@
+// #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 raiseOutputWithoutDependency} from './raiseOutputWithoutDependency.js';
+export {default as raiseOutputWithoutUpdateValue} from './raiseOutputWithoutUpdateValue.js';
+export {default as withResultOfAvailabilityCheck} from './withResultOfAvailabilityCheck.js';
diff --git a/src/data/composite/control-flow/inputAvailabilityCheckMode.js b/src/data/composite/control-flow/inputAvailabilityCheckMode.js
new file mode 100644
index 0000000..8008fde
--- /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 0000000..3d04f8a
--- /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 0000000..ffa83a9
--- /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/withResultOfAvailabilityCheck.js b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js
new file mode 100644
index 0000000..a694201
--- /dev/null
+++ b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js
@@ -0,0 +1,71 @@
+// 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
+//  - raiseOutputWithoutDependency
+//  - raiseOutputWithoutUpdateValue
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {empty} from '#sugar';
+
+import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.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,
+      }) => {
+        let availability;
+
+        switch (mode) {
+          case 'null':
+            availability = value !== undefined && value !== null;
+            break;
+
+          case 'empty':
+            availability = value !== undefined && !empty(value);
+            break;
+
+          case 'falsy':
+            availability = !!value && (!Array.isArray(value) || !empty(value));
+            break;
+
+          case 'index':
+            availability = typeof value === 'number' && value >= 0;
+            break;
+        }
+
+        return continuation({'#availability': availability});
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/data/excludeFromList.js b/src/data/composite/data/excludeFromList.js
new file mode 100644
index 0000000..d798dcd
--- /dev/null
+++ b/src/data/composite/data/excludeFromList.js
@@ -0,0 +1,55 @@
+// 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
+//
+// More list utilities:
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+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 0000000..4f818a7
--- /dev/null
+++ b/src/data/composite/data/fillMissingListItems.js
@@ -0,0 +1,50 @@
+// Replaces items of a list, which are null or undefined, with some fallback
+// value. By default, this replaces the passed dependency.
+//
+// See also:
+//  - excludeFromList
+//
+// More list utilities:
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+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 0000000..256c049
--- /dev/null
+++ b/src/data/composite/data/index.js
@@ -0,0 +1,17 @@
+// #composite/data
+//
+// Entries here may depend on entries in #composite/control-flow.
+//
+
+export {default as excludeFromList} from './excludeFromList.js';
+export {default as fillMissingListItems} from './fillMissingListItems.js';
+export {default as withFilteredList} from './withFilteredList.js';
+export {default as withFlattenedList} from './withFlattenedList.js';
+export {default as withMappedList} from './withMappedList.js';
+export {default as withPropertiesFromList} from './withPropertiesFromList.js';
+export {default as withPropertiesFromObject} from './withPropertiesFromObject.js';
+export {default as withPropertyFromList} from './withPropertyFromList.js';
+export {default as withPropertyFromObject} from './withPropertyFromObject.js';
+export {default as withSortedList} from './withSortedList.js';
+export {default as withUnflattenedList} from './withUnflattenedList.js';
+export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js';
diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js
new file mode 100644
index 0000000..82e5690
--- /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.
+//
+// TODO: It would be neat to apply an availability check here, e.g. to allow
+// not providing a filter at all and performing the check on the contents of
+// the list (though on the filter, if present, is fine too). But that's best
+// done by some shmancy-fancy mapping support in composite.js, so a bit out
+// of reach for now (apart from proving uses built on top of a more boring
+// implementation).
+//
+// TODO: There should be two outputs - one for the items included according to
+// the filter, and one for the items excluded.
+//
+// See also:
+//  - withMappedList
+//  - withSortedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withFilteredList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    filter: input({type: 'array'}),
+  },
+
+  outputs: ['#filteredList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('filter')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('filter')]: filter,
+      }) => continuation({
+        '#filteredList':
+          list.filter((item, index) => filter[index]),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withFlattenedList.js b/src/data/composite/data/withFlattenedList.js
new file mode 100644
index 0000000..edfa340
--- /dev/null
+++ b/src/data/composite/data/withFlattenedList.js
@@ -0,0 +1,47 @@
+// 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
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFilteredList, withMappedList, withSortedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+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/withMappedList.js b/src/data/composite/data/withMappedList.js
new file mode 100644
index 0000000..e0a700b
--- /dev/null
+++ b/src/data/composite/data/withMappedList.js
@@ -0,0 +1,39 @@
+// Applies a map function to each item in a list, just like a normal JavaScript
+// map.
+//
+// See also:
+//  - withFilteredList
+//  - withSortedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withMappedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    map: input({type: 'function'}),
+  },
+
+  outputs: ['#mappedList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('map')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('map')]: mapFn,
+      }) => continuation({
+        ['#mappedList']:
+          list.map(mapFn),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertiesFromList.js b/src/data/composite/data/withPropertiesFromList.js
new file mode 100644
index 0000000..08907ba
--- /dev/null
+++ b/src/data/composite/data/withPropertiesFromList.js
@@ -0,0 +1,92 @@
+// 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
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
+//
+
+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 0000000..21726b5
--- /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 0000000..a2c66d7
--- /dev/null
+++ b/src/data/composite/data/withPropertyFromList.js
@@ -0,0 +1,82 @@
+// 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.
+//
+// See also:
+//  - withPropertiesFromList
+//  - withPropertyFromObject
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
+//
+
+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}),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+    [input.staticValue('property')]: property,
+    [input.staticValue('prefix')]: prefix,
+  }) =>
+    [getOutputName({list, property, prefix})],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('property')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('property')]: property,
+      }) => continuation({
+        ['#values']:
+          list.map(item => 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 0000000..b31bab1
--- /dev/null
+++ b/src/data/composite/data/withPropertyFromObject.js
@@ -0,0 +1,69 @@
+// 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.
+//
+// See also:
+//  - withPropertiesFromObject
+//  - withPropertyFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withPropertyFromObject`,
+
+  inputs: {
+    object: input({type: 'object', acceptsNull: true}),
+    property: input({type: 'string'}),
+  },
+
+  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: [
+        '#output',
+        input('object'),
+        input('property'),
+      ],
+
+      compute: (continuation, {
+        ['#output']: output,
+        [input('object')]: object,
+        [input('property')]: property,
+      }) => continuation({
+        [output]:
+          (object === null
+            ? null
+            : object[property] ?? null),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withSortedList.js b/src/data/composite/data/withSortedList.js
new file mode 100644
index 0000000..dd81078
--- /dev/null
+++ b/src/data/composite/data/withSortedList.js
@@ -0,0 +1,121 @@
+// 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
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+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/withUnflattenedList.js b/src/data/composite/data/withUnflattenedList.js
new file mode 100644
index 0000000..39a666d
--- /dev/null
+++ b/src/data/composite/data/withUnflattenedList.js
@@ -0,0 +1,72 @@
+// 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
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFilteredList, withMappedList, withSortedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+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 0000000..7ee08b0
--- /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 0000000..8139f10
--- /dev/null
+++ b/src/data/composite/things/album/index.js
@@ -0,0 +1,2 @@
+export {default as withTracks} from './withTracks.js';
+export {default as withTrackSections} from './withTrackSections.js';
diff --git a/src/data/composite/things/album/withTrackSections.js b/src/data/composite/things/album/withTrackSections.js
new file mode 100644
index 0000000..0a1ebeb
--- /dev/null
+++ b/src/data/composite/things/album/withTrackSections.js
@@ -0,0 +1,127 @@
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
+import {isTrackSectionList} from '#validators';
+
+import {exitWithoutDependency, exitWithoutUpdateValue}
+  from '#composite/control-flow';
+import {withResolvedReferenceList} from '#composite/wiki-data';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withPropertiesFromList,
+  withUnflattenedList,
+} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withTrackSections`,
+
+  outputs: ['#trackSections'],
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: 'ownTrackData',
+      value: input.value([]),
+    }),
+
+    exitWithoutUpdateValue({
+      mode: input.value('empty'),
+      value: input.value([]),
+    }),
+
+    // TODO: input.updateValue description down here is a kludge.
+    withPropertiesFromList({
+      list: input.updateValue({
+        validate: isTrackSectionList,
+      }),
+      prefix: input.value('#sections'),
+      properties: input.value([
+        'tracks',
+        'dateOriginallyReleased',
+        'isDefaultTrackSection',
+        'name',
+        'color',
+      ]),
+    }),
+
+    fillMissingListItems({
+      list: '#sections.tracks',
+      fill: input.value([]),
+    }),
+
+    fillMissingListItems({
+      list: '#sections.isDefaultTrackSection',
+      fill: input.value(false),
+    }),
+
+    fillMissingListItems({
+      list: '#sections.name',
+      fill: input.value('Unnamed Track Section'),
+    }),
+
+    fillMissingListItems({
+      list: '#sections.color',
+      fill: input.dependency('color'),
+    }),
+
+    withFlattenedList({
+      list: '#sections.tracks',
+    }).outputs({
+      ['#flattenedList']: '#trackRefs',
+      ['#flattenedIndices']: '#sections.startIndex',
+    }),
+
+    withResolvedReferenceList({
+      list: '#trackRefs',
+      data: 'ownTrackData',
+      notFoundMode: input.value('null'),
+      find: input.value(find.track),
+    }).outputs({
+      ['#resolvedReferenceList']: '#tracks',
+    }),
+
+    withUnflattenedList({
+      list: '#tracks',
+      indices: '#sections.startIndex',
+    }).outputs({
+      ['#unflattenedList']: '#sections.tracks',
+    }),
+
+    {
+      dependencies: [
+        '#sections.tracks',
+        '#sections.name',
+        '#sections.color',
+        '#sections.dateOriginallyReleased',
+        '#sections.isDefaultTrackSection',
+        '#sections.startIndex',
+      ],
+
+      compute: (continuation, {
+        '#sections.tracks': tracks,
+        '#sections.name': name,
+        '#sections.color': color,
+        '#sections.dateOriginallyReleased': dateOriginallyReleased,
+        '#sections.isDefaultTrackSection': isDefaultTrackSection,
+        '#sections.startIndex': startIndex,
+      }) => {
+        filterMultipleArrays(
+          tracks, name, color, dateOriginallyReleased, isDefaultTrackSection, startIndex,
+          tracks => !empty(tracks));
+
+        return continuation({
+          ['#trackSections']:
+            stitchArrays({
+              tracks,
+              name,
+              color,
+              dateOriginallyReleased,
+              isDefaultTrackSection,
+              startIndex,
+            }),
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js
new file mode 100644
index 0000000..fff3d5a
--- /dev/null
+++ b/src/data/composite/things/album/withTracks.js
@@ -0,0 +1,51 @@
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+
+import {exitWithoutDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+import {withResolvedReferenceList} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withTracks`,
+
+  outputs: ['#tracks'],
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: 'ownTrackData',
+      value: input.value([]),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: 'trackSections',
+      mode: input.value('empty'),
+      output: input.value({
+        ['#tracks']: [],
+      }),
+    }),
+
+    {
+      dependencies: ['trackSections'],
+      compute: (continuation, {trackSections}) =>
+        continuation({
+          '#trackRefs': trackSections
+            .flatMap(section => section.tracks ?? []),
+        }),
+    },
+
+    withResolvedReferenceList({
+      list: '#trackRefs',
+      data: 'ownTrackData',
+      find: input.value(find.track),
+    }),
+
+    {
+      dependencies: ['#resolvedReferenceList'],
+      compute: (continuation, {
+        ['#resolvedReferenceList']: resolvedReferenceList,
+      }) => continuation({
+        ['#tracks']: resolvedReferenceList,
+      })
+    },
+  ],
+});
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 0000000..40fecd2
--- /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 0000000..64daa1f
--- /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 {input, templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withFlashSide`,
+
+  outputs: ['#flashSide'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      data: 'flashSideData',
+      list: input.value('acts'),
+    }).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 0000000..63ac13d
--- /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 0000000..652b8bf
--- /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 {input, templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withFlashAct`,
+
+  outputs: ['#flashAct'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      data: 'flashActData',
+      list: input.value('flashes'),
+    }).outputs({
+      ['#uniqueReferencingThing']: '#flashAct',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/exitWithoutUniqueCoverArt.js b/src/data/composite/things/track/exitWithoutUniqueCoverArt.js
new file mode 100644
index 0000000..f47086d
--- /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 0000000..cc723a2
--- /dev/null
+++ b/src/data/composite/things/track/index.js
@@ -0,0 +1,11 @@
+export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js';
+export {default as inferredAdditionalNameList} from './inferredAdditionalNameList.js';
+export {default as inheritFromOriginalRelease} from './inheritFromOriginalRelease.js';
+export {default as sharedAdditionalNameList} from './sharedAdditionalNameList.js';
+export {default as trackReverseReferenceList} from './trackReverseReferenceList.js';
+export {default as withAlbum} from './withAlbum.js';
+export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js';
+export {default as withContainingTrackSection} from './withContainingTrackSection.js';
+export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js';
+export {default as withOtherReleases} from './withOtherReleases.js';
+export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js';
diff --git a/src/data/composite/things/track/inferredAdditionalNameList.js b/src/data/composite/things/track/inferredAdditionalNameList.js
new file mode 100644
index 0000000..58e8d2a
--- /dev/null
+++ b/src/data/composite/things/track/inferredAdditionalNameList.js
@@ -0,0 +1,67 @@
+// Infers additional name entries from other releases that were titled
+// differently; the corresponding releases are stored in eacn entry's "from"
+// array, which will include multiple items, if more than one other release
+// shares the same name differing from this one's.
+
+import {input, templateCompositeFrom} from '#composite';
+import {chunkByProperties} from '#sugar';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+import {withFilteredList, withPropertyFromList} from '#composite/data';
+import {withThingsSortedAlphabetically} from '#composite/wiki-data';
+
+import withOtherReleases from './withOtherReleases.js';
+
+export default templateCompositeFrom({
+  annotation: `inferredAdditionalNameList`,
+
+  compose: false,
+
+  steps: () => [
+    withOtherReleases(),
+
+    exitWithoutDependency({
+      dependency: '#otherReleases',
+      mode: input.value('empty'),
+      value: input.value([]),
+    }),
+
+    withPropertyFromList({
+      list: '#otherReleases',
+      property: input.value('name'),
+    }),
+
+    {
+      dependencies: ['#otherReleases.name', 'name'],
+      compute: (continuation, {
+        ['#otherReleases.name']: releaseNames,
+        ['name']: ownName,
+      }) => continuation({
+        ['#nameFilter']:
+          releaseNames.map(name => name !== ownName),
+      }),
+    },
+
+    withFilteredList({
+      list: '#otherReleases',
+      filter: '#nameFilter',
+    }).outputs({
+      '#filteredList': '#differentlyNamedReleases',
+    }),
+
+    withThingsSortedAlphabetically({
+      things: '#differentlyNamedReleases',
+    }).outputs({
+      '#sortedThings': '#differentlyNamedReleases',
+    }),
+
+    {
+      dependencies: ['#differentlyNamedReleases'],
+      compute: ({
+        ['#differentlyNamedReleases']: releases,
+      }) =>
+        chunkByProperties(releases, ['name'])
+          .map(({name, chunk}) => ({name, from: chunk})),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/inheritFromOriginalRelease.js b/src/data/composite/things/track/inheritFromOriginalRelease.js
new file mode 100644
index 0000000..27ed138
--- /dev/null
+++ b/src/data/composite/things/track/inheritFromOriginalRelease.js
@@ -0,0 +1,50 @@
+// Early exits with a value inherited from the original release, if
+// this track is a rerelease, and otherwise continues with no further
+// dependencies provided. If allowOverride is true, then the continuation
+// will also be called if the original release exposed the requested
+// property as null.
+//
+// Like withOriginalRelease, this will early exit (with notFoundValue) if the
+// original release is specified by reference and that reference doesn't
+// resolve to anything.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import withOriginalRelease from './withOriginalRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `inheritFromOriginalRelease`,
+
+  inputs: {
+    property: input({type: 'string'}),
+    allowOverride: input({type: 'boolean', defaultValue: false}),
+    notFoundValue: input({defaultValue: null}),
+  },
+
+  steps: () => [
+    withOriginalRelease({
+      notFoundValue: input('notFoundValue'),
+    }),
+
+    {
+      dependencies: [
+        '#originalRelease',
+        input('property'),
+        input('allowOverride'),
+      ],
+
+      compute: (continuation, {
+        ['#originalRelease']: originalRelease,
+        [input('property')]: originalProperty,
+        [input('allowOverride')]: allowOverride,
+      }) => {
+        if (!originalRelease) return continuation();
+
+        const value = originalRelease[originalProperty];
+        if (allowOverride && value === null) return continuation();
+
+        return continuation.exit(value);
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/sharedAdditionalNameList.js b/src/data/composite/things/track/sharedAdditionalNameList.js
new file mode 100644
index 0000000..1806ec8
--- /dev/null
+++ b/src/data/composite/things/track/sharedAdditionalNameList.js
@@ -0,0 +1,38 @@
+// Compiles additional names directly provided by other releases.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withFlattenedList, withPropertyFromList} from '#composite/data';
+
+import withOtherReleases from './withOtherReleases.js';
+
+export default templateCompositeFrom({
+  annotation: `sharedAdditionalNameList`,
+
+  compose: false,
+
+  steps: () => [
+    withOtherReleases(),
+
+    exitWithoutDependency({
+      dependency: '#otherReleases',
+      mode: input.value('empty'),
+      value: input.value([]),
+    }),
+
+    withPropertyFromList({
+      list: '#otherReleases',
+      property: input.value('additionalNames'),
+    }),
+
+    withFlattenedList({
+      list: '#otherReleases.additionalNames',
+    }),
+
+    exposeDependency({
+      dependency: '#flattenedList',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/trackAdditionalNameList.js b/src/data/composite/things/track/trackAdditionalNameList.js
new file mode 100644
index 0000000..65a2263
--- /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/trackReverseReferenceList.js b/src/data/composite/things/track/trackReverseReferenceList.js
new file mode 100644
index 0000000..44940ae
--- /dev/null
+++ b/src/data/composite/things/track/trackReverseReferenceList.js
@@ -0,0 +1,38 @@
+// Like a normal reverse reference list ("objects which reference this object
+// under a specified property"), only excluding rereleases from the possible
+// outputs. While it's useful to travel from a rerelease to the tracks it
+// references, rereleases aren't generally relevant from the perspective of
+// the tracks *being* referenced. Apart from hiding rereleases from lists on
+// the site, it also excludes keeps them from relational data processing, such
+// as on the "Tracks - by Times Referenced" listing page.
+
+import {input, templateCompositeFrom} from '#composite';
+import {withReverseReferenceList} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `trackReverseReferenceList`,
+
+  compose: false,
+
+  inputs: {
+    list: input({type: 'string'}),
+  },
+
+  steps: () => [
+    withReverseReferenceList({
+      data: 'trackData',
+      list: input('list'),
+    }),
+
+    {
+      flags: {expose: true},
+      expose: {
+        dependencies: ['#reverseReferenceList'],
+        compute: ({
+          ['#reverseReferenceList']: reverseReferenceList,
+        }) =>
+          reverseReferenceList.filter(track => !track.originalReleaseTrack),
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withAlbum.js b/src/data/composite/things/track/withAlbum.js
new file mode 100644
index 0000000..03b840d
--- /dev/null
+++ b/src/data/composite/things/track/withAlbum.js
@@ -0,0 +1,22 @@
+// Gets the track's album. This will early exit if albumData is missing.
+// If there's no album whose list of tracks includes this track, the output
+// dependency will be null.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withAlbum`,
+
+  outputs: ['#album'],
+
+  steps: () => [
+    withUniqueReferencingThing({
+      data: 'albumData',
+      list: input.value('tracks'),
+    }).outputs({
+      ['#uniqueReferencingThing']: '#album',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
new file mode 100644
index 0000000..fac8e21
--- /dev/null
+++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
@@ -0,0 +1,78 @@
+// 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 {exitWithoutDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+import {withResolvedReference} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withAlwaysReferenceByDirectory`,
+
+  outputs: ['#alwaysReferenceByDirectory'],
+
+  steps: () => [
+    exposeUpdateValueOrContinue({
+      validate: input.value(isBoolean),
+    }),
+
+    // Remaining code is for defaulting to true if this track is a rerelease of
+    // another with the same name, so everything further depends on access to
+    // trackData as well as originalReleaseTrack.
+
+    exitWithoutDependency({
+      dependency: 'trackData',
+      mode: input.value('empty'),
+      value: input.value(false),
+    }),
+
+    exitWithoutDependency({
+      dependency: 'originalReleaseTrack',
+      value: input.value(false),
+    }),
+
+    // It's necessary to use the custom trackOriginalReleasesOnly find function
+    // here, so as to avoid recursion issues - the find.track() function depends
+    // on accessing each track's alwaysReferenceByDirectory, which means it'll
+    // hit *this track* - and thus this step - and end up recursing infinitely.
+    // By definition, find.trackOriginalReleasesOnly excludes tracks which have
+    // an originalReleaseTrack update value set, which means even though it does
+    // still access each of tracks' `alwaysReferenceByDirectory` property, it
+    // won't access that of *this* track - it will never proceed past the
+    // `exitWithoutDependency` step directly above, so there's no opportunity
+    // for recursion.
+    withResolvedReference({
+      ref: 'originalReleaseTrack',
+      data: 'trackData',
+      find: input.value(find.trackOriginalReleasesOnly),
+    }).outputs({
+      '#resolvedReference': '#originalRelease',
+    }),
+
+    exitWithoutDependency({
+      dependency: '#originalRelease',
+      value: input.value(false),
+    }),
+
+    withPropertyFromObject({
+      object: '#originalRelease',
+      property: input.value('name'),
+    }),
+
+    {
+      dependencies: ['name', '#originalRelease.name'],
+      compute: (continuation, {
+        name,
+        ['#originalRelease.name']: originalName,
+      }) => continuation({
+        ['#alwaysReferenceByDirectory']: name === originalName,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js
new file mode 100644
index 0000000..eaac14d
--- /dev/null
+++ b/src/data/composite/things/track/withContainingTrackSection.js
@@ -0,0 +1,42 @@
+// Gets the track section containing this track from its album's track list.
+
+import {input, templateCompositeFrom} from '#composite';
+import {is} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withContainingTrackSection`,
+
+  outputs: ['#trackSection'],
+
+  steps: () => [
+    withPropertyFromAlbum({
+      property: input.value('trackSections'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#album.trackSections',
+      output: input.value({'#trackSection': null}),
+    }),
+
+    {
+      dependencies: [
+        input.myself(),
+        '#album.trackSections',
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: track,
+        [input('notFoundMode')]: notFoundMode,
+        ['#album.trackSections']: trackSections,
+      }) => continuation({
+        ['#trackSection']:
+          trackSections.find(({tracks}) => tracks.includes(track))
+            ?? null,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js
new file mode 100644
index 0000000..96078d5
--- /dev/null
+++ b/src/data/composite/things/track/withHasUniqueCoverArt.js
@@ -0,0 +1,61 @@
+// 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.)
+
+import {input, templateCompositeFrom} from '#composite';
+import {empty} from '#sugar';
+
+import {withResolvedContribs} from '#composite/wiki-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()),
+    },
+
+    withResolvedContribs({from: 'coverArtistContribs'}),
+
+    {
+      dependencies: ['#resolvedContribs'],
+      compute: (continuation, {
+        ['#resolvedContribs']: contribsFromTrack,
+      }) =>
+        (empty(contribsFromTrack)
+          ? continuation()
+          : continuation.raiseOutput({
+              ['#hasUniqueCoverArt']: true,
+            })),
+    },
+
+    withPropertyFromAlbum({
+      property: input.value('trackCoverArtistContribs'),
+    }),
+
+    {
+      dependencies: ['#album.trackCoverArtistContribs'],
+      compute: (continuation, {
+        ['#album.trackCoverArtistContribs']: contribsFromAlbum,
+      }) =>
+        continuation.raiseOutput({
+          ['#hasUniqueCoverArt']:
+            !empty(contribsFromAlbum),
+        }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withOriginalRelease.js b/src/data/composite/things/track/withOriginalRelease.js
new file mode 100644
index 0000000..c7f4965
--- /dev/null
+++ b/src/data/composite/things/track/withOriginalRelease.js
@@ -0,0 +1,78 @@
+// Just includes the original release of this track as a dependency.
+// If this track isn't a rerelease, then it'll provide null, unless the
+// {selfIfOriginal} option is set, in which case it'll provide this track
+// itself. This will early exit (with notFoundValue) if the original release
+// is specified by reference and that reference doesn't resolve to anything.
+
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {validateWikiData} from '#validators';
+
+import {exitWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {withResolvedReference} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withOriginalRelease`,
+
+  inputs: {
+    selfIfOriginal: input({type: 'boolean', defaultValue: false}),
+
+    data: input({
+      validate: validateWikiData({referenceType: 'track'}),
+      defaultDependency: 'trackData',
+    }),
+
+    notFoundValue: input({defaultValue: null}),
+  },
+
+  outputs: ['#originalRelease'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: 'originalReleaseTrack',
+    }),
+
+    {
+      dependencies: [
+        input.myself(),
+        input('selfIfOriginal'),
+        '#availability',
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: track,
+        [input('selfIfOriginal')]: selfIfOriginal,
+        '#availability': availability,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.raiseOutput({
+              ['#originalRelease']:
+                (selfIfOriginal ? track : null),
+            })),
+    },
+
+    withResolvedReference({
+      ref: 'originalReleaseTrack',
+      data: input('data'),
+      find: input.value(find.track),
+    }),
+
+    exitWithoutDependency({
+      dependency: '#resolvedReference',
+      value: input('notFoundValue'),
+    }),
+
+    {
+      dependencies: ['#resolvedReference'],
+
+      compute: (continuation, {
+        ['#resolvedReference']: resolvedReference,
+      }) =>
+        continuation({
+          ['#originalRelease']: resolvedReference,
+        }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js
new file mode 100644
index 0000000..f8c1c3f
--- /dev/null
+++ b/src/data/composite/things/track/withOtherReleases.js
@@ -0,0 +1,41 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+
+import withOriginalRelease from './withOriginalRelease.js';
+
+export default templateCompositeFrom({
+  annotation: `withOtherReleases`,
+
+  outputs: ['#otherReleases'],
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: 'trackData',
+      mode: input.value('empty'),
+    }),
+
+    withOriginalRelease({
+      selfIfOriginal: input.value(true),
+      notFoundValue: input.value([]),
+    }),
+
+    {
+      dependencies: [input.myself(), '#originalRelease', 'trackData'],
+      compute: (continuation, {
+        [input.myself()]: thisTrack,
+        ['#originalRelease']: originalRelease,
+        trackData,
+      }) => continuation({
+        ['#otherReleases']:
+          (originalRelease === thisTrack
+            ? []
+            : [originalRelease])
+            .concat(trackData.filter(track =>
+              track !== originalRelease &&
+              track !== thisTrack &&
+              track.originalReleaseTrack === originalRelease)),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withPropertyFromAlbum.js b/src/data/composite/things/track/withPropertyFromAlbum.js
new file mode 100644
index 0000000..d41390f
--- /dev/null
+++ b/src/data/composite/things/track/withPropertyFromAlbum.js
@@ -0,0 +1,40 @@
+// 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 {is} from '#validators';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import withAlbum from './withAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withPropertyFromAlbum`,
+
+  inputs: {
+    property: input.staticValue({type: 'string'}),
+  },
+
+  outputs: ({
+    [input.staticValue('property')]: property,
+  }) => ['#album.' + property],
+
+  steps: () => [
+    withAlbum(),
+
+    withPropertyFromObject({
+      object: '#album',
+      property: input('property'),
+    }),
+
+    {
+      dependencies: ['#value', input.staticValue('property')],
+      compute: (continuation, {
+        ['#value']: value,
+        [input.staticValue('property')]: property,
+      }) => continuation({
+        ['#album.' + property]: value,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/exitWithoutContribs.js b/src/data/composite/wiki-data/exitWithoutContribs.js
new file mode 100644
index 0000000..2c8219f
--- /dev/null
+++ b/src/data/composite/wiki-data/exitWithoutContribs.js
@@ -0,0 +1,47 @@
+// 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'),
+    }),
+
+    // 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/index.js b/src/data/composite/wiki-data/index.js
new file mode 100644
index 0000000..b4cf6d1
--- /dev/null
+++ b/src/data/composite/wiki-data/index.js
@@ -0,0 +1,16 @@
+// #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 inputWikiData} from './inputWikiData.js';
+export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js';
+export {default as withResolvedContribs} from './withResolvedContribs.js';
+export {default as withResolvedReference} from './withResolvedReference.js';
+export {default as withResolvedReferenceList} from './withResolvedReferenceList.js';
+export {default as withReverseContributionList} from './withReverseContributionList.js';
+export {default as 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/inputWikiData.js b/src/data/composite/wiki-data/inputWikiData.js
new file mode 100644
index 0000000..cf7a7c2
--- /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}),
+    acceptsNull: true,
+  });
+}
diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
new file mode 100644
index 0000000..f0404a5
--- /dev/null
+++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -0,0 +1,179 @@
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {stitchArrays} from '#sugar';
+import {isCommentary} from '#validators';
+import {commentaryRegexCaseSensitive} from '#wiki-data';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withPropertiesFromList,
+  withUnflattenedList,
+} from '#composite/data';
+
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withParsedCommentaryEntries`,
+
+  inputs: {
+    from: input({validate: isCommentary}),
+  },
+
+  outputs: ['#parsedCommentaryEntries'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+
+      compute: (continuation, {
+        [input('from')]: commentaryText,
+      }) => continuation({
+        ['#rawMatches']:
+          Array.from(commentaryText.matchAll(commentaryRegexCaseSensitive)),
+      }),
+    },
+
+    withPropertiesFromList({
+      list: '#rawMatches',
+      properties: input.value([
+        '0', // The entire match as a string.
+        'groups',
+        'index',
+      ]),
+    }).outputs({
+      '#rawMatches.0': '#rawMatches.text',
+      '#rawMatches.groups': '#rawMatches.groups',
+      '#rawMatches.index': '#rawMatches.startIndex',
+    }),
+
+    {
+      dependencies: [
+        '#rawMatches.text',
+        '#rawMatches.startIndex',
+      ],
+
+      compute: (continuation, {
+        ['#rawMatches.text']: text,
+        ['#rawMatches.startIndex']: startIndex,
+      }) => continuation({
+        ['#rawMatches.endIndex']:
+          stitchArrays({text, startIndex})
+            .map(({text, startIndex}) => startIndex + text.length),
+      }),
+    },
+
+    {
+      dependencies: [
+        input('from'),
+        '#rawMatches.startIndex',
+        '#rawMatches.endIndex',
+      ],
+
+      compute: (continuation, {
+        [input('from')]: commentaryText,
+        ['#rawMatches.startIndex']: startIndex,
+        ['#rawMatches.endIndex']: endIndex,
+      }) => continuation({
+        ['#entries.body']:
+          stitchArrays({startIndex, endIndex})
+            .map(({endIndex}, index, stitched) =>
+              (index === stitched.length - 1
+                ? commentaryText.slice(endIndex)
+                : commentaryText.slice(
+                    endIndex,
+                    stitched[index + 1].startIndex)))
+            .map(body => body.trim()),
+      }),
+    },
+
+    withPropertiesFromList({
+      list: '#rawMatches.groups',
+      prefix: input.value('#entries'),
+      properties: input.value([
+        'artistReferences',
+        'artistDisplayText',
+        'annotation',
+        'date',
+      ]),
+    }),
+
+    // 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',
+      data: 'artistData',
+      find: input.value(find.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),
+    }),
+
+    {
+      dependencies: ['#entries.date'],
+      compute: (continuation, {
+        ['#entries.date']: date,
+      }) => continuation({
+        ['#entries.date']:
+          date.map(date => date ? new Date(date) : null),
+      }),
+    },
+
+    {
+      dependencies: [
+        '#entries.artists',
+        '#entries.artistDisplayText',
+        '#entries.annotation',
+        '#entries.date',
+        '#entries.body',
+      ],
+
+      compute: (continuation, {
+        ['#entries.artists']: artists,
+        ['#entries.artistDisplayText']: artistDisplayText,
+        ['#entries.annotation']: annotation,
+        ['#entries.date']: date,
+        ['#entries.body']: body,
+      }) => continuation({
+        ['#parsedCommentaryEntries']:
+          stitchArrays({
+            artists,
+            artistDisplayText,
+            annotation,
+            date,
+            body,
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js
new file mode 100644
index 0000000..77b0f96
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedContribs.js
@@ -0,0 +1,76 @@
+// Resolves the contribsByRef contained in the provided dependency,
+// providing (named by the second argument) the result. "Resolving"
+// means mapping the "who" reference of each contribution to an artist
+// object, and filtering out those whose "who" doesn't match any artist.
+
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {filterMultipleArrays, stitchArrays} from '#sugar';
+import {is, isContributionList} from '#validators';
+
+import {
+  raiseOutputWithoutDependency,
+} from '#composite/control-flow';
+
+import {
+  withPropertiesFromList,
+} from '#composite/data';
+
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedContribs`,
+
+  inputs: {
+    from: input({
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+
+    notFoundMode: input({
+      validate: is('exit', 'filter', 'null'),
+      defaultValue: 'null',
+    }),
+  },
+
+  outputs: ['#resolvedContribs'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('from'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedContribs']: [],
+      }),
+    }),
+
+    withPropertiesFromList({
+      list: input('from'),
+      properties: input.value(['who', 'what']),
+      prefix: input.value('#contribs'),
+    }),
+
+    withResolvedReferenceList({
+      list: '#contribs.who',
+      data: 'artistData',
+      find: input.value(find.artist),
+      notFoundMode: input('notFoundMode'),
+    }).outputs({
+      ['#resolvedReferenceList']: '#contribs.who',
+    }),
+
+    {
+      dependencies: ['#contribs.who', '#contribs.what'],
+
+      compute(continuation, {
+        ['#contribs.who']: who,
+        ['#contribs.what']: what,
+      }) {
+        filterMultipleArrays(who, what, (who, _what) => who);
+        return continuation({
+          ['#resolvedContribs']: stitchArrays({who, what}),
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedReference.js b/src/data/composite/wiki-data/withResolvedReference.js
new file mode 100644
index 0000000..ea71707
--- /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. This will early exit if the
+// data dependency is null. Otherwise, the data object is provided on the
+// output dependency, or null, if the reference doesn't match anything or
+// itself was null to begin with.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {
+  exitWithoutDependency,
+  raiseOutputWithoutDependency,
+} from '#composite/control-flow';
+
+import inputWikiData from './inputWikiData.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedReference`,
+
+  inputs: {
+    ref: input({type: 'string', acceptsNull: true}),
+
+    data: inputWikiData({allowMixedTypes: false}),
+    find: input({type: 'function'}),
+  },
+
+  outputs: ['#resolvedReference'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('ref'),
+      output: input.value({
+        ['#resolvedReference']: null,
+      }),
+    }),
+
+    exitWithoutDependency({
+      dependency: input('data'),
+    }),
+
+    {
+      dependencies: [
+        input('ref'),
+        input('data'),
+        input('find'),
+      ],
+
+      compute: (continuation, {
+        [input('ref')]: ref,
+        [input('data')]: data,
+        [input('find')]: findFunction,
+      }) => continuation({
+        ['#resolvedReference']:
+          findFunction(ref, data, {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 0000000..1d39e5b
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedReferenceList.js
@@ -0,0 +1,101 @@
+// Resolves a list of references, with each reference matched with provided
+// data in the same way as withResolvedReference. This will early exit if the
+// data dependency is null (even if the reference list is empty). By default
+// it will filter out references which don't match, but this can be changed
+// to early exit ({notFoundMode: 'exit'}) or leave null in place ('null').
+
+import {input, templateCompositeFrom} from '#composite';
+import {is, isString, validateArrayItems} from '#validators';
+
+import {
+  exitWithoutDependency,
+  raiseOutputWithoutDependency,
+} from '#composite/control-flow';
+
+import inputWikiData from './inputWikiData.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedReferenceList`,
+
+  inputs: {
+    list: input({
+      validate: validateArrayItems(isString),
+      acceptsNull: true,
+    }),
+
+    data: inputWikiData({allowMixedTypes: false}),
+    find: input({type: 'function'}),
+
+    notFoundMode: input({
+      validate: is('exit', 'filter', 'null'),
+      defaultValue: 'filter',
+    }),
+  },
+
+  outputs: ['#resolvedReferenceList'],
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input('data'),
+      value: input.value([]),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: input('list'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedReferenceList']: [],
+      }),
+    }),
+
+    {
+      dependencies: [input('list'), input('data'), input('find')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('data')]: data,
+        [input('find')]: findFunction,
+      }) =>
+        continuation({
+          '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})),
+        }),
+    },
+
+    {
+      dependencies: ['#matches'],
+      compute: (continuation, {'#matches': matches}) =>
+        (matches.every(match => match)
+          ? continuation.raiseOutput({
+              ['#resolvedReferenceList']: matches,
+            })
+          : continuation()),
+    },
+
+    {
+      dependencies: ['#matches', input('notFoundMode')],
+      compute(continuation, {
+        ['#matches']: matches,
+        [input('notFoundMode')]: notFoundMode,
+      }) {
+        switch (notFoundMode) {
+          case 'exit':
+            return continuation.exit([]);
+
+          case 'filter':
+            return continuation.raiseOutput({
+              ['#resolvedReferenceList']:
+                matches.filter(match => match),
+            });
+
+          case 'null':
+            return continuation.raiseOutput({
+              ['#resolvedReferenceList']:
+                matches.map(match => match ?? null),
+            });
+
+          default:
+            throw new TypeError(`Expected notFoundMode to be exit, filter, or null`);
+        }
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withReverseContributionList.js b/src/data/composite/wiki-data/withReverseContributionList.js
new file mode 100644
index 0000000..eccb58b
--- /dev/null
+++ b/src/data/composite/wiki-data/withReverseContributionList.js
@@ -0,0 +1,83 @@
+// Analogous implementation for withReverseReferenceList, for contributions.
+// This is all duplicate code and both should be ported to the same underlying
+// data form later on.
+//
+// This implementation uses a global cache (via WeakMap) to attempt to speed
+// up subsequent similar accesses.
+//
+// This has absolutely not been rigorously tested with altering properties of
+// data objects in a wiki data array which is reused. If a new wiki data array
+// is used, a fresh cache will always be created.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+
+import inputWikiData from './inputWikiData.js';
+
+// Mapping of reference list property to WeakMap.
+// Each WeakMap maps a wiki data array to another weak map,
+// which in turn maps each referenced thing to an array of
+// things referencing it.
+const caches = new Map();
+
+export default templateCompositeFrom({
+  annotation: `withReverseContributionList`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: false}),
+    list: input({type: 'string'}),
+  },
+
+  outputs: ['#reverseContributionList'],
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input('data'),
+      value: input.value([]),
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: [input.myself(), input('data'), input('list')],
+
+      compute: (continuation, {
+        [input.myself()]: myself,
+        [input('data')]: data,
+        [input('list')]: list,
+      }) => {
+        if (!caches.has(list)) {
+          caches.set(list, new WeakMap());
+        }
+
+        const cache = caches.get(list);
+
+        if (!cache.has(data)) {
+          const cacheRecord = new WeakMap();
+
+          for (const referencingThing of data) {
+            const referenceList = referencingThing[list];
+
+            // Destructuring {who} is the only unique part of the
+            // withReverseContributionList implementation, compared to
+            // withReverseReferneceList.
+            for (const {who: referencedThing} of referenceList) {
+              if (cacheRecord.has(referencedThing)) {
+                cacheRecord.get(referencedThing).push(referencingThing);
+              } else {
+                cacheRecord.set(referencedThing, [referencingThing]);
+              }
+            }
+          }
+
+          cache.set(data, cacheRecord);
+        }
+
+        return continuation({
+          ['#reverseContributionList']:
+            cache.get(data).get(myself) ?? [],
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js
new file mode 100644
index 0000000..2d7a421
--- /dev/null
+++ b/src/data/composite/wiki-data/withReverseReferenceList.js
@@ -0,0 +1,81 @@
+// Check out the info on reverseReferenceList!
+// This is its composable form.
+//
+// This implementation uses a global cache (via WeakMap) to attempt to speed
+// up subsequent similar accesses.
+//
+// This has absolutely not been rigorously tested with altering properties of
+// data objects in a wiki data array which is reused. If a new wiki data array
+// is used, a fresh cache will always be created.
+//
+// Note that this implementation is mirrored in withReverseContributionList,
+// so any changes should be reflected there (until these are combined).
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+
+import inputWikiData from './inputWikiData.js';
+
+// Mapping of reference list property to WeakMap.
+// Each WeakMap maps a wiki data array to another weak map,
+// which in turn maps each referenced thing to an array of
+// things referencing it.
+const caches = new Map();
+
+export default templateCompositeFrom({
+  annotation: `withReverseReferenceList`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: false}),
+    list: input({type: 'string'}),
+  },
+
+  outputs: ['#reverseReferenceList'],
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input('data'),
+      value: input.value([]),
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: [input.myself(), input('data'), input('list')],
+
+      compute: (continuation, {
+        [input.myself()]: myself,
+        [input('data')]: data,
+        [input('list')]: list,
+      }) => {
+        if (!caches.has(list)) {
+          caches.set(list, new WeakMap());
+        }
+
+        const cache = caches.get(list);
+
+        if (!cache.has(data)) {
+          const cacheRecord = new WeakMap();
+
+          for (const referencingThing of data) {
+            const referenceList = referencingThing[list];
+            for (const referencedThing of referenceList) {
+              if (cacheRecord.has(referencedThing)) {
+                cacheRecord.get(referencedThing).push(referencingThing);
+              } else {
+                cacheRecord.set(referencedThing, [referencingThing]);
+              }
+            }
+          }
+
+          cache.set(data, cacheRecord);
+        }
+
+        return continuation({
+          ['#reverseReferenceList']:
+            cache.get(data).get(myself) ?? [],
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withThingsSortedAlphabetically.js b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js
new file mode 100644
index 0000000..5e85fa6
--- /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 0000000..ce04f83
--- /dev/null
+++ b/src/data/composite/wiki-data/withUniqueReferencingThing.js
@@ -0,0 +1,52 @@
+// 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 {exitWithoutDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+
+import inputWikiData from './inputWikiData.js';
+import withReverseReferenceList from './withReverseReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withUniqueReferencingThing`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: false}),
+    list: input({type: 'string'}),
+  },
+
+  outputs: ['#uniqueReferencingThing'],
+
+  steps: () => [
+    // withReverseRefernceList does this check too, but it early exits with
+    // an empty array. That's no good here!
+    exitWithoutDependency({
+      dependency: input('data'),
+      mode: input.value('empty'),
+    }),
+
+    withReverseReferenceList({
+      data: input('data'),
+      list: input('list'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#reverseReferenceList',
+      mode: input.value('empty'),
+      output: input.value({'#uniqueReferencingThing': null}),
+    }),
+
+    {
+      dependencies: ['#reverseReferenceList'],
+      compute: (continuation, {
+        ['#reverseReferenceList']: reverseReferenceList,
+      }) => continuation({
+        ['#uniqueReferencingThing']:
+          reverseReferenceList[0],
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-properties/additionalFiles.js b/src/data/composite/wiki-properties/additionalFiles.js
new file mode 100644
index 0000000..6760527
--- /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 0000000..c5971d4
--- /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/color.js b/src/data/composite/wiki-properties/color.js
new file mode 100644
index 0000000..1bc9888
--- /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 0000000..cd6b7ac
--- /dev/null
+++ b/src/data/composite/wiki-properties/commentary.js
@@ -0,0 +1,30 @@
+// 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,
+
+  steps: () => [
+    exitWithoutDependency({
+      dependency: input.updateValue({validate: isCommentary}),
+      mode: input.value('falsy'),
+      value: input.value(null),
+    }),
+
+    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 0000000..c5c1476
--- /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/contentString.js b/src/data/composite/wiki-properties/contentString.js
new file mode 100644
index 0000000..b0e8244
--- /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 0000000..24f302a
--- /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 0000000..8fde2ca
--- /dev/null
+++ b/src/data/composite/wiki-properties/contributionList.js
@@ -0,0 +1,35 @@
+// 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:
+//
+//   [
+//     {who: 'Artist Name', what: 'Viola'},
+//     {who: 'artist:john-cena', what: null},
+//     ...
+//   ]
+//
+// ...typically as processed from YAML, spreadsheet, or elsewhere.
+// Exposes as the same, but with the "who" replaced with matches found in
+// artistData - which means this always depends on an `artistData` property
+// also existing on this object!
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList} from '#validators';
+
+import {exposeConstant, exposeDependencyOrContinue} from '#composite/control-flow';
+import {withResolvedContribs} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `contributionList`,
+
+  compose: false,
+
+  update: {validate: isContributionList},
+
+  steps: () => [
+    withResolvedContribs({from: input.updateValue()}),
+    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 0000000..57a0127
--- /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 0000000..0b2181c
--- /dev/null
+++ b/src/data/composite/wiki-properties/directory.js
@@ -0,0 +1,23 @@
+// 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 {isDirectory} from '#validators';
+import {getKebabCase} from '#wiki-data';
+
+// TODO: Not templateCompositeFrom.
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isDirectory},
+    expose: {
+      dependencies: ['name'],
+      transform(directory, {name}) {
+        if (directory === null && name === null) return null;
+        else if (directory === null) return getKebabCase(name);
+        else return directory;
+      },
+    },
+  };
+}
diff --git a/src/data/composite/wiki-properties/duration.js b/src/data/composite/wiki-properties/duration.js
new file mode 100644
index 0000000..827f282
--- /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 0000000..c388da6
--- /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 0000000..c926fa8
--- /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 0000000..076e663
--- /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/index.js b/src/data/composite/wiki-properties/index.js
new file mode 100644
index 0000000..89cb683
--- /dev/null
+++ b/src/data/composite/wiki-properties/index.js
@@ -0,0 +1,28 @@
+// #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 color} from './color.js';
+export {default as commentary} from './commentary.js';
+export {default as commentatorArtists} from './commentatorArtists.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 name} from './name.js';
+export {default as referenceList} from './referenceList.js';
+export {default as reverseContributionList} from './reverseContributionList.js';
+export {default as reverseReferenceList} from './reverseReferenceList.js';
+export {default as simpleDate} from './simpleDate.js';
+export {default as simpleString} from './simpleString.js';
+export {default as singleReference} from './singleReference.js';
+export {default as urls} from './urls.js';
+export {default as wikiData} from './wikiData.js';
diff --git a/src/data/composite/wiki-properties/name.js b/src/data/composite/wiki-properties/name.js
new file mode 100644
index 0000000..5146488
--- /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 0000000..af634a6
--- /dev/null
+++ b/src/data/composite/wiki-properties/referenceList.js
@@ -0,0 +1,45 @@
+// Stores and exposes a list of references to other data objects; all items
+// must be references to the same type, which is specified on the class input.
+//
+// See also:
+//  - singleReference
+//  - withResolvedReferenceList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isThingClass, validateReferenceList} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputWikiData, withResolvedReferenceList} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `referenceList`,
+
+  compose: false,
+
+  inputs: {
+    class: input.staticValue({validate: isThingClass}),
+
+    data: inputWikiData({allowMixedTypes: false}),
+
+    find: input({type: 'function'}),
+  },
+
+  update: ({
+    [input.staticValue('class')]: thingClass,
+  }) => ({
+    validate:
+      validateReferenceList(
+        thingClass[Symbol.for('Thing.referenceType')]),
+  }),
+
+  steps: () => [
+    withResolvedReferenceList({
+      list: input.updateValue(),
+      data: input('data'),
+      find: input('find'),
+    }),
+
+    exposeDependency({dependency: '#resolvedReferenceList'}),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/reverseContributionList.js b/src/data/composite/wiki-properties/reverseContributionList.js
new file mode 100644
index 0000000..7f3f9c8
--- /dev/null
+++ b/src/data/composite/wiki-properties/reverseContributionList.js
@@ -0,0 +1,24 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputWikiData, withReverseContributionList} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `reverseContributionList`,
+
+  compose: false,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: false}),
+    list: input({type: 'string'}),
+  },
+
+  steps: () => [
+    withReverseContributionList({
+      data: input('data'),
+      list: input('list'),
+    }),
+
+    exposeDependency({dependency: '#reverseContributionList'}),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/reverseReferenceList.js b/src/data/composite/wiki-properties/reverseReferenceList.js
new file mode 100644
index 0000000..84ba67d
--- /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. Naturally, the passed ref list property is of the things in the
+// wiki data provided, not the requesting Thing itself.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exposeDependency} from '#composite/control-flow';
+import {inputWikiData, withReverseReferenceList} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `reverseReferenceList`,
+
+  compose: false,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: false}),
+    list: input({type: 'string'}),
+  },
+
+  steps: () => [
+    withReverseReferenceList({
+      data: input('data'),
+      list: input('list'),
+    }),
+
+    exposeDependency({dependency: '#reverseReferenceList'}),
+  ],
+});
diff --git a/src/data/composite/wiki-properties/simpleDate.js b/src/data/composite/wiki-properties/simpleDate.js
new file mode 100644
index 0000000..f08d832
--- /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 0000000..7bf317a
--- /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 0000000..db4fc9f
--- /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 {inputWikiData, withResolvedReference} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `singleReference`,
+
+  compose: false,
+
+  inputs: {
+    class: input.staticValue({validate: isThingClass}),
+
+    find: input({type: 'function'}),
+
+    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/urls.js b/src/data/composite/wiki-properties/urls.js
new file mode 100644
index 0000000..3160a0b
--- /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/wikiData.js b/src/data/composite/wiki-properties/wikiData.js
new file mode 100644
index 0000000..3bebed3
--- /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 0000000..a149e19
--- /dev/null
+++ b/src/data/language.js
@@ -0,0 +1,331 @@
+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 {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) {
+        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 =
+        (refKeys.length === 1
+          ? walkEntry(ownNode[firstKey], refNode)
+          : recursive(restKeys, ownNode[firstKey], refNode));
+
+      if (typeof result === 'undefined') {
+        return undefined;
+      }
+
+      if (typeof result === 'string') {
+        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, refNode[key]);
+      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 (entries.length === 0) {
+      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
index 3ed4fad..feeaf39 100644
--- a/src/data/patches.js
+++ b/src/data/patches.js
@@ -1,370 +1,386 @@
 // --> Patch
 
 export class Patch {
-    static INPUT_NONE = 0;
-    static INPUT_CONSTANT = 1;
-    static INPUT_DIRECT_CONNECTION = 2;
-    static INPUT_MANAGED_CONNECTION = 3;
+  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 INPUT_UNAVAILABLE = 0;
+  static INPUT_AVAILABLE = 1;
 
-    static OUTPUT_UNAVAILABLE = 0;
-    static OUTPUT_AVAILABLE = 1;
+  static OUTPUT_UNAVAILABLE = 0;
+  static OUTPUT_AVAILABLE = 1;
 
-    static inputNames = []; inputNames = null;
-    static outputNames = []; outputNames = null;
+  static inputNames = [];
+  inputNames = null;
+  static outputNames = [];
+  outputNames = null;
 
-    manager = null;
-    inputs = Object.create(null);
+  manager = null;
+  inputs = Object.create(null);
 
-    constructor({
-        manager,
+  constructor({
+    manager,
 
-        inputNames,
-        outputNames,
+    inputNames,
+    outputNames,
 
-        inputs,
-    } = {}) {
-        this.inputNames = inputNames ?? this.constructor.inputNames;
-        this.outputNames = outputNames ?? this.constructor.outputNames;
+    inputs,
+  } = {}) {
+    this.inputNames = inputNames ?? this.constructor.inputNames;
+    this.outputNames = outputNames ?? this.constructor.outputNames;
 
-        manager?.addManagedPatch(this);
+    manager?.addManagedPatch(this);
 
-        if (inputs) {
-            Object.assign(this.inputs, inputs);
-        }
-
-        this.initializeInputs();
+    if (inputs) {
+      Object.assign(this.inputs, inputs);
     }
 
-    initializeInputs() {
-        for (const inputName of this.inputNames) {
-            if (!this.inputs[inputName]) {
-                this.inputs[inputName] = [Patch.INPUT_NONE];
-            }
-        }
-    }
+    this.initializeInputs();
+  }
 
-    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;
-                }
-            }
+  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');
         }
 
-        return inputs;
-    }
+        case Patch.INPUT_MANAGED_CONNECTION: {
+          if (!this.manager) {
+            inputs[inputName] = [Patch.INPUT_UNAVAILABLE];
+            break;
+          }
 
-    computeOutputs() {
-        const inputs = this.computeInputs();
-        const outputs = Object.create(null);
-        console.log(`Compute: ${this.constructor.name}`);
-        this.compute(inputs, outputs);
-        return outputs;
+          inputs[inputName] = this.manager.getManagedInput(input[1]);
+          break;
+        }
+      }
     }
 
-    compute(inputs, outputs) {
-        // No-op. Return all outputs as unavailable. This should be overridden
-        // in subclasses.
+    return inputs;
+  }
 
-        for (const outputName of this.constructor.outputNames) {
-            outputs[outputName] = [Patch.OUTPUT_UNAVAILABLE];
-        }
-    }
+  computeOutputs() {
+    const inputs = this.computeInputs();
+    const outputs = Object.create(null);
+    console.log(`Compute: ${this.constructor.name}`);
+    this.compute(inputs, outputs);
+    return outputs;
+  }
 
-    attachToManager(manager) {
-        manager.addManagedPatch(this);
+  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];
     }
+  }
 
-    detachFromManager() {
-        if (this.manager) {
-            this.manager.removeManagedPatch(this);
-        }
+  attachToManager(manager) {
+    manager.addManagedPatch(this);
+  }
+
+  detachFromManager() {
+    if (this.manager) {
+      this.manager.removeManagedPatch(this);
     }
+  }
 }
 
 // --> PatchManager
 
 export class PatchManager extends Patch {
-    managedPatches = [];
-    managedInputs = {};
+  managedPatches = [];
+  managedInputs = {};
 
-    #externalInputPatch = null;
-    #externalOutputPatch = null;
+  #externalInputPatch = null;
+  #externalOutputPatch = null;
 
-    constructor(...args) {
-        super(...args);
+  constructor(...args) {
+    super(...args);
 
-        this.#externalInputPatch = new PatchManagerExternalInputPatch({manager: this});
-        this.#externalOutputPatch = new PatchManagerExternalOutputPatch({manager: this});
-    }
-
-    addManagedPatch(patch) {
-        if (patch.manager === this) {
-            return false;
-        }
+    this.#externalInputPatch = new PatchManagerExternalInputPatch({
+      manager: this,
+    });
 
-        patch.detachFromManager();
-        patch.manager = this;
+    this.#externalOutputPatch = new PatchManagerExternalOutputPatch({
+      manager: this,
+    });
+  }
 
-        if (patch.manager === this) {
-            this.managedPatches.push(patch);
-            return true;
-        } else {
-            return false;
-        }
+  addManagedPatch(patch) {
+    if (patch.manager === this) {
+      return false;
     }
 
-    removeManagedPatch(patch) {
-        if (patch.manager !== this) {
-            return false;
-        }
-
-        patch.manager = null;
-
-        if (patch.manager === this) {
-            return false;
-        }
+    patch.detachFromManager();
+    patch.manager = this;
 
-        for (const inputNames 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;
+    if (patch.manager === this) {
+      this.managedPatches.push(patch);
+      return true;
+    } else {
+      return false;
     }
+  }
 
-    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;
+  removeManagedPatch(patch) {
+    if (patch.manager !== this) {
+      return false;
     }
 
-    dropManagedInput(identifier) {
-        return delete this.managedInputs[key];
+    patch.manager = null;
+
+    if (patch.manager === this) {
+      return false;
     }
 
-    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);
+    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];
+      }
     }
 
-    computeManagedInput(patch, outputName, memory) {
-        // Override this function in subclasses to alter behavior of the "wire"
-        // used for connecting patches.
+    this.managedPatches.splice(this.managedPatches.indexOf(patch), 1);
 
-        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]];
-        }
-    }
+    return true;
+  }
 
-    #managedConnectionIdentifier = 0;
-    getManagedConnectionIdentifier() {
-        return this.#managedConnectionIdentifier++;
+  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)`);
     }
 
-    addExternalInput(patchWithInput, patchInputName, managerInputName) {
-        return this.addManagedInput(patchWithInput, patchInputName, this.#externalInputPatch, managerInputName);
+    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];
     }
 
-    setExternalOutput(managerOutputName, patchWithOutput, patchOutputName) {
-        return this.addManagedInput(this.#externalOutputPatch, managerOutputName, patchWithOutput, patchOutputName);
-    }
-
-    compute(inputs, outputs) {
-        Object.assign(outputs, this.#externalOutputPatch.computeOutputs());
+    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;
-            }
-        }
+  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;
-            }
-        }
+  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();
-const hsmusic = 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];
+  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];
-            }
-        }
-    },
+  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'],
+  inputNames: ['externalInput'],
+  outputNames: ['externalOutput'],
 });
 
 const P1 = new Patch[common].Stringify({manager: PM});
diff --git a/src/data/serialize.js b/src/data/serialize.js
index 9d4e888..8cac330 100644
--- a/src/data/serialize.js
+++ b/src/data/serialize.js
@@ -1,22 +1,26 @@
-// serialize-util.js: simple interface and utility functions for converting
+// serialize.js: simple interface and utility functions for converting
 // Things into a directly serializeable format
 
 // Utility functions
 
 export function id(x) {
-    return x;
+  return x;
 }
 
 export function toRef(thing) {
-    return thing?.constructor.getReference(thing);
+  return thing?.constructor.getReference(thing);
 }
 
 export function toRefs(things) {
-    return things?.map(toRef);
+  return things?.map(toRef);
 }
 
 export function toContribRefs(contribs) {
-    return contribs?.map(({ who, what }) => ({who: toRef(who), what}));
+  return contribs?.map(({who, what}) => ({who: toRef(who), what}));
+}
+
+export function toCommentaryRefs(entries) {
+  return entries?.map(({artist, ...props}) => ({artist: toRef(artist), ...props}));
 }
 
 // Interface
@@ -24,15 +28,18 @@ export function toContribRefs(contribs) {
 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`);
-    }
+  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])]));
+  return Object.fromEntries(
+    Object.entries(descriptors)
+      .map(([property, transform]) => [property, transform(thing[property])])
+  );
 }
 
 export function serializeThings(things) {
-    return things.map(serializeThing);
+  return things.map(serializeThing);
 }
diff --git a/src/data/thing.js b/src/data/thing.js
new file mode 100644
index 0000000..706e893
--- /dev/null
+++ b/src/data/thing.js
@@ -0,0 +1,77 @@
+// 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 yamlDocumentSpec = Symbol.for('Thing.yamlDocumentSpec');
+  static getYamlLoadingSpec = Symbol.for('Thing.getYamlLoadingSpec');
+
+  // Default custom inspect function, which may be overridden by Thing
+  // subclasses. This will be used when displaying aggregate errors and other
+  // command-line logging - it's the place to provide information useful in
+  // identifying the Thing being presented.
+  [inspect.custom]() {
+    const cname = this.constructor.name;
+
+    return (
+      (this.name ? `${cname} ${colors.green(`"${this.name}"`)}` : `${cname}`) +
+      (this.directory ? ` (${colors.blue(Thing.getReference(this))})` : '')
+    );
+  }
+
+  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.js b/src/data/things.js
deleted file mode 100644
index daec610..0000000
--- a/src/data/things.js
+++ /dev/null
@@ -1,1720 +0,0 @@
-// things.js: class definitions for various object types used across the wiki,
-// most of which correspond to an output page, such as Track, Album, Artist
-
-import CacheableObject from './cacheable-object.js';
-
-import {
-    isAdditionalFileList,
-    isBoolean,
-    isColor,
-    isCommentary,
-    isCountingNumber,
-    isContributionList,
-    isDate,
-    isDimensions,
-    isDirectory,
-    isDuration,
-    isInstance,
-    isFileExtension,
-    isLanguageCode,
-    isName,
-    isNumber,
-    isURL,
-    isString,
-    isWholeNumber,
-    oneOf,
-    validateArrayItems,
-    validateInstanceOf,
-    validateReference,
-    validateReferenceList,
-} from './validators.js';
-
-import * as S from './serialize.js';
-
-import {
-    getKebabCase,
-    sortByArtDate,
-} from '../util/wiki-data.js';
-
-import find from '../util/find.js';
-
-import { inspect } from 'util';
-import { color } from '../util/cli.js';
-
-// Stub classes (and their exports) at the top of the file - these are
-// referenced later when we actually define static class fields. We deliberately
-// define the classes and set their static fields in two separate steps so that
-// every class coexists from the outset, and can be directly referenced in field
-// definitions later.
-
-// This list also acts as a quick table of contents for this JS file - use
-// ctrl+F or similar to skip to a section.
-
-// -> Thing
-export class Thing extends CacheableObject {}
-
-// -> Album
-export class Album extends Thing {}
-export class TrackGroup extends CacheableObject {}
-
-// -> Track
-export class Track extends Thing {}
-
-// -> Artist
-export class Artist extends Thing {}
-
-// -> Group
-export class Group extends Thing {}
-export class GroupCategory extends CacheableObject {}
-
-// -> ArtTag
-export class ArtTag extends Thing {}
-
-// -> NewsEntry
-export class NewsEntry extends Thing {}
-
-// -> StaticPage
-export class StaticPage extends Thing {}
-
-// -> HomepageLayout
-export class HomepageLayout extends CacheableObject {}
-export class HomepageLayoutRow extends CacheableObject {}
-export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {}
-
-// -> Flash
-export class Flash extends Thing {}
-export class FlashAct extends CacheableObject {}
-
-// -> WikiInfo
-export class WikiInfo extends CacheableObject {}
-
-// -> Language
-export class Language extends CacheableObject {}
-
-// Before initializing property descriptors, set additional independent
-// constants on the classes (which are referenced later).
-
-Thing.referenceType = Symbol('Thing.referenceType');
-
-Album[Thing.referenceType] = 'album';
-Track[Thing.referenceType] = 'track';
-Artist[Thing.referenceType] = 'artist';
-Group[Thing.referenceType] = 'group';
-ArtTag[Thing.referenceType] = 'tag';
-NewsEntry[Thing.referenceType] = 'news-entry';
-StaticPage[Thing.referenceType] = 'static';
-Flash[Thing.referenceType] = 'flash';
-
-// -> Thing: base class for wiki data types, providing wiki-specific utility
-// functions on top of essential CacheableObject behavior.
-
-// Regularly reused property descriptors, for ease of access and generally
-// duplicating less code across wiki data types. These are specialized utility
-// functions, so check each for how its own arguments behave!
-Thing.common = {
-    name: (defaultName) => ({
-        flags: {update: true, expose: true},
-        update: {validate: isName, default: defaultName}
-    }),
-
-    color: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isColor}
-    }),
-
-    directory: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isDirectory},
-        expose: {
-            dependencies: ['name'],
-            transform(directory, { name }) {
-                if (directory === null && name === null)
-                    return null;
-                else if (directory === null)
-                    return getKebabCase(name);
-                else
-                    return directory;
-            }
-        }
-    }),
-
-    urls: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: validateArrayItems(isURL)}
-    }),
-
-    // A file extension! Or the default, if provided when calling this.
-    fileExtension: (defaultFileExtension = null) => ({
-        flags: {update: true, expose: true},
-        update: {validate: isFileExtension},
-        expose: {transform: value => value ?? defaultFileExtension}
-    }),
-
-    // Straightforward flag descriptor for a variety of property purposes.
-    // Provide a default value, true or false!
-    flag: (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}
-        };
-    },
-
-    // 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.
-    simpleDate: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isDate}
-    }),
-
-    // 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.
-    simpleString: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isString}
-    }),
-
-    // External function. These should only be used as dependencies for other
-    // properties, so they're left unexposed.
-    externalFunction: () => ({
-        flags: {update: true},
-        update: {validate: t => typeof t === 'function'}
-    }),
-
-    // Super simple "contributions by reference" list, used for a variety of
-    // properties (Artists, Cover Artists, etc). This is the property which is
-    // externally provided, in the form:
-    //
-    //     [
-    //         {who: 'Artist Name', what: 'Viola'},
-    //         {who: 'artist:john-cena', what: null},
-    //         ...
-    //     ]
-    //
-    // ...processed from YAML, spreadsheet, or any other kind of input.
-    contribsByRef: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isContributionList}
-    }),
-
-    // Artist commentary! Generally present on tracks and albums.
-    commentary: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isCommentary}
-    }),
-
-    // 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: [...]},
-    //         ...
-    //     ]
-    //
-    additionalFiles: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isAdditionalFileList}
-    }),
-
-    // A reference list! Keep in mind this is for general references to wiki
-    // objects of (usually) other Thing subclasses, not specifically leitmotif
-    // references in tracks (although that property uses referenceList too!).
-    //
-    // The underlying function validateReferenceList expects a string like
-    // 'artist' or 'track', but this utility keeps from having to hard-code the
-    // string in multiple places by referencing the value saved on the class
-    // instead.
-    referenceList: thingClass => {
-        const { [Thing.referenceType]: referenceType } = thingClass;
-        if (!referenceType) {
-            throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
-        }
-
-        return {
-            flags: {update: true, expose: true},
-            update: {validate: validateReferenceList(referenceType)}
-        };
-    },
-
-    // Corresponding function for a single reference.
-    singleReference: thingClass => {
-        const { [Thing.referenceType]: referenceType } = thingClass;
-        if (!referenceType) {
-            throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
-        }
-
-        return {
-            flags: {update: true, expose: true},
-            update: {validate: validateReference(referenceType)}
-        };
-    },
-
-    // Corresponding dynamic property to referenceList, which takes the values
-    // in the provided property and searches the specified wiki data for
-    // matching actual Thing-subclass objects.
-    dynamicThingsFromReferenceList: (
-        referenceListProperty,
-        thingDataProperty,
-        findFn
-    ) => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: [referenceListProperty, thingDataProperty],
-            compute: ({ [referenceListProperty]: refs, [thingDataProperty]: thingData }) => (
-                (refs && thingData
-                    ? (refs
-                        .map(ref => findFn(ref, thingData, {mode: 'quiet'}))
-                        .filter(Boolean))
-                    : [])
-            )
-        }
-    }),
-
-    // Corresponding function for a single reference.
-    dynamicThingFromSingleReference: (
-        singleReferenceProperty,
-        thingDataProperty,
-        findFn
-    ) => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: [singleReferenceProperty, thingDataProperty],
-            compute: ({ [singleReferenceProperty]: ref, [thingDataProperty]: thingData }) => (
-                (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null)
-            )
-        }
-    }),
-
-    // Corresponding dynamic property to contribsByRef, which takes the values
-    // in the provided property and searches the object's artistData for
-    // matching actual Artist objects. The computed structure has the same form
-    // as contribsByRef, but with Artist objects instead of string references:
-    //
-    //     [
-    //         {who: (an Artist), what: 'Viola'},
-    //         {who: (an Artist), what: null},
-    //         ...
-    //     ]
-    //
-    // Contributions whose "who" values don't match anything in artistData are
-    // filtered out. (So if the list is all empty, chances are that either the
-    // reference list is somehow messed up, or artistData isn't being provided
-    // properly.)
-    dynamicContribs: (contribsByRefProperty) => ({
-        flags: {expose: true},
-        expose: {
-            dependencies: ['artistData', contribsByRefProperty],
-            compute: ({ artistData, [contribsByRefProperty]: contribsByRef }) => (
-                ((contribsByRef && artistData)
-                    ? (contribsByRef
-                        .map(({ who: ref, what }) => ({
-                            who: find.artist(ref, artistData),
-                            what
-                        }))
-                        .filter(({ who }) => who))
-                    : [])
-            )
-        }
-    }),
-
-    // Dynamically inherit a contribution list from some other object, if it
-    // hasn't been overridden on this object. This is handy for solo albums
-    // where all tracks have the same artist, for example.
-    //
-    // Note: The arguments of this function aren't currently final! The final
-    // format will look more like (contribsByRef, parentContribsByRef), e.g.
-    // ('artistContribsByRef', '@album/artistContribsByRef').
-    dynamicInheritContribs: (
-        contribsByRefProperty,
-        parentContribsByRefProperty,
-        thingDataProperty,
-        findFn
-    ) => ({
-        flags: {expose: true},
-        expose: {
-            dependencies: [contribsByRefProperty, thingDataProperty, 'artistData'],
-            compute({
-                [Thing.instance]: thing,
-                [contribsByRefProperty]: contribsByRef,
-                [thingDataProperty]: thingData,
-                artistData
-            }) {
-                if (!artistData) return [];
-                const refs = (contribsByRef ?? findFn(thing, thingData, {mode: 'quiet'})?.[parentContribsByRefProperty]);
-                if (!refs) return [];
-                return (refs
-                    .map(({ who: ref, what }) => ({
-                        who: find.artist(ref, artistData),
-                        what
-                    }))
-                    .filter(({ who }) => who));
-            }
-        }
-    }),
-
-    // Neat little shortcut for "reversing" the reference lists stored on other
-    // things - for example, tracks specify a "referenced tracks" property, and
-    // you would use this to compute a corresponding "referenced *by* tracks"
-    // property. Naturally, the passed ref list property is of the things in the
-    // wiki data provided, not the requesting Thing itself.
-    reverseReferenceList: (wikiDataProperty, referencerRefListProperty) => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: [wikiDataProperty],
-
-            compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => (
-                (wikiData
-                    ? wikiData.filter(t => t[referencerRefListProperty]?.includes(thing))
-                    : [])
-            )
-        }
-    }),
-
-    // Corresponding function for single references. Note that the return value
-    // is still a list - this is for matching all the objects whose single
-    // reference (in the given property) matches this Thing.
-    reverseSingleReference: (wikiDataProperty, referencerRefListProperty) => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: [wikiDataProperty],
-
-            compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => (
-                wikiData?.filter(t => t[referencerRefListProperty] === thing))
-        }
-    }),
-
-    // General purpose wiki data constructor, for properties like artistData,
-    // trackData, etc.
-    wikiData: (thingClass) => ({
-        flags: {update: true},
-        update: {
-            validate: validateArrayItems(validateInstanceOf(thingClass))
-        }
-    }),
-
-    // This one's kinda tricky: it parses artist "references" from the
-    // commentary content, and finds the matching artist for each reference.
-    // This is mostly useful for credits and listings on artist pages.
-    commentatorArtists: () => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['artistData', 'commentary'],
-
-            compute: ({ artistData, commentary }) => (
-                (artistData && commentary
-                    ? Array.from(new Set((Array
-                        .from(commentary
-                            .replace(/<\/?b>/g, '')
-                            .matchAll(/<i>(?<who>.*?):<\/i>/g))
-                        .map(({ groups: {who} }) => find.artist(who, artistData, {mode: 'quiet'})))))
-                    : []))
-        }
-    }),
-};
-
-// Get a reference to a thing (e.g. track:showtime-piano-refrain), using its
-// constructor's [Thing.referenceType] as the prefix. This will throw an error
-// if the thing's directory isn't yet provided/computable.
-Thing.getReference = function(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}`;
-};
-
-// Default custom inspect function, which may be overridden by Thing subclasses.
-// This will be used when displaying aggregate errors and other in command-line
-// logging - it's the place to provide information useful in identifying the
-// Thing being presented.
-Thing.prototype[inspect.custom] = function() {
-    const cname = this.constructor.name;
-
-    return (this.name
-        ? `${cname} ${color.green(`"${this.name}"`)}`
-        : `${cname}`) + (this.directory
-            ? ` (${color.blue(Thing.getReference(this))})`
-            : '');
-};
-
-// -> Album
-
-Album.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.name('Unnamed Album'),
-    color: Thing.common.color(),
-    directory: Thing.common.directory(),
-    urls: Thing.common.urls(),
-
-    date: Thing.common.simpleDate(),
-    trackArtDate: Thing.common.simpleDate(),
-    dateAddedToWiki: Thing.common.simpleDate(),
-
-    coverArtDate: {
-        flags: {update: true, expose: true},
-
-        update: {validate: isDate},
-
-        expose: {
-            dependencies: ['date'],
-            transform: (coverArtDate, { date }) => coverArtDate ?? date ?? null
-        }
-    },
-
-    artistContribsByRef: Thing.common.contribsByRef(),
-    coverArtistContribsByRef: Thing.common.contribsByRef(),
-    trackCoverArtistContribsByRef: Thing.common.contribsByRef(),
-    wallpaperArtistContribsByRef: Thing.common.contribsByRef(),
-    bannerArtistContribsByRef: Thing.common.contribsByRef(),
-
-    groupsByRef: Thing.common.referenceList(Group),
-    artTagsByRef: Thing.common.referenceList(ArtTag),
-
-    trackGroups: {
-        flags: {update: true, expose: true},
-
-        update: {
-            validate: validateArrayItems(validateInstanceOf(TrackGroup))
-        }
-    },
-
-    coverArtFileExtension: Thing.common.fileExtension('jpg'),
-    trackCoverArtFileExtension: Thing.common.fileExtension('jpg'),
-
-    wallpaperStyle: Thing.common.simpleString(),
-    wallpaperFileExtension: Thing.common.fileExtension('jpg'),
-
-    bannerStyle: Thing.common.simpleString(),
-    bannerFileExtension: Thing.common.fileExtension('jpg'),
-    bannerDimensions: {
-        flags: {update: true, expose: true},
-        update: {validate: isDimensions}
-    },
-
-    hasCoverArt: Thing.common.flag(true),
-    hasTrackArt: Thing.common.flag(true),
-    hasTrackNumbers: Thing.common.flag(true),
-    isMajorRelease: Thing.common.flag(false),
-    isListedOnHomepage: Thing.common.flag(true),
-
-    commentary: Thing.common.commentary(),
-    additionalFiles: Thing.common.additionalFiles(),
-
-    // Update only
-
-    artistData: Thing.common.wikiData(Artist),
-    artTagData: Thing.common.wikiData(ArtTag),
-    groupData: Thing.common.wikiData(Group),
-    trackData: Thing.common.wikiData(Track),
-
-    // Expose only
-
-    artistContribs: Thing.common.dynamicContribs('artistContribsByRef'),
-    coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'),
-    trackCoverArtistContribs: Thing.common.dynamicContribs('trackCoverArtistContribsByRef'),
-    wallpaperArtistContribs: Thing.common.dynamicContribs('wallpaperArtistContribsByRef'),
-    bannerArtistContribs: Thing.common.dynamicContribs('bannerArtistContribsByRef'),
-
-    commentatorArtists: Thing.common.commentatorArtists(),
-
-    tracks: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['trackGroups', 'trackData'],
-            compute: ({ trackGroups, trackData }) => (
-                (trackGroups && trackData
-                    ? (trackGroups
-                        .flatMap(group => group.tracksByRef ?? [])
-                        .map(ref => find.track(ref, trackData, {mode: 'quiet'}))
-                        .filter(Boolean))
-                    : [])
-            )
-        }
-    },
-
-    groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group),
-
-    artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag),
-};
-
-Album[S.serializeDescriptors] = {
-    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,
-    isMajorRelease: S.id,
-    isListedOnHomepage: S.id,
-
-    commentary: S.id,
-    additionalFiles: S.id,
-
-    tracks: S.toRefs,
-    groups: S.toRefs,
-    artTags: S.toRefs,
-    commentatorArtists: S.toRefs,
-};
-
-TrackGroup.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.name('Unnamed Track Group'),
-
-    color: {
-        flags: {update: true, expose: true},
-
-        update: {validate: isColor},
-
-        expose: {
-            dependencies: ['album'],
-
-            transform(color, { album }) {
-                return color ?? album?.color ?? null;
-            }
-        }
-    },
-
-    dateOriginallyReleased: Thing.common.simpleDate(),
-
-    tracksByRef: Thing.common.referenceList(Track),
-
-    isDefaultTrackGroup: Thing.common.flag(false),
-
-    // Update only
-
-    album: {
-        flags: {update: true},
-        update: {validate: validateInstanceOf(Album)}
-    },
-
-    trackData: Thing.common.wikiData(Track),
-
-    // Expose only
-
-    tracks: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['tracksByRef', 'trackData'],
-            compute: ({ tracksByRef, trackData }) => (
-                (tracksByRef && trackData
-                    ? (tracksByRef
-                        .map(ref => find.track(ref, trackData))
-                        .filter(Boolean))
-                    : [])
-            )
-        }
-    },
-
-    startIndex: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['album'],
-            compute: ({ album, [TrackGroup.instance]: trackGroup }) => (album.trackGroups
-                .slice(0, album.trackGroups.indexOf(trackGroup))
-                .reduce((acc, tg) => acc + tg.tracks.length, 0))
-        }
-    },
-};
-
-// -> Track
-
-// This is a quick utility function for now, since the same code is reused in
-// several places. Ideally it wouldn't be - we'd just reuse the `album` property
-// - but support for that hasn't been coded yet :P
-Track.findAlbum = (track, albumData) => {
-    return albumData?.find(album => album.tracks.includes(track));
-};
-
-// Another reused utility function. This one's logic is a bit more complicated.
-Track.hasCoverArt = (track, albumData, coverArtistContribsByRef, hasCoverArt) => {
-    return (
-        hasCoverArt ??
-        (coverArtistContribsByRef?.length > 0 || null) ??
-        Track.findAlbum(track, albumData)?.hasTrackArt ??
-        true);
-};
-
-Track.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.name('Unnamed Track'),
-    directory: Thing.common.directory(),
-
-    duration: {
-        flags: {update: true, expose: true},
-        update: {validate: isDuration}
-    },
-
-    urls: Thing.common.urls(),
-    dateFirstReleased: Thing.common.simpleDate(),
-
-    hasURLs: Thing.common.flag(true),
-
-    artistContribsByRef: Thing.common.contribsByRef(),
-    contributorContribsByRef: Thing.common.contribsByRef(),
-    coverArtistContribsByRef: Thing.common.contribsByRef(),
-
-    referencedTracksByRef: Thing.common.referenceList(Track),
-    artTagsByRef: Thing.common.referenceList(ArtTag),
-
-    hasCoverArt: {
-        flags: {update: true, expose: true},
-
-        update: {validate: isBoolean},
-
-        expose: {
-            dependencies: ['albumData', 'coverArtistContribsByRef'],
-            transform: (hasCoverArt, { albumData, coverArtistContribsByRef, [Track.instance]: track }) => (
-                Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt))
-        }
-    },
-
-    coverArtFileExtension: {
-        flags: {update: true, expose: true},
-
-        update: {validate: isFileExtension},
-
-        expose: {
-            dependencies: ['albumData', 'coverArtistContribsByRef'],
-            transform: (coverArtFileExtension, { albumData, coverArtistContribsByRef, hasCoverArt, [Track.instance]: track }) => (
-                coverArtFileExtension ??
-                (Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt)
-                    ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension
-                    : Track.findAlbum(track, albumData)?.coverArtFileExtension) ??
-                'jpg')
-        }
-    },
-
-    // Previously known as: (track).aka
-    originalReleaseTrackByRef: Thing.common.singleReference(Track),
-
-    dataSourceAlbumByRef: Thing.common.singleReference(Album),
-
-    commentary: Thing.common.commentary(),
-    lyrics: Thing.common.simpleString(),
-    additionalFiles: Thing.common.additionalFiles(),
-
-    // Update only
-
-    albumData: Thing.common.wikiData(Album),
-    artistData: Thing.common.wikiData(Artist),
-    artTagData: Thing.common.wikiData(ArtTag),
-    flashData: Thing.common.wikiData(Flash),
-    trackData: Thing.common.wikiData(Track),
-
-    // Expose only
-
-    commentatorArtists: Thing.common.commentatorArtists(),
-
-    album: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['albumData'],
-            compute: ({ [Track.instance]: track, albumData }) => (
-                albumData?.find(album => album.tracks.includes(track)) ?? null)
-        }
-    },
-
-    // Note - this is an internal property used only to help identify a track.
-    // It should not be assumed in general that the album and dataSourceAlbum match
-    // (i.e. a track may dynamically be moved from one album to another, at
-    // which point dataSourceAlbum refers to where it was originally from, and is
-    // not generally relevant information). It's also not guaranteed that
-    // dataSourceAlbum is available (depending on the Track creator to optionally
-    // provide dataSourceAlbumByRef).
-    dataSourceAlbum: Thing.common.dynamicThingFromSingleReference('dataSourceAlbumByRef', 'albumData', find.album),
-
-    date: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['albumData', 'dateFirstReleased'],
-            compute: ({ albumData, dateFirstReleased, [Track.instance]: track }) => (
-                dateFirstReleased ??
-                Track.findAlbum(track, albumData)?.date ??
-                null
-            )
-        }
-    },
-
-    color: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['albumData'],
-
-            compute: ({ albumData, [Track.instance]: track }) => (
-                (Track.findAlbum(track, albumData)?.trackGroups
-                    .find(tg => tg.tracks.includes(track))?.color)
-                ?? null
-            )
-        }
-    },
-
-    coverArtDate: {
-        flags: {update: true, expose: true},
-
-        update: {validate: isDate},
-
-        expose: {
-            dependencies: ['albumData', 'dateFirstReleased'],
-            transform: (coverArtDate, { albumData, dateFirstReleased, [Track.instance]: track }) => (
-                coverArtDate ??
-                dateFirstReleased ??
-                Track.findAlbum(track, albumData)?.trackArtDate ??
-                Track.findAlbum(track, albumData)?.date ??
-                null
-            )
-        }
-    },
-
-    originalReleaseTrack: Thing.common.dynamicThingFromSingleReference('originalReleaseTrackByRef', 'trackData', find.track),
-
-    otherReleases: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['originalReleaseTrackByRef', 'trackData'],
-
-            compute: ({ originalReleaseTrackByRef: t1origRef, trackData, [Track.instance]: t1 }) => {
-                if (!trackData) {
-                    return [];
-                }
-
-                const t1orig = find.track(t1origRef, trackData);
-
-                return [
-                    t1orig,
-                    ...trackData.filter(t2 => {
-                        const { originalReleaseTrack: t2orig } = t2;
-                        return (
-                            t2 !== t1 &&
-                            t2orig &&
-                            (t2orig === t1orig || t2orig === t1)
-                        );
-                    })
-                ].filter(Boolean);
-            }
-        }
-    },
-
-    // Previously known as: (track).artists
-    artistContribs: Thing.common.dynamicInheritContribs('artistContribsByRef', 'artistContribsByRef', 'albumData', Track.findAlbum),
-
-    // Previously known as: (track).contributors
-    contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
-
-    // Previously known as: (track).coverArtists
-    coverArtistContribs: Thing.common.dynamicInheritContribs('coverArtistContribsByRef', 'trackCoverArtistContribsByRef', 'albumData', Track.findAlbum),
-
-    // Previously known as: (track).references
-    referencedTracks: Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track),
-
-    // Specifically exclude re-releases from this list - while it's useful to
-    // get from a re-release to the tracks it references, re-releases aren't
-    // generally relevant from the perspective of the tracks being referenced.
-    // Filtering them from data here hides them from the corresponding field
-    // on the site (obviously), and has the bonus of not counting them when
-    // counting the number of times a track has been referenced, for use in
-    // the "Tracks - by Times Referenced" listing page (or other data
-    // processing).
-    referencedByTracks: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['trackData'],
-
-            compute: ({ trackData, [Track.instance]: track }) => (trackData
-                ? (trackData
-                    .filter(t => !t.originalReleaseTrack)
-                    .filter(t => t.referencedTracks?.includes(track)))
-                : [])
-        }
-    },
-
-    // Previously known as: (track).flashes
-    featuredInFlashes: Thing.common.reverseReferenceList('flashData', 'featuredTracks'),
-
-    artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag),
-};
-
-Track.prototype[inspect.custom] = function() {
-    const base = Thing.prototype[inspect.custom].apply(this);
-
-    const { album, dataSourceAlbum } = this;
-    const albumName = (album ? album.name : dataSourceAlbum?.name);
-    const albumIndex = albumName && (album ? album.tracks.indexOf(this) : dataSourceAlbum.tracks.indexOf(this));
-    const trackNum = (albumIndex === -1 ? '#?' : `#${albumIndex + 1}`);
-
-    return (albumName
-        ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})`
-        : base);
-};
-
-// -> Artist
-
-Artist.filterByContrib = (thingDataProperty, contribsProperty) => ({
-    flags: {expose: true},
-
-    expose: {
-        dependencies: [thingDataProperty],
-
-        compute: ({ [thingDataProperty]: thingData, [Artist.instance]: artist }) => (
-            thingData?.filter(({ [contribsProperty]: contribs }) => (
-                contribs?.some(contrib => contrib.who === artist))))
-    }
-});
-
-Artist.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.name('Unnamed Artist'),
-    directory: Thing.common.directory(),
-    urls: Thing.common.urls(),
-    contextNotes: Thing.common.simpleString(),
-
-    hasAvatar: Thing.common.flag(false),
-    avatarFileExtension: Thing.common.fileExtension('jpg'),
-
-    aliasNames: {
-        flags: {update: true, expose: true},
-        update: {
-            validate: validateArrayItems(isName)
-        }
-    },
-
-    isAlias: Thing.common.flag(),
-    aliasedArtistRef: Thing.common.singleReference(Artist),
-
-    // Update only
-
-    albumData: Thing.common.wikiData(Album),
-    artistData: Thing.common.wikiData(Artist),
-    flashData: Thing.common.wikiData(Flash),
-    trackData: Thing.common.wikiData(Track),
-
-    // Expose only
-
-    aliasedArtist: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['artistData', 'aliasedArtistRef'],
-            compute: ({ artistData, aliasedArtistRef }) => (
-                (aliasedArtistRef && artistData
-                    ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'})
-                    : null)
-            )
-        }
-    },
-
-    tracksAsArtist: Artist.filterByContrib('trackData', 'artistContribs'),
-    tracksAsContributor: Artist.filterByContrib('trackData', 'contributorContribs'),
-    tracksAsCoverArtist: Artist.filterByContrib('trackData', 'coverArtistContribs'),
-
-    tracksAsAny: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['trackData'],
-
-            compute: ({ trackData, [Artist.instance]: artist }) => (
-                trackData?.filter(track => (
-                    [
-                        ...track.artistContribs,
-                        ...track.contributorContribs,
-                        ...track.coverArtistContribs
-                    ].some(({ who }) => who === artist))))
-        }
-    },
-
-    tracksAsCommentator: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['trackData'],
-
-            compute: ({ trackData, [Artist.instance]: artist }) => (
-                trackData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist)))
-        }
-    },
-
-    albumsAsAlbumArtist: Artist.filterByContrib('albumData', 'artistContribs'),
-    albumsAsCoverArtist: Artist.filterByContrib('albumData', 'coverArtistContribs'),
-    albumsAsWallpaperArtist: Artist.filterByContrib('albumData', 'wallpaperArtistContribs'),
-    albumsAsBannerArtist: Artist.filterByContrib('albumData', 'bannerArtistContribs'),
-
-    albumsAsCommentator: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['albumData'],
-
-            compute: ({ albumData, [Artist.instance]: artist }) => (
-                albumData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist)))
-        }
-    },
-
-    flashesAsContributor: Artist.filterByContrib('flashData', 'contributorContribs'),
-};
-
-Artist[S.serializeDescriptors] = {
-    name: S.id,
-    directory: S.id,
-    urls: S.id,
-    contextNotes: S.id,
-
-    hasAvatar: S.id,
-    avatarFileExtension: S.id,
-
-    aliasNames: S.id,
-
-    tracksAsArtist: S.toRefs,
-    tracksAsContributor: S.toRefs,
-    tracksAsCoverArtist: S.toRefs,
-    tracksAsCommentator: S.toRefs,
-
-    albumsAsAlbumArtist: S.toRefs,
-    albumsAsCoverArtist: S.toRefs,
-    albumsAsWallpaperArtist: S.toRefs,
-    albumsAsBannerArtist: S.toRefs,
-    albumsAsCommentator: S.toRefs,
-
-    flashesAsContributor: S.toRefs,
-};
-
-// -> Group
-
-Group.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.name('Unnamed Group'),
-    directory: Thing.common.directory(),
-
-    description: Thing.common.simpleString(),
-
-    urls: Thing.common.urls(),
-
-    // Update only
-
-    albumData: Thing.common.wikiData(Album),
-    groupCategoryData: Thing.common.wikiData(GroupCategory),
-
-    // Expose only
-
-    descriptionShort: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['description'],
-            compute: ({ description }) => description.split('<hr class="split">')[0]
-        }
-    },
-
-    albums: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['albumData'],
-            compute: ({ albumData, [Group.instance]: group }) => (
-                albumData?.filter(album => album.groups.includes(group)) ?? [])
-        }
-    },
-
-    color: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['groupCategoryData'],
-
-            compute: ({ groupCategoryData, [Group.instance]: group }) => (
-                groupCategoryData.find(category => category.groups.includes(group))?.color ?? null)
-        }
-    },
-
-    category: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['groupCategoryData'],
-            compute: ({ groupCategoryData, [Group.instance]: group }) => (
-                groupCategoryData.find(category => category.groups.includes(group)) ?? null)
-        }
-    },
-};
-
-GroupCategory.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.name('Unnamed Group Category'),
-    color: Thing.common.color(),
-
-    groupsByRef: Thing.common.referenceList(Group),
-
-    // Update only
-
-    groupData: Thing.common.wikiData(Group),
-
-    // Expose only
-
-    groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group),
-};
-
-// -> ArtTag
-
-ArtTag.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.name('Unnamed Art Tag'),
-    directory: Thing.common.directory(),
-    color: Thing.common.color(),
-    isContentWarning: Thing.common.flag(false),
-
-    // Update only
-
-    albumData: Thing.common.wikiData(Album),
-    trackData: Thing.common.wikiData(Track),
-
-    // Expose only
-
-    // Previously known as: (tag).things
-    taggedInThings: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['albumData', 'trackData'],
-            compute: ({ albumData, trackData, [ArtTag.instance]: artTag }) => (
-                sortByArtDate([...albumData, ...trackData]
-                    .filter(thing => thing.artTags?.includes(artTag))))
-        }
-    }
-};
-
-// -> NewsEntry
-
-NewsEntry.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.name('Unnamed News Entry'),
-    directory: Thing.common.directory(),
-    date: Thing.common.simpleDate(),
-
-    content: Thing.common.simpleString(),
-
-    // Expose only
-
-    contentShort: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['content'],
-
-            compute: ({ content }) => content.split('<hr class="split">')[0]
-        }
-    },
-};
-
-// -> StaticPage
-
-StaticPage.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.name('Unnamed Static Page'),
-
-    nameShort: {
-        flags: {update: true, expose: true},
-        update: {validate: isName},
-
-        expose: {
-            dependencies: ['name'],
-            transform: (value, { name }) => value ?? name
-        }
-    },
-
-    directory: Thing.common.directory(),
-    content: Thing.common.simpleString(),
-    stylesheet: Thing.common.simpleString(),
-    showInNavigationBar: Thing.common.flag(true),
-};
-
-// -> HomepageLayout
-
-HomepageLayout.propertyDescriptors = {
-    // Update & expose
-
-    sidebarContent: Thing.common.simpleString(),
-
-    rows: {
-        flags: {update: true, expose: true},
-
-        update: {
-            validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow))
-        }
-    },
-};
-
-HomepageLayoutRow.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.name('Unnamed Homepage Row'),
-
-    type: {
-        flags: {update: true, expose: true},
-
-        update: {
-            validate(value) {
-                throw new Error(`'type' property validator must be overridden`);
-            }
-        }
-    },
-
-    color: Thing.common.color(),
-
-    // Update only
-
-    // These aren't necessarily used by every HomepageLayoutRow subclass, but
-    // for convenience of providing this data, every row accepts all wiki data
-    // arrays depended upon by any subclass's behavior.
-    albumData: Thing.common.wikiData(Album),
-    groupData: Thing.common.wikiData(Group),
-};
-
-HomepageLayoutAlbumsRow.propertyDescriptors = {
-    ...HomepageLayoutRow.propertyDescriptors,
-
-    // Update & expose
-
-    type: {
-        flags: {update: true, expose: true},
-        update: {
-            validate(value) {
-                if (value !== 'albums') {
-                    throw new TypeError(`Expected 'albums'`);
-                }
-
-                return true;
-            }
-        }
-    },
-
-    sourceGroupByRef: Thing.common.singleReference(Group),
-    sourceAlbumsByRef: Thing.common.referenceList(Album),
-
-    countAlbumsFromGroup: {
-        flags: {update: true, expose: true},
-        update: {validate: isCountingNumber}
-    },
-
-    actionLinks: {
-        flags: {update: true, expose: true},
-        update: {validate: validateArrayItems(isString)}
-    },
-
-    // Expose only
-
-    sourceGroup: Thing.common.dynamicThingFromSingleReference('sourceGroupByRef', 'groupData', find.group),
-    sourceAlbums: Thing.common.dynamicThingsFromReferenceList('sourceAlbumsByRef', 'albumData', find.album),
-};
-
-// -> Flash
-
-Flash.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.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: oneOf(isString, isNumber)},
-
-        expose: {
-            transform: value => (value === null ? null : value.toString())
-        }
-    },
-
-    date: Thing.common.simpleDate(),
-
-    coverArtFileExtension: Thing.common.fileExtension('jpg'),
-
-    contributorContribsByRef: Thing.common.contribsByRef(),
-
-    featuredTracksByRef: Thing.common.referenceList(Track),
-
-    urls: Thing.common.urls(),
-
-    // Update only
-
-    artistData: Thing.common.wikiData(Artist),
-    trackData: Thing.common.wikiData(Track),
-    flashActData: Thing.common.wikiData(FlashAct),
-
-    // Expose only
-
-    contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
-
-    featuredTracks: Thing.common.dynamicThingsFromReferenceList('featuredTracksByRef', 'trackData', find.track),
-
-    act: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['flashActData'],
-
-            compute: ({ flashActData, [Flash.instance]: flash }) => (
-                flashActData.find(act => act.flashes.includes(flash)) ?? null)
-        }
-    },
-
-    color: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['flashActData'],
-
-            compute: ({ flashActData, [Flash.instance]: flash }) => (
-                flashActData.find(act => act.flashes.includes(flash))?.color ?? null)
-        }
-    },
-};
-
-Flash[S.serializeDescriptors] = {
-    name: S.id,
-    page: S.id,
-    directory: S.id,
-    date: S.id,
-    contributors: S.toContribRefs,
-    tracks: S.toRefs,
-    urls: S.id,
-    color: S.id,
-};
-
-FlashAct.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.name('Unnamed Flash Act'),
-    color: Thing.common.color(),
-    anchor: Thing.common.simpleString(),
-    jump: Thing.common.simpleString(),
-    jumpColor: Thing.common.color(),
-
-    flashesByRef: Thing.common.referenceList(Flash),
-
-    // Update only
-
-    flashData: Thing.common.wikiData(Flash),
-
-    // Expose only
-
-    flashes: Thing.common.dynamicThingsFromReferenceList('flashesByRef', 'flashData', find.flash),
-};
-
-// -> WikiInfo
-
-WikiInfo.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.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: Thing.common.color(),
-
-    // One-line description used for <meta rel="description"> tag.
-    description: Thing.common.simpleString(),
-
-    footerContent: Thing.common.simpleString(),
-
-    defaultLanguage: {
-        flags: {update: true, expose: true},
-        update: {validate: isLanguageCode}
-    },
-
-    canonicalBase: {
-        flags: {update: true, expose: true},
-        update: {validate: isURL}
-    },
-
-    divideTrackListsByGroupsByRef: Thing.common.referenceList(Group),
-
-    // Feature toggles
-    enableFlashesAndGames: Thing.common.flag(false),
-    enableListings: Thing.common.flag(false),
-    enableNews: Thing.common.flag(false),
-    enableArtTagUI: Thing.common.flag(false),
-    enableGroupUI: Thing.common.flag(false),
-
-    // Update only
-
-    groupData: Thing.common.wikiData(Group),
-
-    // Expose only
-
-    divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList('divideTrackListsByGroupsByRef', 'groupData', find.group),
-};
-
-// -> Language
-
-const intlHelper = (constructor, opts) => ({
-    flags: {expose: true},
-    expose: {
-        dependencies: ['code', 'intlCode'],
-        compute: ({ code, intlCode }) => {
-            const constructCode = intlCode ?? code;
-            if (!constructCode) return null;
-            return Reflect.construct(constructor, [constructCode, opts]);
-        }
-    }
-});
-
-Language.propertyDescriptors = {
-    // 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: Thing.common.simpleString(),
-
-    // 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: Thing.common.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'],
-            transform(strings, { inheritedStrings }) {
-                if (strings || inheritedStrings) {
-                    return {...inheritedStrings ?? {}, ...strings ?? {}};
-                } else {
-                    return null;
-                }
-            }
-        }
-    },
-
-    // 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'}
-    },
-
-    // Update only
-
-    escapeHTML: Thing.common.externalFunction(),
-
-    // Expose only
-
-    intl_date: intlHelper(Intl.DateTimeFormat, {full: true}),
-    intl_number: intlHelper(Intl.NumberFormat),
-    intl_listConjunction: intlHelper(Intl.ListFormat, {type: 'conjunction'}),
-    intl_listDisjunction: intlHelper(Intl.ListFormat, {type: 'disjunction'}),
-    intl_listUnit: intlHelper(Intl.ListFormat, {type: 'unit'}),
-    intl_pluralCardinal: intlHelper(Intl.PluralRules, {type: 'cardinal'}),
-    intl_pluralOrdinal: 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 ?? {})
-            ]))
-        }
-    },
-
-    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)]));
-            }
-        }
-    },
-};
-
-const countHelper = (stringKey, argName = stringKey) => function(value, {unit = false} = {}) {
-    return this.$(
-        (unit
-            ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
-            : `count.${stringKey}`),
-        {[argName]: this.formatNumber(value)});
-};
-
-Object.assign(Language.prototype, {
-    $(key, args = {}) {
-        return this.formatString(key, 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(key, args = {}) {
-        if (this.strings && !this.strings_htmlEscaped) {
-            throw new Error(`HTML-escaped strings unavailable - please ensure escapeHTML function is provided`);
-        }
-
-        return this.formatStringHelper(this.strings_htmlEscaped, key, args);
-    },
-
-    formatStringNoHTMLEscape(key, args = {}) {
-        return this.formatStringHelper(this.strings, key, args);
-    },
-
-    formatStringHelper(strings, key, args = {}) {
-        if (!strings) {
-            throw new Error(`Strings unavailable`);
-        }
-
-        if (!this.validKeys.includes(key)) {
-            throw new Error(`Invalid key ${key} accessed`);
-        }
-
-        const template = strings[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_]+\}/)) {
-            throw new Error(`Args in ${key} were missing - output: ${output}`);
-        }
-
-        return output;
-    },
-
-    formatDate(date) {
-        this.assertIntlAvailable('intl_date');
-        return this.intl_date.format(date);
-    },
-
-    formatDateRange(startDate, endDate) {
-        this.assertIntlAvailable('intl_date');
-        return this.intl_date.formatRange(startDate, endDate);
-    },
-
-    formatDuration(secTotal, {approximate = false, unit = false} = {}) {
-        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);
-    },
-
-    formatIndex(value) {
-        this.assertIntlAvailable('intl_pluralOrdinal');
-        return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
-    },
-
-    formatNumber(value) {
-        this.assertIntlAvailable('intl_number');
-        return this.intl_number.format(value);
-    },
-
-    formatWordCount(value) {
-        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});
-    },
-
-    // Conjunction list: A, B, and C
-    formatConjunctionList(array) {
-        this.assertIntlAvailable('intl_listConjunction');
-        return this.intl_listConjunction.format(array);
-    },
-
-    // Disjunction lists: A, B, or C
-    formatDisjunctionList(array) {
-        this.assertIntlAvailable('intl_listDisjunction');
-        return this.intl_listDisjunction.format(array);
-    },
-
-    // Unit lists: A, B, C
-    formatUnitList(array) {
-        this.assertIntlAvailable('intl_listUnit');
-        return this.intl_listUnit.format(array);
-    },
-
-    // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
-    formatFileSize(bytes) {
-        if (!bytes) return '';
-
-        bytes = parseInt(bytes);
-        if (isNaN(bytes)) return '';
-
-        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});
-        }
-    },
-
-    // TODO: These are hard-coded. Is there a better way?
-    countAlbums: countHelper('albums'),
-    countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
-    countContributions: countHelper('contributions'),
-    countCoverArts: countHelper('coverArts'),
-    countTimesReferenced: countHelper('timesReferenced'),
-    countTimesUsed: countHelper('timesUsed'),
-    countTracks: countHelper('tracks'),
-});
diff --git a/src/data/things/album.js b/src/data/things/album.js
new file mode 100644
index 0000000..40cd463
--- /dev/null
+++ b/src/data/things/album.js
@@ -0,0 +1,434 @@
+export const DATA_ALBUM_DIRECTORY = 'album';
+
+import * as path from 'node:path';
+
+import {input} from '#composite';
+import find from '#find';
+import {traverse} from '#node-utils';
+import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
+import {empty} from '#sugar';
+import Thing from '#thing';
+import {isDate} from '#validators';
+import {parseAdditionalFiles, parseContributors, parseDate, parseDimensions}
+  from '#yaml';
+
+import {exposeDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+import {exitWithoutContribs} from '#composite/wiki-data';
+
+import {
+  additionalFiles,
+  commentary,
+  color,
+  commentatorArtists,
+  contribsPresent,
+  contributionList,
+  dimensions,
+  directory,
+  fileExtension,
+  flag,
+  name,
+  referenceList,
+  simpleDate,
+  simpleString,
+  urls,
+  wikiData,
+} from '#composite/wiki-properties';
+
+import {withTracks, withTrackSections} from '#composite/things/album';
+
+export class Album extends Thing {
+  static [Thing.referenceType] = 'album';
+
+  static [Thing.getPropertyDescriptors] = ({ArtTag, Artist, Group, Track}) => ({
+    // Update & expose
+
+    name: name('Unnamed Album'),
+    color: color(),
+    directory: directory(),
+    urls: urls(),
+
+    bandcampAlbumIdentifier: simpleString(),
+    bandcampArtworkIdentifier: simpleString(),
+
+    date: simpleDate(),
+    trackArtDate: simpleDate(),
+    dateAddedToWiki: simpleDate(),
+
+    coverArtDate: [
+      exitWithoutContribs({contribs: 'coverArtistContribs'}),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
+
+      exposeDependency({dependency: 'date'}),
+    ],
+
+    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(),
+    ],
+
+    bannerStyle: [
+      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
+      simpleString(),
+    ],
+
+    coverArtDimensions: [
+      exitWithoutContribs({contribs: 'coverArtistContribs'}),
+      dimensions(),
+    ],
+
+    bannerDimensions: [
+      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
+      dimensions(),
+    ],
+
+    hasTrackNumbers: flag(true),
+    isListedOnHomepage: flag(true),
+    isListedInGalleries: flag(true),
+
+    commentary: commentary(),
+    additionalFiles: additionalFiles(),
+
+    trackSections: [
+      withTrackSections(),
+      exposeDependency({dependency: '#trackSections'}),
+    ],
+
+    artistContribs: contributionList(),
+    coverArtistContribs: contributionList(),
+    trackCoverArtistContribs: contributionList(),
+    wallpaperArtistContribs: contributionList(),
+    bannerArtistContribs: contributionList(),
+
+    groups: referenceList({
+      class: input.value(Group),
+      find: input.value(find.group),
+      data: 'groupData',
+    }),
+
+    artTags: [
+      exitWithoutContribs({
+        contribs: 'coverArtistContribs',
+        value: input.value([]),
+      }),
+
+      referenceList({
+        class: input.value(ArtTag),
+        find: input.value(find.artTag),
+        data: 'artTagData',
+      }),
+    ],
+
+    // Update only
+
+    artistData: wikiData({
+      class: input.value(Artist),
+    }),
+
+    artTagData: wikiData({
+      class: input.value(ArtTag),
+    }),
+
+    groupData: wikiData({
+      class: input.value(Group),
+    }),
+
+    // Only the tracks which belong to this album.
+    // Necessary for computing the track list, so provide this statically
+    // or keep it updated.
+    ownTrackData: wikiData({
+      class: input.value(Track),
+    }),
+
+    // Expose only
+
+    commentatorArtists: commentatorArtists(),
+
+    hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}),
+    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',
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Album': {property: 'name'},
+      'Directory': {property: 'directory'},
+
+      '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 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,
+      },
+
+      'Wallpaper Artists': {
+        property: 'wallpaperArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Wallpaper Style': {property: 'wallpaperStyle'},
+      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
+
+      'Banner Artists': {
+        property: 'bannerArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Banner Style': {property: 'bannerStyle'},
+      'Banner File Extension': {property: 'bannerFileExtension'},
+
+      'Banner Dimensions': {
+        property: 'bannerDimensions',
+        transform: parseDimensions,
+      },
+
+      'Commentary': {property: 'commentary'},
+
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      '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},
+    },
+  };
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {headerAndEntries},
+    thingConstructors: {Album, Track, TrackSectionHelper},
+  }) => ({
+    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
+        ? TrackSectionHelper
+        : Track),
+
+    save(results) {
+      const albumData = [];
+      const trackData = [];
+
+      for (const {header: album, entries} of results) {
+        // We can't mutate an array once it's set as a property value,
+        // so prepare the track sections that will show up in a track list
+        // all the way before actually applying them. (It's okay to mutate
+        // an individual section before applying it, since those are just
+        // generic objects; they aren't Things in and of themselves.)
+        const trackSections = [];
+        const ownTrackData = [];
+
+        let currentTrackSection = {
+          name: `Default Track Section`,
+          isDefaultTrackSection: true,
+          tracks: [],
+        };
+
+        const albumRef = Thing.getReference(album);
+
+        const closeCurrentTrackSection = () => {
+          if (!empty(currentTrackSection.tracks)) {
+            trackSections.push(currentTrackSection);
+          }
+        };
+
+        for (const entry of entries) {
+          if (entry instanceof TrackSectionHelper) {
+            closeCurrentTrackSection();
+
+            currentTrackSection = {
+              name: entry.name,
+              color: entry.color,
+              dateOriginallyReleased: entry.dateOriginallyReleased,
+              isDefaultTrackSection: false,
+              tracks: [],
+            };
+
+            continue;
+          }
+
+          trackData.push(entry);
+
+          entry.dataSourceAlbum = albumRef;
+
+          ownTrackData.push(entry);
+          currentTrackSection.tracks.push(Thing.getReference(entry));
+        }
+
+        closeCurrentTrackSection();
+
+        albumData.push(album);
+
+        album.trackSections = trackSections;
+        album.ownTrackData = ownTrackData;
+      }
+
+      return {albumData, trackData};
+    },
+
+    sort({albumData, trackData}) {
+      sortChronologically(albumData);
+      sortAlbumsTracksChronologically(trackData);
+    },
+  });
+}
+
+export class TrackSectionHelper extends Thing {
+  static [Thing.friendlyName] = `Track Section`;
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    name: name('Unnamed Track Section'),
+    color: color(),
+    dateOriginallyReleased: simpleDate(),
+    isDefaultTrackGroup: flag(false),
+  })
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Section': {property: 'name'},
+      'Color': {property: 'color'},
+
+      'Date Originally Released': {
+        property: 'dateOriginallyReleased',
+        transform: parseDate,
+      },
+    },
+  };
+}
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
new file mode 100644
index 0000000..3149b31
--- /dev/null
+++ b/src/data/things/art-tag.js
@@ -0,0 +1,107 @@
+export const ART_TAG_DATA_FILE = 'tags.yaml';
+
+import {input} from '#composite';
+import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort';
+import Thing from '#thing';
+import {isName} from '#validators';
+
+import {exposeUpdateValueOrContinue} from '#composite/control-flow';
+
+import {
+  color,
+  directory,
+  flag,
+  name,
+  wikiData,
+} from '#composite/wiki-properties';
+
+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),
+
+    nameShort: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isName),
+      }),
+
+      {
+        dependencies: ['name'],
+        compute: ({name}) =>
+          name.replace(/ \([^)]*?\)$/, ''),
+      },
+    ],
+
+    // Update only
+
+    albumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    trackData: wikiData({
+      class: input.value(Track),
+    }),
+
+    // Expose only
+
+    taggedInThings: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['this', 'albumData', 'trackData'],
+        compute: ({this: artTag, albumData, trackData}) =>
+          sortAlbumsTracksChronologically(
+            [...albumData, ...trackData]
+              .filter(({artTags}) => artTags.includes(artTag)),
+            {getDate: thing => thing.coverArtDate ?? thing.date}),
+      },
+    },
+  });
+
+  static [Thing.findSpecs] = {
+    artTag: {
+      referenceTypes: ['tag'],
+      bindTo: 'artTagData',
+
+      getMatchableNames: tag =>
+        (tag.isContentWarning
+          ? [`cw: ${tag.name}`]
+          : [tag.name]),
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Tag': {property: 'name'},
+      'Short Name': {property: 'nameShort'},
+      'Directory': {property: 'directory'},
+
+      'Color': {property: 'color'},
+      'Is CW': {property: 'isContentWarning'},
+    },
+  };
+
+  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 0000000..841d652
--- /dev/null
+++ b/src/data/things/artist.js
@@ -0,0 +1,392 @@
+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 find from '#find';
+import {sortAlphabetically} from '#sort';
+import {stitchArrays, unique} from '#sugar';
+import Thing from '#thing';
+import {isName, validateArrayItems} from '#validators';
+import {getKebabCase} from '#wiki-data';
+
+import {withReverseContributionList} from '#composite/wiki-data';
+
+import {
+  contentString,
+  directory,
+  fileExtension,
+  flag,
+  name,
+  reverseContributionList,
+  reverseReferenceList,
+  singleReference,
+  urls,
+  wikiData,
+} from '#composite/wiki-properties';
+
+export class Artist extends Thing {
+  static [Thing.referenceType] = 'artist';
+  static [Thing.wikiDataArray] = 'artistData';
+
+  static [Thing.getPropertyDescriptors] = ({Album, Flash, Track}) => ({
+    // Update & expose
+
+    name: name('Unnamed Artist'),
+    directory: directory(),
+    urls: urls(),
+
+    contextNotes: contentString(),
+
+    hasAvatar: flag(false),
+    avatarFileExtension: fileExtension('jpg'),
+
+    aliasNames: {
+      flags: {update: true, expose: true},
+      update: {validate: validateArrayItems(isName)},
+      expose: {transform: (names) => names ?? []},
+    },
+
+    isAlias: flag(),
+
+    aliasedArtist: singleReference({
+      class: input.value(Artist),
+      find: input.value(find.artist),
+      data: 'artistData',
+    }),
+
+    // Update only
+
+    albumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    artistData: wikiData({
+      class: input.value(Artist),
+    }),
+
+    flashData: wikiData({
+      class: input.value(Flash),
+    }),
+
+    trackData: wikiData({
+      class: input.value(Track),
+    }),
+
+    // Expose only
+
+    tracksAsArtist: reverseContributionList({
+      data: 'trackData',
+      list: input.value('artistContribs'),
+    }),
+
+    tracksAsContributor: reverseContributionList({
+      data: 'trackData',
+      list: input.value('contributorContribs'),
+    }),
+
+    tracksAsCoverArtist: reverseContributionList({
+      data: 'trackData',
+      list: input.value('coverArtistContribs'),
+    }),
+
+    tracksAsAny: [
+      withReverseContributionList({
+        data: 'trackData',
+        list: input.value('artistContribs'),
+      }).outputs({
+        '#reverseContributionList': '#tracksAsArtist',
+      }),
+
+      withReverseContributionList({
+        data: 'trackData',
+        list: input.value('contributorContribs'),
+      }).outputs({
+        '#reverseContributionList': '#tracksAsContributor',
+      }),
+
+      withReverseContributionList({
+        data: 'trackData',
+        list: input.value('coverArtistContribs'),
+      }).outputs({
+        '#reverseContributionList': '#tracksAsCoverArtist',
+      }),
+
+      {
+        dependencies: [
+          '#tracksAsArtist',
+          '#tracksAsContributor',
+          '#tracksAsCoverArtist',
+        ],
+
+        compute: ({
+          ['#tracksAsArtist']: tracksAsArtist,
+          ['#tracksAsContributor']: tracksAsContributor,
+          ['#tracksAsCoverArtist']: tracksAsCoverArtist,
+        }) =>
+          unique([
+            ...tracksAsArtist,
+            ...tracksAsContributor,
+            ...tracksAsCoverArtist,
+          ]),
+      },
+    ],
+
+    tracksAsCommentator: reverseReferenceList({
+      data: 'trackData',
+      list: input.value('commentatorArtists'),
+    }),
+
+    albumsAsAlbumArtist: reverseContributionList({
+      data: 'albumData',
+      list: input.value('artistContribs'),
+    }),
+
+    albumsAsCoverArtist: reverseContributionList({
+      data: 'albumData',
+      list: input.value('coverArtistContribs'),
+    }),
+
+    albumsAsWallpaperArtist: reverseContributionList({
+      data: 'albumData',
+      list: input.value('wallpaperArtistContribs'),
+    }),
+
+    albumsAsBannerArtist: reverseContributionList({
+      data: 'albumData',
+      list: input.value('bannerArtistContribs'),
+    }),
+
+    albumsAsAny: [
+      withReverseContributionList({
+        data: 'albumData',
+        list: input.value('artistContribs'),
+      }).outputs({
+        '#reverseContributionList': '#albumsAsArtist',
+      }),
+
+      withReverseContributionList({
+        data: 'albumData',
+        list: input.value('coverArtistContribs'),
+      }).outputs({
+        '#reverseContributionList': '#albumsAsCoverArtist',
+      }),
+
+      withReverseContributionList({
+        data: 'albumData',
+        list: input.value('wallpaperArtistContribs'),
+      }).outputs({
+        '#reverseContributionList': '#albumsAsWallpaperArtist',
+      }),
+
+      withReverseContributionList({
+        data: 'albumData',
+        list: input.value('bannerArtistContribs'),
+      }).outputs({
+        '#reverseContributionList': '#albumsAsBannerArtist',
+      }),
+
+      {
+        dependencies: [
+          '#albumsAsArtist',
+          '#albumsAsCoverArtist',
+          '#albumsAsWallpaperArtist',
+          '#albumsAsBannerArtist',
+        ],
+
+        compute: ({
+          ['#albumsAsArtist']: albumsAsArtist,
+          ['#albumsAsCoverArtist']: albumsAsCoverArtist,
+          ['#albumsAsWallpaperArtist']: albumsAsWallpaperArtist,
+          ['#albumsAsBannerArtist']: albumsAsBannerArtist,
+        }) =>
+          unique([
+            ...albumsAsArtist,
+            ...albumsAsCoverArtist,
+            ...albumsAsWallpaperArtist,
+            ...albumsAsBannerArtist,
+          ]),
+      },
+    ],
+
+    albumsAsCommentator: reverseReferenceList({
+      data: 'albumData',
+      list: input.value('commentatorArtists'),
+    }),
+
+    flashesAsContributor: reverseContributionList({
+      data: 'flashData',
+      list: input.value('contributorContribs'),
+    }),
+
+    flashesAsCommentator: reverseReferenceList({
+      data: 'flashData',
+      list: input.value('commentatorArtists'),
+    }),
+  });
+
+  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,
+
+    tracksAsArtist: S.toRefs,
+    tracksAsContributor: S.toRefs,
+    tracksAsCoverArtist: S.toRefs,
+    tracksAsCommentator: S.toRefs,
+
+    albumsAsAlbumArtist: S.toRefs,
+    albumsAsCoverArtist: S.toRefs,
+    albumsAsWallpaperArtist: S.toRefs,
+    albumsAsBannerArtist: S.toRefs,
+    albumsAsCommentator: S.toRefs,
+
+    flashesAsContributor: S.toRefs,
+  });
+
+  static [Thing.findSpecs] = {
+    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'},
+
+      '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];
+
+      return {artistData};
+    },
+
+    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('');
+  }
+}
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
new file mode 100644
index 0000000..ceed79f
--- /dev/null
+++ b/src/data/things/flash.js
@@ -0,0 +1,370 @@
+export const FLASH_DATA_FILE = 'flashes.yaml';
+
+import {input} from '#composite';
+import find from '#find';
+import {empty} from '#sugar';
+import {sortFlashesChronologically} from '#sort';
+import Thing from '#thing';
+import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
+  from '#validators';
+import {parseDate, parseContributors} from '#yaml';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import {
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  color,
+  commentary,
+  commentatorArtists,
+  contentString,
+  contributionList,
+  directory,
+  fileExtension,
+  name,
+  referenceList,
+  simpleDate,
+  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] = ({Artist, Track, FlashAct}) => ({
+    // 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'),
+
+    contributorContribs: contributionList(),
+
+    featuredTracks: referenceList({
+      class: input.value(Track),
+      find: input.value(find.track),
+      data: 'trackData',
+    }),
+
+    urls: urls(),
+
+    commentary: commentary(),
+
+    // Update only
+
+    artistData: wikiData({
+      class: input.value(Artist),
+    }),
+
+    trackData: wikiData({
+      class: input.value(Track),
+    }),
+
+    flashActData: wikiData({
+      class: input.value(FlashAct),
+    }),
+
+    // 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.yamlDocumentSpec] = {
+    fields: {
+      'Flash': {property: 'name'},
+      'Directory': {property: 'directory'},
+      'Page': {property: 'page'},
+      'Color': {property: 'color'},
+      'URLs': {property: 'urls'},
+
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
+
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+
+      'Featured Tracks': {property: 'featuredTracks'},
+
+      'Contributors': {
+        property: 'contributorContribs',
+        transform: parseContributors,
+      },
+
+      'Commentary': {property: 'commentary'},
+
+      'Review Points': {ignore: true},
+    },
+  };
+}
+
+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: input.value(find.flash),
+      data: 'flashData',
+    }),
+
+    // Update only
+
+    flashData: wikiData({
+      class: input.value(Flash),
+    }),
+
+    flashSideData: wikiData({
+      class: input.value(FlashSide),
+    }),
+
+    // Expose only
+
+    side: [
+      withFlashSide(),
+      exposeDependency({dependency: '#flashSide'}),
+    ],
+  });
+
+  static [Thing.findSpecs] = {
+    flashAct: {
+      referenceTypes: ['flash-act'],
+      bindTo: 'flashActData',
+    },
+  };
+
+  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: input.value(find.flashAct),
+      data: 'flashActData',
+    }),
+
+    // Update only
+
+    flashActData: wikiData({
+      class: input.value(FlashAct),
+    }),
+  });
+
+  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.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);
+
+      return {flashData, flashActData, flashSideData};
+    },
+
+    sort({flashData}) {
+      sortFlashesChronologically(flashData);
+    },
+  });
+}
diff --git a/src/data/things/group.js b/src/data/things/group.js
new file mode 100644
index 0000000..0dbbbb7
--- /dev/null
+++ b/src/data/things/group.js
@@ -0,0 +1,194 @@
+export const GROUP_DATA_FILE = 'groups.yaml';
+
+import {input} from '#composite';
+import find from '#find';
+import Thing from '#thing';
+
+import {
+  color,
+  contentString,
+  directory,
+  name,
+  referenceList,
+  urls,
+  wikiData,
+} from '#composite/wiki-properties';
+
+export class Group extends Thing {
+  static [Thing.referenceType] = 'group';
+
+  static [Thing.getPropertyDescriptors] = ({Album}) => ({
+    // Update & expose
+
+    name: name('Unnamed Group'),
+    directory: directory(),
+
+    description: contentString(),
+
+    urls: urls(),
+
+    featuredAlbums: referenceList({
+      class: input.value(Album),
+      find: input.value(find.album),
+      data: 'albumData',
+    }),
+
+    // Update only
+
+    albumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    groupCategoryData: wikiData({
+      class: input.value(GroupCategory),
+    }),
+
+    // 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', 'albumData'],
+        compute: ({this: group, albumData}) =>
+          albumData?.filter((album) => album.groups.includes(group)) ?? [],
+      },
+    },
+
+    color: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['this', 'groupCategoryData'],
+        compute: ({this: group, groupCategoryData}) =>
+          groupCategoryData.find((category) => category.groups.includes(group))
+            ?.color,
+      },
+    },
+
+    category: {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['this', 'groupCategoryData'],
+        compute: ({this: group, groupCategoryData}) =>
+          groupCategoryData.find((category) => category.groups.includes(group)) ??
+          null,
+      },
+    },
+  });
+
+  static [Thing.findSpecs] = {
+    group: {
+      referenceTypes: ['group', 'group-gallery'],
+      bindTo: 'groupData',
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Group': {property: 'name'},
+      'Directory': {property: 'directory'},
+      'Description': {property: 'description'},
+      'URLs': {property: 'urls'},
+
+      'Featured Albums': {property: 'featuredAlbums'},
+
+      '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: input.value(find.group),
+      data: 'groupData',
+    }),
+
+    // Update only
+
+    groupData: wikiData({
+      class: input.value(Group),
+    }),
+  });
+
+  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 0000000..00d6aef
--- /dev/null
+++ b/src/data/things/homepage-layout.js
@@ -0,0 +1,223 @@
+export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml';
+
+import {input} from '#composite';
+import find from '#find';
+import Thing from '#thing';
+
+import {
+  anyOf,
+  is,
+  isCountingNumber,
+  isString,
+  isStringNonEmpty,
+  validateArrayItems,
+  validateInstanceOf,
+  validateReference,
+} from '#validators';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withResolvedReference} from '#composite/wiki-data';
+import {color, contentString, name, referenceList, wikiData}
+  from '#composite/wiki-properties';
+
+export class HomepageLayout extends Thing {
+  static [Thing.friendlyName] = `Homepage Layout`;
+
+  static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({
+    // Update & expose
+
+    sidebarContent: contentString(),
+
+    navbarLinks: {
+      flags: {update: true, expose: true},
+      update: {validate: validateArrayItems(isStringNonEmpty)},
+    },
+
+    rows: {
+      flags: {update: true, expose: true},
+
+      update: {
+        validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)),
+      },
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Homepage': {ignore: true},
+
+      'Sidebar Content': {property: 'sidebarContent'},
+      'Navbar Links': {property: 'navbarLinks'},
+    },
+  };
+}
+
+export class HomepageLayoutRow extends Thing {
+  static [Thing.friendlyName] = `Homepage Row`;
+
+  static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({
+    // Update & expose
+
+    name: name('Unnamed Homepage Row'),
+
+    type: {
+      flags: {update: true, expose: true},
+
+      update: {
+        validate() {
+          throw new Error(`'type' property validator must be overridden`);
+        },
+      },
+    },
+
+    color: color(),
+
+    // Update only
+
+    // These wiki data arrays aren't necessarily used by every subclass, but
+    // to the convenience of providing these, the superclass accepts all wiki
+    // data arrays depended upon by any subclass.
+
+    albumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    groupData: wikiData({
+      class: input.value(Group),
+    }),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Row': {property: 'name'},
+      'Color': {property: 'color'},
+      'Type': {property: 'type'},
+    },
+  };
+}
+
+export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {
+  static [Thing.friendlyName] = `Homepage Albums Row`;
+
+  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
+    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
+
+    // Update & expose
+
+    type: {
+      flags: {update: true, expose: true},
+      update: {
+        validate(value) {
+          if (value !== 'albums') {
+            throw new TypeError(`Expected 'albums'`);
+          }
+
+          return true;
+        },
+      },
+    },
+
+    displayStyle: {
+      flags: {update: true, expose: true},
+
+      update: {
+        validate: is('grid', 'carousel'),
+      },
+
+      expose: {
+        transform: (displayStyle) =>
+          displayStyle ?? 'grid',
+      },
+    },
+
+    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(),
+        data: 'groupData',
+        find: input.value(find.group),
+      }),
+
+      exposeDependency({dependency: '#resolvedReference'}),
+    ],
+
+    sourceAlbums: referenceList({
+      class: input.value(Album),
+      find: input.value(find.album),
+      data: 'albumData',
+    }),
+
+    countAlbumsFromGroup: {
+      flags: {update: true, expose: true},
+      update: {validate: isCountingNumber},
+    },
+
+    actionLinks: {
+      flags: {update: true, expose: true},
+      update: {validate: validateArrayItems(isString)},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+    fields: {
+      'Display Style': {property: 'displayStyle'},
+      'Group': {property: 'sourceGroup'},
+      'Count': {property: 'countAlbumsFromGroup'},
+      'Albums': {property: 'sourceAlbums'},
+      'Actions': {property: 'actionLinks'},
+    },
+  });
+
+  static [Thing.getYamlLoadingSpec] = ({
+    documentModes: {headerAndEntries}, // Kludge, see below
+    thingConstructors: {
+      HomepageLayout,
+      HomepageLayoutAlbumsRow,
+    },
+  }) => ({
+    title: `Process homepage layout file`,
+
+    // Kludge: This benefits from the same headerAndEntries style messaging as
+    // albums and tracks (for example), but that document mode is designed to
+    // support multiple files, and only one is actually getting processed here.
+    files: [HOMEPAGE_LAYOUT_DATA_FILE],
+
+    documentMode: headerAndEntries,
+    headerDocumentThing: HomepageLayout,
+    entryDocumentThing: document => {
+      switch (document['Type']) {
+        case 'albums':
+          return HomepageLayoutAlbumsRow;
+        default:
+          throw new TypeError(`No processDocument function for row type ${document['Type']}!`);
+      }
+    },
+
+    save(results) {
+      if (!results[0]) {
+        return;
+      }
+
+      const {header: homepageLayout, entries: rows} = results[0];
+      Object.assign(homepageLayout, {rows});
+      return {homepageLayout};
+    },
+  });
+}
diff --git a/src/data/things/index.js b/src/data/things/index.js
new file mode 100644
index 0000000..3bf8409
--- /dev/null
+++ b/src/data/things/index.js
@@ -0,0 +1,188 @@
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+import {openAggregate, showAggregate} from '#aggregate';
+import {logError} from '#cli';
+import {compositeFrom} from '#composite';
+import * as serialize from '#serialize';
+
+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 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 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,
+  'flash.js': flashClasses,
+  'group.js': groupClasses,
+  'homepage-layout.js': homepageLayoutClasses,
+  'language.js': languageClasses,
+  'news-entry.js': newsEntryClasses,
+  '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() {
+  for (const classes of Object.values(allClassLists)) {
+    for (const [name, constructor] of Object.entries(classes)) {
+      if (typeof constructor !== 'function') continue;
+      if (!(constructor.prototype instanceof Thing)) continue;
+      allClasses[name] = constructor;
+    }
+  }
+}
+
+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.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(', ')}`;
+    },
+  });
+}
+
+if (!errorDuplicateClassNames())
+  process.exit(1);
+
+flattenClassLists();
+
+if (!evaluatePropertyDescriptors())
+  process.exit(1);
+
+if (!evaluateSerializeDescriptors())
+  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 0000000..dbe1ff3
--- /dev/null
+++ b/src/data/things/language.js
@@ -0,0 +1,730 @@
+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 util/external-links.js for info.
+    externalLinkSpec: {
+      flags: {update: true, expose: true},
+      update: {validate: isExternalLinkSpec},
+    },
+
+    // Update only
+
+    escapeHTML: externalFunction(),
+
+    // Expose only
+
+    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 =
+      (hasOptions ? args.slice(0, -1) : args)
+        .filter(Boolean)
+        .join('.');
+
+    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`);
+    }
+
+    // These will be filled up as we iterate over the template, slotting in
+    // each option (if it's present).
+    const missingOptionNames = new Set();
+
+    // And this will have entries deleted as they're encountered in the
+    // template. Leftover entries are misplaced.
+    const optionsMap =
+      new Map(
+        Object.entries(options).map(([name, value]) => [
+          name
+            .replace(/[A-Z]/g, '_$&')
+            .toUpperCase(),
+          value,
+        ]));
+
+    const output = this.#iterateOverTemplate({
+      template: this.strings[key],
+
+      match: languageOptionRegex,
+
+      insert: ({name: optionName}, canceledForming) => {
+        if (optionsMap.has(optionName)) {
+          let optionValue;
+
+          // We'll only need the option's value if we're going to use it as
+          // part of the formed output (see below).
+          if (!canceledForming) {
+            optionValue = optionsMap.get(optionName);
+          }
+
+          // But we always have to delete expected options off the provided
+          // option map, since the leftovers are what will be used to tell
+          // which are misplaced.
+          optionsMap.delete(optionName);
+
+          if (canceledForming) {
+            return undefined;
+          } else {
+            return optionValue;
+          }
+        } else {
+          // We don't need to continue forming the output if we've hit a
+          // missing option name, since the end result of this formatString
+          // call will be a thrown error, and formed output won't be needed.
+          missingOptionNames.add(optionName);
+          return undefined;
+        }
+      },
+    });
+
+    const misplacedOptionNames =
+      Array.from(optionsMap.keys());
+
+    withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => {
+      if (!empty(missingOptionNames)) {
+        const names = Array.from(missingOptionNames).join(`, `);
+        push(new Error(`Missing options: ${names}`));
+      }
+
+      if (!empty(misplacedOptionNames)) {
+        const names = Array.from(misplacedOptionNames).join(`, `);
+        push(new Error(`Unexpected options: ${names}`));
+      }
+    });
+
+    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) {
+    this.assertIntlAvailable('intl_date');
+    return this.intl_date.format(date);
+  }
+
+  formatDateRange(startDate, endDate) {
+    this.assertIntlAvailable('intl_date');
+    return this.intl_date.formatRange(startDate, endDate);
+  }
+
+  formatDateDuration({
+    years: numYears = 0,
+    months: numMonths = 0,
+    days: numDays = 0,
+    approximate = false,
+  }) {
+    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,
+  } = {}) {
+    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} = {}) {
+    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`);
+    }
+
+    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) {
+    this.assertIntlAvailable('intl_pluralOrdinal');
+    return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
+  }
+
+  formatNumber(value) {
+    this.assertIntlAvailable('intl_number');
+    return this.intl_number.format(value);
+  }
+
+  formatWordCount(value) {
+    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) {
+    // 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) {
+    if (!bytes) return '';
+
+    bytes = parseInt(bytes);
+    if (isNaN(bytes)) return '';
+
+    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});
+    }
+  }
+}
+
+const countHelper = (stringKey, optionName = stringKey) =>
+  function(value, {unit = false} = {}) {
+    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'),
+  countArtworks: countHelper('artworks'),
+  countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
+  countContributions: countHelper('contributions'),
+  countCoverArts: countHelper('coverArts'),
+  countDays: countHelper('days'),
+  countFlashes: countHelper('flashes'),
+  countMonths: countHelper('months'),
+  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 0000000..43d1638
--- /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/static-page.js b/src/data/things/static-page.js
new file mode 100644
index 0000000..0327497
--- /dev/null
+++ b/src/data/things/static-page.js
@@ -0,0 +1,80 @@
+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, 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(),
+    content: contentString(),
+    stylesheet: simpleString(),
+    script: simpleString(),
+  });
+
+  static [Thing.findSpecs] = {
+    staticPage: {
+      referenceTypes: ['static'],
+      bindTo: 'staticPageData',
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Name': {property: 'name'},
+      'Short Name': {property: 'nameShort'},
+      'Directory': {property: 'directory'},
+
+      '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 0000000..cc49fc2
--- /dev/null
+++ b/src/data/things/track.js
@@ -0,0 +1,566 @@
+import {inspect} from 'node:util';
+
+import CacheableObject from '#cacheable-object';
+import {colors} from '#cli';
+import {input} from '#composite';
+import find from '#find';
+import Thing from '#thing';
+import {isColor, isContributionList, isDate, isFileExtension}
+  from '#validators';
+
+import {
+  parseAdditionalFiles,
+  parseAdditionalNames,
+  parseContributors,
+  parseDate,
+  parseDimensions,
+  parseDuration,
+} from '#yaml';
+
+import {withPropertyFromObject} from '#composite/data';
+import {withResolvedContribs} from '#composite/wiki-data';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  additionalFiles,
+  additionalNameList,
+  commentary,
+  commentatorArtists,
+  contentString,
+  contributionList,
+  dimensions,
+  directory,
+  duration,
+  flag,
+  name,
+  referenceList,
+  reverseReferenceList,
+  simpleDate,
+  singleReference,
+  simpleString,
+  urls,
+  wikiData,
+} from '#composite/wiki-properties';
+
+import {
+  exitWithoutUniqueCoverArt,
+  inferredAdditionalNameList,
+  inheritFromOriginalRelease,
+  sharedAdditionalNameList,
+  trackReverseReferenceList,
+  withAlbum,
+  withAlwaysReferenceByDirectory,
+  withContainingTrackSection,
+  withHasUniqueCoverArt,
+  withOtherReleases,
+  withPropertyFromAlbum,
+} from '#composite/things/track';
+
+export class Track extends Thing {
+  static [Thing.referenceType] = 'track';
+
+  static [Thing.getPropertyDescriptors] = ({Album, ArtTag, Artist, Flash}) => ({
+    // Update & expose
+
+    name: name('Unnamed Track'),
+    directory: directory(),
+
+    additionalNames: additionalNameList(),
+    sharedAdditionalNames: sharedAdditionalNameList(),
+    inferredAdditionalNames: inferredAdditionalNameList(),
+
+    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'),
+      }),
+    ],
+
+    // Date of cover art release. Like coverArtFileExtension, this represents
+    // only the track's own unique cover artwork, if any. This exposes only as
+    // the track's own coverArtDate or its album's trackArtDate, so if neither
+    // is specified, this value is null.
+    coverArtDate: [
+      withHasUniqueCoverArt(),
+
+      exitWithoutDependency({
+        dependency: '#hasUniqueCoverArt',
+        mode: input.value('falsy'),
+      }),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
+
+      withPropertyFromAlbum({
+        property: input.value('trackArtDate'),
+      }),
+
+      exposeDependency({dependency: '#album.trackArtDate'}),
+    ],
+
+    coverArtDimensions: [
+      exitWithoutUniqueCoverArt(),
+      dimensions(),
+    ],
+
+    commentary: commentary(),
+
+    lyrics: [
+      inheritFromOriginalRelease({
+        property: input.value('lyrics'),
+      }),
+
+      contentString(),
+    ],
+
+    additionalFiles: additionalFiles(),
+    sheetMusicFiles: additionalFiles(),
+    midiProjectFiles: additionalFiles(),
+
+    originalReleaseTrack: singleReference({
+      class: input.value(Track),
+      find: input.value(find.track),
+      data: 'trackData',
+    }),
+
+    // Internal use only - for directly identifying an album inside a track's
+    // util.inspect display, if it isn't indirectly available (by way of being
+    // included in an album's track list).
+    dataSourceAlbum: singleReference({
+      class: input.value(Album),
+      find: input.value(find.album),
+      data: 'albumData',
+    }),
+
+    artistContribs: [
+      inheritFromOriginalRelease({
+        property: input.value('artistContribs'),
+        notFoundValue: input.value([]),
+      }),
+
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+      }).outputs({
+        '#resolvedContribs': '#artistContribs',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#artistContribs',
+        mode: input.value('empty'),
+      }),
+
+      withPropertyFromAlbum({
+        property: input.value('artistContribs'),
+      }),
+
+      exposeDependency({dependency: '#album.artistContribs'}),
+    ],
+
+    contributorContribs: [
+      inheritFromOriginalRelease({
+        property: input.value('contributorContribs'),
+        notFoundValue: input.value([]),
+      }),
+
+      contributionList(),
+    ],
+
+    // Cover artists aren't inherited from the original release, since it
+    // typically varies by release and isn't defined by the musical qualities
+    // of the track.
+    coverArtistContribs: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
+
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+      }).outputs({
+        '#resolvedContribs': '#coverArtistContribs',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#coverArtistContribs',
+        mode: input.value('empty'),
+      }),
+
+      withPropertyFromAlbum({
+        property: input.value('trackCoverArtistContribs'),
+      }),
+
+      exposeDependency({dependency: '#album.trackCoverArtistContribs'}),
+    ],
+
+    referencedTracks: [
+      inheritFromOriginalRelease({
+        property: input.value('referencedTracks'),
+        notFoundValue: input.value([]),
+      }),
+
+      referenceList({
+        class: input.value(Track),
+        find: input.value(find.track),
+        data: 'trackData',
+      }),
+    ],
+
+    sampledTracks: [
+      inheritFromOriginalRelease({
+        property: input.value('sampledTracks'),
+        notFoundValue: input.value([]),
+      }),
+
+      referenceList({
+        class: input.value(Track),
+        find: input.value(find.track),
+        data: 'trackData',
+      }),
+    ],
+
+    artTags: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
+
+      referenceList({
+        class: input.value(ArtTag),
+        find: input.value(find.artTag),
+        data: 'artTagData',
+      }),
+    ],
+
+    // Update only
+
+    albumData: wikiData({
+      class: input.value(Album),
+    }),
+
+    artistData: wikiData({
+      class: input.value(Artist),
+    }),
+
+    artTagData: wikiData({
+      class: input.value(ArtTag),
+    }),
+
+    flashData: wikiData({
+      class: input.value(Flash),
+    }),
+
+    trackData: wikiData({
+      class: input.value(Track),
+    }),
+
+    // Expose only
+
+    commentatorArtists: commentatorArtists(),
+
+    album: [
+      withAlbum(),
+      exposeDependency({dependency: '#album'}),
+    ],
+
+    date: [
+      exposeDependencyOrContinue({dependency: 'dateFirstReleased'}),
+
+      withPropertyFromAlbum({
+        property: input.value('date'),
+      }),
+
+      exposeDependency({dependency: '#album.date'}),
+    ],
+
+    hasUniqueCoverArt: [
+      withHasUniqueCoverArt(),
+      exposeDependency({dependency: '#hasUniqueCoverArt'}),
+    ],
+
+    otherReleases: [
+      withOtherReleases(),
+      exposeDependency({dependency: '#otherReleases'}),
+    ],
+
+    referencedByTracks: trackReverseReferenceList({
+      list: input.value('referencedTracks'),
+    }),
+
+    sampledByTracks: trackReverseReferenceList({
+      list: input.value('sampledTracks'),
+    }),
+
+    featuredInFlashes: reverseReferenceList({
+      data: 'flashData',
+      list: input.value('featuredTracks'),
+    }),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Track': {property: 'name'},
+      'Directory': {property: 'directory'},
+
+      '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'},
+
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'Sheet Music Files': {
+        property: 'sheetMusicFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'MIDI Project Files': {
+        property: 'midiProjectFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      'Originally Released As': {property: 'originalReleaseTrack'},
+      'Referenced Tracks': {property: 'referencedTracks'},
+      'Sampled Tracks': {property: 'sampledTracks'},
+
+      'Franchises': {ignore: true},
+      'Inherit Franchises': {ignore: true},
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Contributors': {
+        property: 'contributorContribs',
+        transform: parseContributors,
+      },
+
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Art Tags': {property: 'artTags'},
+
+      'Review Points': {ignore: true},
+    },
+
+    invalidFieldCombinations: [
+      {message: `Rereleases inherit references from the original`, fields: [
+        'Originally Released As',
+        'Referenced Tracks',
+      ]},
+
+      {message: `Rereleases inherit samples from the original`, fields: [
+        'Originally Released As',
+        'Sampled Tracks',
+      ]},
+
+      {message: `Rereleases inherit artists from the original`, fields: [
+        'Originally Released As',
+        'Artists',
+      ]},
+
+      {message: `Rereleases inherit contributors from the original`, fields: [
+        'Originally Released As',
+        'Contributors',
+      ]},
+
+      {message: `Rereleases inherit lyrics from the original`, fields: [
+        'Originally Released As',
+        '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]),
+    },
+
+    trackOriginalReleasesOnly: {
+      referenceTypes: ['track'],
+      bindTo: 'trackData',
+
+      include: track =>
+        !CacheableObject.getUpdateValue(track, 'originalReleaseTrack'),
+
+      // 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]),
+    },
+  };
+
+  // Track YAML loading is handled in album.js.
+  static [Thing.getYamlLoadingSpec] = null;
+
+  [inspect.custom](depth) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (CacheableObject.getUpdateValue(this, 'originalReleaseTrack')) {
+      parts.unshift(`${colors.yellow('[rerelease]')} `);
+    }
+
+    let album;
+
+    if (depth >= 0) {
+      try {
+        album = this.album;
+      } catch (_error) {
+        // Computing album might crash for any reason, which we don't want to
+        // distract from another error we might be trying to work out at the
+        // moment (for which debugging might involve inspecting this track!).
+      }
+
+      album ??= this.dataSourceAlbum;
+    }
+
+    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 0000000..316bd3b
--- /dev/null
+++ b/src/data/things/wiki-info.js
@@ -0,0 +1,110 @@
+export const WIKI_INFO_FILE = 'wiki-info.yaml';
+
+import {input} from '#composite';
+import find from '#find';
+import Thing from '#thing';
+import {isColor, isLanguageCode, isName, isURL} from '#validators';
+
+import {contentString, flag, name, referenceList, wikiData}
+  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},
+    },
+
+    divideTrackListsByGroups: referenceList({
+      class: input.value(Group),
+      find: input.value(find.group),
+      data: 'groupData',
+    }),
+
+    // Feature toggles
+    enableFlashesAndGames: flag(false),
+    enableListings: flag(false),
+    enableNews: flag(false),
+    enableArtTagUI: flag(false),
+    enableGroupUI: flag(false),
+
+    // Update only
+
+    groupData: wikiData({
+      class: input.value(Group),
+    }),
+  });
+
+  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'},
+    },
+  };
+
+  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/validators.js b/src/data/validators.js
index 0d325ae..987f806 100644
--- a/src/data/validators.js
+++ b/src/data/validators.js
@@ -1,367 +1,997 @@
-import { withAggregate } from '../util/sugar.js';
+import {inspect as nodeInspect} from 'node:util';
 
-import { color, ENABLE_COLOR, decorateTime } from '../util/cli.js';
-
-import { inspect as nodeInspect } from 'util';
+import {openAggregate, withAggregate} from '#aggregate';
+import {colors, ENABLE_COLOR} from '#cli';
+import {cut, empty, matchMultiline, typeAppearance} from '#sugar';
+import {commentaryRegexCaseInsensitive, commentaryRegexCaseSensitiveOneShot}
+  from '#wiki-data';
 
 function inspect(value) {
-    return nodeInspect(value, {colors: ENABLE_COLOR});
+  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)
 
-function a(noun) {
-    return (/[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`);
+export function a(noun) {
+  return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`;
 }
 
-function isType(value, type) {
+export function validateType(type) {
+  const fn = value => {
     if (typeof value !== type)
-        throw new TypeError(`Expected ${a(type)}, got ${typeof value}`);
+      throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`);
 
     return true;
-}
+  };
 
-export function isBoolean(value) {
-    return isType(value, 'boolean');
-}
+  setValidatorCreatorMeta(fn, validateType, {type});
 
-export function isNumber(value) {
-    return isType(value, 'number');
+  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);
+  isNumber(number);
 
-    if (number <= 0)
-        throw new TypeError(`Expected positive number`);
+  if (number <= 0) throw new TypeError(`Expected positive number`);
 
-    return true;
+  return true;
 }
 
 export function isNegative(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number >= 0)
-        throw new TypeError(`Expected negative number`);
+  if (number >= 0) throw new TypeError(`Expected negative number`);
 
-    return true;
+  return true;
 }
 
 export function isPositiveOrZero(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number < 0)
-        throw new TypeError(`Expected positive number or zero`);
+  if (number < 0) throw new TypeError(`Expected positive number or zero`);
 
-    return true;
+  return true;
 }
 
 export function isNegativeOrZero(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number > 0)
-        throw new TypeError(`Expected negative number or zero`);
+  if (number > 0) throw new TypeError(`Expected negative number or zero`);
 
-    return true;
+  return true;
 }
 
 export function isInteger(number) {
-    isNumber(number);
+  isNumber(number);
 
-    if (number % 1 !== 0)
-        throw new TypeError(`Expected integer`);
+  if (number % 1 !== 0) throw new TypeError(`Expected integer`);
 
-    return true;
+  return true;
 }
 
 export function isCountingNumber(number) {
-    isInteger(number);
-    isPositive(number);
+  isInteger(number);
+  isPositive(number);
 
-    return true;
+  return true;
 }
 
 export function isWholeNumber(number) {
-    isInteger(number);
-    isPositiveOrZero(number);
-
-    return true;
-}
+  isInteger(number);
+  isPositiveOrZero(number);
 
-export function isString(value) {
-    return isType(value, 'string');
+  return true;
 }
 
 export function isStringNonEmpty(value) {
-    isString(value);
+  isString(value);
 
-    if (value.trim().length === 0)
-        throw new TypeError(`Expected non-empty string`);
+  if (value.trim().length === 0)
+    throw new TypeError(`Expected non-empty string`);
 
-    return true;
+  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);
+  isObject(value);
 
-    if (!(value instanceof constructor))
-        throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`);
+  if (!(value instanceof constructor))
+    throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`);
 
-    return true;
+  return true;
 }
 
 export function isDate(value) {
-    return isInstance(value, Date);
+  isInstance(value, Date);
+
+  if (isNaN(value))
+    throw new TypeError(`Expected valid date`);
+
+  return true;
 }
 
 export function isObject(value) {
-    isType(value, 'object');
+  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`);
+  // 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;
+  return true;
 }
 
 export function isArray(value) {
-    if (typeof value !== 'object' || value === null || !Array.isArray(value))
-        throw new TypeError(`Expected an array, got ${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) => {
-        try {
-            const value = itemValidator(item);
-
-            if (value !== true) {
-                throw new Error(`Expected validator to return true`);
-            }
-        } catch (error) {
-            error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${error.message}`;
-            throw error;
-        }
-    };
+  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 fn = validateArrayItemsHelper(itemValidator);
+  const helper = validateArrayItemsHelper(itemValidator);
 
-    return array => {
-        isArray(array);
+  return (array) => {
+    isArray(array);
 
-        withAggregate({message: 'Errors validating array items'}, ({ wrap }) => {
-            array.forEach(wrap(fn));
-        });
+    withAggregate({message: 'Errors validating array items'}, ({call}) => {
+      for (let index = 0; index < array.length; index++) {
+        call(helper, array[index], index, array);
+      }
+    });
 
-        return true;
-    };
+    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) {
-    return object => isInstance(object, constructor);
+  const fn = (object) => isInstance(object, constructor);
+
+  setValidatorCreatorMeta(fn, validateInstanceOf, {constructor});
+
+  return fn;
 }
 
 // Wiki data (primitives & non-primitives)
 
 export function isColor(color) {
-    isStringNonEmpty(color);
+  isStringNonEmpty(color);
 
-    if (color.startsWith('#')) {
-        if (![1 + 3, 1 + 4, 1 + 6, 1 + 8].includes(color.length))
-            throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`);
+  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`);
+    if (/[^0-9a-fA-F]/.test(color.slice(1)))
+      throw new TypeError(`Expected hexadecimal digits`);
 
-        return true;
-    }
+    return true;
+  }
 
-    throw new TypeError(`Unknown color format`);
+  throw new TypeError(`Unknown color format`);
 }
 
-export function isCommentary(commentary) {
-    return isString(commentary);
+export function isCommentary(commentaryText) {
+  isContentString(commentaryText);
+
+  const rawMatches =
+    Array.from(commentaryText.matchAll(commentaryRegexCaseInsensitive));
+
+  if (empty(rawMatches)) {
+    throw new TypeError(`Expected at least one commentary heading`);
+  }
+
+  const niceMatches =
+    rawMatches.map(match => ({
+      position: match.index,
+      length: match[0].length,
+    }));
+
+  validateArrayItems(({position, length}, index) => {
+    if (index === 0 && position > 0) {
+      throw new TypeError(`Expected first commentary heading to be at top`);
+    }
+
+    const ownInput = commentaryText.slice(position, position + length);
+    const restOfInput = commentaryText.slice(position + length);
+
+    const upToNextLineBreak =
+      (restOfInput.includes('\n')
+        ? restOfInput.slice(0, restOfInput.indexOf('\n'))
+        : restOfInput);
+
+    if (/\S/.test(upToNextLineBreak)) {
+      throw new TypeError(
+        `Expected commentary heading to occupy entire line, got extra text:\n` +
+        `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` +
+        `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` +
+        `(Check for missing "|-" in YAML, or a misshapen annotation)`);
+    }
+
+    if (!commentaryRegexCaseSensitiveOneShot.test(ownInput)) {
+      throw new TypeError(
+        `Miscapitalization in commentary heading:\n` +
+        `${colors.red(`"${cut(ownInput, 60)}"`)}\n` +
+        `(Check for ${colors.red(`"<I>"`)} instead of ${colors.green(`"<i>"`)})`);
+    }
+
+    const nextHeading =
+      (index === niceMatches.length - 1
+        ? commentaryText.length
+        : niceMatches[index + 1].position);
+
+    const upToNextHeading =
+      commentaryText.slice(position + length, nextHeading);
+
+    if (!/\S/.test(upToNextHeading)) {
+      throw new TypeError(
+        `Expected commentary entry to have body text, only got a heading`);
+    }
+
+    return true;
+  })(niceMatches);
+
+  return true;
 }
 
 const isArtistRef = validateReference('artist');
 
 export function validateProperties(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`}, ({ call }) => {
-            for (const [ specKey, specValidator ] of specEntries) {
-                call(() => {
-                    const value = object[specKey];
-                    try {
-                        specValidator(value);
-                    } catch (error) {
-                        error.message = `(key: ${color.green(specKey)}, value: ${inspect(value)}) ${error.message}`;
-                        throw error;
-                    }
-                });
-            }
-
-            const unknownKeys = Object.keys(object).filter(key => !specKeys.includes(key));
-            if (unknownKeys.length > 0) {
-                call(() => {
-                    throw new Error(`Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`);
-                });
-            }
-        });
+  const {
+    [validateProperties.validateOtherKeys]: validateOtherKeys = null,
+    [validateProperties.allowOtherKeys]: allowOtherKeys = false,
+  } = spec;
 
-        return true;
-    };
-}
+  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 illegalContentRegexp =
+  new RegExp(
+    illegalContentSpec
+      .map(entry => entry.illegal)
+      .map(illegal => `${illegal}+`)
+      .join('|'),
+    'g');
+
+const illegalCharactersInContent =
+  illegalContentSpec
+    .map(entry => entry.illegal)
+    .join('');
+
+const legalContentNearEndRegexp =
+  new RegExp(`[^\n${illegalCharactersInContent}]+$`);
+
+const legalContentNearStartRegexp =
+  new RegExp(`^[^\n${illegalCharactersInContent}]+`);
+
+const trimWhitespaceNearBothSidesRegexp =
+  /^ +| +$/gm;
+
+const trimWhitespaceNearEndRegexp =
+  / +$/gm;
+
+export function isContentString(content) {
+  isStringNonEmpty(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,
+      `(${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);
+
+  if (!Object.hasOwn(thingClass, Symbol.for('Thing.referenceType'))) {
+    throw new TypeError(`Expected a Thing constructor, missing Thing.referenceType`);
+  }
+
+  return true;
+}
 
 export const isContribution = validateProperties({
-    who: isArtistRef,
-    what: value => value === undefined || value === null || isStringNonEmpty(value),
+  who: isArtistRef,
+  what: optional(isStringNonEmpty),
 });
 
 export const isContributionList = validateArrayItems(isContribution);
 
 export const isAdditionalFile = validateProperties({
-    title: isString,
-    description: value => (value === undefined || value === null || isString(value)),
-    files: validateArrayItems(isString)
+  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 function isDimensions(dimensions) {
-    isArray(dimensions);
+  isArray(dimensions);
 
-    if (dimensions.length !== 2)
-        throw new TypeError(`Expected 2 item array`);
+  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;
+  return true;
 }
 
 export function isDirectory(directory) {
-    isStringNonEmpty(directory);
+  isStringNonEmpty(directory);
 
-    if (directory.match(/[^a-zA-Z0-9_\-]/))
-        throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`);
+  if (directory.match(/[^a-zA-Z0-9_-]/))
+    throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`);
 
-    return true;
+  return true;
 }
 
 export function isDuration(duration) {
-    isNumber(duration);
-    isPositiveOrZero(duration);
+  isNumber(duration);
+  isPositiveOrZero(duration);
 
-    return true;
+  return true;
 }
 
 export function isFileExtension(string) {
-    isStringNonEmpty(string);
+  isStringNonEmpty(string);
 
-    if (string[0] === '.')
-        throw new TypeError(`Expected no dot (.) at the start of file extension`);
+  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`);
+  if (string.match(/[^a-zA-Z0-9_]/))
+    throw new TypeError(`Expected only alphanumeric and underscore`);
 
-    return true;
+  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.
+  // TODO: This is a stub function because really we don't need a detailed
+  // is-language-code parser right now.
 
-    isString(string);
+  isString(string);
 
-    return true;
+  return true;
 }
 
 export function isName(name) {
-    return isString(name);
+  return isContentString(name);
 }
 
 export function isURL(string) {
-    isStringNonEmpty(string);
+  isStringNonEmpty(string);
 
-    new URL(string);
+  new URL(string);
 
-    return true;
+  return true;
 }
 
 export function validateReference(type = 'track') {
-    return ref => {
-        isStringNonEmpty(ref);
+  return (ref) => {
+    isStringNonEmpty(ref);
 
-        const match = ref.trim().match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/);
+    const match = ref
+      .trim()
+      .match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/);
 
-        if (!match)
-            throw new TypeError(`Malformed reference`);
+    if (!match) throw new TypeError(`Malformed reference`);
 
-        const { groups: { typePart, directoryPart } } = match;
+    const {groups: {typePart, directoryPart}} = match;
 
-        if (typePart && typePart !== type)
-            throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`);
+    if (typePart) {
+      if (typePart !== type)
+        throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`);
 
-        if (typePart)
-            isDirectory(directoryPart);
+      isDirectory(directoryPart);
+    }
 
-        isName(ref);
+    isName(ref);
 
-        return true;
-    };
+    return true;
+  };
 }
 
 export function validateReferenceList(type = '') {
-    return validateArrayItems(validateReference(type));
+  return validateArrayItems(validateReference(type));
 }
 
+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) {
+        const {[Symbol.for('Thing.referenceType')]: referenceType} = object.constructor;
+
+        if (referenceType === undefined) {
+          foundOtherObject = true;
+
+          // Early-exit if a Thing has been found - nothing more can be learned.
+          if (foundThing) {
+            throw new TypeError(`Expected array of wiki data objects, got mixed items`);
+          }
+        } else {
+          foundThing = true;
+
+          // Early-exit if a non-Thing object has been found - nothing more can
+          // be learned.
+          if (foundOtherObject) {
+            throw new TypeError(`Expected array of wiki data objects, got mixed items`);
+          }
+
+          allRefTypes.add(referenceType);
+        }
+      }
+
+      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: isName,
+  annotation: optional(isContentString),
+
+  // TODO: This only allows indicating sourcing from a track.
+  // That's okay for the current limited use of "from", but
+  // could be expanded later.
+  from:
+    // Double TODO: Explicitly allowing both references and
+    // live objects to co-exist is definitely weird, and
+    // altogether questions the way we define validators...
+    optional(anyOf(
+      validateReferenceList('track'),
+      validateWikiData({referenceType: 'track'}))),
+});
+
+export const isAdditionalNameList = validateArrayItems(isAdditionalName);
+
 // Compositional utilities
 
-export function oneOf(...checks) {
-    return value => {
-        const errorMeta = [];
+export function anyOf(...validators) {
+  const validConstants = new Set();
+  const validConstructors = new Set();
+  const validTypes = new Set();
 
-        for (let i = 0, check; check = checks[i]; i++) {
-            try {
-                const result = check(value);
+  const constantValidators = [];
+  const constructorValidators = [];
+  const typeValidators = [];
 
-                if (result !== true) {
-                    throw new Error(`Check returned false`);
-                }
+  const leftoverValidators = [];
 
-                return true;
-            } catch (error) {
-                errorMeta.push([check, i, error]);
-            }
+  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);
         }
 
-        // Don't process error messages until every check has failed.
-        const errors = [];
-        for (const [ check, i, error ] of errorMeta) {
-            error.message = (check.name
-                ? `(#${i} "${check.name}") ${error.message}`
-                : `(#${i}) ${error.message}`);
-            error.check = check;
-            errors.push(error);
+        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`);
         }
-        throw new AggregateError(errors, `Expected one of ${checks.length} possible checks, but none were true`);
-    };
+
+        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/data/yaml.js b/src/data/yaml.js
index 32cf729..86f3014 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -1,595 +1,494 @@
 // yaml.js - specification for HSMusic YAML data file format and utilities for
-// loading and processing YAML files and documents
+// loading, processing, and validating YAML files and documents
 
-import * as path from 'path';
-import yaml from 'js-yaml';
+import {readFile, stat} from 'node:fs/promises';
+import * as path from 'node:path';
+import {inspect as nodeInspect} from 'node:util';
 
-import { readFile } from 'fs/promises';
-import { inspect as nodeInspect } from 'util';
+import yaml from 'js-yaml';
 
-import {
-    Album,
-    Artist,
-    ArtTag,
-    Flash,
-    FlashAct,
-    Group,
-    GroupCategory,
-    HomepageLayout,
-    HomepageLayoutAlbumsRow,
-    HomepageLayoutRow,
-    NewsEntry,
-    StaticPage,
-    Thing,
-    Track,
-    TrackGroup,
-    WikiInfo,
-} from './things.js';
+import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
+import {sortByName} from '#sort';
+import {atOffset, empty, filterProperties, typeAppearance, withEntries}
+  from '#sugar';
+import Thing from '#thing';
+import thingConstructors from '#things';
 
 import {
-    color,
-    ENABLE_COLOR,
-    logInfo,
-    logWarn,
-} from '../util/cli.js';
+  filterReferenceErrors,
+  reportContentTextErrors,
+  reportDuplicateDirectories,
+} from '#data-checks';
 
 import {
-    decorateErrorWithIndex,
-    mapAggregate,
-    openAggregate,
-    showAggregate,
-    withAggregate,
-} from '../util/sugar.js';
-
-import {
-    sortByDate,
-    sortByName,
-} from '../util/wiki-data.js';
-
-import find, { bindFind } from '../util/find.js';
-import { findFiles } from '../util/io.js';
-
-// --> General supporting stuff
-
-function inspect(value) {
-    return nodeInspect(value, {colors: ENABLE_COLOR});
+  annotateErrorWithFile,
+  decorateErrorWithIndex,
+  decorateErrorWithAnnotation,
+  openAggregate,
+  showAggregate,
+  withAggregate,
+} from '#aggregate';
+
+function inspect(value, opts = {}) {
+  return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
 
-// --> YAML data repository structure constants
-
-export const WIKI_INFO_FILE = 'wiki-info.yaml';
-export const BUILD_DIRECTIVE_DATA_FILE = 'build-directives.yaml';
-export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml';
-export const ARTIST_DATA_FILE = 'artists.yaml';
-export const FLASH_DATA_FILE = 'flashes.yaml';
-export const NEWS_DATA_FILE = 'news.yaml';
-export const ART_TAG_DATA_FILE = 'tags.yaml';
-export const GROUP_DATA_FILE = 'groups.yaml';
-export const STATIC_PAGE_DATA_FILE = 'static-pages.yaml';
-
-export const DATA_ALBUM_DIRECTORY = 'album';
-
-// --> Document processing functions
-
 // 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(thingClass, {
-    // Optional early step for transforming field values before providing them
-    // to the Thing's update() method. This is useful when the input format
-    // (i.e. values in the document) differ from the format the actual Thing
-    // expects.
-    //
-    // Each key and value are a field name (not an update() property) and a
-    // function which takes the value for that field and returns the value which
-    // will be passed on to update().
-    fieldTransformations = {},
-
-    // Mapping of Thing.update() source properties to field names.
-    //
-    // Note this is property -> field, not field -> property. This is a
-    // shorthand convenience because properties are generally typical
-    // camel-cased JS properties, while fields may contain whitespace and be
-    // more easily represented as quoted strings.
-    propertyFieldMapping,
-
-    // Completely ignored fields. These won't throw an unknown field error if
-    // they're present in a document, but they won't be used for Thing property
-    // generation, either. Useful for stuff that's present in data files but not
-    // yet implemented as part of a Thing's data model!
-    ignoredFields = []
+//
+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 = [],
 }) {
-    if (!propertyFieldMapping) {
-        throw new Error(`Expected propertyFieldMapping to be provided`);
-    }
-
-    const knownFields = Object.values(propertyFieldMapping);
-
-    // Invert the property-field mapping, since it'll come in handy for
-    // assigning update() source values later.
-    const fieldPropertyMapping = Object.fromEntries(
-        (Object.entries(propertyFieldMapping)
-            .map(([ property, field ]) => [field, property])));
-
-    const decorateErrorWithName = fn => {
-        const nameField = propertyFieldMapping['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}`
-                    : `(${color.dim(`no name found`)}) ${error.message}`);
-                throw error;
-            }
-        };
+  if (!thingConstructor) {
+    throw new Error(`Missing Thing class`);
+  }
+
+  if (!fieldSpecs) {
+    throw new Error(`Expected fields to be provided`);
+  }
+
+  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 documentEntries = Object.entries(document)
-            .filter(([ field ]) => !ignoredFields.includes(field));
-
-        const unknownFields = documentEntries
-            .map(([ field ]) => field)
-            .filter(field => !knownFields.includes(field));
-
-        if (unknownFields.length) {
-            throw new makeProcessDocument.UnknownFieldsError(unknownFields);
-        }
-
-        const fieldValues = {};
-
-        for (const [ field, value ] of documentEntries) {
-            if (Object.hasOwn(fieldTransformations, field)) {
-                fieldValues[field] = fieldTransformations[field](value);
-            } else {
-                fieldValues[field] = value;
-            }
-        }
-
-        const sourceProperties = {};
-
-        for (const [ field, value ] of Object.entries(fieldValues)) {
-            const property = fieldPropertyMapping[field];
-            sourceProperties[property] = value;
-        }
-
-        const thing = Reflect.construct(thingClass, []);
-
-        withAggregate({message: `Errors applying ${color.green(thingClass.name)} properties`}, ({ call }) => {
-            for (const [ property, value ] of Object.entries(sourceProperties)) {
-                call(() => (thing[property] = value));
-            }
-        });
-
-        return thing;
+  };
+
+  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({
+      message: `Errors processing ${constructorPart}` + namePart,
     });
-}
-
-makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends Error {
-    constructor(fields) {
-        super(`Unknown fields present: ${fields.join(', ')}`);
-        this.fields = fields;
-    }
-};
 
-export const processAlbumDocument = makeProcessDocument(Album, {
-    fieldTransformations: {
-        'Artists': parseContributors,
-        'Cover Artists': parseContributors,
-        'Default Track Cover Artists': parseContributors,
-        'Wallpaper Artists': parseContributors,
-        'Banner Artists': parseContributors,
+    const documentEntries = Object.entries(document)
+      .filter(([field]) => !ignoredFields.includes(field));
 
-        'Date': value => new Date(value),
-        'Date Added': value => new Date(value),
-        'Cover Art Date': value => new Date(value),
-        'Default Track Cover Art Date': value => new Date(value),
+    const skippedFields = new Set();
 
-        'Banner Dimensions': parseDimensions,
+    const unknownFields = documentEntries
+      .map(([field]) => field)
+      .filter((field) => !knownFields.includes(field));
 
-        'Additional Files': parseAdditionalFiles,
-    },
+    if (!empty(unknownFields)) {
+      aggregate.push(new UnknownFieldsError(unknownFields));
 
-    propertyFieldMapping: {
-        name: 'Album',
-
-        color: 'Color',
-        directory: 'Directory',
-        urls: 'URLs',
-
-        artistContribsByRef: 'Artists',
-        coverArtistContribsByRef: 'Cover Artists',
-        trackCoverArtistContribsByRef: 'Default Track Cover Artists',
-
-        coverArtFileExtension: 'Cover Art File Extension',
-        trackCoverArtFileExtension: 'Track Art File Extension',
+      for (const field of unknownFields) {
+        skippedFields.add(field);
+      }
+    }
 
-        wallpaperArtistContribsByRef: 'Wallpaper Artists',
-        wallpaperStyle: 'Wallpaper Style',
-        wallpaperFileExtension: 'Wallpaper File Extension',
+    const presentFields = Object.keys(document);
 
-        bannerArtistContribsByRef: 'Banner Artists',
-        bannerStyle: 'Banner Style',
-        bannerFileExtension: 'Banner File Extension',
-        bannerDimensions: 'Banner Dimensions',
+    const fieldCombinationErrors = [];
 
-        date: 'Date',
-        trackArtDate: 'Default Track Cover Art Date',
-        coverArtDate: 'Cover Art Date',
-        dateAddedToWiki: 'Date Added',
+    for (const {message, fields} of invalidFieldCombinations) {
+      const fieldsPresent =
+        presentFields.filter(field => fields.includes(field));
 
-        hasCoverArt: 'Has Cover Art',
-        hasTrackArt: 'Has Track Art',
-        hasTrackNumbers: 'Has Track Numbers',
-        isMajorRelease: 'Major Release',
-        isListedOnHomepage: 'Listed on Homepage',
+      if (fieldsPresent.length >= 2) {
+        const filteredDocument =
+          filterProperties(
+            document,
+            fieldsPresent,
+            {preserveOriginalOrder: true});
 
-        groupsByRef: 'Groups',
-        artTagsByRef: 'Art Tags',
-        commentary: 'Commentary',
+        fieldCombinationErrors.push(
+          new FieldCombinationError(filteredDocument, message));
 
-        additionalFiles: 'Additional Files',
+        for (const field of Object.keys(filteredDocument)) {
+          skippedFields.add(field);
+        }
+      }
     }
-});
-
-export const processTrackGroupDocument = makeProcessDocument(TrackGroup, {
-    fieldTransformations: {
-        'Date Originally Released': value => new Date(value),
-    },
 
-    propertyFieldMapping: {
-        name: 'Group',
-        color: 'Color',
-        dateOriginallyReleased: 'Date Originally Released',
+    if (!empty(fieldCombinationErrors)) {
+      aggregate.push(new FieldCombinationAggregateError(fieldCombinationErrors));
     }
-});
-
-export const processTrackDocument = makeProcessDocument(Track, {
-    fieldTransformations: {
-        'Duration': getDurationInSeconds,
 
-        'Date First Released': value => new Date(value),
-        'Cover Art Date': value => new Date(value),
+    const fieldValues = {};
 
-        'Artists': parseContributors,
-        'Contributors': parseContributors,
-        'Cover Artists': parseContributors,
+    for (const [field, documentValue] of documentEntries) {
+      if (skippedFields.has(field)) continue;
 
-        'Additional Files': parseAdditionalFiles,
-    },
+      // This variable would like to certify itself as "not into capitalism".
+      let propertyValue =
+        (fieldSpecs[field].transform
+          ? fieldSpecs[field].transform(documentValue)
+          : documentValue);
 
-    propertyFieldMapping: {
-        name: 'Track',
+      // 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);
 
-        directory: 'Directory',
-        duration: 'Duration',
-        urls: 'URLs',
+        propertyValue =
+          propertyValue.filter(item => item !== null);
 
-        coverArtDate: 'Cover Art Date',
-        coverArtFileExtension: 'Cover Art File Extension',
-        dateFirstReleased: 'Date First Released',
-        hasCoverArt: 'Has Cover Art',
-        hasURLs: 'Has URLs',
+        const isEmpty = empty(propertyValue);
 
-        referencedTracksByRef: 'Referenced Tracks',
-        artistContribsByRef: 'Artists',
-        contributorContribsByRef: 'Contributors',
-        coverArtistContribsByRef: 'Cover Artists',
-        artTagsByRef: 'Art Tags',
-        originalReleaseTrackByRef: 'Originally Released As',
-
-        commentary: 'Commentary',
-        lyrics: 'Lyrics',
-
-        additionalFiles: 'Additional Files',
-    },
+        // 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;
+        }
+      }
 
-    ignoredFields: ['Sampled Tracks']
-});
+      fieldValues[field] = propertyValue;
+    }
 
-export const processArtistDocument = makeProcessDocument(Artist, {
-    propertyFieldMapping: {
-        name: 'Artist',
+    const thing = Reflect.construct(thingConstructor, []);
 
-        directory: 'Directory',
-        urls: 'URLs',
-        hasAvatar: 'Has Avatar',
-        avatarFileExtension: 'Avatar File Extension',
+    const fieldValueErrors = [];
 
-        aliasNames: 'Aliases',
+    for (const [field, value] of Object.entries(fieldValues)) {
+      const {property} = fieldSpecs[field];
 
-        contextNotes: 'Context Notes'
-    },
+      try {
+        thing[property] = value;
+      } catch (caughtError) {
+        skippedFields.add(field);
+        fieldValueErrors.push(new FieldValueError(
+          field, value, {cause: caughtError}));
+      }
+    }
 
-    ignoredFields: ['Dead URLs']
-});
+    if (!empty(fieldValueErrors)) {
+      aggregate.push(new FieldValueAggregateError(
+        fieldValueErrors, thingConstructor));
+    }
 
-export const processFlashDocument = makeProcessDocument(Flash, {
-    fieldTransformations: {
-        'Date': value => new Date(value),
+    if (skippedFields.size >= 1) {
+      aggregate.push(
+        new SkippedFieldsSummaryError(
+          filterProperties(
+            document,
+            Array.from(skippedFields),
+            {preserveOriginalOrder: true})));
+    }
 
-        'Contributors': parseContributors,
-    },
+    return {thing, aggregate};
+  });
+}
 
-    propertyFieldMapping: {
-        name: 'Flash',
+export class UnknownFieldsError extends Error {
+  constructor(fields) {
+    super(`Unknown fields ignored: ${fields.map(field => colors.red(field)).join(', ')}`);
+    this.fields = fields;
+  }
+}
 
-        directory: 'Directory',
-        page: 'Page',
-        date: 'Date',
-        coverArtFileExtension: 'Cover Art File Extension',
+export class FieldCombinationAggregateError extends AggregateError {
+  constructor(errors) {
+    super(errors, `Invalid field combinations - all involved fields ignored`);
+  }
+}
 
-        featuredTracksByRef: 'Featured Tracks',
-        contributorContribsByRef: 'Contributors',
-        urls: 'URLs'
-    },
-});
+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),
+    });
 
-export const processFlashActDocument = makeProcessDocument(FlashAct, {
-    propertyFieldMapping: {
-        name: 'Act',
-        color: 'Color',
-        anchor: 'Anchor',
-        jump: 'Jump',
-        jumpColor: 'Jump Color'
-    }
-});
-
-export const processNewsEntryDocument = makeProcessDocument(NewsEntry, {
-    fieldTransformations: {
-        'Date': value => new Date(value)
-    },
-
-    propertyFieldMapping: {
-        name: 'Name',
-        directory: 'Directory',
-        date: 'Date',
-        content: 'Content',
-    }
-});
-
-export const processArtTagDocument = makeProcessDocument(ArtTag, {
-    propertyFieldMapping: {
-        name: 'Tag',
-        directory: 'Directory',
-        color: 'Color',
-        isContentWarning: 'Is CW'
-    }
-});
-
-export const processGroupDocument = makeProcessDocument(Group, {
-    propertyFieldMapping: {
-        name: 'Group',
-        directory: 'Directory',
-        description: 'Description',
-        urls: 'URLs',
-    }
-});
+    this.fields = fields;
+  }
+}
 
-export const processGroupCategoryDocument = makeProcessDocument(GroupCategory, {
-    propertyFieldMapping: {
-        name: 'Category',
-        color: 'Color',
-    }
-});
+export class FieldValueAggregateError extends AggregateError {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
 
-export const processStaticPageDocument = makeProcessDocument(StaticPage, {
-    propertyFieldMapping: {
-        name: 'Name',
-        nameShort: 'Short Name',
-        directory: 'Directory',
+  constructor(errors, thingConstructor) {
+    const constructorText =
+      colors.green(thingConstructor.name);
 
-        content: 'Content',
-        stylesheet: 'Style',
+    super(
+      errors,
+      `Errors processing field values for ${constructorText}`);
+  }
+}
 
-        showInNavigationBar: 'Show in Navigation Bar'
-    }
-});
-
-export const processWikiInfoDocument = makeProcessDocument(WikiInfo, {
-    propertyFieldMapping: {
-        name: 'Name',
-        nameShort: 'Short Name',
-        color: 'Color',
-        description: 'Description',
-        footerContent: 'Footer Content',
-        defaultLanguage: 'Default Language',
-        canonicalBase: 'Canonical Base',
-        divideTrackListsByGroupsByRef: 'Divide Track Lists By Groups',
-        enableFlashesAndGames: 'Enable Flashes & Games',
-        enableListings: 'Enable Listings',
-        enableNews: 'Enable News',
-        enableArtTagUI: 'Enable Art Tag UI',
-        enableGroupUI: 'Enable Group UI',
-    }
-});
+export class FieldValueError extends Error {
+  constructor(field, value, options) {
+    const fieldText =
+      colors.green(`"${field}"`);
 
-export const processHomepageLayoutDocument = makeProcessDocument(HomepageLayout, {
-    propertyFieldMapping: {
-        sidebarContent: 'Sidebar Content'
-    },
+    const valueText =
+      inspect(value, {maxStringLength: 40});
 
-    ignoredFields: ['Homepage']
-});
+    super(
+      `Failed to set ${fieldText} field to ${valueText}`,
+      options);
+  }
+}
 
-export function makeProcessHomepageLayoutRowDocument(rowClass, spec) {
-    return makeProcessDocument(rowClass, {
-        ...spec,
+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.`)));
+  }
+}
 
-        propertyFieldMapping: {
-            name: 'Row',
-            color: 'Color',
-            type: 'Type',
-            ...spec.propertyFieldMapping,
-        }
-    });
+export function parseDate(date) {
+  return new Date(date);
 }
 
-export const homepageLayoutRowTypeProcessMapping = {
-    albums: makeProcessHomepageLayoutRowDocument(HomepageLayoutAlbumsRow, {
-        propertyFieldMapping: {
-            sourceGroupByRef: 'Group',
-            countAlbumsFromGroup: 'Count',
-            sourceAlbumsByRef: 'Albums',
-            actionLinks: 'Actions'
-        }
-    })
-};
+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 function processHomepageLayoutRowDocument(document) {
-    const type = document['Type'];
+export function parseAdditionalFiles(array) {
+  if (!Array.isArray(array)) {
+    // Error will be caught when validating against whatever this value is
+    return array;
+  }
+
+  return array.map((item) => ({
+    title: item['Title'],
+    description: item['Description'] ?? null,
+    files: item['Files'],
+  }));
+}
 
-    const match = Object.entries(homepageLayoutRowTypeProcessMapping)
-        .find(([ key ]) => key === type);
+export const extractAccentRegex =
+  /^(?<main>.*?)(?: \((?<accent>.*)\))?$/;
 
-    if (!match) {
-        throw new TypeError(`No processDocument function for row type ${type}!`);
-    }
+export const extractPrefixAccentRegex =
+  /^(?:\((?<accent>.*)\) )?(?<main>.*?)$/;
 
-    return match[1](document);
-}
+export function parseContributors(contributionStrings) {
+  // If this isn't something we can parse, just return it as-is.
+  // The Thing object's validators will handle the data error better
+  // than we're able to here.
+  if (!Array.isArray(contributionStrings)) {
+    return contributionStrings;
+  }
 
-// --> Utilities shared across document parsing functions
+  return contributionStrings.map(item => {
+    if (typeof item === 'object' && item['Who'])
+      return {who: item['Who'], what: item['What'] ?? null};
 
-export function getDurationInSeconds(string) {
-    if (typeof string === 'number') {
-        return string;
-    }
+    if (typeof item !== 'string') return item;
 
-    if (typeof string !== 'string') {
-        throw new TypeError(`Expected a string or number, got ${string}`);
-    }
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
 
-    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
-    }
+    return {
+      who: match.groups.main,
+      what: match.groups.accent ?? null,
+    };
+  });
 }
 
-export function parseAdditionalFiles(array) {
-    if (!array) return null;
-    if (!Array.isArray(array)) {
-        // Error will be caught when validating against whatever this value is
-        return array;
-    }
+export function parseAdditionalNames(additionalNameStrings) {
+  if (!Array.isArray(additionalNameStrings)) {
+    return additionalNameStrings;
+  }
 
-    return array.map(item => ({
-        title: item['Title'],
-        description: item['Description'] ?? null,
-        files: item['Files']
-    }));
-}
+  return additionalNameStrings.map(item => {
+    if (typeof item === 'object' && item['Name'])
+      return {name: item['Name'], annotation: item['Annotation'] ?? null};
 
-export function parseCommentary(text) {
-    if (text) {
-        const lines = String(text).split('\n');
-        if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) {
-            return {error: `An entry is missing commentary citation: "${lines[0].slice(0, 40)}..."`};
-        }
-        return text;
-    } else {
-        return null;
-    }
-}
+    if (typeof item !== 'string') return item;
 
-export function parseContributors(contributors) {
-    if (!contributors) {
-        return null;
-    }
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
 
-    if (contributors.length === 1 && contributors[0].startsWith('<i>')) {
-        const arr = [];
-        arr.textContent = contributors[0];
-        return arr;
-    }
+    return {
+      name: match.groups.main,
+      annotation: match.groups.accent ?? null,
+    };
+  });
+}
 
-    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};
-    });
+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 badContributor = contributors.find(val => typeof val === 'string');
-    if (badContributor) {
-        return {error: `An entry has an incorrectly formatted contributor, "${badContributor}".`};
-    }
+  const parts = string.split(/[x,* ]+/g);
 
-    if (contributors.length === 1 && contributors[0].who === 'none') {
-        return null;
-    }
+  if (parts.length !== 2) {
+    throw new Error(`Invalid dimensions: ${string} (expected "width & height")`);
+  }
 
-    return contributors;
-}
+  const nums = parts.map((part) => Number(part.trim()));
 
-function parseDimensions(string) {
-    if (!string) {
-        return null;
-    }
+  if (nums.includes(NaN)) {
+    throw new Error(`Invalid dimensions: ${string} (couldn't parse as numbers)`);
+  }
 
-    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;
+  return nums;
 }
 
-// --> Data repository loading functions and descriptors
-
 // 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'),
+  // 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.
@@ -624,670 +523,490 @@ export const documentModes = {
 //   them to each other, setting additional properties, etc). Input argument
 //   format depends on documentMode.
 //
-export const dataSteps = [
-    {
-        title: `Process wiki info file`,
-        file: WIKI_INFO_FILE,
+export const getDataSteps = () => {
+  const steps = [];
 
-        documentMode: documentModes.oneDocumentTotal,
-        processDocument: processWikiInfoDocument,
+  for (const thingConstructor of Object.values(thingConstructors)) {
+    const getSpecFn = thingConstructor[Thing.getYamlLoadingSpec];
+    if (!getSpecFn) continue;
 
-        save(wikiInfo) {
-            if (!wikiInfo) {
-                return;
-            }
+    steps.push(getSpecFn({
+      documentModes,
+      thingConstructors,
+    }));
+  }
 
-            return {wikiInfo};
+  sortByName(steps, {getName: step => step.title});
+
+  return steps;
+};
+
+export async function loadAndProcessDataDocuments({dataPath}) {
+  const processDataAggregate = openAggregate({
+    message: `Errors processing data files`,
+  });
+  const wikiDataResult = {};
+
+  function decorateErrorWithFile(fn) {
+    return decorateErrorWithAnnotation(fn,
+      (caughtError, firstArg) =>
+        annotateErrorWithFile(
+          caughtError,
+          path.relative(
+            dataPath,
+            (typeof firstArg === 'object'
+              ? firstArg.file
+              : firstArg))));
+  }
+
+  function asyncDecorateErrorWithFile(fn) {
+    return decorateErrorWithFile(fn).async;
+  }
+
+  for (const dataStep of getDataSteps()) {
+    await processDataAggregate.nestAsync(
+      {
+        message: `Errors during data step: ${colors.bright(dataStep.title)}`,
+        translucent: true,
+      },
+      async ({call, callAsync, map, mapAsync, push}) => {
+        const {documentMode} = dataStep;
+
+        if (!Object.values(documentModes).includes(documentMode)) {
+          throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
         }
-    },
-
-    {
-        title: `Process album files`,
-        files: async dataPath => (
-            (await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY), {
-                filter: f => path.extname(f) === '.yaml',
-                joinParentDirectory: false
-            })).map(file => path.join(DATA_ALBUM_DIRECTORY, file))),
-
-        documentMode: documentModes.headerAndEntries,
-        processHeaderDocument: processAlbumDocument,
-        processEntryDocument(document) {
-            return ('Group' in document
-                ? processTrackGroupDocument(document)
-                : processTrackDocument(document));
-        },
-
-        save(results) {
-            const albumData = [];
-            const trackData = [];
-
-            for (const { header: album, entries } of results) {
-                // We can't mutate an array once it's set as a property
-                // value, so prepare the tracks and track groups that will
-                // show up in a track list all the way before actually
-                // applying them.
-                const trackGroups = [];
-                let currentTracksByRef = null;
-                let currentTrackGroup = null;
-
-                const albumRef = Thing.getReference(album);
-
-                function closeCurrentTrackGroup() {
-                    if (currentTracksByRef) {
-                        let trackGroup;
-
-                        if (currentTrackGroup) {
-                            trackGroup = currentTrackGroup;
-                        } else {
-                            trackGroup = new TrackGroup();
-                            trackGroup.name = `Default Track Group`;
-                            trackGroup.isDefaultTrackGroup = true;
-                        }
-
-                        trackGroup.album = album;
-                        trackGroup.tracksByRef = currentTracksByRef;
-                        trackGroups.push(trackGroup);
-                    }
-                }
 
-                for (const entry of entries) {
-                    if (entry instanceof TrackGroup) {
-                        closeCurrentTrackGroup();
-                        currentTracksByRef = [];
-                        currentTrackGroup = entry;
-                        continue;
-                    }
+        // Hear me out, it's been like 1200 years since I wrote the rest of
+        // this beautifully error-containing code and I don't know how to
+        // integrate this nicely. So I'm just returning the result and the
+        // error that should be thrown. Yes, we're back in callback hell,
+        // just without the callbacks. Thank you.
+        const filterBlankDocuments = documents => {
+          const aggregate = openAggregate({
+            message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`,
+          });
+
+          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('')));
+            }
+          }
 
-                    trackData.push(entry);
+          return {documents: filteredDocuments, aggregate};
+        };
 
-                    entry.dataSourceAlbumByRef = albumRef;
+        const processDocument = (document, thingClassOrFn) => {
+          const thingClass =
+            (thingClassOrFn.prototype instanceof Thing
+              ? thingClassOrFn
+              : thingClassOrFn(document));
 
-                    const trackRef = Thing.getReference(entry);
-                    if (currentTracksByRef) {
-                        currentTracksByRef.push(trackRef);
-                    } else {
-                        currentTracksByRef = [trackRef];
-                    }
-                }
+          if (typeof thingClass !== 'function') {
+            throw new Error(`Expected a thing class, got ${typeAppearance(thingClass)}`);
+          }
 
-                closeCurrentTrackGroup();
+          if (!(thingClass.prototype instanceof Thing)) {
+            throw new Error(`Expected a thing class, got ${thingClass.name}`);
+          }
 
-                album.trackGroups = trackGroups;
-                albumData.push(album);
-            }
+          const spec = thingClass[Thing.yamlDocumentSpec];
 
-            return {albumData, trackData};
-        }
-    },
-
-    {
-        title: `Process artists file`,
-        file: ARTIST_DATA_FILE,
-
-        documentMode: documentModes.allInOne,
-        processDocument: processArtistDocument,
-
-        save(results) {
-            const artistData = results;
-
-            const artistAliasData = results.flatMap(artist => {
-                const origRef = Thing.getReference(artist);
-                return (artist.aliasNames?.map(name => {
-                    const alias = new Artist();
-                    alias.name = name;
-                    alias.isAlias = true;
-                    alias.aliasedArtistRef = origRef;
-                    alias.artistData = artistData;
-                    return alias;
-                }) ?? []);
-            });
-
-            return {artistData, artistAliasData};
-        }
-    },
-
-    // TODO: WD.wikiInfo.enableFlashesAndGames &&
-    {
-        title: `Process flashes file`,
-        file: FLASH_DATA_FILE,
-
-        documentMode: documentModes.allInOne,
-        processDocument(document) {
-            return ('Act' in document
-                ? processFlashActDocument(document)
-                : processFlashDocument(document));
-        },
-
-        save(results) {
-            let flashAct;
-            let flashesByRef = [];
-
-            if (results[0] && !(results[0] instanceof FlashAct)) {
-                throw new Error(`Expected an act at top of flash data file`);
-            }
+          if (!spec) {
+            throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
+          }
 
-            for (const thing of results) {
-                if (thing instanceof FlashAct) {
-                    if (flashAct) {
-                        Object.assign(flashAct, {flashesByRef});
-                    }
+          // TODO: Making a function to only call it just like that is
+          // obviously pretty jank! It should be created once per data step.
+          const fn = makeProcessDocument(thingClass, spec);
+          return fn(document);
+        };
 
-                    flashAct = thing;
-                    flashesByRef = [];
+        if (
+          documentMode === documentModes.allInOne ||
+          documentMode === documentModes.oneDocumentTotal
+        ) {
+          if (!dataStep.file) {
+            throw new Error(`Expected 'file' property for ${documentMode.toString()}`);
+          }
+
+          const file = path.join(
+            dataPath,
+            typeof dataStep.file === 'function'
+              ? await callAsync(dataStep.file, dataPath)
+              : dataStep.file);
+
+          const statResult = await callAsync(() =>
+            stat(file).then(
+              () => true,
+              error => {
+                if (error.code === 'ENOENT') {
+                  return false;
                 } else {
-                    flashesByRef.push(Thing.getReference(thing));
+                  throw error;
                 }
-            }
+              }));
 
-            if (flashAct) {
-                Object.assign(flashAct, {flashesByRef});
-            }
+          if (statResult === false) {
+            const saveResult = call(dataStep.save, {
+              [documentModes.allInOne]: [],
+              [documentModes.oneDocumentTotal]: {},
+            }[documentMode]);
 
-            const flashData = results.filter(x => x instanceof Flash);
-            const flashActData = results.filter(x => x instanceof FlashAct);
+            if (!saveResult) return;
 
-            return {flashData, flashActData};
-        }
-    },
+            Object.assign(wikiDataResult, saveResult);
 
-    {
-        title: `Process groups file`,
-        file: GROUP_DATA_FILE,
+            return;
+          }
 
-        documentMode: documentModes.allInOne,
-        processDocument(document) {
-            return ('Category' in document
-                ? processGroupCategoryDocument(document)
-                : processGroupDocument(document));
-        },
+          const readResult = await callAsync(readFile, file, 'utf-8');
 
-        save(results) {
-            let groupCategory;
-            let groupsByRef = [];
+          if (!readResult) {
+            return;
+          }
 
-            if (results[0] && !(results[0] instanceof GroupCategory)) {
-                throw new Error(`Expected a category at top of group data file`);
-            }
+          let processResults;
 
-            for (const thing of results) {
-                if (thing instanceof GroupCategory) {
-                    if (groupCategory) {
-                        Object.assign(groupCategory, {groupsByRef});
-                    }
+          switch (documentMode) {
+            case documentModes.oneDocumentTotal: {
+              const yamlResult = call(yaml.load, readResult);
 
-                    groupCategory = thing;
-                    groupsByRef = [];
-                } else {
-                    groupsByRef.push(Thing.getReference(thing));
-                }
-            }
+              if (!yamlResult) {
+                processResults = null;
+                break;
+              }
 
-            if (groupCategory) {
-                Object.assign(groupCategory, {groupsByRef});
-            }
+              const {thing, aggregate} =
+                processDocument(yamlResult, dataStep.documentThing);
 
-            const groupData = results.filter(x => x instanceof Group);
-            const groupCategoryData = results.filter(x => x instanceof GroupCategory);
+              processResults = thing;
 
-            return {groupData, groupCategoryData};
-        }
-    },
+              call(() => aggregate.close());
 
-    {
-        title: `Process homepage layout file`,
-        files: [HOMEPAGE_LAYOUT_DATA_FILE],
+              break;
+            }
 
-        documentMode: documentModes.headerAndEntries,
-        processHeaderDocument: processHomepageLayoutDocument,
-        processEntryDocument: processHomepageLayoutRowDocument,
+            case documentModes.allInOne: {
+              const yamlResults = call(yaml.loadAll, readResult);
 
-        save(results) {
-            if (!results[0]) {
+              if (!yamlResults) {
+                processResults = [];
                 return;
-            }
+              }
 
-            const { header: homepageLayout, entries: rows } = results[0];
-            Object.assign(homepageLayout, {rows});
-            return {homepageLayout};
-        }
-    },
+              const {documents, aggregate: filterAggregate} =
+                filterBlankDocuments(yamlResults);
 
-    // TODO: WD.wikiInfo.enableNews &&
-    {
-        title: `Process news data file`,
-        file: NEWS_DATA_FILE,
+              call(filterAggregate.close);
 
-        documentMode: documentModes.allInOne,
-        processDocument: processNewsEntryDocument,
+              processResults = [];
 
-        save(newsData) {
-            sortByDate(newsData);
-            newsData.reverse();
+              map(documents, decorateErrorWithIndex(document => {
+                const {thing, aggregate} =
+                  processDocument(document, dataStep.documentThing);
 
-            return {newsData};
-        }
-    },
+                processResults.push(thing);
+                aggregate.close();
+              }), {message: `Errors processing documents`});
 
-    {
-        title: `Process art tags file`,
-        file: ART_TAG_DATA_FILE,
+              break;
+            }
+          }
 
-        documentMode: documentModes.allInOne,
-        processDocument: processArtTagDocument,
+          if (!processResults) return;
 
-        save(artTagData) {
-            artTagData.sort(sortByName);
+          const saveResult = call(dataStep.save, processResults);
 
-            return {artTagData};
-        }
-    },
+          if (!saveResult) return;
 
-    {
-        title: `Process static pages file`,
-        file: STATIC_PAGE_DATA_FILE,
+          Object.assign(wikiDataResult, saveResult);
 
-        documentMode: documentModes.allInOne,
-        processDocument: processStaticPageDocument,
+          return;
+        }
 
-        save(staticPageData) {
-            return {staticPageData};
+        if (!dataStep.files) {
+          throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
         }
-    },
-];
 
-export async function loadAndProcessDataDocuments({
-    dataPath,
-}) {
-    const processDataAggregate = openAggregate({message: `Errors processing data files`});
-    const wikiDataResult = {};
+        const filesFromDataStep =
+          (typeof dataStep.files === 'function'
+            ? await callAsync(() =>
+                dataStep.files(dataPath).then(
+                  files => files,
+                  error => {
+                    if (error.code === 'ENOENT') {
+                      return [];
+                    } else {
+                      throw error;
+                    }
+                  }))
+            : dataStep.files);
 
-    function decorateErrorWithFile(fn) {
-        return (x, index, array) => {
+        const filesUnderDataPath =
+          filesFromDataStep
+            .map(file => path.join(dataPath, file));
+
+        const yamlResults = [];
+
+        await mapAsync(filesUnderDataPath, {message: `Errors loading data files`},
+          asyncDecorateErrorWithFile(async file => {
+            let contents;
             try {
-                return fn(x, index, array);
-            } catch (error) {
-                error.message += (
-                    (error.message.includes('\n') ? '\n' : ' ') +
-                    `(file: ${color.bright(color.blue(path.relative(dataPath, x.file)))})`
-                );
-                throw error;
+              contents = await readFile(file, 'utf-8');
+            } catch (caughtError) {
+              throw new Error(`Failed to read data file`, {cause: caughtError});
             }
-        };
-    }
 
-    for (const dataStep of dataSteps) {
-        await processDataAggregate.nestAsync(
-            {message: `Errors during data step: ${dataStep.title}`},
-            async ({call, callAsync, map, mapAsync, nest}) => {
-                const { documentMode } = dataStep;
+            let documents;
+            try {
+              documents = yaml.loadAll(contents);
+            } catch (caughtError) {
+              throw new Error(`Failed to parse valid YAML`, {cause: caughtError});
+            }
 
-                if (!(Object.values(documentModes).includes(documentMode))) {
-                    throw new Error(`Invalid documentMode: ${documentMode.toString()}`);
-                }
+            const {documents: filteredDocuments, aggregate: filterAggregate} =
+              filterBlankDocuments(documents);
 
-                if (documentMode === documentModes.allInOne || documentMode === documentModes.oneDocumentTotal) {
-                    if (!dataStep.file) {
-                        throw new Error(`Expected 'file' property for ${documentMode.toString()}`);
-                    }
+            try {
+              filterAggregate.close();
+            } catch (caughtError) {
+              // Blank documents aren't a critical error, they're just something
+              // that should be noted - the (filtered) documents still get pushed.
+              const pathToFile = path.relative(dataPath, file);
+              annotateErrorWithFile(caughtError, pathToFile);
+              push(caughtError);
+            }
 
-                    const file = path.join(dataPath,
-                        (typeof dataStep.file === 'function'
-                            ? await callAsync(dataStep.file, dataPath)
-                            : dataStep.file));
+            yamlResults.push({file, documents: filteredDocuments});
+          }));
 
-                    const readResult = await callAsync(readFile, file, 'utf-8');
+        const processResults = [];
 
-                    if (!readResult) {
-                        return;
-                    }
+        switch (documentMode) {
+          case documentModes.headerAndEntries:
+            map(yamlResults, {message: `Errors processing documents in data files`, translucent: true},
+              decorateErrorWithFile(({documents}) => {
+                const headerDocument = documents[0];
+                const entryDocuments = documents.slice(1).filter(Boolean);
 
-                    const yamlResult = (documentMode === documentModes.oneDocumentTotal
-                        ? call(yaml.load, readResult)
-                        : call(yaml.loadAll, readResult));
+                if (!headerDocument)
+                  throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`);
 
-                    if (!yamlResult) {
-                        return;
-                    }
+                withAggregate({message: `Errors processing documents`}, ({push}) => {
+                  const {thing: headerObject, aggregate: headerAggregate} =
+                    processDocument(headerDocument, dataStep.headerDocumentThing);
 
-                    let processResults;
+                  try {
+                    headerAggregate.close();
+                  } catch (caughtError) {
+                    caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`;
+                    push(caughtError);
+                  }
 
-                    if (documentMode === documentModes.oneDocumentTotal) {
-                        nest({message: `Errors processing document`}, ({ call }) => {
-                            processResults = call(dataStep.processDocument, yamlResult);
-                        });
-                    } else {
-                        const { result, aggregate } = mapAggregate(
-                            yamlResult,
-                            decorateErrorWithIndex(dataStep.processDocument),
-                            {message: `Errors processing documents`}
-                        );
-                        processResults = result;
-                        call(aggregate.close);
-                    }
+                  const entryObjects = [];
 
-                    if (!processResults) return;
+                  for (let index = 0; index < entryDocuments.length; index++) {
+                    const entryDocument = entryDocuments[index];
 
-                    const saveResult = call(dataStep.save, processResults);
+                    const {thing: entryObject, aggregate: entryAggregate} =
+                      processDocument(entryDocument, dataStep.entryDocumentThing);
 
-                    if (!saveResult) return;
+                    entryObjects.push(entryObject);
 
-                    Object.assign(wikiDataResult, saveResult);
+                    try {
+                      entryAggregate.close();
+                    } catch (caughtError) {
+                      caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`;
+                      push(caughtError);
+                    }
+                  }
 
-                    return;
-                }
+                  processResults.push({
+                    header: headerObject,
+                    entries: entryObjects,
+                  });
+                });
+              }));
+            break;
 
-                if (!dataStep.files) {
-                    throw new Error(`Expected 'files' property for ${documentMode.toString()}`);
-                }
+          case documentModes.onePerFile:
+            map(yamlResults, {message: `Errors processing data files as valid documents`},
+              decorateErrorWithFile(({documents}) => {
+                if (documents.length > 1)
+                  throw new Error(`Only expected one document to be present per file, got ${documents.length} here`);
 
-                const files = (
-                    (typeof dataStep.files === 'function'
-                        ? await callAsync(dataStep.files, dataPath)
-                        : dataStep.files)
-                    .map(file => path.join(dataPath, file)));
-
-                const readResults = await mapAsync(
-                    files,
-                    file => (readFile(file, 'utf-8')
-                        .then(contents => ({file, contents}))),
-                    {message: `Errors reading data files`});
-
-                const yamlResults = map(
-                    readResults,
-                    decorateErrorWithFile(
-                        ({ file, contents }) => ({file, documents: yaml.loadAll(contents)})),
-                    {message: `Errors parsing data files as valid YAML`});
-
-                let processResults;
-
-                if (documentMode === documentModes.headerAndEntries) {
-                    nest({message: `Errors processing data files as valid documents`}, ({ call, map }) => {
-                        processResults = [];
-
-                        yamlResults.forEach(({ file, documents }) => {
-                            const [ headerDocument, ...entryDocuments ] = documents;
-
-                            const header = call(
-                                decorateErrorWithFile(
-                                    ({ document }) => dataStep.processHeaderDocument(document)),
-                                {file, document: headerDocument});
-
-                            // Don't continue processing files whose header
-                            // document is invalid - the entire file is excempt
-                            // from data in this case.
-                            if (!header) {
-                                return;
-                            }
-
-                            const entries = map(
-                                entryDocuments.map(document => ({file, document})),
-                                decorateErrorWithFile(
-                                    decorateErrorWithIndex(
-                                        ({ document }) => dataStep.processEntryDocument(document))),
-                                {message: `Errors processing entry documents`});
-
-                            // Entries may be incomplete (i.e. any errored
-                            // documents won't have a processed output
-                            // represented here) - this is intentional! By
-                            // principle, partial output is preferred over
-                            // erroring an entire file.
-                            processResults.push({header, entries});
-                        });
-                    });
-                }
+                if (empty(documents) || !documents[0])
+                  throw new Error(`Expected a document, this file is empty`);
 
-                if (documentMode === documentModes.onePerFile) {
-                    nest({message: `Errors processing data files as valid documents`}, ({ call, map }) => {
-                        processResults = [];
-
-                        yamlResults.forEach(({ file, documents }) => {
-                            if (documents.length > 1) {
-                                call(decorateErrorWithFile(() => {
-                                    throw new Error(`Only expected one document to be present per file`);
-                                }));
-                                return;
-                            }
-
-                            const result = call(
-                                decorateErrorWithFile(
-                                    ({ document }) => dataStep.processDocument(document)),
-                                {file, document: documents[0]});
-
-                            if (!result) {
-                                return;
-                            }
-
-                            processResults.push(result);
-                        });
-                    });
-                }
+                const {thing, aggregate} =
+                  processDocument(documents[0], dataStep.documentThing);
 
-                const saveResult = call(dataStep.save, processResults);
+                processResults.push(thing);
+                aggregate.close();
+              }));
+            break;
+        }
 
-                if (!saveResult) return;
+        const saveResult = call(dataStep.save, processResults);
 
-                Object.assign(wikiDataResult, saveResult);
-            });
-    }
+        if (!saveResult) return;
 
-    return {
-        aggregate: processDataAggregate,
-        result: wikiDataResult
-    };
+        Object.assign(wikiDataResult, saveResult);
+      }
+    );
+  }
+
+  return {
+    aggregate: processDataAggregate,
+    result: wikiDataResult,
+  };
 }
 
 // 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).
+// of which are required for page HTML generation and other expected behavior).
 export function linkWikiDataArrays(wikiData) {
-    function assignWikiData(things, ...keys) {
-        for (let i = 0; i < things.length; i++) {
-            for (let j = 0; j < keys.length; j++) {
-                const key = keys[j];
-                things[i][key] = wikiData[key];
-            }
-        }
+  const linkWikiDataSpec = new Map([
+    [wikiData.albumData, [
+      'artTagData',
+      'artistData',
+      'groupData',
+    ]],
+
+    [wikiData.artTagData, [
+      'albumData',
+      'trackData',
+    ]],
+
+    [wikiData.artistData, [
+      'albumData',
+      'artistData',
+      'flashData',
+      'trackData',
+    ]],
+
+    [wikiData.flashData, [
+      'artistData',
+      'flashActData',
+      'trackData',
+    ]],
+
+    [wikiData.flashActData, [
+      'flashData',
+      'flashSideData',
+    ]],
+
+    [wikiData.flashSideData, [
+      'flashActData',
+    ]],
+
+    [wikiData.groupData, [
+      'albumData',
+      'groupCategoryData',
+    ]],
+
+    [wikiData.groupCategoryData, [
+      'groupData',
+    ]],
+
+    [wikiData.homepageLayout?.rows, [
+      'albumData',
+      'groupData',
+    ]],
+
+    [wikiData.trackData, [
+      'albumData',
+      'artTagData',
+      'artistData',
+      'flashData',
+      'trackData',
+    ]],
+
+    [[wikiData.wikiInfo], [
+      'groupData',
+    ]],
+  ]);
+
+  for (const [things, keys] of linkWikiDataSpec.entries()) {
+    if (things === undefined) continue;
+    for (const thing of things) {
+      if (thing === undefined) continue;
+      for (const key of keys) {
+        if (!(key in wikiData)) continue;
+        thing[key] = wikiData[key];
+      }
     }
-
-    const WD = wikiData;
-
-    assignWikiData([WD.wikiInfo], 'groupData');
-
-    assignWikiData(WD.albumData, 'artistData', 'artTagData', 'groupData', 'trackData');
-    WD.albumData.forEach(album => assignWikiData(album.trackGroups, 'trackData'));
-
-    assignWikiData(WD.trackData, 'albumData', 'artistData', 'artTagData', 'flashData', 'trackData');
-    assignWikiData(WD.artistData, 'albumData', 'artistData', 'flashData', 'trackData');
-    assignWikiData(WD.groupData, 'albumData', 'groupCategoryData');
-    assignWikiData(WD.groupCategoryData, 'groupData');
-    assignWikiData(WD.flashData, 'artistData', 'flashActData', 'trackData');
-    assignWikiData(WD.flashActData, 'flashData');
-    assignWikiData(WD.artTagData, 'albumData', 'trackData');
-    assignWikiData(WD.homepageLayout.rows, 'albumData', 'groupData');
+  }
 }
 
 export function sortWikiDataArrays(wikiData) {
-    Object.assign(wikiData, {
-        albumData: sortByDate(wikiData.albumData.slice()),
-        trackData: sortByDate(wikiData.trackData.slice())
-    });
-
-    // 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);
-}
-
-// Warn about directories which are reused across more than one of the same type
-// of Thing. Directories are the unique identifier for most data objects across
-// the wiki, so we have to make sure they aren't duplicated!  This also
-// altogether filters out instances of things with duplicate directories (so if
-// two tracks share the directory "megalovania", they'll both be skipped for the
-// build, for example).
-export function filterDuplicateDirectories(wikiData) {
-    const deduplicateSpec = [
-        'albumData',
-        'artTagData',
-        'flashData',
-        'groupData',
-        'newsData',
-        'trackData',
-    ];
-
-    const aggregate = openAggregate({message: `Duplicate directories found`});
-    for (const thingDataProp of deduplicateSpec) {
-        const thingData = wikiData[thingDataProp];
-        aggregate.nest({message: `Duplicate directories found in ${color.green('wikiData.' + thingDataProp)}`}, ({ call }) => {
-            const directoryPlaces = Object.create(null);
-            const duplicateDirectories = [];
-            for (const thing of thingData) {
-                const { directory } = thing;
-                if (directory in directoryPlaces) {
-                    directoryPlaces[directory].push(thing);
-                    duplicateDirectories.push(directory);
-                } else {
-                    directoryPlaces[directory] = [thing];
-                }
-            }
-            if (!duplicateDirectories.length) return;
-            duplicateDirectories.sort((a, b) => {
-                const aL = a.toLowerCase();
-                const bL = b.toLowerCase();
-                return aL < bL ? -1 : aL > bL ? 1 : 0;
-            });
-            for (const directory of duplicateDirectories) {
-                const places = directoryPlaces[directory];
-                call(() => {
-                    throw new Error(`Duplicate directory ${color.green(directory)}:\n` +
-                        places.map(thing => ` - ` + inspect(thing)).join('\n'));
-                });
-            }
-            const allDuplicatedThings = Object.values(directoryPlaces).filter(arr => arr.length > 1).flat();
-            const filteredThings = thingData.filter(thing => !allDuplicatedThings.includes(thing));
-            wikiData[thingDataProp] = filteredThings;
-        });
-    }
-
-    // TODO: This code closes the aggregate but it generally gets closed again
-    // by the caller. This works but it might be weird to assume closing an
-    // aggregate twice is okay, maybe there's a better solution? Expose a new
-    // function on aggregates for checking if it *would* error?
-    // (i.e: errors.length > 0)
-    try {
-        aggregate.close();
-    } catch (error) {
-        // Duplicate entries were found and filtered out, resulting in altered
-        // wikiData arrays. These must be re-linked so objects receive the new
-        // data.
-        linkWikiDataArrays(wikiData);
-    }
-    return aggregate;
-}
-
-// 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) {
-    const referenceSpec = [
-        ['wikiInfo', {
-            divideTrackListsByGroupsByRef: 'group',
-        }],
-
-        ['albumData', {
-            artistContribsByRef: '_contrib',
-            coverArtistContribsByRef: '_contrib',
-            trackCoverArtistContribsByRef: '_contrib',
-            wallpaperArtistContribsByRef: '_contrib',
-            bannerArtistContribsByRef: '_contrib',
-            groupsByRef: 'group',
-            artTagsByRef: 'artTag',
-        }],
-
-        ['trackData', {
-            artistContribsByRef: '_contrib',
-            contributorContribsByRef: '_contrib',
-            coverArtistContribsByRef: '_contrib',
-            referencedTracksByRef: 'track',
-            artTagsByRef: 'artTag',
-            originalReleaseTrackByRef: 'track',
-        }],
-
-        ['groupCategoryData', {
-            groupsByRef: 'group',
-        }],
-
-        ['homepageLayout.rows', {
-            sourceGroupsByRef: 'group',
-            sourceAlbumsByRef: 'album',
-        }],
-
-        ['flashData', {
-            contributorContribsByRef: '_contrib',
-            featuredTracksByRef: 'track',
-        }],
-
-        ['flashActData', {
-            flashesByRef: 'flash',
-        }],
-    ];
-
-    function getNestedProp(obj, key) {
-        const recursive = (o, k) => (k.length === 1
-            ? o[k[0]]
-            : recursive(o[k[0]], k.slice(1)));
-        const keys = key.split(/(?<=(?<!\\)(?:\\\\)*)\./);
-        return recursive(obj, keys);
-    }
-
-    const aggregate = openAggregate({message: `Errors validating between-thing references in data`});
-    const boundFind = bindFind(wikiData, {mode: 'error'});
-    for (const [ thingDataProp, propSpec ] of referenceSpec) {
-        const thingData = getNestedProp(wikiData, thingDataProp);
-        aggregate.nest({message: `Reference errors in ${color.green('wikiData.' + thingDataProp)}`}, ({ nest }) => {
-            const things = Array.isArray(thingData) ? thingData : [thingData];
-            for (const thing of things) {
-                nest({message: `Reference errors in ${inspect(thing)}`}, ({ filter }) => {
-                    for (const [ property, findFnKey ] of Object.entries(propSpec)) {
-                        if (!thing[property]) continue;
-                        if (findFnKey === '_contrib') {
-                            thing[property] = filter(thing[property],
-                                decorateErrorWithIndex(({ who }) => {
-                                    const alias = find.artist(who, wikiData.artistAliasData, {mode: 'quiet'});
-                                    if (alias) {
-                                        const original = find.artist(alias.aliasedArtistRef, wikiData.artistData, {mode: 'quiet'});
-                                        throw new Error(`Reference ${color.red(who)} is to an alias, should be ${color.green(original.name)}`);
-                                    }
-                                    return boundFind.artist(who);
-                                }),
-                                {message: `Reference errors in contributions ${color.green(property)} (${color.green('find.artist')})`});
-                            continue;
-                        }
-                        const findFn = boundFind[findFnKey];
-                        const value = thing[property];
-                        if (Array.isArray(value)) {
-                            thing[property] = filter(value, decorateErrorWithIndex(findFn),
-                                {message: `Reference errors in property ${color.green(property)} (${color.green('find.' + findFnKey)})`});
-                        } else {
-                            nest({message: `Reference error in property ${color.green(property)} (${color.green('find.' + findFnKey)})`}, ({ call }) => {
-                                try {
-                                    call(findFn, value);
-                                } catch (error) {
-                                    thing[property] = null;
-                                    throw error;
-                                }
-                            });
-                        }
-                    }
-                });
-            }
-        });
-    }
-
-    return aggregate;
+  for (const [key, value] of Object.entries(wikiData)) {
+    if (!Array.isArray(value)) continue;
+    wikiData[key] = value.slice();
+  }
+
+  const steps = getDataSteps();
+
+  for (const step of steps) {
+    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);
 }
 
 // Utility function for loading all wiki data from the provided YAML data
@@ -1297,47 +1016,56 @@ export function filterReferenceErrors(wikiData) {
 // where reporting info about data loading isn't as relevant as during the
 // main wiki build process.
 export async function quickLoadAllFromYAML(dataPath, {
-    showAggregate: customShowAggregate = showAggregate,
-} = {}) {
-    const showAggregate = customShowAggregate;
+  bindFind,
+  getAllFindSpecs,
 
-    let wikiData;
-
-    {
-        const { aggregate, result } = await loadAndProcessDataDocuments({
-            dataPath,
-        });
+  showAggregate: customShowAggregate = showAggregate,
+}) {
+  const showAggregate = customShowAggregate;
 
-        wikiData = result;
+  let wikiData;
 
-        try {
-            aggregate.close();
-            logInfo`Loaded data without errors. (complete data)`;
-        } catch (error) {
-            showAggregate(error);
-            logWarn`Loaded data with errors. (partial data)`;
-        }
-    }
+  {
+    const {aggregate, result} = await loadAndProcessDataDocuments({dataPath});
 
-    linkWikiDataArrays(wikiData);
+    wikiData = result;
 
     try {
-        filterDuplicateDirectories(wikiData).close();
-        logInfo`No duplicate directories found. (complete data)`;
+      aggregate.close();
+      logInfo`Loaded data without errors. (complete data)`;
     } catch (error) {
-        showAggregate(error);
-        logWarn`Duplicate directories found. (partial data)`;
+      showAggregate(error);
+      logWarn`Loaded data with errors. (partial data)`;
     }
-
-    try {
-        filterReferenceErrors(wikiData).close();
-        logInfo`No reference errors found. (complete data)`;
-    } catch (error) {
-        showAggregate(error);
-        logWarn`Duplicate directories found. (partial data)`;
-    }
-
-    sortWikiDataArrays(wikiData);
-
-    return wikiData;
+  }
+
+  linkWikiDataArrays(wikiData);
+
+  try {
+    reportDuplicateDirectories(wikiData, {getAllFindSpecs});
+    logInfo`No duplicate directories found. (complete data)`;
+  } catch (error) {
+    showAggregate(error);
+    logWarn`Duplicate directories found. (partial data)`;
+  }
+
+  try {
+    filterReferenceErrors(wikiData, {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(wikiData);
+
+  return wikiData;
 }
diff --git a/src/file-size-preloader.js b/src/file-size-preloader.js
index d0807cc..4eadde7 100644
--- a/src/file-size-preloader.js
+++ b/src/file-size-preloader.js
@@ -17,84 +17,88 @@
 // This only processes files one at a time because I'm lazy and stat calls
 // are very, very fast.
 
-import { stat } from 'fs/promises';
-import { logWarn } from './util/cli.js';
+import {stat} from 'node:fs/promises';
 
-export default class FileSizePreloader {
-    #paths = [];
-    #sizes = [];
-    #loadedPathIndex = -1;
-
-    #loadingPromise = null;
-    #resolveLoadingPromise = null;
+import {logWarn} from '#cli';
 
-    loadPaths(...paths) {
-        this.#paths.push(...paths.filter(p => !this.#paths.includes(p)));
-        return this.#startLoadingPaths();
-    }
+export default class FileSizePreloader {
+  #paths = [];
+  #sizes = [];
+  #loadedPathIndex = -1;
 
-    waitUntilDoneLoading() {
-        return this.#loadingPromise ?? Promise.resolve();
-    }
+  #loadingPromise = null;
+  #resolveLoadingPromise = null;
 
-    #startLoadingPaths() {
-        if (this.#loadingPromise) {
-            return this.#loadingPromise;
-        }
+  hadErrored = false;
 
-        this.#loadingPromise = new Promise((resolve => {
-            this.#resolveLoadingPromise = resolve;
-        }));
+  loadPaths(...paths) {
+    this.#paths.push(...paths.filter((p) => !this.#paths.includes(p)));
+    return this.#startLoadingPaths();
+  }
 
-        this.#loadNextPath();
+  waitUntilDoneLoading() {
+    return this.#loadingPromise ?? Promise.resolve();
+  }
 
-        return this.#loadingPromise;
+  #startLoadingPaths() {
+    if (this.#loadingPromise) {
+      return this.#loadingPromise;
     }
 
-    async #loadNextPath() {
-        if (this.#loadedPathIndex === this.#paths.length - 1) {
-            return this.#doneLoadingPaths();
-        }
+    this.#loadingPromise = new Promise((resolve) => {
+      this.#resolveLoadingPromise = resolve;
+    });
 
-        let size;
+    this.#loadNextPath();
 
-        const path = this.#paths[this.#loadedPathIndex + 1];
+    return this.#loadingPromise;
+  }
 
-        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);
-            logWarn`Failed to process file size for ${path}: ${error.message}`;
-            return this.#loadNextPath();
-        }
-
-        this.#sizes.push(size);
-        this.#loadedPathIndex++;
-        return this.#loadNextPath();
+  async #loadNextPath() {
+    if (this.#loadedPathIndex === this.#paths.length - 1) {
+      return this.#doneLoadingPaths();
     }
 
-    #doneLoadingPaths() {
-        this.#resolveLoadingPromise();
-        this.#loadingPromise = null;
-        this.#resolveLoadingPromise = null;
-    }
+    let size;
 
-    // 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;
-    }
+    const path = this.#paths[this.#loadedPathIndex + 1];
 
-    getSizeOfPath(path) {
-        const index = this.#paths.indexOf(path);
-        if (index === -1) return null;
-        if (index > this.#loadedPathIndex) return null;
-        return this.#sizes[index];
+    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) {
+    const index = this.#paths.indexOf(path);
+    if (index === -1) return null;
+    if (index > this.#loadedPathIndex) return null;
+    return this.#sizes[index];
+  }
 }
diff --git a/src/find.js b/src/find.js
new file mode 100644
index 0000000..afe34dd
--- /dev/null
+++ b/src/find.js
@@ -0,0 +1,253 @@
+import {inspect} from 'node:util';
+
+import {colors, logWarn} from '#cli';
+import thingConstructors from '#things';
+import {typeAppearance} from '#sugar';
+
+function warnOrThrow(mode, message) {
+  if (mode === 'error') {
+    throw new Error(message);
+  }
+
+  if (mode === 'warn') {
+    logWarn(message);
+  }
+
+  return null;
+}
+
+export function processAllAvailableMatches(data, {
+  include = _thing => true,
+
+  getMatchableNames = thing =>
+    (Object.hasOwn(thing, 'name')
+      ? [thing.name]
+      : []),
+
+  getMatchableDirectories = thing =>
+    (Object.hasOwn(thing, 'directory')
+      ? [thing.directory]
+      : [null]),
+} = {}) {
+  const byName = Object.create(null);
+  const byDirectory = Object.create(null);
+  const multipleNameMatches = Object.create(null);
+
+  for (const thing of data) {
+    if (!include(thing)) continue;
+
+    for (const directory of getMatchableDirectories(thing)) {
+      if (typeof directory !== 'string') {
+        logWarn`Unexpected ${typeAppearance(directory)} returned in directories for ${inspect(thing)}`;
+        continue;
+      }
+
+      byDirectory[directory] = thing;
+    }
+
+    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 byName) {
+        const alreadyMatchesByName = byName[normalizedName];
+        byName[normalizedName] = null;
+        if (normalizedName in multipleNameMatches) {
+          multipleNameMatches[normalizedName].push(thing);
+        } else {
+          multipleNameMatches[normalizedName] = [alreadyMatchesByName, thing];
+        }
+      } else {
+        byName[normalizedName] = thing;
+      }
+    }
+  }
+
+  return {byName, byDirectory, multipleNameMatches};
+}
+
+function findHelper({
+  referenceTypes,
+
+  include = undefined,
+  getMatchableNames = undefined,
+  getMatchableDirectories = undefined,
+}) {
+  const keyRefRegex =
+    new RegExp(String.raw`^(?:(${referenceTypes.join('|')}):(?=\S))?(.*)$`);
+
+  // Note: This cache explicitly *doesn't* support mutable data arrays. If the
+  // data array is modified, make sure it's actually a new array object, not
+  // the original, or the cache here will break and act as though the data
+  // 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 regexMatch = fullRef.match(keyRefRegex);
+    if (!regexMatch) {
+      return warnOrThrow(mode,
+        `Malformed link reference: "${fullRef}"`);
+    }
+
+    const typePart = regexMatch[1];
+    const refPart = regexMatch[2];
+
+    const normalizedName =
+      (typePart
+        ? null
+        : refPart.toLowerCase());
+
+    const match =
+      (typePart
+        ? subcache.byDirectory[refPart]
+        : subcache.byName[normalizedName]);
+
+    if (!match && !typePart) {
+      if (subcache.multipleNameMatches[normalizedName]) {
+        return warnOrThrow(mode,
+          `Multiple matches for reference "${fullRef}". Please resolve:\n` +
+          subcache.multipleNameMatches[normalizedName]
+            .map(match => `- ${inspect(match)}\n`)
+            .join('') +
+          `Returning null for this reference.`);
+      }
+    }
+
+    if (!match) {
+      return warnOrThrow(mode,
+        `Didn't match anything for ${colors.bright(fullRef)}`);
+    }
+
+    return match;
+  };
+}
+
+const hardcodedFindSpecs = {
+  // Listings aren't Thing objects, so this find spec isn't provided by any
+  // Thing constructor.
+  listing: {
+    referenceTypes: ['listing'],
+    bindTo: 'listingSpec',
+  },
+};
+
+export function getAllFindSpecs() {
+  try {
+    thingConstructors;
+  } catch (error) {
+    throw new Error(`Thing constructors aren't ready yet, can't get all find specs`);
+  }
+
+  const findSpecs = {...hardcodedFindSpecs};
+
+  for (const thingConstructor of Object.values(thingConstructors)) {
+    const thingFindSpecs = thingConstructor[Symbol.for('Thing.findSpecs')];
+    if (!thingFindSpecs) continue;
+
+    Object.assign(findSpecs, thingFindSpecs);
+  }
+
+  return findSpecs;
+}
+
+export function findFindSpec(key) {
+  if (Object.hasOwn(hardcodedFindSpecs, key)) {
+    return hardcodedFindSpecs[key];
+  }
+
+  try {
+    thingConstructors;
+  } catch (error) {
+    throw new Error(`Thing constructors aren't ready yet, can't check if "find.${key}" available`);
+  }
+
+  for (const thingConstructor of Object.values(thingConstructors)) {
+    const thingFindSpecs = thingConstructor[Symbol.for('Thing.findSpecs')];
+    if (!thingFindSpecs) continue;
+
+    if (Object.hasOwn(thingFindSpecs, key)) {
+      return thingFindSpecs[key];
+    }
+  }
+
+  throw new Error(`"find.${key}" isn't available`);
+}
+
+export default new Proxy({}, {
+  get: (store, key) => {
+    if (!Object.hasOwn(store, key)) {
+      let behavior = (...args) => {
+        // This will error if the find spec isn't available...
+        const findSpec = findFindSpec(key);
+
+        // ...or, if it is available, replace this function with the
+        // ready-for-use find function made out of that find spec.
+        return (behavior = findHelper(findSpec))(...args);
+      };
+
+      store[key] = (...args) => behavior(...args);
+    }
+
+    return store[key];
+  },
+});
+
+// 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, opts1) {
+  const findSpecs = getAllFindSpecs();
+
+  const boundFindFns = {};
+
+  for (const [key, spec] of Object.entries(findSpecs)) {
+    if (!spec.bindTo) continue;
+
+    const findFn = findHelper(spec);
+    const thingData = wikiData[spec.bindTo];
+
+    boundFindFns[key] =
+      (opts1
+        ? (ref, opts2) =>
+            (opts2
+              ? findFn(ref, thingData, {...opts1, ...opts2})
+              : findFn(ref, thingData, opts1))
+        : (ref, opts2) =>
+            (opts2
+              ? findFn(ref, thingData, opts2)
+              : findFn(ref, thingData)));
+  }
+
+  return boundFindFns;
+}
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index e6dfeae..8a58269 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -74,260 +74,1349 @@
 
 '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.)
+//
+// * `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,
+  },
+
+  '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 {commandExists, isMain, promisifyProcess, traverse} from '#node-utils';
+import {sortByName} from '#sort';
 
 import {
-    isMain,
-    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,
+  ];
+}
+
+function stringifyCache(cache) {
+  if (Object.keys(cache).length === 0) {
+    return `{}`;
+  }
+
+  const entries = Object.entries(cache);
+  sortByName(entries, {getName: entry => entry[0]});
+
+  return [
+    `{`,
+    entries
+      .map(([key, value]) => [JSON.stringify(key), JSON.stringify(value)])
+      .map(([key, value]) => `${key}: ${value}`)
+      .map((line, index, array) =>
+        (index < array.length - 1
+          ? `${line},`
+          : line))
+      .map(line => `  ${line}`),
+    `}`,
+  ].flat().join('\n');
+}
+
+getThumbnailsAvailableForDimensions.all =
+  Object.entries(thumbnailSpec)
+    .map(([name, {size}]) => [name, size])
+    .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];
 }
 
-export default async function genThumbs(mediaPath, {
-    queueSize = 0,
-    quiet = false
-} = {}) {
-    if (!mediaPath) {
-        throw new Error('Expected mediaPath to be passed');
+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];
+}
+
+// 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 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 quietInfo = (quiet
-        ? () => null
-        : logInfo);
+  const dirnameInCache =
+    path.join(mediaCachePath, path.dirname(imagePath));
 
-    const filterFile = name => {
-        // TODO: Why is this not working????????
-        // thumbnail-cache.json is 8eing passed through, for some reason.
+  const filename =
+    path.basename(imagePath, path.extname(imagePath)) +
+    `.${thumbtack}.jpg`;
 
-        const ext = path.extname(name);
-        if (ext !== '.jpg' && ext !== '.png') return false;
+  const filePathInCache =
+    path.join(dirnameInCache, filename);
 
-        const rest = path.basename(name, ext);
-        if (rest.endsWith('.medium') || rest.endsWith('.small')) return false;
+  await mkdir(dirnameInCache, {recursive: true});
 
-        return 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,
+  providedMediaCachePath,
+  disallowDoubling = 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);
+  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,
+    };
+  }
+
+  const inferredPath =
+    path.join(
+      path.dirname(mediaPath),
+      path.basename(mediaPath) + '-cache');
+
+  let inferredIncludesThumbnailCache;
+
+  try {
+    const files = await readdir(inferredPath);
+    inferredIncludesThumbnailCache = files.includes(CACHE_FILE);
+  } catch (error) {
+    if (error.code === 'ENOENT') {
+      inferredIncludesThumbnailCache = null;
+    } else {
+      inferredIncludesThumbnailCache = undefined;
+    }
+  }
+
+  if (inferredIncludesThumbnailCache === true) {
+    return {
+      annotation: 'inferred path has cache',
+      mediaCachePath: inferredPath,
+    };
+  } else if (inferredIncludesThumbnailCache === false) {
+    return {
+      annotation: 'inferred path does not have cache',
+      mediaCachePath: null,
+    };
+  } else if (inferredIncludesThumbnailCache === null) {
+    return {
+      annotation: 'inferred path will be created',
+      mediaCachePath: inferredPath,
+    };
+  } else {
+    return {
+      annotation: 'inferred path not readable',
+      mediaCachePath: null,
+    };
+  }
+}
+
+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);
+      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 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;
+          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}`;
+  }
+
+  quietInfo`Running up to ${magickThreads + ' magick threads'} simultaneously.`;
+
+  let cache = {};
+  let firstRun = false;
+
+  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);
     }
+  }
 
-    // Technically we could pro8a8ly mut8te the cache varia8le in-place?
-    // 8ut that seems kinda iffy.
-    const updatedCache = Object.assign({}, cache);
+  await refreshThumbnailCache(cache, {mediaPath, queueSize});
 
-    const entriesToGenerate = imageToMD5Entries
-        .filter(([filePath, md5]) => md5 !== cache[filePath]);
+  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 (entriesToGenerate.length === 0) {
-        logInfo`All image thumbnails are already up-to-date - nice!`;
-        return true;
+  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 {
+      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 failed = [];
-    const succeeded = [];
-    const writeMessageFn = () => `Writing image thumbnails. [failed: ${failed.length}]`;
-
-    // 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]);
-            }
-        )
-    )));
+  if (firstRun) {
+    cache = {};
+  }
 
-    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!`;
+  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 {
-        logInfo`Generated all (updated) thumbnails successfully!`;
+      quietInfo`All image files successfully read.`;
     }
+  }
 
-    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!`;
+  // 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}`;
     }
 
-    return true;
+    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.albumData
+      .flatMap(album => [
+        album.hasCoverArt && fromRoot.to('media.albumCover', album.directory, album.coverArtFileExtension),
+        !empty(CacheableObject.getUpdateValue(album, 'bannerArtistContribs')) && fromRoot.to('media.albumBanner', album.directory, album.bannerFileExtension),
+        !empty(CacheableObject.getUpdateValue(album, 'wallpaperArtistContribs')) && fromRoot.to('media.albumWallpaper', album.directory, album.wallpaperFileExtension),
+      ])
+      .filter(Boolean),
+
+    wikiData.artistData
+      .filter(artist => artist.hasAvatar)
+      .map(artist => fromRoot.to('media.artistAvatar', artist.directory, artist.avatarFileExtension)),
+
+    wikiData.flashData
+      .map(flash => fromRoot.to('media.flashArt', flash.directory, flash.coverArtFileExtension)),
+
+    wikiData.trackData
+      .filter(track => track.hasUniqueCoverArt)
+      .map(track => fromRoot.to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension)),
+  ].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);
-
-        await genThumbs(mediaPath, {queueSize});
-    })().catch(err => {
-        console.error(err);
+  (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);
+
+    await genThumbs(mediaPath, {queueSize});
+  })().catch((err) => {
+    console.error(err);
+  });
 }
diff --git a/src/listing-spec.js b/src/listing-spec.js
index bb0c0a5..73fbee6 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -1,824 +1,312 @@
-import fixWS from 'fix-whitespace';
-
-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, language}) {
-            return language.$('listingPage.listAlbums.byName.item', {
-                album: link.album(album),
-                tracks: language.countTracks(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 no groups...
+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: 'listTags.byName',
+  contentFunction: 'listTagsByName',
+  featureFlag: 'enableArtTagUI',
+});
+
+listingSpec.push({
+  directory: 'tags/by-uses',
+  stringsKey: 'listTags.byUses',
+  contentFunction: 'listTagsByUses',
+  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,
+});
+
+{
+  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, language}) {
-            return language.$('listingPage.listAlbums.byTracks.item', {
-                album: link.album(album),
-                tracks: language.countTracks(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, language}) {
-            return language.$('listingPage.listAlbums.byDuration.item', {
-                album: link.album(album),
-                duration: language.formatDuration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'albums/by-date',
-        stringsKey: 'listAlbums.byDate',
-
-        data({wikiData}) {
-            return sortByDate(wikiData.albumData.filter(album => album.date));
-        },
-
-        row(album, {link, language}) {
-            return language.$('listingPage.listAlbums.byDate.item', {
-                album: link.album(album),
-                date: language.formatDate(album.date)
-            });
-        }
-    },
-
-    {
-        directory: 'albums/by-date-added',
-        stringsKey: 'listAlbums.byDateAdded',
-
-        data({wikiData}) {
-            return chunkByProperties(wikiData.albumData.slice().sort((a, b) => {
-                if (a.dateAddedToWiki < b.dateAddedToWiki) return -1;
-                if (a.dateAddedToWiki > b.dateAddedToWiki) return 1;
-            }), ['dateAddedToWiki']);
-        },
-
-        html(chunks, {link, language}) {
-            return fixWS`
-                <dl>
-                    ${chunks.map(({dateAddedToWiki, chunk: albums}) => fixWS`
-                        <dt>${language.$('listingPage.listAlbums.byDateAdded.date', {
-                            date: language.formatDate(dateAddedToWiki)
-                        })}</dt>
-                        <dd><ul>
-                            ${(albums
-                                .map(album => language.$('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, language}) {
-            return language.$('listingPage.listArtists.byName.item', {
-                artist: link.artist(artist),
-                contributions: language.countContributions(contributions, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'artists/by-contribs',
-        stringsKey: 'listArtists.byContribs',
-
-        data({wikiData}) {
-            return {
-                toTracks: (wikiData.artistData
-                    .map(artist => ({
-                        artist,
-                        contributions: (
-                            (artist.tracksAsContributor?.length ?? 0) +
-                            (artist.tracksAsArtist?.length ?? 0)
-                        )
-                    }))
-                    .sort((a, b) => b.contributions - a.contributions)
-                    .filter(({ contributions }) => contributions)),
-
-                toArtAndFlashes: (wikiData.artistData
-                    .map(artist => ({
-                        artist,
-                        contributions: (
-                            (artist.tracksAsCoverArtist?.length ?? 0) +
-                            (artist.albumsAsCoverArtist?.length ?? 0) +
-                            (artist.albumsAsWallpaperArtist?.length ?? 0) +
-                            (artist.albumsAsBannerArtist?.length ?? 0) +
-                            (wikiData.wikiInfo.enableFlashesAndGames
-                                ? (artist.flashesAsContributor?.length ?? 0)
-                                : 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.enableFlashesAndGames
-            };
-        },
-
-        html({toTracks, toArtAndFlashes, showAsFlashes}, {link, language}) {
-            return fixWS`
-                <div class="content-columns">
-                    <div class="column">
-                        <h2>${language.$('listingPage.misc.trackContributors')}</h2>
-                        <ul>
-                            ${(toTracks
-                                .map(({ artist, contributions }) => language.$('listingPage.listArtists.byContribs.item', {
-                                    artist: link.artist(artist),
-                                    contributions: language.countContributions(contributions, {unit: true})
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                         </ul>
-                    </div>
-                    <div class="column">
-                        <h2>${language.$('listingPage.misc' +
-                            (showAsFlashes
-                                ? '.artAndFlashContributors'
-                                : '.artContributors'))}</h2>
-                        <ul>
-                            ${(toArtAndFlashes
-                                .map(({ artist, contributions }) => language.$('listingPage.listArtists.byContribs.item', {
-                                    artist: link.artist(artist),
-                                    contributions: language.countContributions(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.tracksAsCommentator?.length ?? 0) +
-                    (artist.albumsAsCommentator?.length ?? 0)
-                )}))
-                .filter(({ entries }) => entries)
-                .sort((a, b) => b.entries - a.entries);
-        },
-
-        row({artist, entries}, {link, language}) {
-            return language.$('listingPage.listArtists.byCommentary.item', {
-                artist: link.artist(artist),
-                entries: language.countCommentaryEntries(entries, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'artists/by-duration',
-        stringsKey: 'listArtists.byDuration',
-
-        data({wikiData}) {
-            return wikiData.artistData
-                .map(artist => ({
-                    artist,
-                    duration: getTotalDuration([
-                        ...artist.tracksAsArtist ?? [],
-                        ...artist.tracksAsContributor ?? []
-                    ])
-                }))
-                .filter(({ duration }) => duration > 0)
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({artist, duration}, {link, language}) {
-            return language.$('listingPage.listArtists.byDuration.item', {
-                artist: link.artist(artist),
-                duration: language.formatDuration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'artists/by-latest',
-        stringsKey: 'listArtists.byLatest',
-
-        data({wikiData}) {
-            const reversedTracks = wikiData.trackData.filter(t => t.date).reverse();
-            const reversedArtThings = wikiData.justEverythingSortedByArtDateMan.filter(t => t.date).reverse();
-
-            return {
-                toTracks: sortByDate(wikiData.artistData
-                    .map(artist => ({
-                        artist,
-                        date: reversedTracks.find(track => ([
-                            ...track.artistContribs ?? [],
-                            ...track.contributorContribs ?? []
-                        ].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
-                    .map(artist => {
-                        const thing = reversedArtThings.find(thing => ([
-                            ...thing.coverArtistContribs ?? [],
-                            ...!thing.album && thing.contributorContribs || []
-                        ].some(({ who }) => who === artist)));
-                        return thing && {
-                            artist,
-                            date: (thing.coverArtistContribs?.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.enableFlashesAndGames
-            };
-        },
-
-        html({toTracks, toArtAndFlashes, showAsFlashes}, {link, language}) {
-            return fixWS`
-                <div class="content-columns">
-                    <div class="column">
-                        <h2>${language.$('listingPage.misc.trackContributors')}</h2>
-                        <ul>
-                            ${(toTracks
-                                .map(({ artist, date }) => language.$('listingPage.listArtists.byLatest.item', {
-                                    artist: link.artist(artist),
-                                    date: language.formatDate(date)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul>
-                    </div>
-                    <div class="column">
-                        <h2>${language.$('listingPage.misc' +
-                            (showAsFlashes
-                                ? '.artAndFlashContributors'
-                                : '.artContributors'))}</h2>
-                        <ul>
-                            ${(toArtAndFlashes
-                                .map(({ artist, date }) => language.$('listingPage.listArtists.byLatest.item', {
-                                    artist: link.artist(artist),
-                                    date: language.formatDate(date)
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul>
-                    </div>
-                </div>
-            `;
-        }
-    },
-
-    {
-        directory: 'groups/by-name',
-        stringsKey: 'listGroups.byName',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI,
-        data: ({wikiData}) => wikiData.groupData.slice().sort(sortByName),
-
-        row(group, {link, language}) {
-            return language.$('listingPage.listGroups.byCategory.group', {
-                group: link.groupInfo(group),
-                gallery: link.groupGallery(group, {
-                    text: language.$('listingPage.listGroups.byCategory.group.gallery')
-                })
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-category',
-        stringsKey: 'listGroups.byCategory',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI,
-        data: ({wikiData}) => wikiData.groupCategoryData,
-
-        html(groupCategoryData, {link, language}) {
-            return fixWS`
-                <dl>
-                    ${groupCategoryData.map(category => fixWS`
-                        <dt>${language.$('listingPage.listGroups.byCategory.category', {
-                            category: link.groupInfo(category.groups[0], {text: category.name})
-                        })}</dt>
-                        <dd><ul>
-                            ${(category.groups
-                                .map(group => language.$('listingPage.listGroups.byCategory.group', {
-                                    group: link.groupInfo(group),
-                                    gallery: link.groupGallery(group, {
-                                        text: language.$('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.enableGroupUI,
-
-        data({wikiData}) {
-            return wikiData.groupData
-                .map(group => ({group, albums: group.albums.length}))
-                .sort((a, b) => b.albums - a.albums);
-        },
-
-        row({group, albums}, {link, language}) {
-            return language.$('listingPage.listGroups.byAlbums.item', {
-                group: link.groupInfo(group),
-                albums: language.countAlbums(albums, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-tracks',
-        stringsKey: 'listGroups.byTracks',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI,
-
-        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, language}) {
-            return language.$('listingPage.listGroups.byTracks.item', {
-                group: link.groupInfo(group),
-                tracks: language.countTracks(tracks, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-duration',
-        stringsKey: 'listGroups.byDuration',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI,
-
-        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, language}) {
-            return language.$('listingPage.listGroups.byDuration.item', {
-                group: link.groupInfo(group),
-                duration: language.formatDuration(duration)
-            });
-        }
-    },
-
-    {
-        directory: 'groups/by-latest-album',
-        stringsKey: 'listGroups.byLatest',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableGroupUI,
-
-        data({wikiData}) {
-            return sortByDate(wikiData.groupData
-                .map(group => {
-                    const albums = group.albums.filter(a => a.date);
-                    return albums.length && {
-                        group,
-                        date: albums[albums.length - 1].date
-                    };
-                })
-                .filter(Boolean)
-                // 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, language}) {
-            return language.$('listingPage.listGroups.byLatest.item', {
-                group: link.groupInfo(group),
-                date: language.formatDate(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, language}) {
-            return language.$('listingPage.listTracks.byName.item', {
-                track: link.track(track)
-            });
-        }
-    },
-
-    {
-        directory: 'tracks/by-album',
-        stringsKey: 'listTracks.byAlbum',
-        data: ({wikiData}) => wikiData.albumData,
-
-        html(albumData, {link, language}) {
-            return fixWS`
-                <dl>
-                    ${albumData.map(album => fixWS`
-                        <dt>${language.$('listingPage.listTracks.byAlbum.album', {
-                            album: link.album(album)
-                        })}</dt>
-                        <dd><ol>
-                            ${(album.tracks
-                                .map(track => language.$('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(t => t.date)),
-                ['album', 'date']
-            );
-        },
-
-        html(chunks, {link, language}) {
-            return fixWS`
-                <dl>
-                    ${chunks.map(({album, date, chunk: tracks}) => fixWS`
-                        <dt>${language.$('listingPage.listTracks.byDate.album', {
-                            album: link.album(album),
-                            date: language.formatDate(date)
-                        })}</dt>
-                        <dd><ul>
-                            ${(tracks
-                                .map(track => track.aka
-                                    ? `<li class="rerelease">${language.$('listingPage.listTracks.byDate.track.rerelease', {
-                                        track: link.track(track)
-                                    })}</li>`
-                                    : `<li>${language.$('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
-                .map(track => ({track, duration: track.duration}))
-                .filter(({ duration }) => duration > 0)
-                .sort((a, b) => b.duration - a.duration);
-        },
-
-        row({track, duration}, {link, language}) {
-            return language.$('listingPage.listTracks.byDuration.item', {
-                track: link.track(track),
-                duration: language.formatDuration(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 ?? 0) - (a.duration ?? 0))
-            }));
-        },
-
-        html(albums, {link, language}) {
-            return fixWS`
-                <dl>
-                    ${albums.map(({album, tracks}) => fixWS`
-                        <dt>${language.$('listingPage.listTracks.byDurationInAlbum.album', {
-                            album: link.album(album)
-                        })}</dt>
-                        <dd><ul>
-                            ${(tracks
-                                .map(track => language.$('listingPage.listTracks.byDurationInAlbum.track', {
-                                    track: link.track(track),
-                                    duration: language.formatDuration(track.duration ?? 0)
-                                }))
-                                .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.referencedByTracks.length}))
-                .filter(({ timesReferenced }) => timesReferenced > 0)
-                .sort((a, b) => b.timesReferenced - a.timesReferenced);
-        },
-
-        row({track, timesReferenced}, {link, language}) {
-            return language.$('listingPage.listTracks.byTimesReferenced.item', {
-                track: link.track(track),
-                timesReferenced: language.countTimesReferenced(timesReferenced, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'tracks/in-flashes/by-album',
-        stringsKey: 'listTracks.inFlashes.byAlbum',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableFlashesAndGames,
-
-        data({wikiData}) {
-            return chunkByProperties(wikiData.trackData
-                .filter(t => t.featuredInFlashes?.length > 0), ['album']);
-        },
-
-        html(chunks, {link, language}) {
-            return fixWS`
-                <dl>
-                    ${chunks.map(({album, chunk: tracks}) => fixWS`
-                        <dt>${language.$('listingPage.listTracks.inFlashes.byAlbum.album', {
-                            album: link.album(album),
-                            date: language.formatDate(album.date)
-                        })}</dt>
-                        <dd><ul>
-                            ${(tracks
-                                .map(track => language.$('listingPage.listTracks.inFlashes.byAlbum.track', {
-                                    track: link.track(track),
-                                    flashes: language.formatConjunctionList(track.featuredInFlashes.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.enableFlashesAndGames,
-        data: ({wikiData}) => wikiData.flashData,
-
-        html(flashData, {link, language}) {
-            return fixWS`
-                <dl>
-                    ${sortByDate(flashData.slice()).map(flash => fixWS`
-                        <dt>${language.$('listingPage.listTracks.inFlashes.byFlash.flash', {
-                            flash: link.flash(flash),
-                            date: language.formatDate(flash.date)
-                        })}</dt>
-                        <dd><ul>
-                            ${(flash.featuredTracks
-                                .map(track => language.$('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, language}) {
-            return fixWS`
-                <dl>
-                    ${chunks.map(({album, chunk: tracks}) => fixWS`
-                        <dt>${language.$('listingPage.listTracks.withLyrics.album', {
-                            album: link.album(album),
-                            date: language.formatDate(album.date)
-                        })}</dt>
-                        <dd><ul>
-                            ${(tracks
-                                .map(track => language.$('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.enableArtTagUI,
-
-        data({wikiData}) {
-            return wikiData.artTagData
-                .filter(tag => !tag.isContentWarning)
-                .sort(sortByName)
-                .map(tag => ({tag, timesUsed: tag.taggedInThings?.length}));
-        },
-
-        row({tag, timesUsed}, {link, language}) {
-            return language.$('listingPage.listTags.byName.item', {
-                tag: link.tag(tag),
-                timesUsed: language.countTimesUsed(timesUsed, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'tags/by-uses',
-        stringsKey: 'listTags.byUses',
-        condition: ({wikiData}) => wikiData.wikiInfo.enableArtTagUI,
-
-        data({wikiData}) {
-            return wikiData.artTagData
-                .filter(tag => !tag.isContentWarning)
-                .map(tag => ({tag, timesUsed: tag.taggedInThings?.length}))
-                .sort((a, b) => b.timesUsed - a.timesUsed);
-        },
-
-        row({tag, timesUsed}, {link, language}) {
-            return language.$('listingPage.listTags.byUses.item', {
-                tag: link.tag(tag),
-                timesUsed: language.countTimesUsed(timesUsed, {unit: true})
-            });
-        }
-    },
-
-    {
-        directory: 'random',
-        stringsKey: 'other.randomPages',
-
-        data: ({wikiData}) => ({
-            officialAlbumData: wikiData.officialAlbumData,
-            fandomAlbumData: wikiData.fandomAlbumData
-        }),
-
-        html: ({officialAlbumData, fandomAlbumData}, {
-            getLinkThemeString,
-            language
-        }) => 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;
     }
-];
+  }
+
+  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 filterListings = (directoryPrefix) =>
+  listingSpec.filter(l => l.directory.startsWith(directoryPrefix));
 
 const listingTargetSpec = [
-    {
-        title: ({language}) => language.$('listingPage.target.album'),
-        listings: filterListings('album')
-    },
-    {
-        title: ({language}) => language.$('listingPage.target.artist'),
-        listings: filterListings('artist')
-    },
-    {
-        title: ({language}) => language.$('listingPage.target.group'),
-        listings: filterListings('group')
-    },
-    {
-        title: ({language}) => language.$('listingPage.target.track'),
-        listings: filterListings('track')
-    },
-    {
-        title: ({language}) => language.$('listingPage.target.tag'),
-        listings: filterListings('tag')
-    },
-    {
-        title: ({language}) => language.$('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 f40229d..0000000
--- a/src/misc-templates.js
+++ /dev/null
@@ -1,494 +0,0 @@
-// Miscellaneous utility functions which are useful across page specifications.
-// These are made available right on a page spec's ({wikiData, language, ...})
-// args object!
-
-import fixWS from 'fix-whitespace';
-
-import * as html from './util/html.js';
-
-import {
-    getColors
-} from './util/colors.js';
-
-import {
-    unique
-} from './util/sugar.js';
-
-import {
-    getTotalDuration,
-    sortByDate
-} from './util/wiki-data.js';
-
-const BANDCAMP_DOMAINS = [
-    'bc.s3m.us',
-    'music.solatrux.com',
-];
-
-const MASTODON_DOMAINS = [
-    'types.pl',
-];
-
-// "Additional Files" listing
-
-export function generateAdditionalFilesShortcut(additionalFiles, {language}) {
-    if (!additionalFiles?.length) return '';
-
-    return language.$('releaseInfo.additionalFiles.shortcut', {
-        anchorLink: `<a href="#additional-files">${language.$('releaseInfo.additionalFiles.shortcut.anchorLink')}</a>`,
-        titles: language.formatUnitList(additionalFiles.map(g => g.title))
-    });
-}
-
-export function generateAdditionalFilesList(additionalFiles, {language, getFileSize, linkFile}) {
-    if (!additionalFiles?.length) return '';
-
-    const fileCount = additionalFiles.flatMap(g => g.files).length;
-
-    return fixWS`
-        <p id="additional-files">${language.$('releaseInfo.additionalFiles.heading', {fileCount})}</p>
-        <dl>
-            ${additionalFiles.map(({ title, description, files }) => fixWS`
-                <dt>${(description
-                    ? language.$('releaseInfo.additionalFiles.entry.withDescription', {title, description})
-                    : language.$('releaseInfo.additionalFiles.entry', {title}))}</dt>
-                <dd><ul>
-                    ${files.map(file => {
-                        const size = getFileSize(file);
-                        return (size
-                            ? `<li>${language.$('releaseInfo.additionalFiles.file.withSize', {
-                                file: linkFile(file),
-                                size: language.formatFileSize(getFileSize(file))
-                            })}</li>`
-                            : `<li>${language.$('releaseInfo.additionalFiles.file', {
-                                file: linkFile(file)
-                            })}</li>`);
-                    }).join('\n')}
-                </ul></dd>
-            `).join('\n')}
-        </dl>
-    `;
-}
-
-// Artist strings
-
-export function getArtistString(artists, {
-    iconifyURL, link, language,
-    showIcons = false,
-    showContrib = false
-}) {
-    return language.formatConjunctionList(artists.map(({ who, what }) => {
-        const { urls, directory, name } = who;
-        return [
-            link.artist(who),
-            showContrib && what && `(${what})`,
-            showIcons && urls?.length && `<span class="icons">(${
-                language.formatUnitList(urls.map(url => iconifyURL(url, {language})))
-            })</span>`
-        ].filter(Boolean).join(' ');
-    }));
-}
-
-// Chronology links
-
-export function generateChronologyLinks(currentThing, {
-    dateKey = 'date',
-    contribKey,
-    getThings,
-    headingString,
-    link,
-    linkAnythingMan,
-    language,
-    wikiData
-}) {
-    const { albumData } = wikiData;
-
-    const contributions = currentThing[contribKey];
-    if (!contributions) {
-        return '';
-    }
-
-    if (contributions.length > 8) {
-        return `<div class="chronology">${language.$('misc.chronology.seeArtistPages')}</div>`;
-    }
-
-    return contributions.map(({ who: artist }) => {
-        const things = sortByDate(unique(getThings(artist)).filter(t => t[dateKey]), dateKey);
-        const index = things.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 = things[index - 1];
-        const next = things[index + 1];
-        const parts = [
-            previous && linkAnythingMan(previous, {
-                color: false,
-                text: language.$('misc.nav.previous')
-            }),
-            next && linkAnythingMan(next, {
-                color: false,
-                text: language.$('misc.nav.next')
-            })
-        ].filter(Boolean);
-
-        if (!parts.length) {
-            return '';
-        }
-
-        const stringOpts = {
-            index: language.formatIndex(index + 1, {language}),
-            artist: link.artist(artist)
-        };
-
-        return fixWS`
-            <div class="chronology">
-                <span class="heading">${language.$(headingString, stringOpts)}</span>
-                ${parts.length && `<span class="buttons">(${parts.join(', ')})</span>`}
-            </div>
-        `;
-    }).filter(Boolean).join('\n');
-}
-
-// Content warning tags
-
-export function getRevealStringFromWarnings(warnings, {language}) {
-    return language.$('misc.contentWarnings', {warnings}) + `<br><span class="reveal-interaction">${language.$('misc.contentWarnings.reveal')}</span>`
-}
-
-export function getRevealStringFromTags(tags, {language}) {
-    return tags && tags.some(tag => tag.isContentWarning) && (
-        getRevealStringFromWarnings(language.formatUnitList(tags.filter(tag => tag.isContentWarning).map(tag => tag.name)), {language}));
-}
-
-// Cover art links
-
-export function generateCoverLink({
-    img, link, language, 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, {language})
-            })}
-            ${wikiInfo.enableArtTagUI && tags.filter(tag => !tag.isContentWarning).length && fixWS`
-                <p class="tags">
-                    ${language.$('releaseInfo.artTags')}
-                    ${(tags
-                        .filter(tag => !tag.isContentWarning)
-                        .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.wallpaperArtistContribs.length && fixWS`
-            body::before {
-                background-image: url("${to('media.albumWallpaper', album.directory, album.wallpaperFileExtension)}");
-                ${album.wallpaperStyle}
-            }
-        `,
-        album.bannerStyle && fixWS`
-            #banner img {
-                ${album.bannerStyle}
-            }
-        `
-    ].filter(Boolean).join('\n');
-}
-
-// Divided track lists
-
-export function generateTrackListDividedByGroups(tracks, {
-    getTrackItem,
-    language,
-    wikiData,
-}) {
-    const { divideTrackListsByGroups: groups } = wikiData.wikiInfo;
-
-    if (!groups?.length) {
-        return html.tag('ul', tracks.map(t => getTrackItem(t)));
-    }
-
-    const lists = Object.fromEntries(groups.map(group => [group.directory, {group, tracks: []}]));
-    const other = [];
-
-    for (const track of tracks) {
-        const { album } = track;
-        const group = groups.find(g => g.albums.includes(album));
-        if (group) {
-            lists[group.directory].tracks.push(track);
-        } else {
-            other.push(track);
-        }
-    }
-
-    const ddul = tracks => fixWS`
-        <dd><ul>
-            ${tracks.map(t => getTrackItem(t)).join('\n')}
-        </ul></dd>
-    `;
-
-    return html.tag('dl', Object.values(lists)
-        .filter(({ tracks }) => tracks.length)
-        .flatMap(({ group, tracks }) => [
-            html.tag('dt', language.formatString('trackList.group', {group: group.name})),
-            ddul(tracks)
-        ])
-        .concat(other.length ? [
-            `<dt>${language.formatString('trackList.group', {
-                group: language.formatString('trackList.group.other')
-            })}</dt>`,
-            ddul(other)
-        ] : []));
-}
-
-// Fancy lookin' links
-
-export function fancifyURL(url, {language, 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 ? language.$('misc.external.local') :
-        domain.includes('bandcamp.com') ? language.$('misc.external.bandcamp') :
-        BANDCAMP_DOMAINS.includes(domain) ? language.$('misc.external.bandcamp.domain', {domain}) :
-        MASTODON_DOMAINS.includes(domain) ? language.$('misc.external.mastodon.domain', {domain}) :
-        domain.includes('youtu') ? (album
-            ? (url.includes('list=')
-                ? language.$('misc.external.youtube.playlist')
-                : language.$('misc.external.youtube.fullAlbum'))
-            : language.$('misc.external.youtube')) :
-        domain.includes('soundcloud') ? language.$('misc.external.soundcloud') :
-        domain.includes('tumblr.com') ? language.$('misc.external.tumblr') :
-        domain.includes('twitter.com') ? language.$('misc.external.twitter') :
-        domain.includes('deviantart.com') ? language.$('misc.external.deviantart') :
-        domain.includes('wikipedia.org') ? language.$('misc.external.wikipedia') :
-        domain.includes('poetryfoundation.org') ? language.$('misc.external.poetryFoundation') :
-        domain.includes('instagram.com') ? language.$('misc.external.instagram') :
-        domain.includes('patreon.com') ? language.$('misc.external.patreon') :
-        domain
-    }</a>`;
-}
-
-export function fancifyFlashURL(url, flash, {language}) {
-    const link = fancifyURL(url, {language});
-    return `<span class="nowrap">${
-        url.includes('homestuck.com') ? (isNaN(Number(flash.page))
-            ? language.$('misc.external.flash.homestuck.secret', {link})
-            : language.$('misc.external.flash.homestuck.page', {link, page: flash.page})) :
-        url.includes('bgreco.net') ? language.$('misc.external.flash.bgreco', {link}) :
-        url.includes('youtu') ? language.$('misc.external.flash.youtube', {link}) :
-        link
-    }</span>`;
-}
-
-export function iconifyURL(url, {language, to}) {
-    const domain = new URL(url).hostname;
-    const [ id, msg ] = (
-        domain.includes('bandcamp.com') ? ['bandcamp', language.$('misc.external.bandcamp')] :
-        BANDCAMP_DOMAINS.includes(domain) ? ['bandcamp', language.$('misc.external.bandcamp.domain', {domain})] :
-        MASTODON_DOMAINS.includes(domain) ? ['mastodon', language.$('misc.external.mastodon.domain', {domain})] :
-        domain.includes('youtu') ? ['youtube', language.$('misc.external.youtube')] :
-        domain.includes('soundcloud') ? ['soundcloud', language.$('misc.external.soundcloud')] :
-        domain.includes('tumblr.com') ? ['tumblr', language.$('misc.external.tumblr')] :
-        domain.includes('twitter.com') ? ['twitter', language.$('misc.external.twitter')] :
-        domain.includes('deviantart.com') ? ['deviantart', language.$('misc.external.deviantart')] :
-        domain.includes('instagram.com') ? ['instagram', language.$('misc.external.bandcamp')] :
-        ['globe', language.$('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({
-    img,
-    language,
-
-    entries,
-    srcFn,
-    linkFn,
-    noSrcTextFn = () => '',
-    altFn = () => '',
-    detailsFn = null,
-    lazy = true
-}) {
-    return entries.map(({ large, item }, i) => linkFn(item,
-        {
-            class: ['grid-item', 'box', large && 'large-grid-item'],
-            text: fixWS`
-                ${img({
-                    src: srcFn(item),
-                    alt: altFn(item),
-                    thumb: 'small',
-                    lazy: (typeof lazy === 'number' ? i >= lazy : lazy),
-                    square: true,
-                    reveal: getRevealStringFromTags(item.artTags, {language}),
-                    noSrcText: noSrcTextFn(item)
-                })}
-                <span>${item.name}</span>
-                ${detailsFn && `<span>${detailsFn(item)}</span>`}
-            `
-        })).join('\n');
-}
-
-export function getAlbumGridHTML({
-    getAlbumCover, getGridHTML, link, language,
-    details = false,
-    ...props
-}) {
-    return getGridHTML({
-        srcFn: getAlbumCover,
-        linkFn: link.album,
-        detailsFn: details && (album => language.$('misc.albumGrid.details', {
-            tracks: language.countTracks(album.tracks.length, {unit: true}),
-            time: language.formatDuration(getTotalDuration(album.tracks))
-        })),
-        noSrcTextFn: album => language.$('misc.albumGrid.noCoverArt', {
-            album: album.name
-        }),
-        ...props
-    });
-}
-
-export function getFlashGridHTML({
-    getFlashCover, getGridHTML, link,
-    ...props
-}) {
-    return getGridHTML({
-        srcFn: getFlashCover,
-        linkFn: link.flash,
-        ...props
-    });
-}
-
-// Nav-bar links
-
-export function generateInfoGalleryLinks(currentThing, isGallery, {
-    link, language,
-    linkKeyGallery,
-    linkKeyInfo
-}) {
-    return [
-        link[linkKeyInfo](currentThing, {
-            class: isGallery ? '' : 'current',
-            text: language.$('misc.nav.info')
-        }),
-        link[linkKeyGallery](currentThing, {
-            class: isGallery ? 'current' : '',
-            text: language.$('misc.nav.gallery')
-        })
-    ].join(', ');
-}
-
-export function generatePreviousNextLinks(current, {
-    data,
-    link,
-    linkKey,
-    language
-}) {
-    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: language.$('misc.nav.previous'),
-            color: false
-        }),
-        next && linkFn(next, {
-            attributes: {
-                id: 'next-button',
-                title: next.name
-            },
-            text: language.$('misc.nav.next'),
-            color: false
-        })
-    ].filter(Boolean).join(', ');
-}
-
-// Footer stuff
-
-export function getFooterLocalizationLinks(pathname, {
-    defaultLanguage,
-    languages,
-    paths,
-    language,
-    to
-}) {
-    const { toPath } = paths;
-    const keySuffix = toPath[0].replace(/^localized\./, '.');
-    const toArgs = toPath.slice(1);
-
-    const links = Object.entries(languages)
-        .filter(([ code, language ]) => code !== 'default' && !language.hidden)
-        .map(([ code, language ]) => language)
-        .sort(({ name: a }, { name: b }) => a < b ? -1 : a > b ? 1 : 0)
-        .map(language => html.tag('span', html.tag('a', {
-            href: (language === defaultLanguage
-                ? to('localizedDefaultLanguage' + keySuffix, ...toArgs)
-                : to('localizedWithBaseDirectory' + keySuffix, language.code, ...toArgs))
-        }, language.name)));
-
-    return html.tag('div',
-        {class: 'footer-localization-links'},
-        language.$('misc.uiLanguage', {languages: links.join('\n')}));
-}
diff --git a/src/page/album-commentary.js b/src/page/album-commentary.js
deleted file mode 100644
index e587b16..0000000
--- 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,
-            language,
-            to,
-            transformMultiline
-        }) => ({
-            title: language.$('albumCommentaryPage.title', {album: album.name}),
-            stylesheet: getAlbumStylesheet(album),
-            theme: getThemeString(album.color),
-
-            main: {
-                content: fixWS`
-                    <div class="long-content">
-                        <h1>${language.$('albumCommentaryPage.title', {
-                            album: link.album(album)
-                        })}</h1>
-                        <p>${language.$('albumCommentaryPage.infoLine', {
-                            words: `<b>${language.formatWordCount(words, {unit: true})}</b>`,
-                            entries: `<b>${language.countCommentaryEntries(entries.length, {unit: true})}</b>`
-                        })}</p>
-                        ${album.commentary && fixWS`
-                            <h3>${language.$('albumCommentaryPage.entry.title.albumCommentary')}</h3>
-                            <blockquote>
-                                ${transformMultiline(album.commentary)}
-                            </blockquote>
-                        `}
-                        ${album.tracks.filter(t => t.commentary).map(track => fixWS`
-                            <h3 id="${track.directory}">${language.$('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: language.$('commentaryIndex.title')
-                    },
-                    {
-                        html: language.$('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,
-            language
-        }) => ({
-            title: language.$('commentaryIndex.title'),
-
-            main: {
-                content: fixWS`
-                    <div class="long-content">
-                        <h1>${language.$('commentaryIndex.title')}</h1>
-                        <p>${language.$('commentaryIndex.infoLine', {
-                            words: `<b>${language.formatWordCount(totalWords, {unit: true})}</b>`,
-                            entries: `<b>${language.countCommentaryEntries(totalEntries, {unit: true})}</b>`
-                        })}</p>
-                        <p>${language.$('commentaryIndex.albumList.title')}</p>
-                        <ul>
-                            ${data
-                                .map(({ album, entries, words }) => fixWS`
-                                    <li>${language.$('commentaryIndex.albumList.item', {
-                                        album: link.albumCommentary(album),
-                                        words: language.formatWordCount(words, {unit: true}),
-                                        entries: language.countCommentaryEntries(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 b68189f..c7327cc 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -1,443 +1,112 @@
-// Album page specification.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import * as html from '../util/html.js';
-
-import {
-    bindOpts,
-    compareArrays,
-} 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,
-        language
-    }) => {
-        const itemOpts = {
-            duration: language.formatDuration(track.duration ?? 0),
-            track: link.track(track)
-        };
-        return `<li style="${getLinkThemeString(track.color)}">${
-            (compareArrays(
-                track.artistContribs.map(c => c.who),
-                album.artistContribs.map(c => c.who),
-                {checkOrder: false})
-                ? language.$('trackList.item.withDuration', itemOpts)
-                : language.$('trackList.item.withDuration.withArtists', {
-                    ...itemOpts,
-                    by: `<span class="by">${
-                        language.$('trackList.item.withArtists.by', {
-                            artists: getArtistString(track.artistContribs)
-                        })
-                    }</span>`
-                }))
-        }</li>`;
-    };
-
-    const hasCommentaryEntries = ([album, ...album.tracks].filter(x => x.commentary).length > 0);
-    const hasAdditionalFiles = (album.additionalFiles?.length > 0);
-    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.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),
-            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,
-            generateAdditionalFilesShortcut,
-            generateAdditionalFilesList,
-            generateChronologyLinks,
-            generateCoverLink,
-            getAlbumCover,
-            getAlbumStylesheet,
-            getArtistString,
-            getLinkThemeString,
-            getSizeOfAdditionalFile,
-            getThemeString,
-            link,
-            language,
-            transformMultiline,
-            urls,
-        }) => {
-            const trackToListItem = bindOpts(unbound_trackToListItem, {
-                getArtistString,
-                getLinkThemeString,
-                link,
-                language
-            });
-
-            const cover = getAlbumCover(album);
+export function pathsForTarget(album) {
+  const hasCommentaryPage = !!album.commentary || album.tracks.some(t => t.commentary);
 
-            return {
-                title: language.$('albumPage.title', {album: album.name}),
-                stylesheet: getAlbumStylesheet(album),
-                theme: getThemeString(album.color, [
-                    `--album-directory: ${album.directory}`
-                ]),
+  return [
+    {
+      type: 'page',
+      path: ['album', album.directory],
 
-                banner: album.bannerArtistContribs.length && {
-                    dimensions: album.bannerDimensions,
-                    path: ['media.albumBanner', album.directory, album.bannerFileExtension],
-                    alt: language.$('misc.alt.albumBanner'),
-                    position: 'top'
-                },
+      contentFunction: {
+        name: 'generateAlbumInfoPage',
+        args: [album],
+      },
+    },
 
-                main: {
-                    content: fixWS`
-                        ${cover && generateCoverLink({
-                            src: cover,
-                            alt: language.$('misc.alt.albumCover'),
-                            tags: album.artTags
-                        })}
-                        <h1>${language.$('albumPage.title', {album: album.name})}</h1>
-                        <p>
-                            ${[
-                                album.artistContribs.length && language.$('releaseInfo.by', {
-                                    artists: getArtistString(album.artistContribs, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                album.coverArtistContribs.length && language.$('releaseInfo.coverArtBy', {
-                                    artists: getArtistString(album.coverArtistContribs, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                album.wallpaperArtistContribs.length && language.$('releaseInfo.wallpaperArtBy', {
-                                    artists: getArtistString(album.wallpaperArtistContribs, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                album.bannerArtistContribs.length && language.$('releaseInfo.bannerArtBy', {
-                                    artists: getArtistString(album.bannerArtistContribs, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                album.date && language.$('releaseInfo.released', {
-                                    date: language.formatDate(album.date)
-                                }),
-                                (album.coverArtDate &&
-                                    +album.coverArtDate !== +album.date &&
-                                    language.$('releaseInfo.artReleased', {
-                                        date: language.formatDate(album.coverArtDate)
-                                    })),
-                                language.$('releaseInfo.duration', {
-                                    duration: language.formatDuration(albumDuration, {approximate: album.tracks.length > 1})
-                                })
-                            ].filter(Boolean).join('<br>\n')}
-                        </p>
-                        ${(hasAdditionalFiles || hasCommentaryEntries) && fixWS`<p>
-                            ${[
-                                hasAdditionalFiles && generateAdditionalFilesShortcut(album.additionalFiles, {language}),
-                                hasCommentaryEntries && language.$('releaseInfo.viewCommentary', {
-                                    link: link.albumCommentary(album, {
-                                        text: language.$('releaseInfo.viewCommentary.link')
-                                    })
-                                })
-                            ].filter(Boolean).join('<br>\n')
-                        }</p>`}
-                        ${album.urls?.length && `<p>${
-                            language.$('releaseInfo.listenOn', {
-                                links: language.formatDisjunctionList(album.urls.map(url => fancifyURL(url, {album: true})))
-                            })
-                        }</p>`}
-                        ${album.trackGroups && (album.trackGroups.length > 1 || !album.trackGroups[0].isDefaultTrackGroup) ? fixWS`
-                            <dl class="album-group-list">
-                                ${album.trackGroups.map(({ name, color, startIndex, tracks }) => fixWS`
-                                    <dt>${
-                                        language.$('trackList.section.withDuration', {
-                                            duration: language.formatDuration(getTotalDuration(tracks), {approximate: tracks.length > 1}),
-                                            section: 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}>
-                        `}
-                        ${album.dateAddedToWiki && fixWS`
-                            <p>
-                                ${[
-                                    language.$('releaseInfo.addedToWiki', {
-                                        date: language.formatDate(album.dateAddedToWiki)
-                                    })
-                                ].filter(Boolean).join('<br>\n')}
-                            </p>
-                        `}
-                        ${hasAdditionalFiles && generateAdditionalFilesList(album.additionalFiles, {
-                            // TODO: Kinda near the metal here...
-                            getFileSize: file => getSizeOfAdditionalFile(urls
-                                .from('media.root')
-                                .to('media.albumAdditionalFile', album.directory, file)),
-                            linkFile: file => link.albumAdditionalFile({album, file}),
-                        })}
-                        ${album.commentary && fixWS`
-                            <p>${language.$('releaseInfo.artistCommentary')}</p>
-                            <blockquote>
-                                ${transformMultiline(album.commentary)}
-                            </blockquote>
-                        `}
-                    `
-                },
+    {
+      type: 'page',
+      path: ['albumGallery', album.directory],
 
-                sidebarLeft: generateAlbumSidebar(album, null, {
-                    fancifyURL,
-                    getLinkThemeString,
-                    link,
-                    language,
-                    transformMultiline,
-                    wikiData
-                }),
+      contentFunction: {
+        name: 'generateAlbumGalleryPage',
+        args: [album],
+      },
+    },
 
-                nav: {
-                    links: [
-                        {toHome: true},
-                        {
-                            html: language.$('albumPage.nav.album', {
-                                album: link.album(album, {class: 'current'})
-                            })
-                        },
-                        album.tracks.length > 1 &&
-                        {
-                            divider: false,
-                            html: generateAlbumNavLinks(album, null, {language})
-                        }
-                    ],
-                    content: html.tag('div', generateAlbumChronologyLinks(album, null, {generateChronologyLinks}))
-                }
-            };
-        }
-    };
+    hasCommentaryPage && {
+      type: 'page',
+      path: ['albumCommentary', album.directory],
 
-    return [page, data];
-}
-
-// Utility functions
-
-export function generateAlbumSidebar(album, currentTrack, {
-    fancifyURL,
-    getLinkThemeString,
-    link,
-    language,
-    transformMultiline,
-    wikiData
-}) {
-    const listTag = getAlbumListTag(album);
+      contentFunction: {
+        name: 'generateAlbumCommentaryPage',
+        args: [album],
+      },
+    },
 
     /*
-    const trackGroups = album.trackGroups || [{
-        name: language.$('albumSidebar.trackList.fallbackGroupName'),
-        color: album.color,
-        startIndex: 0,
-        tracks: album.tracks
-    }];
+    {
+      type: 'data',
+      path: ['album', album.directory],
+
+      contentFunction: {
+        name: 'generateAlbumDataFile',
+        args: [album],
+      },
+    },
     */
-
-    const { trackGroups } = album;
-
-    const trackToListItem = track => html.tag('li',
-        {class: track === currentTrack && 'current'},
-        language.$('albumSidebar.trackList.item', {
-            track: link.track(track)
-        }));
-
-    const nameOrDefault = (isDefaultTrackGroup, name) =>
-        (isDefaultTrackGroup
-            ? language.$('albumSidebar.trackList.fallbackGroupName')
-            : name);
-
-    const trackListPart = fixWS`
-        <h1>${link.album(album)}</h1>
-        ${trackGroups.map(({ name, color, startIndex, tracks, isDefaultTrackGroup }) =>
-            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'
-                        ? language.$('albumSidebar.trackList.group.withRange', {
-                            group: `<span class="group-name">${nameOrDefault(isDefaultTrackGroup, name)}</span>`,
-                            range: `${startIndex + 1}&ndash;${startIndex + tracks.length}`
-                        })
-                        : language.$('albumSidebar.trackList.group', {
-                            group: `<span class="group-name">${nameOrDefault(isDefaultTrackGroup, 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 albums = group.albums.filter(album => album.date);
-        const index = albums.indexOf(album);
-        const next = index >= 0 && albums[index + 1];
-        const previous = index > 0 && albums[index - 1];
-        return {group, next, previous};
-    }).map(({group, next, previous}) => fixWS`
-        <h1>${
-            language.$('albumSidebar.groupBox.title', {
-                group: link.groupInfo(group)
-            })
-        }</h1>
-        ${!currentTrack && transformMultiline(group.descriptionShort)}
-        ${group.urls?.length && `<p>${
-            language.$('releaseInfo.visitOn', {
-                links: language.formatDisjunctionList(group.urls.map(url => fancifyURL(url)))
-            })
-        }</p>`}
-        ${!currentTrack && fixWS`
-            ${next && `<p class="group-chronology-link">${
-                language.$('albumSidebar.groupBox.next', {
-                    album: link.album(next)
-                })
-            }</p>`}
-            ${previous && `<p class="group-chronology-link">${
-                language.$('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 generateAlbumNavLinks(album, currentTrack, {
-    generatePreviousNextLinks,
-    language
-}) {
-    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
-            ? language.$('trackPage.nav.random')
-            : language.$('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'},
+    },
+
+    wikiInfo.canonicalBase === 'https://hsmusic.wiki/' &&
+      {
+        type: 'redirect',
+        fromPath: ['page', 'list/all-commentary'],
+        toPath: ['commentaryIndex'],
+        title: 'Album Commentary',
+      },
+  ];
 }
 
-export function generateAlbumChronologyLinks(album, currentTrack, {generateChronologyLinks}) {
-    return [
-        currentTrack && generateChronologyLinks(currentTrack, {
-            contribKey: 'artistContribs',
-            getThings: artist => [...artist.tracksAsArtist, ...artist.tracksAsContributor],
-            headingString: 'misc.chronology.heading.track'
-        }),
-        currentTrack && generateChronologyLinks(currentTrack, {
-            contribKey: 'contributorContribs',
-            getThings: artist => [...artist.tracksAsArtist, ...artist.tracksAsContributor],
-            headingString: 'misc.chronology.heading.track'
-        }),
-        generateChronologyLinks(currentTrack || album, {
-            contribKey: 'coverArtistContribs',
-            dateKey: 'coverArtDate',
-            getThings: artist => [...artist.albumsAsCoverArtist, ...artist.tracksAsCoverArtist],
-            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/artist-alias.js b/src/page/artist-alias.js
index ac23e90..c117779 100644
--- a/src/page/artist-alias.js
+++ b/src/page/artist-alias.js
@@ -1,22 +1,24 @@
-// 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;
+  return wikiData.artistData.filter(artist => artist.isAlias);
 }
 
-export function write(aliasArtist, {wikiData}) {
-    // This function doesn't actually use wikiData, 8ut, um, consistency?
+export function pathsForTarget(aliasArtist) {
+  const {aliasedArtist} = aliasArtist;
 
-    const { aliasedArtist } = aliasArtist;
+  // Don't generate a redirect page if this aliased name resolves to the same
+  // directory as the original artist! See issue #280.
+  if (aliasArtist.directory === aliasedArtist.directory) {
+    return [];
+  }
 
-    const redirect = {
-        type: 'redirect',
-        fromPath: ['artist', aliasArtist.directory],
-        toPath: ['artist', aliasedArtist.directory],
-        title: () => aliasedArtist.name
-    };
-
-    return [redirect];
+  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 c15e034..f80bd90 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -1,514 +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 {
-    bindOpts,
-    unique
-} from '../util/sugar.js';
-
-import {
-    chunkByProperties,
-    getTotalDuration,
-    sortByDate
-} from '../util/wiki-data.js';
+import {empty} from '#sugar';
 
-// 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, contextNotes } = artist;
-
-    const artThingsAll = sortByDate(unique([
-        ...artist.albumsAsCoverArtist ?? [],
-        ...artist.albumsAsWallpaperArtist ?? [],
-        ...artist.albumsAsBannerArtist ?? [],
-        ...artist.tracksAsCoverArtist ?? []
-    ]));
-
-    const artThingsGallery = sortByDate([
-        ...artist.albumsAsCoverArtist ?? [],
-        ...artist.tracksAsCoverArtist ?? []
-    ]);
-
-    const commentaryThings = sortByDate([
-        ...artist.albumsAsCommentator ?? [],
-        ...artist.tracksAsCommentator ?? []
-    ]);
-
-    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 =>
-        (['coverArtistContribs', 'wallpaperArtistContribs', 'bannerArtistContribs']
-            .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.tracksAsArtist ?? [],
-        ...artist.tracksAsContributor ?? []
-    ]));
-
-    const chunkTracks = tracks => (
-        chunkByProperties(tracks.map(track => ({
-            track,
-            date: +track.date,
-            album: track.album,
-            duration: track.duration,
-            artists: (track.artistContribs.some(({ who }) => who === artist)
-                ? track.artistContribs.filter(({ who }) => who !== artist)
-                : track.contributorContribs.filter(({ who }) => who !== artist)),
-            contrib: {
-                who: artist,
-                whatArray: [
-                    track.artistContribs.find(({ who }) => who === artist)?.what,
-                    track.contributorContribs.find(({ who }) => who === artist)?.what
-                ].filter(Boolean)
-            }
-        })), ['date', 'album'])
-        .map(({date, album, chunk}) => ({
-            date, album, chunk,
-            duration: getTotalDuration(chunk),
-        })));
-
-    const trackListChunks = chunkTracks(allTracks);
-    const totalDuration = getTotalDuration(allTracks);
-
-    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(allTracks);
-    const artGroups = countGroups(artThingsAll);
-
-    let flashes, flashListChunks;
-    if (wikiInfo.enableFlashesAndGames) {
-        flashes = sortByDate(artist.flashesAsContributor?.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.contributorContribs.find(({ who }) => who === artist)
-            })), ['act'])
-            .map(({ act, chunk }) => ({
-                act, chunk,
-                dateFirst: chunk[0].date,
-                dateLast: chunk[chunk.length - 1].date
-            })));
-    }
-
-    const generateEntryAccents = ({
-        getArtistString, language,
-        aka, entry, artists, contrib
-    }) =>
-        (aka
-            ? language.$('artistPage.creditList.entry.rerelease', {entry})
-            : (artists.length
-                ? ((contrib.what || contrib.whatArray?.length)
-                    ? language.$('artistPage.creditList.entry.withArtists.withContribution', {
-                        entry,
-                        artists: getArtistString(artists),
-                        contribution: (contrib.whatArray ? language.formatUnitList(contrib.whatArray) : contrib.what)
-                    })
-                    : language.$('artistPage.creditList.entry.withArtists', {
-                        entry,
-                        artists: getArtistString(artists)
-                    }))
-                : ((contrib.what || contrib.whatArray?.length)
-                    ? language.$('artistPage.creditList.entry.withContribution', {
-                        entry,
-                        contribution: (contrib.whatArray ? language.formatUnitList(contrib.whatArray) : contrib.what)
-                    })
-                    : entry)));
-
-    const unbound_generateTrackList = (chunks, {
-        getArtistString, link, language
-    }) => fixWS`
-        <dl>
-            ${chunks.map(({date, album, chunk, duration}) => fixWS`
-                <dt>${
-                    (date && duration) ? language.$('artistPage.creditList.album.withDate.withDuration', {
-                        album: link.album(album),
-                        date: language.formatDate(date),
-                        duration: language.formatDuration(duration, {approximate: true})
-                    }) : date ? language.$('artistPage.creditList.album.withDate', {
-                        album: link.album(album),
-                        date: language.formatDate(date)
-                    }) : duration ? language.$('artistPage.creditList.album.withDuration', {
-                        album: link.album(album),
-                        duration: language.formatDuration(duration, {approximate: true})
-                    }) : language.$('artistPage.creditList.album', {
-                        album: link.album(album)
-                    })}</dt>
-                <dd><ul>
-                    ${(chunk
-                        .map(({track, ...props}) => ({
-                            aka: track.aka,
-                            entry: language.$('artistPage.creditList.entry.track.withDuration', {
-                                track: link.track(track),
-                                duration: language.formatDuration(track.duration ?? 0)
-                            }),
-                            ...props
-                        }))
-                        .map(({aka, ...opts}) => html.tag('li',
-                            {class: aka && 'rerelease'},
-                            generateEntryAccents({getArtistString, language, 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.albumsAsCoverArtist?.map(serializeArtistsAndContrib('coverArtistContribs')),
-                    asWallpaperArtist: artist.albumsAsWallpaperArtist?.map(serializeArtistsAndContrib('wallpaperArtistContribs')),
-                    asBannerArtist: artist.albumsAsBannerArtist?.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.tracksAsContributor.map(serializeArtistsAndContrib('contributorContribs')),
-                    chunked: serializeTrackListChunks(trackListChunks)
-                }
-            };
-        }
-    };
-
-    const infoPage = {
-        type: 'page',
-        path: ['artist', artist.directory],
-        page: ({
-            fancifyURL,
-            generateCoverLink,
-            generateInfoGalleryLinks,
-            getArtistAvatar,
-            getArtistString,
-            link,
-            language,
-            to,
-            transformMultiline
-        }) => {
-            const generateTrackList = bindOpts(unbound_generateTrackList, {
-                getArtistString,
-                link,
-                language
-            });
-
-            return {
-                title: language.$('artistPage.title', {artist: name}),
-
-                main: {
-                    content: fixWS`
-                        ${artist.hasAvatar && generateCoverLink({
-                            src: getArtistAvatar(artist),
-                            alt: language.$('misc.alt.artistAvatar')
-                        })}
-                        <h1>${language.$('artistPage.title', {artist: name})}</h1>
-                        ${contextNotes && fixWS`
-                            <p>${language.$('releaseInfo.note')}</p>
-                            <blockquote>
-                                ${transformMultiline(contextNotes)}
-                            </blockquote>
-                            <hr>
-                        `}
-                        ${urls?.length && `<p>${language.$('releaseInfo.visitOn', {
-                            links: language.formatDisjunctionList(urls.map(url => fancifyURL(url, {language})))
-                        })}</p>`}
-                        ${hasGallery && `<p>${language.$('artistPage.viewArtGallery', {
-                            link: link.artistGallery(artist, {
-                                text: language.$('artistPage.viewArtGallery.link')
-                            })
-                        })}</p>`}
-                        <p>${language.$('misc.jumpTo.withLinks', {
-                            links: language.formatUnitList([
-                                allTracks.length && `<a href="#tracks">${language.$('artistPage.trackList.title')}</a>`,
-                                artThingsAll.length && `<a href="#art">${language.$('artistPage.artList.title')}</a>`,
-                                wikiInfo.enableFlashesAndGames && flashes.length && `<a href="#flashes">${language.$('artistPage.flashList.title')}</a>`,
-                                commentaryThings.length && `<a href="#commentary">${language.$('artistPage.commentaryList.title')}</a>`
-                            ].filter(Boolean))
-                        })}</p>
-                        ${allTracks.length && fixWS`
-                            <h2 id="tracks">${language.$('artistPage.trackList.title')}</h2>
-                            <p>${language.$('artistPage.contributedDurationLine', {
-                                artist: artist.name,
-                                duration: language.formatDuration(totalDuration, {approximate: true, unit: true})
-                            })}</p>
-                            <p>${language.$('artistPage.musicGroupsLine', {
-                                groups: language.formatUnitList(musicGroups
-                                    .map(({ group, contributions }) => language.$('artistPage.groupsLine.item', {
-                                        group: link.groupInfo(group),
-                                        contributions: language.countContributions(contributions)
-                                    })))
-                            })}</p>
-                            ${generateTrackList(trackListChunks)}
-                        `}
-                        ${artThingsAll.length && fixWS`
-                            <h2 id="art">${language.$('artistPage.artList.title')}</h2>
-                            ${hasGallery && `<p>${language.$('artistPage.viewArtGallery.orBrowseList', {
-                                link: link.artistGallery(artist, {
-                                    text: language.$('artistPage.viewArtGallery.link')
-                                })
-                            })}</p>`}
-                            <p>${language.$('artistPage.artGroupsLine', {
-                                groups: language.formatUnitList(artGroups
-                                    .map(({ group, contributions }) => language.$('artistPage.groupsLine.item', {
-                                        group: link.groupInfo(group),
-                                        contributions: language.countContributions(contributions)
-                                    })))
-                            })}</p>
-                            <dl>
-                                ${artListChunks.map(({date, album, chunk}) => fixWS`
-                                    <dt>${language.$('artistPage.creditList.album.withDate', {
-                                        album: link.album(album),
-                                        date: language.formatDate(date)
-                                    })}</dt>
-                                    <dd><ul>
-                                        ${(chunk
-                                            .map(({album, track, key, ...props}) => ({
-                                                entry: (track
-                                                    ? language.$('artistPage.creditList.entry.track', {
-                                                        track: link.track(track)
-                                                    })
-                                                    : `<i>${language.$('artistPage.creditList.entry.album.' + {
-                                                        wallpaperArtistContribs: 'wallpaperArt',
-                                                        bannerArtistContribs: 'bannerArt',
-                                                        coverArtistContribs: 'coverArt'
-                                                    }[key])}</i>`),
-                                                ...props
-                                            }))
-                                            .map(opts => generateEntryAccents({getArtistString, language, ...opts}))
-                                            .map(row => `<li>${row}</li>`)
-                                            .join('\n'))}
-                                    </ul></dd>
-                                `).join('\n')}
-                            </dl>
-                        `}
-                        ${wikiInfo.enableFlashesAndGames && flashes.length && fixWS`
-                            <h2 id="flashes">${language.$('artistPage.flashList.title')}</h2>
-                            <dl>
-                                ${flashListChunks.map(({act, chunk, dateFirst, dateLast}) => fixWS`
-                                    <dt>${language.$('artistPage.creditList.flashAct.withDateRange', {
-                                        act: link.flash(chunk[0].flash, {text: act.name}),
-                                        dateRange: language.formatDateRange(dateFirst, dateLast)
-                                    })}</dt>
-                                    <dd><ul>
-                                        ${(chunk
-                                            .map(({flash, ...props}) => ({
-                                                entry: language.$('artistPage.creditList.entry.flash', {
-                                                    flash: link.flash(flash)
-                                                }),
-                                                ...props
-                                            }))
-                                            .map(opts => generateEntryAccents({getArtistString, language, ...opts}))
-                                            .map(row => `<li>${row}</li>`)
-                                            .join('\n'))}
-                                    </ul></dd>
-                                `).join('\n')}
-                            </dl>
-                        `}
-                        ${commentaryThings.length && fixWS`
-                            <h2 id="commentary">${language.$('artistPage.commentaryList.title')}</h2>
-                            <dl>
-                                ${commentaryListChunks.map(({album, chunk}) => fixWS`
-                                    <dt>${language.$('artistPage.creditList.album', {
-                                        album: link.album(album)
-                                    })}</dt>
-                                    <dd><ul>
-                                        ${(chunk
-                                            .map(({album, track, ...props}) => track
-                                                ? language.$('artistPage.creditList.entry.track', {
-                                                    track: link.track(track)
-                                                })
-                                                : `<i>${language.$('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,
-                    language,
-                    wikiData
-                })
-            };
-        }
-    };
-
-    const galleryPage = hasGallery && {
-        type: 'page',
-        path: ['artistGallery', artist.directory],
-        page: ({
-            generateInfoGalleryLinks,
-            getAlbumCover,
-            getGridHTML,
-            getTrackCover,
-            link,
-            language,
-            to
-        }) => ({
-            title: language.$('artistGalleryPage.title', {artist: name}),
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${language.$('artistGalleryPage.title', {artist: name})}</h1>
-                    <p class="quick-info">${language.$('artistGalleryPage.infoLine', {
-                        coverArts: language.countCoverArts(artThingsGallery.length, {unit: true})
-                    })}</p>
-                    <div class="grid-listing">
-                        ${getGridHTML({
-                            entries: artThingsGallery.map(item => ({item})),
-                            srcFn: thing => (thing.album
-                                ? getTrackCover(thing)
-                                : getAlbumCover(thing)),
-                            linkFn: (thing, opts) => (thing.album
-                                ? link.track(thing, opts)
-                                : link.album(thing, opts))
-                        })}
-                    </div>
-                `
-            },
-
-            nav: generateNavForArtist(artist, true, hasGallery, {
-                generateInfoGalleryLinks,
-                link,
-                language,
-                wikiData
-            })
-        })
-    };
-
-    return [data, infoPage, galleryPage].filter(Boolean);
+export function pathsForTarget(artist) {
+  const hasGalleryPage =
+    !empty(artist.tracksAsCoverArtist) ||
+    !empty(artist.albumsAsCoverArtist);
+
+  return [
+    {
+      type: 'page',
+      path: ['artist', artist.directory],
+
+      contentFunction: {
+        name: 'generateArtistInfoPage',
+        args: [artist],
+      },
+    },
+
+    hasGalleryPage && {
+      type: 'page',
+      path: ['artistGallery', artist.directory],
+
+      contentFunction: {
+        name: 'generateArtistGalleryPage',
+        args: [artist],
+      },
+    },
+  ];
 }
 
-// Utility functions
-
-function generateNavForArtist(artist, isGallery, hasGallery, {
-    generateInfoGalleryLinks,
-    link,
-    language,
-    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, language,
-            linkKeyGallery: 'artistGallery',
-            linkKeyInfo: 'artist'
-        }))
+    const serializeTrackListChunks = bindOpts(unbound_serializeTrackListChunks, {
+      serializeLink,
+    });
 
     return {
-        links: [
-            {toHome: true},
-            wikiInfo.enableListings &&
-            {
-                path: ['localized.listingIndex'],
-                title: language.$('listingIndex.title')
-            },
-            {
-                html: language.$('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 0000000..e54525a
--- /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 58969b1..7df7415 100644
--- a/src/page/flash.js
+++ b/src/page/flash.js
@@ -1,254 +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.enableFlashesAndGames;
+  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,
-            language,
-            transformInline
-        }) => ({
-            title: language.$('flashPage.title', {flash: flash.name}),
-            theme: getThemeString(flash.color, [
-                `--flash-directory: ${flash.directory}`
-            ]),
-
-            main: {
-                content: fixWS`
-                    <h1>${language.$('flashPage.title', {flash: flash.name})}</h1>
-                    ${generateCoverLink({
-                        src: getFlashCover(flash),
-                        alt: language.$('misc.alt.flashArt')
-                    })}
-                    <p>${language.$('releaseInfo.released', {date: language.formatDate(flash.date)})}</p>
-                    ${(flash.page || flash.urls?.length) && `<p>${language.$('releaseInfo.playOn', {
-                        links: language.formatDisjunctionList([
-                            flash.page && getFlashLink(flash),
-                            ...flash.urls ?? []
-                        ].map(url => fancifyFlashURL(url, flash)))
-                    })}</p>`}
-                    ${flash.featuredTracks && fixWS`
-                        <p>Tracks featured in <i>${flash.name.replace(/\.$/, '')}</i>:</p>
-                        <ul>
-                            ${(flash.featuredTracks
-                                .map(track => language.$('trackList.item.withArtists', {
-                                    track: link.track(track),
-                                    by: `<span class="by">${
-                                        language.$('trackList.item.withArtists.by', {
-                                            artists: getArtistString(track.artistContribs)
-                                        })
-                                    }</span>`
-                                }))
-                                .map(row => `<li>${row}</li>`)
-                                .join('\n'))}
-                        </ul>
-                    `}
-                    ${flash.contributorContribs.length && fixWS`
-                        <p>${language.$('releaseInfo.contributors')}</p>
-                        <ul>
-                            ${flash.contributorContribs
-                                .map(contrib => `<li>${getArtistString([contrib], {
-                                    showContrib: true,
-                                    showIcons: true
-                                })}</li>`)
-                                .join('\n')}
-                        </ul>
-                    `}
-                `
-            },
-
-            sidebarLeft: generateSidebarForFlash(flash, {link, language, wikiData}),
-            nav: generateNavForFlash(flash, {
-                generateChronologyLinks,
-                generatePreviousNextLinks,
-                link,
-                language,
-                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,
-            language
-        }) => ({
-            title: language.$('flashIndex.title'),
-
-            main: {
-                classes: ['flash-index'],
-                content: fixWS`
-                    <h1>${language.$('flashIndex.title')}</h1>
-                    <div class="long-content">
-                        <p class="quick-info">${language.$('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,
-    language,
-    wikiData
-}) {
-    const { flashData, wikiInfo } = wikiData;
-
-    const previousNextLinks = generatePreviousNextLinks(flash, {
-        data: flashData,
-        linkKey: 'flash'
-    });
-
-    return {
-        links: [
-            {toHome: true},
-            {
-                path: ['localized.flashIndex'],
-                title: language.$('flashIndex.title')
-            },
-            {
-                html: language.$('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: 'contributorContribs',
-                    getThings: artist => artist.flashesAsContributor
-                })}
-            </div>
-        `
-    };
-}
-
-function generateSidebarForFlash(flash, {link, language, 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: language.$('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 eb401dd..b0ed5ba 100644
--- a/src/page/group.js
+++ b/src/page/group.js
@@ -1,263 +1,53 @@
-// Group page specifications.
+import {empty} from '#sugar';
 
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-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;
-}
-
-export function write(group, {wikiData}) {
-    const { listingSpec, wikiInfo } = wikiData;
-
-    const { albums } = group;
-    const tracks = albums.flatMap(album => album.tracks);
-    const totalDuration = getTotalDuration(tracks);
-
-    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,
-            language,
-            transformMultiline
-        }) => ({
-            title: language.$('groupInfoPage.title', {group: group.name}),
-            theme: getThemeString(group.color),
-
-            main: {
-                content: fixWS`
-                    <h1>${language.$('groupInfoPage.title', {group: group.name})}</h1>
-                    ${group.urls?.length && `<p>${
-                        language.$('releaseInfo.visitOn', {
-                            links: language.formatDisjunctionList(group.urls.map(url => fancifyURL(url, {language})))
-                        })
-                    }</p>`}
-                    <blockquote>
-                        ${transformMultiline(group.description)}
-                    </blockquote>
-                    <h2>${language.$('groupInfoPage.albumList.title')}</h2>
-                    <p>${
-                        language.$('groupInfoPage.viewAlbumGallery', {
-                            link: link.groupGallery(group, {
-                                text: language.$('groupInfoPage.viewAlbumGallery.link')
-                            })
-                        })
-                    }</p>
-                    <ul>
-                        ${albumLines.map(({ album, otherGroup }) => {
-                            const item = (album.date
-                                ? language.$('groupInfoPage.albumList.item', {
-                                    year: album.date.getFullYear(),
-                                    album: link.album(album)
-                                })
-                                : language.$('groupInfoPage.albumList.item.withoutYear', {
-                                    album: link.album(album)
-                                }));
-                            return html.tag('li', (otherGroup
-                                ? language.$('groupInfoPage.albumList.item.withAccent', {
-                                    item,
-                                    accent: html.tag('span',
-                                        {class: 'other-group-accent'},
-                                        language.$('groupInfoPage.albumList.item.otherGroupAccent', {
-                                            group: link.groupInfo(otherGroup, {color: false})
-                                        }))
-                                })
-                                : item));
-                        }).join('\n')}
-                    </ul>
-                `
-            },
-
-            sidebarLeft: generateGroupSidebar(group, false, {
-                getLinkThemeString,
-                link,
-                language,
-                wikiData
-            }),
-
-            nav: generateGroupNav(group, false, {
-                generateInfoGalleryLinks,
-                generatePreviousNextLinks,
-                link,
-                language,
-                wikiData
-            })
-        })
-    };
-
-    const galleryPage = {
-        type: 'page',
-        path: ['groupGallery', group.directory],
-        page: ({
-            generateInfoGalleryLinks,
-            generatePreviousNextLinks,
-            getAlbumGridHTML,
-            getLinkThemeString,
-            getThemeString,
-            link,
-            language
-        }) => ({
-            title: language.$('groupGalleryPage.title', {group: group.name}),
-            theme: getThemeString(group.color),
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${language.$('groupGalleryPage.title', {group: group.name})}</h1>
-                    <p class="quick-info">${
-                        language.$('groupGalleryPage.infoLine', {
-                            tracks: `<b>${language.countTracks(tracks.length, {unit: true})}</b>`,
-                            albums: `<b>${language.countAlbums(albums.length, {unit: true})}</b>`,
-                            time: `<b>${language.formatDuration(totalDuration, {unit: true})}</b>`
-                        })
-                    }</p>
-                    ${wikiInfo.enableGroupUI && wikiInfo.enableListings && html.tag('p',
-                        {class: 'quick-info'},
-                        language.$('groupGalleryPage.anotherGroupLine', {
-                            link: link.listing(listingSpec.find(l => l.directory === 'groups/by-category'), {
-                                text: language.$('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,
-                language,
-                wikiData
-            }),
-
-            nav: generateGroupNav(group, true, {
-                generateInfoGalleryLinks,
-                generatePreviousNextLinks,
-                link,
-                language,
-                wikiData
-            })
-        })
-    };
-
-    return [infoPage, galleryPage];
+  return wikiData.groupData;
 }
 
-// Utility functions
-
-function generateGroupSidebar(currentGroup, isGallery, {
-    getLinkThemeString,
-    link,
-    language,
-    wikiData
-}) {
-    const { groupCategoryData, wikiInfo } = wikiData;
-
-    if (!wikiInfo.enableGroupUI) {
-        return null;
-    }
-
-    const linkKey = isGallery ? 'groupGallery' : 'groupInfo';
-
-    return {
-        content: fixWS`
-            <h1>${language.$('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)},
-                        language.$('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)
-                            },
-                            language.$('groupSidebar.groupList.item', {
-                                group: link[linkKey](group)
-                            }))))
-                ])).join('\n')}
-            </dl>
-        `
-    };
+export function pathsForTarget(group) {
+  const hasGalleryPage = !empty(group.albums);
+
+  return [
+    {
+      type: 'page',
+      path: ['groupInfo', group.directory],
+
+      contentFunction: {
+        name: 'generateGroupInfoPage',
+        args: [group],
+      },
+    },
+
+    hasGalleryPage && {
+      type: 'page',
+      path: ['groupGallery', group.directory],
+
+      contentFunction: {
+        name: 'generateGroupGalleryPage',
+        args: [group],
+      },
+    },
+  ];
 }
 
-function generateGroupNav(currentGroup, isGallery, {
-    generateInfoGalleryLinks,
-    generatePreviousNextLinks,
-    link,
-    language,
-    wikiData
-}) {
-    const { groupData, wikiInfo } = wikiData;
-
-    if (!wikiInfo.enableGroupUI) {
-        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.enableListings &&
-            {
-                path: ['localized.listingIndex'],
-                title: language.$('listingIndex.title')
-            },
-            {
-                html: language.$('groupPage.nav.group', {
-                    group: link[linkKey](currentGroup, {class: 'current'})
-                })
-            },
-            {
-                divider: false,
-                html: (previousNextLinks
-                    ? `(${infoGalleryLinks}; ${previousNextLinks})`
-                    : `(${previousNextLinks})`)
-            }
-        ]
-    };
+export function pathsTargetless({wikiData: {wikiInfo}}) {
+  return [
+    wikiInfo.canonicalBase === 'https://hsmusic.wiki/' &&
+      {
+        type: 'redirect',
+        fromPath: ['page', 'albums/fandom'],
+        toPath: ['groupGallery', 'fandom'],
+        title: 'Fandom - Gallery',
+      },
+
+    wikiInfo.canonicalBase === 'https://hsmusic.wiki/' &&
+      {
+        type: 'redirect',
+        fromPath: ['page', 'albums/official'],
+        toPath: ['groupGallery', 'official'],
+        title: 'Official - Gallery',
+      },
+  ];
 }
diff --git a/src/page/homepage.js b/src/page/homepage.js
index 534ce78..53ee6e4 100644
--- a/src/page/homepage.js
+++ b/src/page/homepage.js
@@ -1,123 +1,15 @@
-// Homepage specification.
-
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import * as html from '../util/html.js';
-
-import {
-    getNewAdditions,
-    getNewReleases
-} from '../util/wiki-data.js';
-
-// Page exports
-
-export function writeTargetless({wikiData}) {
-    const { newsData, staticPageData, homepageLayout, wikiInfo } = wikiData;
-
-    const page = {
-        type: 'page',
-        path: ['home'],
-        page: ({
-            getAlbumGridHTML,
-            getLinkThemeString,
-            link,
-            language,
-            to,
-            transformInline,
-            transformMultiline
-        }) => ({
-            title: wikiInfo.name,
-            showWikiNameInTitle: false,
-
-            meta: {
-                description: wikiInfo.description
-            },
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${wikiInfo.name}</h1>
-                    ${homepageLayout.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.sourceGroupByRef === 'new-releases' ? getNewReleases(row.countAlbumsFromGroup, {wikiData}) :
-                                            row.sourceGroupByRef === 'new-additions' ? getNewAdditions(row.countAlbumsFromGroup, {wikiData}) :
-                                            ((row.sourceGroup?.albums ?? [])
-                                                .slice()
-                                                .reverse()
-                                                .filter(album => album.isListedOnHomepage)
-                                                .slice(0, row.countAlbumsFromGroup)
-                                                .map(album => ({item: album})))
-                                        ).concat(row.sourceAlbums.map(album => ({item: album}))),
-                                        lazy: i > 0
-                                    })}
-                                    ${row.actionLinks?.length && fixWS`
-                                        <div class="grid-actions">
-                                            ${row.actionLinks.map(action => transformInline(action)
-                                                .replace('<a', '<a class="box grid-item"')).join('\n')}
-                                        </div>
-                                    `}
-                                </div>
-                            `}
-                        </section>
-                    `).join('\n')}
-                `
-            },
-
-            sidebarLeft: homepageLayout.sidebarContent && {
-                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(homepageLayout.sidebarContent.replace('[[news]]', '__GENERATE_NEWS__'))
-                    .replace('<p>__GENERATE_NEWS__</p>', wikiInfo.enableNews ? fixWS`
-                        <h1>${language.$('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>${language.formatDate(entry.date)}</time> ${link.newsEntry(entry)}</h2>
-                                ${transformMultiline(entry.contentShort)}
-                                ${entry.contentShort !== entry.content && link.newsEntry(entry, {
-                                    text: language.$('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.nameShort, class: 'current', to}),
-                            wikiInfo.enableListings &&
-                            link.listingIndex('', {text: language.$('listingIndex.title'), to}),
-                            wikiInfo.enableNews &&
-                            link.newsIndex('', {text: language.$('newsIndex.title'), to}),
-                            wikiInfo.enableFlashesAndGames &&
-                            link.flashIndex('', {text: language.$('flashIndex.title'), to}),
-                            ...(staticPageData
-                                .filter(page => page.showInNavigationBar)
-                                .map(page => link.staticPage(page, {text: page.nameShort})))
-                        ].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: 'generateWikiHomePage',
+        args: [wikiData.homepageLayout],
+      },
+    },
+  ];
 }
diff --git a/src/page/index.js b/src/page/index.js
index f580cbe..21d93c8 100644
--- a/src/page/index.js
+++ b/src/page/index.js
@@ -1,49 +1,8 @@
-// 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 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';
diff --git a/src/page/listing.js b/src/page/listing.js
index 261b1e9..bb22c21 100644
--- a/src/page/listing.js
+++ b/src/page/listing.js
@@ -1,201 +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 {
-    getTotalDuration
-} from '../util/wiki-data.js';
-
-// Page exports
-
-export function condition({wikiData}) {
-    return wikiData.wikiInfo.enableListings;
-}
-
+//
 export function targets({wikiData}) {
-    return wikiData.listingSpec;
-}
-
-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, language } = opts;
-            const titleKey = `listingPage.${listing.stringsKey}.title`;
-
-            return {
-                title: language.$(titleKey),
-
-                main: {
-                    content: fixWS`
-                        <h1>${language.$(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,
-                        language,
-                        wikiData
-                    })
-                },
-
-                nav: {
-                    links: [
-                        {toHome: true},
-                        {
-                            path: ['localized.listingIndex'],
-                            title: language.$('listingIndex.title')
-                        },
-                        {toCurrentPage: true}
-                    ]
-                }
-            };
-        }
-    };
-
-    return [page];
+  return (
+    wikiData.listingSpec
+      .filter(listing => listing.contentFunction)
+      .filter(listing =>
+        !listing.featureFlag ||
+        wikiData.wikiInfo[listing.featureFlag]));
 }
 
-export function writeTargetless({wikiData}) {
-    const { albumData, trackData, wikiInfo } = wikiData;
-
-    const totalDuration = getTotalDuration(trackData);
-
-    const page = {
-        type: 'page',
-        path: ['listingIndex'],
-        page: ({
-            getLinkThemeString,
-            language,
-            link
-        }) => ({
-            title: language.$('listingIndex.title'),
-
-            main: {
-                content: fixWS`
-                    <h1>${language.$('listingIndex.title')}</h1>
-                    <p>${language.$('listingIndex.infoLine', {
-                        wiki: wikiInfo.name,
-                        tracks: `<b>${language.countTracks(trackData.length, {unit: true})}</b>`,
-                        albums: `<b>${language.countAlbums(albumData.length, {unit: true})}</b>`,
-                        duration: `<b>${language.formatDuration(totalDuration, {approximate: true, unit: true})}</b>`
-                    })}</p>
-                    <hr>
-                    <p>${language.$('listingIndex.exploreList')}</p>
-                    ${generateLinkIndexForListings(null, false, {link, language, wikiData})}
-                `
-            },
-
-            sidebarLeft: {
-                content: generateSidebarForListings(null, {
-                    getLinkThemeString,
-                    link,
-                    language,
-                    wikiData
-                })
-            },
-
-            nav: {simple: true}
-        })
-    };
-
-    return [page];
-};
-
-// Utility functions
-
-function generateSidebarForListings(currentListing, {
-    getLinkThemeString,
-    link,
-    language,
-    wikiData
-}) {
-    return fixWS`
-        <h1>${link.listingIndex('', {text: language.$('listingIndex.title')})}</h1>
-        ${generateLinkIndexForListings(currentListing, true, {
-            getLinkThemeString,
-            link,
-            language,
-            wikiData
-        })}
-    `;
+export function pathsForTarget(listing) {
+  return [
+    {
+      type: 'page',
+      path: ['listing', listing.directory],
+      contentFunction: {
+        name: listing.contentFunction,
+        args: [listing],
+      },
+    },
+  ];
 }
 
-function generateLinkIndexForListings(currentListing, forSidebar, {
-    getLinkThemeString,
-    link,
-    language,
-    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: language.$(`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({language}))),
-                genUL(listings)
-            ])).join('\n');
-    } else {
-        return html.tag('dl',
-            filteredByCondition.flatMap(({ title, listings }) => [
-                html.tag('dt', title({language})),
-                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 4f5c505..194ffdc 100644
--- a/src/page/news.js
+++ b/src/page/news.js
@@ -1,126 +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.enableNews;
+  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,
-            language,
-            transformMultiline,
-        }) => ({
-            title: language.$('newsEntryPage.title', {entry: entry.name}),
-
-            main: {
-                content: fixWS`
-                    <div class="long-content">
-                        <h1>${language.$('newsEntryPage.title', {entry: entry.name})}</h1>
-                        <p>${language.$('newsEntryPage.published', {date: language.formatDate(entry.date)})}</p>
-                        ${transformMultiline(entry.content)}
-                    </div>
-                `
-            },
-
-            nav: generateNewsEntryNav(entry, {
-                generatePreviousNextLinks,
-                link,
-                language,
-                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,
-            language,
-            transformMultiline
-        }) => ({
-            title: language.$('newsIndex.title'),
-
-            main: {
-                content: fixWS`
-                    <div class="long-content news-index">
-                        <h1>${language.$('newsIndex.title')}</h1>
-                        ${newsData.map(entry => fixWS`
-                            <article id="${entry.directory}">
-                                <h2><time>${language.formatDate(entry.date)}</time> ${link.newsEntry(entry)}</h2>
-                                ${transformMultiline(entry.contentShort)}
-                                ${entry.contentShort !== entry.content && `<p>${link.newsEntry(entry, {
-                                    text: language.$('newsIndex.entry.viewRest')
-                                })}</p>`}
-                            </article>
-                        `).join('\n')}
-                    </div>
-                `
-            },
-
-            nav: {simple: true}
-        })
-    };
-
-    return [page];
-}
-
-// Utility functions
-
-function generateNewsEntryNav(entry, {
-    generatePreviousNextLinks,
-    link,
-    language,
-    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, language,
-        data: newsData.slice().reverse(),
-        linkKey: 'newsEntry'
-    });
-
-    return {
-        links: [
-            {toHome: true},
-            {
-                path: ['localized.newsIndex'],
-                title: language.$('newsEntryPage.nav.news')
-            },
-            {
-                html: language.$('newsEntryPage.nav.entry', {
-                    date: language.formatDate(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 e9b6a04..c9d806f 100644
--- a/src/page/static.js
+++ b/src/page/static.js
@@ -1,40 +1,22 @@
-// 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: ({
-            language,
-            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],
+
+      contentFunction: {
+        name: 'generateStaticPage',
+        args: [staticPage],
+      },
+    },
+  ];
 }
diff --git a/src/page/tag.js b/src/page/tag.js
index 8e5e699..8942aea 100644
--- a/src/page/tag.js
+++ b/src/page/tag.js
@@ -1,110 +1,25 @@
 // Art tag page specification.
 
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-// Page exports
+export const description = `per-artwork-tag gallery pages`;
 
 export function condition({wikiData}) {
-    return wikiData.wikiInfo.enableArtTagUI;
+  return wikiData.wikiInfo.enableArtTagUI;
 }
 
 export function targets({wikiData}) {
-    return wikiData.artTagData.filter(tag => !tag.isContentWarning);
-}
-
-export function write(tag, {wikiData}) {
-    const { wikiInfo } = wikiData;
-    const { taggedInThings: 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,
-            language,
-            to
-        }) => ({
-            title: language.$('tagPage.title', {tag: tag.name}),
-            theme: getThemeString(tag.color),
-
-            main: {
-                classes: ['top-index'],
-                content: fixWS`
-                    <h1>${language.$('tagPage.title', {tag: tag.name})}</h1>
-                    <p class="quick-info">${language.$('tagPage.infoLine', {
-                        coverArts: language.countCoverArts(things.length, {unit: true})
-                    })}</p>
-                    <div class="grid-listing">
-                        ${getGridHTML({
-                            entries,
-                            srcFn: thing => (thing.album
-                                ? getTrackCover(thing)
-                                : getAlbumCover(thing)),
-                            linkFn: (thing, opts) => (thing.album
-                                ? link.track(thing, opts)
-                                : link.album(thing, opts))
-                        })}
-                    </div>
-                `
-            },
-
-            nav: generateTagNav(tag, {
-                generatePreviousNextLinks,
-                link,
-                language,
-                wikiData
-            })
-        })
-    };
-
-    return [page];
+  return wikiData.artTagData.filter((tag) => !tag.isContentWarning);
 }
 
-// Utility functions
-
-function generateTagNav(tag, {
-    generatePreviousNextLinks,
-    link,
-    language,
-    wikiData
-}) {
-    const previousNextLinks = generatePreviousNextLinks(tag, {
-        data: wikiData.artTagData.filter(tag => !tag.isContentWarning),
-        linkKey: 'tag'
-    });
-
-    return {
-        links: [
-            {toHome: true},
-            wikiData.wikiInfo.enableListings &&
-            {
-                path: ['localized.listingIndex'],
-                title: language.$('listingIndex.title')
-            },
-            {
-                html: language.$('tagPage.nav.tag', {
-                    tag: link.tag(tag, {class: 'current'})
-                })
-            },
-            /*
-            previousNextLinks && {
-                divider: false,
-                html: `(${previousNextLinks})`
-            }
-            */
-        ]
-    };
+export function pathsForTarget(tag) {
+  return [
+    {
+      type: 'page',
+      path: ['tag', tag.directory],
+
+      contentFunction: {
+        name: 'generateArtTagGalleryPage',
+        args: [tag],
+      },
+    },
+  ];
 }
diff --git a/src/page/track.js b/src/page/track.js
index d51cee2..e75b695 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -1,338 +1,21 @@
 // Track page specification.
 
-// Imports
-
-import fixWS from 'fix-whitespace';
-
-import {
-    generateAlbumChronologyLinks,
-    generateAlbumNavLinks,
-    generateAlbumSidebar
-} from './album.js';
-
-import * as html from '../util/html.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, referencedByTracks, referencedTracks, otherReleases } = track;
-
-    const listTag = getAlbumListTag(album);
-
-    let flashesThatFeature;
-    if (wikiInfo.enableFlashesAndGames) {
-        flashesThatFeature = sortByDate([track, ...otherReleases]
-            .flatMap(track => track.featuredInFlashes.map(flash => ({flash, as: track}))));
-    }
-
-    const unbound_getTrackItem = (track, {getArtistString, link, language}) => (
-        html.tag('li', language.$('trackList.item.withArtists', {
-            track: link.track(track),
-            by: `<span class="by">${language.$('trackList.item.withArtists.by', {
-                artists: getArtistString(track.artistContribs)
-            })}</span>`
-        })));
-
-    const unbound_generateTrackList = (tracks, {getArtistString, link, language}) => html.tag('ul',
-        tracks.map(track => unbound_getTrackItem(track, {getArtistString, link, language}))
-    );
-
-    const hasCommentary = track.commentary || otherReleases.some(t => t.commentary);
-    const generateCommentary = ({
-        link,
-        language,
-        transformMultiline
-    }) => transformMultiline([
-        track.commentary,
-        ...otherReleases.map(track =>
-            (track.commentary?.split('\n')
-                .filter(line => line.replace(/<\/b>/g, '').includes(':</i>'))
-                .map(line => fixWS`
-                    ${line}
-                    ${language.$('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),
-            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 getSocialEmbedDescription = ({
-        getArtistString: _getArtistString,
-        language,
-    }) => {
-        const hasArtists = (track.artistContribs?.length > 0);
-        const hasCoverArtists = (track.coverArtistContribs?.length > 0);
-        const getArtistString = contribs => _getArtistString(contribs, {
-            // We don't want to put actual HTML tags in social embeds (sadly
-            // they don't get parsed and displayed, generally speaking), so
-            // override the link argument so that artist "links" just show
-            // their names.
-            link: {artist: artist => artist.name}
-        });
-        if (!hasArtists && !hasCoverArtists) return '';
-        return language.formatString(
-            'trackPage.socialEmbed.body' + [
-                hasArtists && '.withArtists',
-                hasCoverArtists && '.withCoverArtists',
-            ].filter(Boolean).join(''),
-            Object.fromEntries([
-                hasArtists && ['artists', getArtistString(track.artistContribs)],
-                hasCoverArtists && ['coverArtists', getArtistString(track.coverArtistContribs)],
-            ].filter(Boolean)))
-    };
-
-    const page = {
-        type: 'page',
-        path: ['track', track.directory],
-        page: ({
-            absoluteTo,
-            fancifyURL,
-            generateChronologyLinks,
-            generateCoverLink,
-            generatePreviousNextLinks,
-            generateTrackListDividedByGroups,
-            getAlbumStylesheet,
-            getArtistString,
-            getLinkThemeString,
-            getThemeString,
-            getTrackCover,
-            link,
-            language,
-            transformInline,
-            transformLyrics,
-            transformMultiline,
-            to,
-            urls,
-        }) => {
-            const getTrackItem = bindOpts(unbound_getTrackItem, {getArtistString, link, language});
-            const cover = getTrackCover(track);
-
-            return {
-                title: language.$('trackPage.title', {track: track.name}),
-                stylesheet: getAlbumStylesheet(album, {to}),
-                theme: getThemeString(track.color, [
-                    `--album-directory: ${album.directory}`,
-                    `--track-directory: ${track.directory}`
-                ]),
-
-                socialEmbed: {
-                    heading: language.$('trackPage.socialEmbed.heading', {album: track.album.name}),
-                    headingLink: absoluteTo('localized.album', album.directory),
-                    title: language.$('trackPage.socialEmbed.title', {track: track.name}),
-                    description: getSocialEmbedDescription({getArtistString, language}),
-                    image: '/' + getTrackCover(track, {to: urls.from('shared.root').to}),
-                    color: track.color,
-                },
-
-                // disabled for now! shifting banner position per height of page is disorienting
-                /*
-                banner: album.bannerArtistContribs.length && {
-                    classes: ['dim'],
-                    dimensions: album.bannerDimensions,
-                    path: ['media.albumBanner', album.directory, album.bannerFileExtension],
-                    alt: language.$('misc.alt.albumBanner'),
-                    position: 'bottom'
-                },
-                */
-
-                main: {
-                    content: fixWS`
-                        ${cover && generateCoverLink({
-                            src: cover,
-                            alt: language.$('misc.alt.trackCover'),
-                            tags: track.artTags
-                        })}
-                        <h1>${language.$('trackPage.title', {track: track.name})}</h1>
-                        <p>
-                            ${[
-                                language.$('releaseInfo.by', {
-                                    artists: getArtistString(track.artistContribs, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                track.coverArtistContribs.length && language.$('releaseInfo.coverArtBy', {
-                                    artists: getArtistString(track.coverArtistContribs, {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })
-                                }),
-                                track.date && language.$('releaseInfo.released', {
-                                    date: language.formatDate(track.date)
-                                }),
-                                (track.coverArtDate &&
-                                    +track.coverArtDate !== +track.date &&
-                                    language.$('releaseInfo.artReleased', {
-                                        date: language.formatDate(track.coverArtDate)
-                                    })),
-                                track.duration && language.$('releaseInfo.duration', {
-                                    duration: language.formatDuration(track.duration)
-                                })
-                            ].filter(Boolean).join('<br>\n')}
-                        </p>
-                        <p>${
-                            (track.urls?.length
-                                ? language.$('releaseInfo.listenOn', {
-                                    links: language.formatDisjunctionList(track.urls.map(url => fancifyURL(url, {language})))
-                                })
-                                : language.$('releaseInfo.listenOn.noLinks'))
-                        }</p>
-                        ${otherReleases.length && fixWS`
-                            <p>${language.$('releaseInfo.alsoReleasedAs')}</p>
-                            <ul>
-                                ${otherReleases.map(track => fixWS`
-                                    <li>${language.$('releaseInfo.alsoReleasedAs.item', {
-                                        track: link.track(track),
-                                        album: link.album(track.album)
-                                    })}</li>
-                                `).join('\n')}
-                            </ul>
-                        `}
-                        ${track.contributorContribs.length && fixWS`
-                            <p>${language.$('releaseInfo.contributors')}</p>
-                            <ul>
-                                ${(track.contributorContribs
-                                    .map(contrib => `<li>${getArtistString([contrib], {
-                                        showContrib: true,
-                                        showIcons: true
-                                    })}</li>`)
-                                    .join('\n'))}
-                            </ul>
-                        `}
-                        ${referencedTracks.length && fixWS`
-                            <p>${language.$('releaseInfo.tracksReferenced', {track: `<i>${track.name}</i>`})}</p>
-                            ${html.tag('ul', referencedTracks.map(getTrackItem))}
-                        `}
-                        ${referencedByTracks.length && fixWS`
-                            <p>${language.$('releaseInfo.tracksThatReference', {track: `<i>${track.name}</i>`})}</p>
-                            ${generateTrackListDividedByGroups(referencedByTracks, {
-                                getTrackItem,
-                                wikiData,
-                            })}
-                        `}
-                        ${wikiInfo.enableFlashesAndGames && flashesThatFeature.length && fixWS`
-                            <p>${language.$('releaseInfo.flashesThatFeature', {track: `<i>${track.name}</i>`})}</p>
-                            <ul>
-                                ${flashesThatFeature.map(({ flash, as }) => html.tag('li',
-                                    {class: as !== track && 'rerelease'},
-                                    (as === track
-                                        ? language.$('releaseInfo.flashesThatFeature.item', {
-                                            flash: link.flash(flash)
-                                        })
-                                        : language.$('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
-                                            flash: link.flash(flash),
-                                            track: link.track(as)
-                                        })))).join('\n')}
-                            </ul>
-                        `}
-                        ${track.lyrics && fixWS`
-                            <p>${language.$('releaseInfo.lyrics')}</p>
-                            <blockquote>
-                                ${transformLyrics(track.lyrics)}
-                            </blockquote>
-                        `}
-                        ${hasCommentary && fixWS`
-                            <p>${language.$('releaseInfo.artistCommentary')}</p>
-                            <blockquote>
-                                ${generateCommentary({link, language, transformMultiline})}
-                            </blockquote>
-                        `}
-                    `
-                },
-
-                sidebarLeft: generateAlbumSidebar(album, track, {
-                    fancifyURL,
-                    getLinkThemeString,
-                    link,
-                    language,
-                    transformMultiline,
-                    wikiData
-                }),
-
-                nav: {
-                    links: [
-                        {toHome: true},
-                        {
-                            path: ['localized.album', album.directory],
-                            title: album.name
-                        },
-                        listTag === 'ol' ? {
-                            html: language.$('trackPage.nav.track.withNumber', {
-                                number: album.tracks.indexOf(track) + 1,
-                                track: link.track(track, {class: 'current', to})
-                            })
-                        } : {
-                            html: language.$('trackPage.nav.track', {
-                                track: link.track(track, {class: 'current', to})
-                            })
-                        },
-                        album.tracks.length > 1 &&
-                        {
-                            divider: false,
-                            html: generateAlbumNavLinks(album, track, {
-                                generatePreviousNextLinks,
-                                language
-                            })
-                        }
-                    ].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],
+      },
+    },
+  ];
 }
-
diff --git a/src/repl.js b/src/repl.js
deleted file mode 100644
index cd4c321..0000000
--- a/src/repl.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import * as os from 'os';
-import * as path from 'path';
-import * as repl from 'repl';
-import { fileURLToPath } from 'url';
-import { promisify } from 'util';
-
-import { quickLoadAllFromYAML } from './data/yaml.js';
-import { logError, parseOptions } from './util/cli.js';
-import { showAggregate } from './util/sugar.js';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-
-async function main() {
-    const miscOptions = await parseOptions(process.argv.slice(2), {
-        'data-path': {
-            type: 'value'
-        },
-
-        'show-traces': {
-            type: 'flag'
-        },
-
-        'no-history': {
-            type: 'flag'
-        },
-    });
-
-    const dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA;
-    const showAggregateTraces = miscOptions['show-traces'] ?? false;
-    const disableHistory = miscOptions['no-history'] ?? false;
-
-    if (!dataPath) {
-        logError`Expected --data-path option or HSMUSIC_DATA to be set`;
-        return;
-    }
-
-    console.log('HSMusic data REPL');
-
-    const wikiData = await quickLoadAllFromYAML(dataPath);
-    const replServer = repl.start();
-
-    Object.assign(
-        replServer.context,
-        wikiData,
-        {wikiData, WD: wikiData}
-    );
-
-    if (disableHistory) {
-        console.log(`\rInput history disabled (--no-history provided)`);
-        replServer.displayPrompt(true);
-    } else {
-        const historyFile = path.join(os.homedir(), '.hsmusic_repl_history');
-        replServer.setupHistory(historyFile, err => {
-            if (err) {
-                console.error(`\rFailed to begin locally logging input history to ${historyFile} (provide --no-history to disable)`);
-            } else {
-                console.log(`\rLogging input history to ${historyFile} (provide --no-history to disable)`);
-            }
-            replServer.displayPrompt(true);
-        });
-    }
-}
-
-main().catch(error => {
-    if (error instanceof AggregateError) {
-        showAggregate(error)
-    } else {
-        console.error(error);
-    }
-});
diff --git a/src/static/client.js b/src/static/client.js
deleted file mode 100644
index 7397735..0000000
--- a/src/static/client.js
+++ /dev/null
@@ -1,419 +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';
-
-import {
-    getArtistNumContributions
-} from '../util/wiki-data.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 => 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/client3.js b/src/static/client3.js
new file mode 100644
index 0000000..7d6544a
--- /dev/null
+++ b/src/static/client3.js
@@ -0,0 +1,3483 @@
+/* eslint-env browser */
+
+// This is the JS file that gets loaded on the client! It's only really used for
+// the random track feature right now - the idea is we only use it for stuff
+// that cannot 8e done at static-site compile time, 8y its fundamentally
+// ephemeral nature.
+
+import {accumulateSum, empty, filterMultipleArrays, stitchArrays}
+  from '../util/sugar.js';
+
+const clientInfo = window.hsmusicClientInfo = Object.create(null);
+
+const clientSteps = {
+  getPageReferences: [],
+  addInternalListeners: [],
+  mutatePageContent: [],
+  initializeState: [],
+  addPageListeners: [],
+};
+
+function initInfo(key, description) {
+  const object = {...description};
+
+  for (const obj of [
+    object,
+    object.state,
+    object.setting,
+    object.event,
+  ]) {
+    if (!obj) continue;
+    Object.preventExtensions(obj);
+  }
+
+  clientInfo[key] = object;
+
+  return object;
+}
+
+// Localiz8tion nonsense ----------------------------------
+
+/*
+const language = document.documentElement.getAttribute('lang');
+
+let list;
+if (typeof Intl === 'object' && typeof Intl.ListFormat === 'function') {
+  const getFormat = (type) => {
+    const formatter = new Intl.ListFormat(language, {type});
+    return formatter.format.bind(formatter);
+  };
+
+  list = {
+    conjunction: getFormat('conjunction'),
+    disjunction: getFormat('disjunction'),
+    unit: getFormat('unit'),
+  };
+} else {
+  // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free.
+  // We use the same mock for every list 'cuz we don't have any of the
+  // necessary CLDR info to appropri8tely distinguish 8etween them.
+  const arbitraryMock = (array) => array.join(', ');
+
+  list = {
+    conjunction: arbitraryMock,
+    disjunction: arbitraryMock,
+    unit: arbitraryMock,
+  };
+}
+*/
+
+// Miscellaneous helpers ----------------------------------
+
+function rebase(href, rebaseKey = 'rebaseLocalized') {
+  const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/';
+  if (relative) {
+    return relative + href;
+  } else {
+    return href;
+  }
+}
+
+function pick(array) {
+  return array[Math.floor(Math.random() * array.length)];
+}
+
+function cssProp(el, ...args) {
+  if (typeof args[0] === 'string' && args.length === 1) {
+    return getComputedStyle(el).getPropertyValue(args[0]).trim();
+  }
+
+  if (typeof args[0] === 'string' && args.length === 2) {
+    if (args[1] === null) {
+      el.style.removeProperty(args[0]);
+    } else {
+      el.style.setProperty(args[0], args[1]);
+    }
+    return;
+  }
+
+  if (typeof args[0] === 'object') {
+    for (const [property, value] of Object.entries(args[0])) {
+      cssProp(el, property, value);
+    }
+  }
+}
+
+// Curry-style, so multiple points can more conveniently be tested at once.
+function pointIsOverAnyOf(elements) {
+  return (clientX, clientY) => {
+    const element = document.elementFromPoint(clientX, clientY);
+    return elements.some(el => el.contains(element));
+  };
+}
+
+function getVisuallyContainingElement(child) {
+  let parent = child.parentElement;
+
+  while (parent) {
+    if (
+      cssProp(parent, 'overflow') === 'hidden' ||
+      cssProp(parent, 'contain') === 'paint'
+    ) {
+      return parent;
+    }
+
+    parent = parent.parentElement;
+  }
+
+  return null;
+}
+
+// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to
+// separ8te the tooling around that into common-shared code too.
+
+/*
+const getLinkHref = (type, directory) => rebase(`${type}/${directory}`);
+*/
+
+const openAlbum = (d) => rebase(`album/${d}`);
+const openTrack = (d) => rebase(`track/${d}`);
+const openArtist = (d) => rebase(`artist/${d}`);
+
+// TODO: This should also use urlSpec.
+
+/*
+function fetchData(type, directory) {
+  return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')).then(
+    (res) => res.json()
+  );
+}
+*/
+
+function dispatchInternalEvent(event, eventName, ...args) {
+  const [infoName] =
+    Object.entries(clientInfo)
+      .find(pair => pair[1].event === event);
+
+  if (!infoName) {
+    throw new Error(`Expected event to be stored on clientInfo`);
+  }
+
+  const {[eventName]: listeners} = event;
+
+  if (!listeners) {
+    throw new Error(`Event name "${eventName}" isn't stored on ${infoName}.event`);
+  }
+
+  let results = [];
+  for (const listener of listeners) {
+    try {
+      results.push(listener(...args));
+    } catch (error) {
+      console.warn(`Uncaught error in listener for ${infoName}.${eventName}`);
+      console.debug(error);
+      results.push(undefined);
+    }
+  }
+
+  return results;
+}
+
+// Rectangle math -----------------------------------------
+
+class WikiRect extends DOMRect {
+  // Useful constructors
+
+  static fromWindow() {
+    const {clientWidth: width, clientHeight: height} =
+      document.documentElement;
+
+    return Reflect.construct(this, [0, 0, width, height]);
+  }
+
+  static fromElement(element) {
+    return this.fromRect(element.getBoundingClientRect());
+  }
+
+  static fromMouse() {
+    const {clientX, clientY} = liveMousePositionInfo.state;
+
+    return WikiRect.fromRect({
+      x: clientX,
+      y: clientY,
+      width: 0,
+      height: 0,
+    });
+  }
+
+  static fromElementUnderMouse(element) {
+    const mouseRect = WikiRect.fromMouse();
+
+    const rects =
+      Array.from(element.getClientRects())
+        .map(rect => WikiRect.fromRect(rect));
+
+    const rectUnderMouse =
+      rects.find(rect => rect.contains(mouseRect));
+
+    if (rectUnderMouse) {
+      return rectUnderMouse;
+    } else {
+      return rects[0];
+    }
+  }
+
+  static leftOf(origin, offset = 0) {
+    // Returns a rectangle representing everywhere to the left of the provided
+    // point or rectangle (with no top or bottom bounds), towards negative x.
+    // If an offset is provided, this is added onto the origin.
+
+    return this.#past(origin, offset, {
+      origin: 'x',
+      extent: 'width',
+      edge: 'left',
+      direction: -Infinity,
+      construct: from =>
+        [from, -Infinity, -Infinity, Infinity],
+    });
+  }
+
+  static rightOf(origin, offset = 0) {
+    // Returns a rectangle representing everywhere to the right of the
+    // provided point or rectangle (with no top or bottom bounds), towards
+    // positive x. If an offset is provided, this is added onto the origin.
+
+    return this.#past(origin, offset, {
+      origin: 'x',
+      extent: 'width',
+      edge: 'right',
+      direction: Infinity,
+      construct: from =>
+        [from, -Infinity, Infinity, Infinity],
+    });
+  }
+
+  static above(origin, offset = 0) {
+    // Returns a rectangle representing everywhere above the provided point
+    // or rectangle (with no left or right bounds), towards negative y.
+    // If an offset is provided, this is added onto the origin.
+
+    return this.#past(origin, offset, {
+      origin: 'y',
+      extent: 'height',
+      edge: 'top',
+      direction: -Infinity,
+      construct: from =>
+        [-Infinity, from, Infinity, -Infinity],
+    });
+  }
+
+  static beneath(origin, offset = 0) {
+    // Returns a rectangle representing everywhere beneath the provided point
+    // or rectangle (with no left or right bounds), towards positive y.
+    // If an offset is provided, this is added onto the origin.
+
+    return this.#past(origin, offset, {
+      origin: 'y',
+      extent: 'height',
+      edge: 'bottom',
+      direction: Infinity,
+      construct: from =>
+        [-Infinity, from, Infinity, Infinity],
+    });
+  }
+
+  // Constructor helpers
+
+  static #past(origin, offset, opts) {
+    if (!isFinite(offset)) {
+      throw new TypeError(`Didn't expect infinite offset`);
+    }
+
+    const {direction, edge} = opts;
+
+    if (typeof origin === 'object') {
+      const {origin: originProperty, extent: extentProperty} = opts;
+
+      const normalized =
+        WikiRect.fromRect(origin).toNormalized();
+
+      if (normalized[extentProperty] === direction) {
+        throw new TypeError(`Provided rectangle already extends to ${edge} edge`);
+      }
+
+      if (normalized[extentProperty] === -direction) {
+        return this.#past(normalized[originProperty], offset, opts);
+      }
+
+      if (normalized.y === direction) {
+        throw new TypeError(`Provided rectangle already starts at ${edge} edge`);
+      }
+
+      return this.#past(normalized[edge], offset, opts);
+    }
+
+    const {construct} = opts;
+
+    if (origin === direction) {
+      throw new TypeError(`Provided point is already at ${edge} edge`);
+    }
+
+    return Reflect.construct(this, construct(origin + offset)).toNormalized();
+  }
+
+  // Predicates
+
+  static rejectInfiniteOriginNonZeroFiniteExtent({origin, extent}) {
+    // Indicate that, in this context, it's meaningless to provide
+    // a finite extent starting at an infinite origin and going towards
+    // or away from zero (i.e. a rectangle along a cardinal edge).
+
+    if (!isFinite(origin) && isFinite(extent) && extent !== 0) {
+      throw new TypeError(`Didn't expect infinite origin paired with finite extent`);
+    }
+  }
+
+  static rejectInfiniteOriginZeroExtent({origin, extent}) {
+    // Indicate that, in this context, it's meaningless to provide
+    // a zero extent at an infinite origin (i.e. a cardinal edge).
+
+    if (!isFinite(origin) && extent === 0) {
+      throw new TypeError(`Didn't expect infinite origin paired with zero extent`);
+    }
+  }
+
+  static rejectNonOpposingInfiniteOriginInfiniteExtent({origin, extent}) {
+    // Indicate that, in this context, it's meaningless to provide
+    // an infinite extent going in the same direction as its infinite
+    // origin (an area "infinitely past" a cardinal edge).
+
+    if (!isFinite(origin) && origin === extent) {
+      throw new TypeError(`Didn't expect non-opposing infinite origin and extent`);
+    }
+  }
+
+  // Transformations
+
+  static normalizeOriginExtent({origin, extent}) {
+    // Varying behavior based on inputs:
+    //
+    //  - For finite origin and finite extent, flip the orientation
+    //    (if necessary) so that extent is positive.
+    //  - For finite origin and infinite extent (i.e. an origin up to
+    //    a cardinal edge), leave as-is.
+    //  - For infinite origin and infinite extent, flip the orientation
+    //    (if necessary) so origin is negative and extent is positive.
+    //  - For infinite origin and zero extent (i.e. a cardinal edge),
+    //    leave as-is.
+    //  - For all other cases, error.
+    //
+
+    this.rejectInfiniteOriginNonZeroFiniteExtent({origin, extent});
+    this.rejectNonOpposingInfiniteOriginInfiniteExtent({origin, extent});
+
+    if (isFinite(origin) && isFinite(extent) && extent < 0) {
+      return {origin: origin + extent, extent: -extent};
+    }
+
+    if (!isFinite(origin) && !isFinite(extent)) {
+      return {origin: -Infinity, extent: Infinity};
+    }
+
+    return {origin, extent};
+  }
+
+  toNormalized() {
+    const {origin: newX, extent: newWidth} =
+      WikiRect.normalizeOriginExtent({
+        origin: this.x,
+        extent: this.width,
+      });
+
+    const {origin: newY, extent: newHeight} =
+      WikiRect.normalizeOriginExtent({
+        origin: this.y,
+        extent: this.height,
+      });
+
+    return Reflect.construct(this.constructor, [newX, newY, newWidth, newHeight]);
+  }
+
+  static intersectionFromOriginsExtents(...entries) {
+    // An intersection is the common subsection across two or more regions.
+
+    const [first, second, ...rest] = entries;
+
+    if (entries.length >= 3) {
+      return this.intersection(first, this.intersection(second, ...rest));
+    }
+
+    if (entries.length === 2) {
+      if (first === null || second === null) {
+        return null;
+      }
+
+      this.rejectInfiniteOriginZeroExtent(first);
+      this.rejectInfiniteOriginZeroExtent(second);
+
+      const {origin: origin1, extent: extent1} = this.normalizeOriginExtent(first);
+      const {origin: origin2, extent: extent2} = this.normalizeOriginExtent(second);
+
+      // After normalizing, *each* region will be one of these:
+      //
+      //  - Finite origin, finite extent
+      //    (a standard region, bounded on both sides)
+      //  - Finite origin, infinite extent
+      //    (everything to one direction of a given origin)
+      //  - Infinite origin, infinite extent
+      //    (everything everywhere)
+      //
+      // So we need to handle any *combination* of these kinds of regions.
+
+      // If either origin is infinite, that region represents everywhere,
+      // so it'll never limit the region of the other.
+
+      if (!isFinite(origin1)) {
+        return {origin: origin2, extent: extent2};
+      }
+
+      if (!isFinite(origin2)) {
+        return {origin: origin1, extent: extent1};
+      }
+
+      // If neither origin is infinite, both regions are bounded on at least
+      // one side, and may limit the other accordingly. Find the minimum and
+      // maximum points in each region, letting Infinity propagate through,
+      // which represents no boundary in that direction.
+
+      const minimum1 = Math.min(origin1, origin1 + extent1);
+      const minimum2 = Math.min(origin2, origin2 + extent2);
+      const maximum1 = Math.max(origin1, origin1 + extent1);
+      const maximum2 = Math.max(origin2, origin2 + extent2);
+
+      // Now get the maximum of the regions' minimums, and the minimum of the
+      // regions' maximums. These are the limits of the new region; computing
+      // with minimums and maximums in this way "polarizes" the limits, so we
+      // can perform specific polarized math in the following steps.
+      //
+      // Infinity will also propagate here, but with some important
+      // restricitons: only maxOfMinimums can be positive Infinity, and only
+      // minOfMaximums can be negative Infinity; and if either is Infinity,
+      // the other is not, since otherwise we'd be working with two everywhere
+      // regions, and would've just returned an everywhere region above.
+
+      const maxOfMinimums = Math.max(minimum1, minimum2);
+      const minOfMaximums = Math.min(maximum1, maximum2);
+
+      // Now check if the maximum of minimums is greater than the minimum of
+      // maximums. If so, the regions don't have any overlap - one region
+      // limits the overlap to end before the other region starts. This works
+      // because we've polarized the limits above!
+
+      if (maxOfMinimums > minOfMaximums) {
+        return null;
+      }
+
+      // Otherwise there's at least some overlap, even if it's just one point
+      // (i.e. one ends exactly where the other begins). We have to take care
+      // of infinities in particular, now. As mentioned above, only one of the
+      // points will be infinity (at most). So the origin is the non-infinite
+      // point, and the extent is in the direction of the infinite point.
+
+      if (minOfMaximums === -Infinity) {
+        return {origin: maxOfMinimums, extent: -Infinity};
+      }
+
+      if (maxOfMinimums === Infinity) {
+        return {origin: minOfMaximums, extent: Infinity};
+      }
+
+      // If neither point is infinity, we're working with two regions that are
+      // both bounded on both sides, so the overlapping region is just the
+      // region constrained by the limits above. Since these are polarized,
+      // start from maxOfMinimums and extend to minOfMaximums, resulting in
+      // a standard, already-normalized region.
+
+      return {
+        origin: maxOfMinimums,
+        extent: minOfMaximums - maxOfMinimums,
+      };
+    }
+
+    if (entries.length === 1) {
+      return first;
+    }
+
+    throw new TypeError(`Expected at least one {origin, extent} entry`);
+  }
+
+  intersectionWith(rect) {
+    const horizontalIntersection =
+      WikiRect.intersectionFromOriginsExtents(
+        {origin: this.x, extent: this.width},
+        {origin: rect.x, extent: rect.width});
+
+    const verticalIntersection =
+      WikiRect.intersectionFromOriginsExtents(
+        {origin: this.y, extent: this.height},
+        {origin: rect.y, extent: rect.height});
+
+    if (!horizontalIntersection) return null;
+    if (!verticalIntersection) return null;
+
+    const {origin: x, extent: width} = horizontalIntersection;
+    const {origin: y, extent: height} = verticalIntersection;
+
+    return Reflect.construct(this.constructor, [x, y, width, height]);
+  }
+
+  chopExtendingOutside(rect) {
+    this.intersectionWith(rect).writeOnto(this);
+  }
+
+  static insetOriginExtent({origin, extent, start = 0, end = 0}) {
+    const normalized =
+      this.normalizeOriginExtent({origin, extent});
+
+    // If this would crush the bounds past each other, just return
+    // the halfway point.
+    if (extent < start + end) {
+      return {origin: origin + (start + end) / 2, extent: 0};
+    }
+
+    return {
+      origin: normalized.origin + start,
+      extent: normalized.extent - start - end,
+    };
+  }
+
+  toInset(arg1, arg2) {
+    if (typeof arg1 === 'number' && typeof arg2 === 'number') {
+      return this.toInset({
+        left: arg2,
+        right: arg2,
+        top: arg1,
+        bottom: arg1,
+      });
+    } else if (typeof arg1 === 'number') {
+      return this.toInset({
+        left: arg1,
+        right: arg1,
+        top: arg1,
+        bottom: arg1,
+      });
+    }
+
+    const {top, left, bottom, right} = arg1;
+
+    const {origin: x, extent: width} =
+      WikiRect.insetOriginExtent({
+        origin: this.x,
+        extent: this.width,
+        start: left,
+        end: right,
+      });
+
+    const {origin: y, extent: height} =
+      WikiRect.insetOriginExtent({
+        origin: this.y,
+        extent: this.height,
+        start: top,
+        end: bottom,
+      });
+
+    return Reflect.construct(this.constructor, [x, y, width, height]);
+  }
+
+  static extendOriginExtent({origin, extent, start = 0, end = 0}) {
+    const normalized =
+      this.normalizeOriginExtent({origin, extent});
+
+    return {
+      origin: normalized.origin - start,
+      extent: normalized.extent + start + end,
+    };
+  }
+
+  toExtended(arg1, arg2) {
+    if (typeof arg1 === 'number' && typeof arg2 === 'number') {
+      return this.toExtended({
+        left: arg2,
+        right: arg2,
+        top: arg1,
+        bottom: arg1,
+      });
+    } else if (typeof arg1 === 'number') {
+      return this.toExtended({
+        left: arg1,
+        right: arg1,
+        top: arg1,
+        bottom: arg1,
+      });
+    }
+
+    const {top, left, bottom, right} = arg1;
+
+    const {origin: x, extent: width} =
+      WikiRect.extendOriginExtent({
+        origin: this.x,
+        extent: this.width,
+        start: left,
+        end: right,
+      });
+
+    const {origin: y, extent: height} =
+      WikiRect.extendOriginExtent({
+        origin: this.y,
+        extent: this.height,
+        start: top,
+        end: bottom,
+      });
+
+    return Reflect.construct(this.constructor, [x, y, width, height]);
+  }
+
+  // Comparisons
+
+  equals(rect) {
+    const rectNormalized = WikiRect.fromRect(rect).toNormalized();
+    const thisNormalized = this.toNormalized();
+
+    return (
+      rectNormalized.x === thisNormalized.x &&
+      rectNormalized.y === thisNormalized.y &&
+      rectNormalized.width === thisNormalized.width &&
+      rectNormalized.height === thisNormalized.height
+    );
+  }
+
+  contains(rect) {
+    return !!this.intersectionWith(rect)?.equals(rect);
+  }
+
+  containedWithin(rect) {
+    return !!this.intersectionWith(rect)?.equals(this);
+  }
+
+  fits(rect) {
+    const rectNormalized = WikiRect.fromRect(rect).toNormalized();
+    const thisNormalized = this.toNormalized();
+
+    return (
+      (!isFinite(this.width) || rectNormalized.width <= thisNormalized.width) &&
+      (!isFinite(this.height) || rectNormalized.height <= thisNormalized.height)
+    );
+  }
+
+  fitsWithin(rect) {
+    const rectNormalized = WikiRect.fromRect(rect).toNormalized();
+    const thisNormalized = this.toNormalized();
+
+    return (
+      (!isFinite(rect.width) || thisNormalized.width <= rectNormalized.width) &&
+      (!isFinite(rect.height) || thisNormalized.height <= rectNormalized.height)
+    );
+  }
+
+  // Interfacing utilities
+
+  static fromRect(rect) {
+    return Reflect.construct(this, [rect.x, rect.y, rect.width, rect.height]);
+  }
+
+  writeOnto(destination) {
+    Object.assign(destination, {
+      x: this.x,
+      y: this.y,
+      width: this.width,
+      height: this.height,
+    });
+  }
+}
+
+// CSS compatibility-assistant ----------------------------
+
+const cssCompatibilityAssistantInfo = clientInfo.cssCompatibilityAssistantInfo = {
+  coverArtContainer: null,
+  coverArtImageDetails: null,
+};
+
+function getCSSCompatibilityAssistantInfoReferences() {
+  const info = cssCompatibilityAssistantInfo;
+
+  info.coverArtContainer =
+    document.getElementById('cover-art-container');
+
+  info.coverArtImageDetails =
+    info.coverArtContainer?.querySelector('.image-details');
+}
+
+function mutateCSSCompatibilityContent() {
+  const info = cssCompatibilityAssistantInfo;
+
+  if (info.coverArtImageDetails) {
+    info.coverArtContainer.classList.add('has-image-details');
+  }
+}
+
+clientSteps.getPageReferences.push(getCSSCompatibilityAssistantInfoReferences);
+clientSteps.mutatePageContent.push(mutateCSSCompatibilityContent);
+
+// Ever-updating mouse position helper --------------------
+
+const liveMousePositionInfo = initInfo('liveMousePositionInfo', {
+  state: {
+    clientX: null,
+    clientY: null,
+  },
+});
+
+function addLiveMousePositionPageListeners() {
+  const info = liveMousePositionInfo;
+  const {state} = info;
+
+  document.body.addEventListener('mousemove', domEvent => {
+    Object.assign(state, {
+      clientX: domEvent.clientX,
+      clientY: domEvent.clientY,
+    });
+  });
+}
+
+clientSteps.addPageListeners.push(addLiveMousePositionPageListeners);
+
+// JS-based links -----------------------------------------
+
+const scriptedLinkInfo = initInfo('scriptedLinkInfo', {
+  randomLinks: null,
+  revealLinks: null,
+  revealContainers: null,
+
+  nextNavLink: null,
+  previousNavLink: null,
+  randomNavLink: null,
+
+  state: {
+    albumDirectories: null,
+    albumTrackDirectories: null,
+    artistDirectories: null,
+    artistNumContributions: null,
+  },
+});
+
+function getScriptedLinkReferences() {
+  scriptedLinkInfo.randomLinks =
+    document.querySelectorAll('[data-random]');
+
+  scriptedLinkInfo.revealLinks =
+    document.querySelectorAll('.reveal .image-outer-area > *');
+
+  scriptedLinkInfo.revealContainers =
+    Array.from(scriptedLinkInfo.revealLinks)
+      .map(link => link.closest('.reveal'));
+
+  scriptedLinkInfo.nextNavLink =
+    document.getElementById('next-button');
+
+  scriptedLinkInfo.previousNavLink =
+    document.getElementById('previous-button');
+
+  scriptedLinkInfo.randomNavLink =
+    document.getElementById('random-button');
+}
+
+function addRandomLinkListeners() {
+  for (const a of scriptedLinkInfo.randomLinks ?? []) {
+    a.addEventListener('click', domEvent => {
+      handleRandomLinkClicked(a, domEvent);
+    });
+  }
+}
+
+function handleRandomLinkClicked(a, domEvent) {
+  const href = determineRandomLinkHref(a);
+
+  if (!href) {
+    domEvent.preventDefault();
+    return;
+  }
+
+  setTimeout(() => {
+    a.href = '#'
+  });
+
+  a.href = href;
+}
+
+function determineRandomLinkHref(a) {
+  const {state} = scriptedLinkInfo;
+
+  const trackDirectoriesFromAlbumDirectories = albumDirectories =>
+    albumDirectories
+      .map(directory => state.albumDirectories.indexOf(directory))
+      .map(index => state.albumTrackDirectories[index])
+      .reduce((acc, trackDirectories) => acc.concat(trackDirectories, []));
+
+  switch (a.dataset.random) {
+    case 'album': {
+      const {albumDirectories} = state;
+      if (!albumDirectories) return null;
+
+      return openAlbum(pick(albumDirectories));
+    }
+
+    case 'track': {
+      const {albumDirectories} = state;
+      if (!albumDirectories) return null;
+
+      const trackDirectories =
+        trackDirectoriesFromAlbumDirectories(
+          albumDirectories);
+
+      return openTrack(pick(trackDirectories));
+    }
+
+    case 'album-in-group-dl': {
+      const albumLinks =
+        Array.from(a
+          .closest('dt')
+          .nextElementSibling
+          .querySelectorAll('li a'))
+
+      const listAlbumDirectories =
+        albumLinks
+          .map(a => cssProp(a, '--album-directory'));
+
+      return openAlbum(pick(listAlbumDirectories));
+    }
+
+    case 'track-in-group-dl': {
+      const {albumDirectories} = state;
+      if (!albumDirectories) return null;
+
+      const albumLinks =
+        Array.from(a
+          .closest('dt')
+          .nextElementSibling
+          .querySelectorAll('li a'))
+
+      const listAlbumDirectories =
+        albumLinks
+          .map(a => cssProp(a, '--album-directory'));
+
+      const trackDirectories =
+        trackDirectoriesFromAlbumDirectories(
+          listAlbumDirectories);
+
+      return openTrack(pick(trackDirectories));
+    }
+
+    case 'track-in-sidebar': {
+      // Note that the container for track links may be <ol> or <ul>, and
+      // they can't be identified by href, since links from one track to
+      // another don't include "track" in the href.
+      const trackLinks =
+        Array.from(document
+          .querySelector('.track-list-sidebar-box')
+          .querySelectorAll('li a'));
+
+      return pick(trackLinks).href;
+    }
+
+    case 'track-in-album': {
+      const {albumDirectories, albumTrackDirectories} = state;
+      if (!albumDirectories || !albumTrackDirectories) return null;
+
+      const albumDirectory = cssProp(a, '--album-directory');
+      const albumIndex = albumDirectories.indexOf(albumDirectory);
+      const trackDirectories = albumTrackDirectories[albumIndex];
+
+      return openTrack(pick(trackDirectories));
+    }
+
+    case 'artist': {
+      const {artistDirectories} = state;
+      if (!artistDirectories) return null;
+
+      return openArtist(pick(artistDirectories));
+    }
+
+    case 'artist-more-than-one-contrib': {
+      const {artistDirectories, artistNumContributions} = state;
+      if (!artistDirectories || !artistNumContributions) return null;
+
+      const filteredArtistDirectories =
+        artistDirectories
+          .filter((_artist, index) => artistNumContributions[index] > 1);
+
+      return openArtist(pick(filteredArtistDirectories));
+    }
+  }
+}
+
+function mutateNavigationLinkContent() {
+  const prependTitle = (el, prepend) =>
+    el?.setAttribute('title',
+      (el.hasAttribute('title')
+        ? prepend + ' ' + el.getAttribute('title')
+        : prepend));
+
+  prependTitle(scriptedLinkInfo.nextNavLink, '(Shift+N)');
+  prependTitle(scriptedLinkInfo.previousNavLink, '(Shift+P)');
+  prependTitle(scriptedLinkInfo.randomNavLink, '(Shift+R)');
+}
+
+function addNavigationKeyPressListeners() {
+  document.addEventListener('keypress', (event) => {
+    if (event.shiftKey) {
+      if (event.charCode === 'N'.charCodeAt(0)) {
+        scriptedLinkInfo.nextNavLink?.click();
+      } else if (event.charCode === 'P'.charCodeAt(0)) {
+        scriptedLinkInfo.previousNavLink?.click();
+      } else if (event.charCode === 'R'.charCodeAt(0)) {
+        scriptedLinkInfo.randomNavLink?.click();
+      }
+    }
+  });
+}
+
+function addRevealLinkClickListeners() {
+  const info = scriptedLinkInfo;
+
+  for (const {revealLink, revealContainer} of stitchArrays({
+    revealLink: Array.from(info.revealLinks ?? []),
+    revealContainer: Array.from(info.revealContainers ?? []),
+  })) {
+    revealLink.addEventListener('click', (event) => {
+      handleRevealLinkClicked(event, revealLink, revealContainer);
+    });
+  }
+}
+
+function handleRevealLinkClicked(domEvent, _revealLink, revealContainer) {
+  if (revealContainer.classList.contains('revealed')) {
+    return;
+  }
+
+  domEvent.preventDefault();
+  revealContainer.classList.add('revealed');
+  revealContainer.dispatchEvent(new CustomEvent('hsmusic-reveal'));
+}
+
+clientSteps.getPageReferences.push(getScriptedLinkReferences);
+clientSteps.addPageListeners.push(addRandomLinkListeners);
+clientSteps.addPageListeners.push(addNavigationKeyPressListeners);
+clientSteps.addPageListeners.push(addRevealLinkClickListeners);
+clientSteps.mutatePageContent.push(mutateNavigationLinkContent);
+
+if (
+  document.documentElement.dataset.urlKey === 'localized.listing' &&
+  document.documentElement.dataset.urlValue0 === 'random'
+) {
+  const dataLoadingLine = document.getElementById('data-loading-line');
+  const dataLoadedLine = document.getElementById('data-loaded-line');
+  const dataErrorLine = document.getElementById('data-error-line');
+
+  dataLoadingLine.style.display = 'block';
+
+  fetch(rebase('random-link-data.json', 'rebaseShared'))
+    .then(data => data.json())
+    .then(data => {
+      const {state} = scriptedLinkInfo;
+
+      Object.assign(state, {
+        albumDirectories: data.albumDirectories,
+        albumTrackDirectories: data.albumTrackDirectories,
+        artistDirectories: data.artistDirectories,
+        artistNumContributions: data.artistNumContributions,
+      });
+
+      dataLoadingLine.style.display = 'none';
+      dataLoadedLine.style.display = 'block';
+    }, () => {
+      dataLoadingLine.style.display = 'none';
+      dataErrorLine.style.display = 'block';
+    })
+    .then(() => {
+      const {randomLinks} = scriptedLinkInfo;
+      for (const a of randomLinks) {
+        const href = determineRandomLinkHref(a);
+        if (!href) {
+          a.removeAttribute('href');
+        }
+      }
+    });
+}
+
+// Tooltip-style hover (infrastructure) -------------------
+
+const hoverableTooltipInfo = initInfo('hoverableTooltipInfo', {
+  settings: {
+    // Hovering has two speed settings. The normal setting is used by default,
+    // and once a tooltip is displayed as a result of hover, the entire tooltip
+    // system will enter a "fast hover mode" - hovering will activate tooltips
+    // sooner. "Fast hover mode" is disabled after a sustained duration of not
+    // hovering over any hoverables; it's meant only to accelerate switching
+    // tooltips while still deciding, or getting a quick overview across more
+    // than one tooltip.
+    normalHoverInfoDelay: 400,
+    fastHoveringInfoDelay: 150,
+    endFastHoveringDelay: 500,
+
+    // Focusing has a single speed setting, which is how long it will take to
+    // enter a functional "focus mode" (though it's not actually implemented
+    // in terms of this state). As soon as "focus mode" is entered, the tooltip
+    // for the current hoverable is displayed, and focusing another hoverable
+    // will cause the current tooltip to be swapped for that one immediately.
+    // "Focus mode" ends as soon as anything apart from a tooltip or hoverable
+    // is focused, and it will be necessary to wait on this delay again.
+    focusInfoDelay: 750,
+
+    hideTooltipDelay: 500,
+
+    // If a tooltip that's transitioning to hidden is hovered during the grace
+    // period (or the corresponding hoverable is hovered at any point in the
+    // transition), it'll cancel out of this animation immediately.
+    transitionHiddenDuration: 300,
+    inertGracePeriod: 100,
+  },
+
+  state: {
+    // These maps store a record for each registered element and related state
+    // and registration info, if applicable.
+    registeredTooltips: new Map(),
+    registeredHoverables: new Map(),
+
+    // These are common across all tooltips, rather than stored individually,
+    // based on the principles that 1) only a single tooltip can be displayed
+    // at once, and 2) likewise, only a single hoverable can be hovered,
+    // focused, or otherwise active at once.
+    hoverTimeout: null,
+    focusTimeout: null,
+    touchTimeout: null,
+    hideTimeout: null,
+    transitionHiddenTimeout: null,
+    inertGracePeriodTimeout: null,
+    currentlyShownTooltip: null,
+    currentlyActiveHoverable: null,
+    currentlyTransitioningHiddenTooltip: null,
+    previouslyActiveHoverable: null,
+    tooltipWasJustHidden: false,
+    hoverableWasRecentlyTouched: false,
+
+    // Fast hovering is a global mode which is activated as soon as any tooltip
+    // is displayed and turns off after a delay of no hoverables being hovered.
+    // Note that fast hovering may be turned off while hovering a tooltip, but
+    // it will never be turned off while idling over a hoverable.
+    fastHovering: false,
+    endFastHoveringTimeout: false,
+
+    // These track the identifiers of current touches and a record of current
+    // identifiers that are "banished" by scrolling - that is, touches which
+    // existed while the page scrolled and were probably responsible for that
+    // scrolling. This is a bit loose (we can't actually tell which touches
+    // caused the page to scroll) but it's intended to keep scrolling the page
+    // from causing the current tooltip to be hidden.
+    currentTouchIdentifiers: new Set(),
+    touchIdentifiersBanishedByScrolling: new Set(),
+  },
+
+  event: {
+    whenTooltipShows: [],
+    whenTooltipHides: [],
+  },
+});
+
+// Adds DOM event listeners, so must be called during addPageListeners step.
+function registerTooltipElement(tooltip) {
+  const {state} = hoverableTooltipInfo;
+
+  if (!tooltip)
+    throw new Error(`Expected tooltip`);
+
+  if (state.registeredTooltips.has(tooltip))
+    throw new Error(`This tooltip is already registered`);
+
+  // No state or registration info here.
+  state.registeredTooltips.set(tooltip, {});
+
+  tooltip.addEventListener('mouseenter', () => {
+    handleTooltipMouseEntered(tooltip);
+  });
+
+  tooltip.addEventListener('mouseleave', () => {
+    handleTooltipMouseLeft(tooltip);
+  });
+
+  tooltip.addEventListener('focusin', event => {
+    handleTooltipReceivedFocus(tooltip, event.relatedTarget);
+  });
+
+  tooltip.addEventListener('focusout', event => {
+    // This event gets activated for tabbing *between* links inside the
+    // tooltip, which is no good and certainly doesn't represent the focus
+    // leaving the tooltip.
+    if (currentlyShownTooltipHasFocus(event.relatedTarget)) return;
+
+    handleTooltipLostFocus(tooltip, event.relatedTarget);
+  });
+}
+
+// Adds DOM event listeners, so must be called during addPageListeners step.
+function registerTooltipHoverableElement(hoverable, tooltip) {
+  const {state} = hoverableTooltipInfo;
+
+  if (!hoverable || !tooltip)
+    if (hoverable)
+      throw new Error(`Expected hoverable and tooltip, got only hoverable`);
+    else
+      throw new Error(`Expected hoverable and tooltip, got neither`);
+
+  if (!state.registeredTooltips.has(tooltip))
+    throw new Error(`Register tooltip before registering hoverable`);
+
+  if (state.registeredHoverables.has(hoverable))
+    throw new Error(`This hoverable is already registered`);
+
+  state.registeredHoverables.set(hoverable, {tooltip});
+
+  hoverable.addEventListener('mouseenter', () => {
+    handleTooltipHoverableMouseEntered(hoverable);
+  });
+
+  hoverable.addEventListener('mouseleave', () => {
+    handleTooltipHoverableMouseLeft(hoverable);
+  });
+
+  hoverable.addEventListener('focusin', event => {
+    handleTooltipHoverableReceivedFocus(hoverable, event);
+  });
+
+  hoverable.addEventListener('focusout', event => {
+    handleTooltipHoverableLostFocus(hoverable, event);
+  });
+
+  hoverable.addEventListener('touchend', event => {
+    handleTooltipHoverableTouchEnded(hoverable, event);
+  });
+
+  hoverable.addEventListener('click', event => {
+    handleTooltipHoverableClicked(hoverable, event);
+  });
+}
+
+function handleTooltipMouseEntered(tooltip) {
+  const {state} = hoverableTooltipInfo;
+
+  if (state.currentlyTransitioningHiddenTooltip) {
+    cancelTransitioningTooltipHidden(true);
+    return;
+  }
+
+  if (state.currentlyShownTooltip !== tooltip) return;
+
+  // Don't time out the current tooltip while hovering it.
+
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipMouseLeft(tooltip) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  if (state.currentlyShownTooltip !== tooltip) return;
+
+  // Start timing out the current tooltip when it's left. This could be
+  // canceled by mousing over a hoverable, or back over the tooltip again.
+  if (!state.hideTimeout) {
+    state.hideTimeout =
+      setTimeout(() => {
+        state.hideTimeout = null;
+        hideCurrentlyShownTooltip();
+      }, settings.hideTooltipDelay);
+  }
+}
+
+function handleTooltipReceivedFocus(_tooltip) {
+  const {state} = hoverableTooltipInfo;
+
+  // Cancel the tooltip-hiding timeout if it exists. The tooltip will never
+  // be hidden while it contains the focus anyway, but this ensures the timeout
+  // will be suitably reset when the tooltip loses focus.
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipLostFocus(_tooltip) {
+  // Hide the current tooltip right away when it loses focus. Specify intent
+  // to replace - while we don't strictly know if another tooltip is going to
+  // immediately replace it, the mode of navigating with tab focus (once one
+  // tooltip has been activated) is a "switch focus immediately" kind of
+  // interaction in its nature.
+  hideCurrentlyShownTooltip(true);
+}
+
+function handleTooltipHoverableMouseEntered(hoverable) {
+  const {settings, state} = hoverableTooltipInfo;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  // If this tooltip was transitioning to hidden, hovering should cancel that
+  // animation and show it immediately.
+
+  if (tooltip === state.currentlyTransitioningHiddenTooltip) {
+    cancelTransitioningTooltipHidden(true);
+    return;
+  }
+
+  // Start a timer to show the corresponding tooltip, with the delay depending
+  // on whether fast hovering or not. This could be canceled by mousing out of
+  // the hoverable.
+
+  const hoverTimeoutDelay =
+    (state.fastHovering
+      ? settings.fastHoveringInfoDelay
+      : settings.normalHoverInfoDelay);
+
+  state.hoverTimeout =
+    setTimeout(() => {
+      state.hoverTimeout = null;
+      state.fastHovering = true;
+      showTooltipFromHoverable(hoverable);
+    }, hoverTimeoutDelay);
+
+  // Don't stop fast hovering while over any hoverable.
+  if (state.endFastHoveringTimeout) {
+    clearTimeout(state.endFastHoveringTimeout);
+    state.endFastHoveringTimeout = null;
+  }
+
+  // Don't time out the current tooltip while over any hoverable.
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipHoverableMouseLeft(_hoverable) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  // Don't show a tooltip when not over a hoverable!
+  if (state.hoverTimeout) {
+    clearTimeout(state.hoverTimeout);
+    state.hoverTimeout = null;
+  }
+
+  // Start timing out fast hovering (if active) when not over a hoverable.
+  // This will only be canceled by mousing over another hoverable.
+  if (state.fastHovering && !state.endFastHoveringTimeout) {
+    state.endFastHoveringTimeout =
+      setTimeout(() => {
+        state.endFastHoveringTimeout = null;
+        state.fastHovering = false;
+      }, settings.endFastHoveringDelay);
+  }
+
+  // Start timing out the current tooltip when mousing not over a hoverable.
+  // This could be canceled by mousing over another hoverable, or over the
+  // currently shown tooltip.
+  if (state.currentlyShownTooltip && !state.hideTimeout) {
+    state.hideTimeout =
+      setTimeout(() => {
+        state.hideTimeout = null;
+        hideCurrentlyShownTooltip();
+      }, settings.hideTooltipDelay);
+  }
+}
+
+function handleTooltipHoverableReceivedFocus(hoverable) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  // By default, display the corresponding tooltip after a delay.
+
+  state.focusTimeout =
+    setTimeout(() => {
+      state.focusTimeout = null;
+      showTooltipFromHoverable(hoverable);
+    }, settings.focusInfoDelay);
+
+  // If a tooltip was just hidden - which is almost certainly a result of the
+  // focus changing - then display this tooltip immediately, canceling the
+  // above timeout.
+
+  if (state.tooltipWasJustHidden) {
+    clearTimeout(state.focusTimeout);
+    state.focusTimeout = null;
+
+    showTooltipFromHoverable(hoverable);
+  }
+}
+
+function handleTooltipHoverableLostFocus(hoverable, domEvent) {
+  const {state} = hoverableTooltipInfo;
+
+  // Don't show a tooltip from focusing a hoverable if it isn't focused
+  // anymore! If another hoverable is receiving focus, that will be evaluated
+  // and set its own focus timeout after we clear the previous one here.
+  if (state.focusTimeout) {
+    clearTimeout(state.focusTimeout);
+    state.focusTimeout = null;
+  }
+
+  // Unless focus is entering the tooltip itself, hide the tooltip immediately.
+  // This will set the tooltipWasJustHidden flag, which is detected by a newly
+  // focused hoverable, if applicable. Always specify intent to replace when
+  // navigating via tab focus. (Check `handleTooltipLostFocus` for details.)
+  if (!currentlyShownTooltipHasFocus(domEvent.relatedTarget)) {
+    hideCurrentlyShownTooltip(true);
+  }
+}
+
+function handleTooltipHoverableTouchEnded(hoverable, domEvent) {
+  const {state} = hoverableTooltipInfo;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  // Don't proceed if this hoverable's tooltip is already visible - in that
+  // case touching the hoverable again should behave just like a normal click.
+  if (state.currentlyShownTooltip === tooltip) {
+    // If the hoverable was *recently* touched - meaning that this is a second
+    // touchend in short succession - then just letting the click come through
+    // naturally would (depending on timing) not actually navigate anywhere,
+    // because we've deliberately banished the *first* touch from navigation.
+    // We do want the second touch to navigate, so clear that recently-touched
+    // state, allowing this touch's click to behave as normal.
+    if (state.hoverableWasRecentlyTouched) {
+      clearTimeout(state.touchTimeout);
+      state.touchTimeout = null;
+      state.hoverableWasRecentlyTouched = false;
+    }
+
+    // Otherwise, this is just a second touch after enough time has passed
+    // that the one which showed the tooltip is no longer "recent", and we're
+    // not in any special state. The link will navigate to its page just like
+    // normal.
+    return;
+  }
+
+  const touches = Array.from(domEvent.changedTouches);
+  const identifiers = touches.map(touch => touch.identifier);
+
+  // Don't process touch events that were "banished" because the page was
+  // scrolled while those touches were active, and most likely as a result of
+  // them.
+  filterMultipleArrays(touches, identifiers,
+    (_touch, identifier) =>
+      !state.touchIdentifiersBanishedByScrolling.has(identifier));
+
+  if (empty(touches)) return;
+
+  // Don't proceed if none of the (just-ended) touches ended over the
+  // hoverable.
+
+  const pointIsOverThisHoverable = pointIsOverAnyOf([hoverable]);
+
+  const anyTouchEndedOverHoverable =
+    touches.some(({clientX, clientY}) =>
+      pointIsOverThisHoverable(clientX, clientY));
+
+  if (!anyTouchEndedOverHoverable) {
+    return;
+  }
+
+  if (state.touchTimeout) {
+    clearTimeout(state.touchTimeout);
+    state.touchTimeout = null;
+  }
+
+  // Show the tooltip right away.
+  showTooltipFromHoverable(hoverable);
+
+  // Set a state, for a brief but not instantaneous period, indicating that a
+  // hoverable was recently touched. The touchend event may precede the click
+  // event by some time, and we don't want to navigate away from the page as
+  // a result of the click event which this touch precipitated.
+  state.hoverableWasRecentlyTouched = true;
+  state.touchTimeout =
+    setTimeout(() => {
+      state.touchTimeout = null;
+      state.hoverableWasRecentlyTouched = false;
+    }, 1200);
+}
+
+function handleTooltipHoverableClicked(hoverable) {
+  const {state} = hoverableTooltipInfo;
+
+  // Don't navigate away from the page if the this hoverable was recently
+  // touched (and had its tooltip activated). That flag won't be set if its
+  // tooltip was already open before the touch.
+  if (
+    state.currentlyActiveHoverable === hoverable &&
+    state.hoverableWasRecentlyTouched
+  ) {
+    event.preventDefault();
+  }
+}
+
+function currentlyShownTooltipHasFocus(focusElement = document.activeElement) {
+  const {state} = hoverableTooltipInfo;
+
+  const {
+    currentlyShownTooltip: tooltip,
+    currentlyActiveHoverable: hoverable,
+  } = state;
+
+  // If there's no tooltip, it can't possibly have focus.
+  if (!tooltip) return false;
+
+  // If the tooltip literally contains (or is) the focused element, then that's
+  // the principle condition we're looking for.
+  if (tooltip.contains(focusElement)) return true;
+
+  // If the hoverable *which opened the tooltip* is focused, then that also
+  // represents the tooltip being focused (in its currently shown state).
+  if (hoverable.contains(focusElement)) return true;
+
+  return false;
+}
+
+function beginTransitioningTooltipHidden(tooltip) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  if (state.currentlyTransitioningHiddenTooltip) {
+    cancelTransitioningTooltipHidden();
+  }
+
+  cssProp(tooltip, {
+    'display': 'block',
+    'opacity': '0',
+
+    'transition-property': 'opacity',
+    'transition-timing-function':
+      `steps(${Math.ceil(settings.transitionHiddenDuration / 60)}, end)`,
+    'transition-duration':
+      `${settings.transitionHiddenDuration / 1000}s`,
+  });
+
+  state.currentlyTransitioningHiddenTooltip = tooltip;
+  state.transitionHiddenTimeout =
+    setTimeout(() => {
+      endTransitioningTooltipHidden();
+    }, settings.transitionHiddenDuration);
+}
+
+function cancelTransitioningTooltipHidden(andShow = false) {
+  const {state} = hoverableTooltipInfo;
+
+  endTransitioningTooltipHidden();
+
+  if (andShow) {
+    showTooltipFromHoverable(state.previouslyActiveHoverable);
+  }
+}
+
+function endTransitioningTooltipHidden() {
+  const {state} = hoverableTooltipInfo;
+  const {currentlyTransitioningHiddenTooltip: tooltip} = state;
+
+  if (!tooltip) return;
+
+  cssProp(tooltip, {
+    'display': null,
+    'opacity': null,
+    'transition-property': null,
+    'transition-timing-function': null,
+    'transition-duration': null,
+  });
+
+  state.currentlyTransitioningHiddenTooltip = null;
+
+  if (state.inertGracePeriodTimeout) {
+    clearTimeout(state.inertGracePeriodTimeout);
+    state.inertGracePeriodTimeout = null;
+  }
+
+  if (state.transitionHiddenTimeout) {
+    clearTimeout(state.transitionHiddenTimeout);
+    state.transitionHiddenTimeout = null;
+  }
+}
+
+function hideCurrentlyShownTooltip(intendingToReplace = false) {
+  const {settings, state, event} = hoverableTooltipInfo;
+  const {currentlyShownTooltip: tooltip} = state;
+
+  // If there was no tooltip to begin with, we're functionally in the desired
+  // state already, so return true.
+  if (!tooltip) return true;
+
+  // Never hide the tooltip if it's focused.
+  if (currentlyShownTooltipHasFocus()) return false;
+
+  state.currentlyActiveHoverable.classList.remove('has-visible-tooltip');
+
+  // If there's no intent to replace this tooltip, it's the last one currently
+  // apparent in the interaction, and should be hidden with a transition.
+  if (intendingToReplace) {
+    cssProp(tooltip, 'display', 'none');
+  } else {
+    beginTransitioningTooltipHidden(state.currentlyShownTooltip);
+  }
+
+  // Wait just a moment before making the tooltip inert. You might react
+  // (to the ghosting, or just to time passing) and realize you wanted
+  // to look at the tooltip after all - this delay gives a little buffer
+  // to second guess letting it disappear.
+  state.inertGracePeriodTimeout =
+    setTimeout(() => {
+      tooltip.inert = true;
+    }, settings.inertGracePeriod);
+
+  state.previouslyActiveHoverable = state.currentlyActiveHoverable;
+
+  state.currentlyShownTooltip = null;
+  state.currentlyActiveHoverable = null;
+
+  // Set this for one tick of the event cycle.
+  state.tooltipWasJustHidden = true;
+  setTimeout(() => {
+    state.tooltipWasJustHidden = false;
+  });
+
+  dispatchInternalEvent(event, 'whenTooltipHides', {
+    tooltip,
+  });
+
+  return true;
+}
+
+function showTooltipFromHoverable(hoverable) {
+  const {state, event} = hoverableTooltipInfo;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  if (!hideCurrentlyShownTooltip(true)) return false;
+
+  // Cancel out another tooltip that's transitioning hidden, if that's going
+  // on - it's a distraction that this tooltip is now replacing.
+  cancelTransitioningTooltipHidden();
+
+  hoverable.classList.add('has-visible-tooltip');
+
+  positionTooltipFromHoverableWithBrains(hoverable);
+
+  cssProp(tooltip, 'display', 'block');
+  tooltip.inert = false;
+
+  state.currentlyShownTooltip = tooltip;
+  state.currentlyActiveHoverable = hoverable;
+
+  state.tooltipWasJustHidden = false;
+
+  dispatchInternalEvent(event, 'whenTooltipShows', {
+    tooltip,
+  });
+
+  return true;
+}
+
+function peekTooltipClientRect(tooltip) {
+  const oldDisplayStyle = cssProp(tooltip, 'display');
+  cssProp(tooltip, 'display', 'block');
+
+  // Tooltips have a bit of padding that makes the interactive
+  // area wider, so that you're less likely to accidentally let
+  // the tooltip disappear (by hovering outside it). But this
+  // isn't visual at all, so for placement we only care about
+  // the content element.
+  const content =
+    tooltip.querySelector('.tooltip-content');
+
+  try {
+    return WikiRect.fromElement(content);
+  } finally {
+    cssProp(tooltip, 'display', oldDisplayStyle);
+  }
+}
+
+function positionTooltipFromHoverableWithBrains(hoverable) {
+  const {state} = hoverableTooltipInfo;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  // Reset before doing anything else. We're going to adapt to
+  // its natural placement, adjusted by CSS, which otherwise
+  // could be obscured by a placement we've previously provided.
+  resetDynamicTooltipPositioning(tooltip);
+
+  const opportunities =
+    getTooltipFromHoverablePlacementOpportunityAreas(hoverable);
+
+  const tooltipRect =
+    peekTooltipClientRect(tooltip);
+
+  // If the tooltip is already in the baseline containing area,
+  // prefer to keep it positioned naturally, adjusted by CSS
+  // instead of JavaScript.
+
+  const {numBaselineRects, idealBaseline: baselineRect} = opportunities;
+
+  if (baselineRect.contains(tooltipRect)) {
+    return;
+  }
+
+  let selectedRect = null;
+  for (let i = 0; i < numBaselineRects; i++) {
+    selectedRect = opportunities.right.down[i];
+    if (selectedRect) break;
+
+    selectedRect = opportunities.left.down[i];
+    if (selectedRect) break;
+
+    selectedRect = opportunities.right.up[i];
+    if (selectedRect) break;
+
+    selectedRect = opportunities.left.up[i];
+    if (selectedRect) break;
+  }
+
+  selectedRect ??= baselineRect;
+
+  positionTooltip(tooltip, selectedRect.x, selectedRect.y);
+}
+
+function positionTooltip(tooltip, x, y) {
+  // Imagine what it'd be like if the tooltip were positioned
+  // with zero left/top offset, and calculate its actual offsets
+  // based on that.
+
+  cssProp(tooltip, {
+    left: `0`,
+    top: `0`,
+  });
+
+  const tooltipRect =
+    peekTooltipClientRect(tooltip);
+
+  cssProp(tooltip, {
+    left: `${x - tooltipRect.x}px`,
+    top: `${y - tooltipRect.y}px`,
+  });
+}
+
+function resetDynamicTooltipPositioning(tooltip) {
+  cssProp(tooltip, {
+    left: null,
+    top: null,
+  });
+}
+
+function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) {
+  const {state} = hoverableTooltipInfo;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  const baselineRects =
+    getTooltipBaselineOpportunityAreas(tooltip);
+
+  const hoverableRect =
+    WikiRect.fromElementUnderMouse(hoverable).toExtended(5, 10);
+
+  const tooltipRect =
+    peekTooltipClientRect(tooltip);
+
+  // Get placements relative to the hoverable. Make these available by key,
+  // allowing the caller to choose by preferred orientation. Each value is
+  // an array which corresponds to the baseline areas - placement closer to
+  // front of the array indicates stronger preference. Since not all relative
+  // placements cooperate with all baseline areas, any of these arrays may
+  // include (or be entirely made of) null.
+
+  const keepIfFits = (rect) =>
+    (rect?.fits(tooltipRect)
+      ? rect
+      : null);
+
+  const prepareRegionRects = (relationalRect, direct) =>
+    baselineRects
+      .map(rect => rect.intersectionWith(relationalRect))
+      .map(direct)
+      .map(keepIfFits);
+
+  const regionRects = {
+    left:
+      prepareRegionRects(
+        WikiRect.leftOf(hoverableRect),
+        rect => WikiRect.fromRect({
+          x: rect.right,
+          y: rect.y,
+          width: -rect.width,
+          height: rect.height,
+        })),
+
+    right:
+      prepareRegionRects(
+        WikiRect.rightOf(hoverableRect),
+        rect => rect),
+
+    top:
+      prepareRegionRects(
+        WikiRect.above(hoverableRect),
+        rect => WikiRect.fromRect({
+          x: rect.x,
+          y: rect.bottom,
+          width: rect.width,
+          height: -rect.height,
+        })),
+
+    bottom:
+      prepareRegionRects(
+        WikiRect.beneath(hoverableRect),
+        rect => rect),
+  };
+
+  const neededVerticalOverlap = 30;
+  const neededHorizontalOverlap = 30;
+
+  // Please don't ask us to make this but horizontal?
+  const prepareVerticalOrientationRects = (regionRects) => {
+    const orientations = {};
+
+    const upTopDown =
+      WikiRect.beneath(
+        hoverableRect.top + neededVerticalOverlap - tooltipRect.height);
+
+    const downBottomUp =
+      WikiRect.above(
+        hoverableRect.bottom - neededVerticalOverlap + tooltipRect.height);
+
+    const orientHorizontally = (rect, i) => {
+      if (!rect) return null;
+
+      const regionRect = regionRects[i];
+      if (regionRect.width > 0) {
+        return rect;
+      } else {
+        return WikiRect.fromRect({
+          x: regionRect.right - tooltipRect.width,
+          y: rect.y,
+          width: rect.width,
+          height: rect.height,
+        });
+      }
+    };
+
+    orientations.up =
+      regionRects
+        .map(rect => rect?.intersectionWith(upTopDown))
+        .map(orientHorizontally)
+        .map(keepIfFits);
+
+    orientations.down =
+      regionRects
+        .map(rect => rect?.intersectionWith(downBottomUp))
+        .map(rect =>
+          (rect
+            ? rect.intersectionWith(WikiRect.fromRect({
+                x: rect.x,
+                y: rect.bottom - tooltipRect.height,
+                width: rect.width,
+                height: tooltipRect.height,
+              }))
+            : null))
+        .map(orientHorizontally)
+        .map(keepIfFits);
+
+    const centerRect =
+      WikiRect.fromRect({
+        x: -Infinity, width: Infinity,
+        y: hoverableRect.top
+         + hoverableRect.height / 2
+         - tooltipRect.height / 2,
+        height: tooltipRect.height,
+      });
+
+    orientations.center =
+      regionRects
+        .map(rect => rect?.intersectionWith(centerRect))
+        .map(orientHorizontally)
+        .map(keepIfFits);
+
+    return orientations;
+  };
+
+  const orientationRects = {
+    left: prepareVerticalOrientationRects(regionRects.left),
+    right: prepareVerticalOrientationRects(regionRects.right),
+  };
+
+  return {
+    numBaselineRects: baselineRects.length,
+    idealBaseline: baselineRects[0],
+    ...orientationRects,
+  };
+}
+
+function getTooltipBaselineOpportunityAreas(tooltip) {
+  // Returns multiple basic areas in order of preference, with front of the
+  // array representing greater preference.
+
+  const {stickyContainers} = stickyHeadingInfo;
+  const results = [];
+
+  const windowRect =
+    WikiRect.fromWindow().toInset(10);
+
+  const workingRect =
+    WikiRect.fromRect(windowRect);
+
+  const tooltipRect =
+    peekTooltipClientRect(tooltip);
+
+  // As a baseline, always treat the window rect as fitting the tooltip.
+  results.unshift(WikiRect.fromRect(workingRect));
+
+  const containingParent =
+    getVisuallyContainingElement(tooltip);
+
+  if (containingParent) {
+    const containingRect =
+      WikiRect.fromElement(containingParent);
+
+    // Only respect a portion of the container's padding, giving
+    // the tooltip the impression of a "raised" element.
+    const padding = side =>
+      0.5 *
+      parseFloat(cssProp(containingParent, 'padding-' + side));
+
+    const insetContainingRect =
+      containingRect.toInset({
+        left: padding('left'),
+        right: padding('right'),
+        top: padding('top'),
+        bottom: padding('bottom'),
+      });
+
+    workingRect.chopExtendingOutside(insetContainingRect);
+
+    if (!workingRect.fits(tooltipRect)) {
+      return results;
+    }
+
+    results.unshift(WikiRect.fromRect(workingRect));
+  }
+
+  // This currently assumes a maximum of one sticky container
+  // per visually containing element.
+
+  const stickyContainer =
+    stickyContainers
+      .find(el => el.parentElement === containingParent);
+
+  if (stickyContainer) {
+    const stickyRect =
+      stickyContainer.getBoundingClientRect()
+
+    // Add some padding so the tooltip doesn't line up exactly
+    // with the edge of the sticky container.
+    const beneathStickyContainer =
+      WikiRect.beneath(stickyRect, 10);
+
+    workingRect.chopExtendingOutside(beneathStickyContainer);
+
+    if (!workingRect.fits(tooltipRect)) {
+      return results;
+    }
+
+    results.unshift(WikiRect.fromRect(workingRect));
+  }
+
+  return results;
+}
+
+function addHoverableTooltipPageListeners() {
+  const {state} = hoverableTooltipInfo;
+
+  const getTouchIdentifiers = domEvent =>
+    Array.from(domEvent.changedTouches)
+      .map(touch => touch.identifier)
+      .filter(identifier => typeof identifier !== 'undefined');
+
+  document.body.addEventListener('touchstart', domEvent => {
+    for (const identifier of getTouchIdentifiers(domEvent)) {
+      state.currentTouchIdentifiers.add(identifier);
+    }
+  });
+
+  window.addEventListener('scroll', () => {
+    for (const identifier of state.currentTouchIdentifiers) {
+      state.touchIdentifiersBanishedByScrolling.add(identifier);
+    }
+  });
+
+  document.body.addEventListener('touchend', domEvent => {
+    setTimeout(() => {
+      for (const identifier of getTouchIdentifiers(domEvent)) {
+        state.currentTouchIdentifiers.delete(identifier);
+        state.touchIdentifiersBanishedByScrolling.delete(identifier);
+      }
+    });
+  });
+
+  const getHoverablesAndTooltips = () => [
+    ...Array.from(state.registeredHoverables.keys()),
+    ...Array.from(state.registeredTooltips.keys()),
+  ];
+
+  document.body.addEventListener('touchend', domEvent => {
+    const touches = Array.from(domEvent.changedTouches);
+    const identifiers = touches.map(touch => touch.identifier);
+
+    // Don't process touch events that were "banished" because the page was
+    // scrolled while those touches were active, and most likely as a result of
+    // them.
+    filterMultipleArrays(touches, identifiers,
+      (_touch, identifier) =>
+        !state.touchIdentifiersBanishedByScrolling.has(identifier));
+
+    if (empty(touches)) return;
+
+    const pointIsOverHoverableOrTooltip =
+      pointIsOverAnyOf(getHoverablesAndTooltips());
+
+    const anyTouchOverAnyHoverableOrTooltip =
+      touches.some(({clientX, clientY}) =>
+        pointIsOverHoverableOrTooltip(clientX, clientY));
+
+    if (!anyTouchOverAnyHoverableOrTooltip) {
+      hideCurrentlyShownTooltip();
+    }
+  });
+
+  document.body.addEventListener('click', domEvent => {
+    const {clientX, clientY} = domEvent;
+
+    const pointIsOverHoverableOrTooltip =
+      pointIsOverAnyOf(getHoverablesAndTooltips());
+
+    if (!pointIsOverHoverableOrTooltip(clientX, clientY)) {
+      // Hide with "intent to replace" - we aren't actually going to replace
+      // the tooltip with a new one, but this intent indicates that it should
+      // be hidden right away, instead of showing. What we're really replacing,
+      // or rather removing, is the state of interacting with tooltips at all.
+      hideCurrentlyShownTooltip(true);
+
+      // Part of that state is fast hovering, which should be canceled out.
+      state.fastHovering = false;
+      if (state.endFastHoveringTimeout) {
+        clearTimeout(state.endFastHoveringTimeout);
+        state.endFastHoveringTimeout = null;
+      }
+
+      // Also cancel out of transitioning a tooltip hidden - this isn't caught
+      // by `hideCurrentlyShownTooltip` because a transitioning-hidden tooltip
+      // doesn't count as "shown" anymore.
+      cancelTransitioningTooltipHidden();
+    }
+  });
+}
+
+clientSteps.addPageListeners.push(addHoverableTooltipPageListeners);
+
+// Data & info card ---------------------------------------
+
+/*
+function colorLink(a, color) {
+  console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet');
+  return;
+
+  // eslint-disable-next-line no-unreachable
+  const chroma = {};
+
+  if (color) {
+    const {primary, dim} = getColors(color, {chroma});
+    a.style.setProperty('--primary-color', primary);
+    a.style.setProperty('--dim-color', dim);
+  }
+}
+
+function link(a, type, {name, directory, color}) {
+  colorLink(a, color);
+  a.innerText = name;
+  a.href = getLinkHref(type, directory);
+}
+
+function joinElements(type, elements) {
+  // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on
+  // strings. So instead, we'll pass the element's outer HTML's (which means
+  // the entire HTML of that element).
+  //
+  // That does mean this function returns a string, so always 8e sure to
+  // set innerHTML when using it (not appendChild).
+
+  return list[type](elements.map((el) => el.outerHTML));
+}
+
+const infoCard = (() => {
+  const container = document.getElementById('info-card-container');
+
+  let cancelShow = false;
+  let hideTimeout = null;
+  let showing = false;
+
+  container.addEventListener('mouseenter', cancelHide);
+  container.addEventListener('mouseleave', readyHide);
+
+  function show(type, target) {
+    cancelShow = false;
+
+    fetchData(type, target.dataset[type]).then((data) => {
+      // Manual DOM 'cuz we're laaaazy.
+
+      if (cancelShow) {
+        return;
+      }
+
+      showing = true;
+
+      const rect = target.getBoundingClientRect();
+
+      container.style.setProperty('--primary-color', data.color);
+
+      container.style.top = window.scrollY + rect.bottom + 'px';
+      container.style.left = window.scrollX + rect.left + 'px';
+
+      // Use a short timeout to let a currently hidden (or not yet shown)
+      // info card teleport to the position set a8ove. (If it's currently
+      // shown, it'll transition to that position.)
+      setTimeout(() => {
+        container.classList.remove('hide');
+        container.classList.add('show');
+      }, 50);
+
+      // 8asic details.
+
+      const nameLink = container.querySelector('.info-card-name a');
+      link(nameLink, 'track', data);
+
+      const albumLink = container.querySelector('.info-card-album a');
+      link(albumLink, 'album', data.album);
+
+      const artistSpan = container.querySelector('.info-card-artists span');
+      artistSpan.innerHTML = joinElements(
+        'conjunction',
+        data.artists.map(({artist}) => {
+          const a = document.createElement('a');
+          a.href = getLinkHref('artist', artist.directory);
+          a.innerText = artist.name;
+          return a;
+        })
+      );
+
+      const coverArtistParagraph = container.querySelector(
+        '.info-card-cover-artists'
+      );
+      const coverArtistSpan = coverArtistParagraph.querySelector('span');
+      if (data.coverArtists.length) {
+        coverArtistParagraph.style.display = 'block';
+        coverArtistSpan.innerHTML = joinElements(
+          'conjunction',
+          data.coverArtists.map(({artist}) => {
+            const a = document.createElement('a');
+            a.href = getLinkHref('artist', artist.directory);
+            a.innerText = artist.name;
+            return a;
+          })
+        );
+      } else {
+        coverArtistParagraph.style.display = 'none';
+      }
+
+      // Cover art.
+
+      const [containerNoReveal, containerReveal] = [
+        container.querySelector('.info-card-art-container.no-reveal'),
+        container.querySelector('.info-card-art-container.reveal'),
+      ];
+
+      const [containerShow, containerHide] = data.cover.warnings.length
+        ? [containerReveal, containerNoReveal]
+        : [containerNoReveal, containerReveal];
+
+      containerHide.style.display = 'none';
+      containerShow.style.display = 'block';
+
+      const img = containerShow.querySelector('.info-card-art');
+      img.src = rebase(data.cover.paths.small, 'rebaseMedia');
+
+      const imgLink = containerShow.querySelector('a');
+      colorLink(imgLink, data.color);
+      imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia');
+
+      if (containerShow === containerReveal) {
+        const cw = containerShow.querySelector('.info-card-art-warnings');
+        cw.innerText = list.unit(data.cover.warnings);
+
+        const reveal = containerShow.querySelector('.reveal');
+        reveal.classList.remove('revealed');
+      }
+    });
+  }
+
+  function hide() {
+    container.classList.remove('show');
+    container.classList.add('hide');
+    cancelShow = true;
+    showing = false;
+  }
+
+  function readyHide() {
+    if (!hideTimeout && showing) {
+      hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY);
+    }
+  }
+
+  function cancelHide() {
+    if (hideTimeout) {
+      clearTimeout(hideTimeout);
+      hideTimeout = null;
+    }
+  }
+
+  return {
+    show,
+    hide,
+    readyHide,
+    cancelHide,
+  };
+})();
+
+// Info cards are disa8led for now since they aren't quite ready for release,
+// 8ut you can try 'em out 8y setting this localStorage flag!
+//
+//     localStorage.tryInfoCards = true;
+//
+if (localStorage.tryInfoCards) {
+  addInfoCardLinkHandlers('track');
+}
+*/
+
+// Custom hash links --------------------------------------
+
+const hashLinkInfo = initInfo('hashLinkInfo', {
+  links: null,
+  hrefs: null,
+  targets: null,
+
+  state: {
+    highlightedTarget: null,
+    scrollingAfterClick: false,
+    concludeScrollingStateInterval: null,
+  },
+
+  event: {
+    beforeHashLinkScrolls: [],
+    whenHashLinkClicked: [],
+  },
+});
+
+function getHashLinkReferences() {
+  const info = hashLinkInfo;
+
+  info.links =
+    Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])'));
+
+  info.hrefs =
+    info.links
+      .map(link => link.getAttribute('href'));
+
+  info.targets =
+    info.hrefs
+      .map(href => document.getElementById(href.slice(1)));
+
+  filterMultipleArrays(
+    info.links,
+    info.hrefs,
+    info.targets,
+    (_link, _href, target) => target);
+}
+
+function processScrollingAfterHashLinkClicked() {
+  const {state} = hashLinkInfo;
+
+  if (state.concludeScrollingStateInterval) return;
+
+  let lastScroll = window.scrollY;
+  state.scrollingAfterClick = true;
+  state.concludeScrollingStateInterval = setInterval(() => {
+    if (Math.abs(window.scrollY - lastScroll) < 10) {
+      clearInterval(state.concludeScrollingStateInterval);
+      state.scrollingAfterClick = false;
+      state.concludeScrollingStateInterval = null;
+    } else {
+      lastScroll = window.scrollY;
+    }
+  }, 200);
+}
+
+function addHashLinkListeners() {
+  // Instead of defining a scroll offset (to account for the sticky heading)
+  // in JavaScript, we interface with the CSS property 'scroll-margin-top'.
+  // This lets the scroll offset be consolidated where it makes sense, and
+  // sets an appropriate offset when (re)loading a page with hash for free!
+
+  const info = hashLinkInfo;
+  const {state, event} = info;
+
+  for (const {hashLink, href, target} of stitchArrays({
+    hashLink: info.links,
+    href: info.hrefs,
+    target: info.targets,
+  })) {
+    hashLink.addEventListener('click', evt => {
+      if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) {
+        return;
+      }
+
+      // Don't do anything if the target element isn't actually visible!
+      if (target.offsetParent === null) {
+        return;
+      }
+
+      // Allow event handlers to prevent scrolling.
+      const listenerResults =
+        dispatchInternalEvent(event, 'beforeHashLinkScrolls', {
+          link: hashLink,
+          target,
+        });
+
+      if (listenerResults.includes(false)) {
+        return;
+      }
+
+      // Hide skipper box right away, so the layout is updated on time for the
+      // math operations coming up next.
+      const skipper = document.getElementById('skippers');
+      skipper.style.display = 'none';
+      setTimeout(() => skipper.style.display = '');
+
+      const box = target.getBoundingClientRect();
+      const style = window.getComputedStyle(target);
+
+      const scrollY =
+          window.scrollY
+        + box.top
+        - style['scroll-margin-top'].replace('px', '');
+
+      evt.preventDefault();
+      history.pushState({}, '', href);
+      window.scrollTo({top: scrollY, behavior: 'smooth'});
+      target.focus({preventScroll: true});
+
+      const maxScroll =
+          document.body.scrollHeight
+        - window.innerHeight;
+
+      if (scrollY > maxScroll && target.classList.contains('content-heading')) {
+        if (state.highlightedTarget) {
+          state.highlightedTarget.classList.remove('highlight-hash-link');
+        }
+
+        target.classList.add('highlight-hash-link');
+        state.highlightedTarget = target;
+      }
+
+      processScrollingAfterHashLinkClicked();
+
+      dispatchInternalEvent(event, 'whenHashLinkClicked', {
+        link: hashLink,
+        target,
+      });
+    });
+  }
+
+  for (const target of info.targets) {
+    target.addEventListener('animationend', evt => {
+      if (evt.animationName !== 'highlight-hash-link') return;
+      target.classList.remove('highlight-hash-link');
+      if (target !== state.highlightedTarget) return;
+      state.highlightedTarget = null;
+    });
+  }
+}
+
+clientSteps.getPageReferences.push(getHashLinkReferences);
+clientSteps.addPageListeners.push(addHashLinkListeners);
+
+// Sticky content heading ---------------------------------
+
+const stickyHeadingInfo = initInfo('stickyHeadingInfo', {
+  stickyContainers: null,
+
+  stickySubheadingRows: null,
+  stickySubheadings: null,
+
+  stickyCoverContainers: null,
+  stickyCoverTextAreas: null,
+  stickyCovers: null,
+
+  contentContainers: null,
+  contentHeadings: null,
+  contentCovers: null,
+  contentCoversReveal: null,
+
+  state: {
+    displayedHeading: null,
+  },
+
+  event: {
+    whenDisplayedHeadingChanges: [],
+  },
+});
+
+function getStickyHeadingReferences() {
+  const info = stickyHeadingInfo;
+
+  info.stickyContainers =
+    Array.from(document.getElementsByClassName('content-sticky-heading-container'));
+
+  info.stickyCoverContainers =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-heading-cover-container'));
+
+  info.stickyCovers =
+    info.stickyCoverContainers
+      .map(el => el?.querySelector('.content-sticky-heading-cover'));
+
+  info.stickyCoverTextAreas =
+    info.stickyCovers
+      .map(el => el?.querySelector('.image-text-area'));
+
+  info.stickySubheadingRows =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-subheading-row'));
+
+  info.stickySubheadings =
+    info.stickySubheadingRows
+      .map(el => el.querySelector('h2'));
+
+  info.contentContainers =
+    info.stickyContainers
+      .map(el => el.parentElement);
+
+  info.contentCovers =
+    info.contentContainers
+      .map(el => el.querySelector('#cover-art-container'));
+
+  info.contentCoversReveal =
+    info.contentCovers
+      .map(el => el ? !!el.querySelector('.reveal') : null);
+
+  info.contentHeadings =
+    info.contentContainers
+      .map(el => Array.from(el.querySelectorAll('.content-heading')));
+}
+
+function removeTextPlaceholderStickyHeadingCovers() {
+  const info = stickyHeadingInfo;
+
+  const hasTextArea =
+    info.stickyCoverTextAreas.map(el => !!el);
+
+  const coverContainersWithTextArea =
+    info.stickyCoverContainers
+      .filter((_el, index) => hasTextArea[index]);
+
+  for (const el of coverContainersWithTextArea) {
+    el.remove();
+  }
+
+  info.stickyCoverContainers =
+    info.stickyCoverContainers
+      .map((el, index) => hasTextArea[index] ? null : el);
+
+  info.stickyCovers =
+    info.stickyCovers
+      .map((el, index) => hasTextArea[index] ? null : el);
+
+  info.stickyCoverTextAreas =
+    info.stickyCoverTextAreas
+      .slice()
+      .fill(null);
+}
+
+function addRevealClassToStickyHeadingCovers() {
+  const info = stickyHeadingInfo;
+
+  const stickyCoversWhichReveal =
+    info.stickyCovers
+      .filter((_el, index) => info.contentCoversReveal[index]);
+
+  for (const el of stickyCoversWhichReveal) {
+    el.classList.add('content-sticky-heading-cover-needs-reveal');
+  }
+}
+
+function addRevealListenersForStickyHeadingCovers() {
+  const info = stickyHeadingInfo;
+
+  const stickyCovers = info.stickyCovers.slice();
+  const contentCovers = info.contentCovers.slice();
+
+  filterMultipleArrays(
+    stickyCovers,
+    contentCovers,
+    (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]);
+
+  for (const {stickyCover, contentCover} of stitchArrays({
+    stickyCover: stickyCovers,
+    contentCover: contentCovers,
+  })) {
+    // TODO: Janky - should use internal event instead of DOM event
+    contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => {
+      stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal');
+    });
+  }
+}
+
+function topOfViewInside(el, scroll = window.scrollY) {
+  return (
+    scroll > el.offsetTop &&
+    scroll < el.offsetTop + el.offsetHeight);
+}
+
+function updateStickyCoverVisibility(index) {
+  const info = stickyHeadingInfo;
+
+  const stickyCoverContainer = info.stickyCoverContainers[index];
+  const contentCover = info.contentCovers[index];
+
+  if (contentCover && stickyCoverContainer) {
+    if (contentCover.getBoundingClientRect().bottom < 4) {
+      stickyCoverContainer.classList.add('visible');
+    } else {
+      stickyCoverContainer.classList.remove('visible');
+    }
+  }
+}
+
+function getContentHeadingClosestToStickySubheading(index) {
+  const info = stickyHeadingInfo;
+
+  const contentContainer = info.contentContainers[index];
+
+  if (!topOfViewInside(contentContainer)) {
+    return null;
+  }
+
+  const stickySubheading = info.stickySubheadings[index];
+
+  if (stickySubheading.childNodes.length === 0) {
+    // Supply a non-breaking space to ensure correct basic line height.
+    stickySubheading.appendChild(document.createTextNode('\xA0'));
+  }
+
+  const stickyContainer = info.stickyContainers[index];
+  const stickyRect = stickyContainer.getBoundingClientRect();
+
+  // TODO: Should this compute with the subheading row instead of h2?
+  const subheadingRect = stickySubheading.getBoundingClientRect();
+
+  const stickyBottom = stickyRect.bottom + subheadingRect.height;
+
+  // Iterate from bottom to top of the content area.
+  const contentHeadings = info.contentHeadings[index];
+  for (const heading of contentHeadings.slice().reverse()) {
+    const headingRect = heading.getBoundingClientRect();
+    if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) {
+      return heading;
+    }
+  }
+
+  return null;
+}
+
+function updateStickySubheadingContent(index) {
+  const info = stickyHeadingInfo;
+  const {event, state} = info;
+
+  const closestHeading = getContentHeadingClosestToStickySubheading(index);
+
+  if (state.displayedHeading === closestHeading) return;
+
+  const stickySubheadingRow = info.stickySubheadingRows[index];
+
+  if (closestHeading) {
+    const stickySubheading = info.stickySubheadings[index];
+
+    // Array.from needed to iterate over a live array with for..of
+    for (const child of Array.from(stickySubheading.childNodes)) {
+      child.remove();
+    }
+
+    const textContainer =
+      closestHeading.querySelector('.content-heading-main-title')
+        // Just for compatibility with older builds of the site.
+        ?? closestHeading;
+
+    for (const child of textContainer.childNodes) {
+      if (child.tagName === 'A') {
+        for (const grandchild of child.childNodes) {
+          stickySubheading.appendChild(grandchild.cloneNode(true));
+        }
+      } else {
+        stickySubheading.appendChild(child.cloneNode(true));
+      }
+    }
+
+    stickySubheadingRow.classList.add('visible');
+  } else {
+    stickySubheadingRow.classList.remove('visible');
+  }
+
+  const oldDisplayedHeading = state.displayedHeading;
+
+  state.displayedHeading = closestHeading;
+
+  dispatchInternalEvent(event, 'whenDisplayedHeadingChanges', index, {
+    oldHeading: oldDisplayedHeading,
+    newHeading: closestHeading,
+  });
+}
+
+function updateStickyHeadings(index) {
+  updateStickyCoverVisibility(index);
+  updateStickySubheadingContent(index);
+}
+
+function initializeStateForStickyHeadings() {
+  for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) {
+    updateStickyHeadings(i);
+  }
+}
+
+function addScrollListenerForStickyHeadings() {
+  document.addEventListener('scroll', () => {
+    for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) {
+      updateStickyHeadings(i);
+    }
+  });
+}
+
+clientSteps.getPageReferences.push(getStickyHeadingReferences);
+clientSteps.mutatePageContent.push(removeTextPlaceholderStickyHeadingCovers);
+clientSteps.mutatePageContent.push(addRevealClassToStickyHeadingCovers);
+clientSteps.initializeState.push(initializeStateForStickyHeadings);
+clientSteps.addPageListeners.push(addRevealListenersForStickyHeadingCovers);
+clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings);
+
+// Image overlay ------------------------------------------
+
+// TODO: Update to clientSteps style.
+
+function addImageOverlayClickHandlers() {
+  const container = document.getElementById('image-overlay-container');
+
+  if (!container) {
+    console.warn(`#image-overlay-container missing, image overlay module disabled.`);
+    return;
+  }
+
+  for (const link of document.querySelectorAll('.image-link')) {
+    if (link.closest('.no-image-preview')) {
+      continue;
+    }
+
+    link.addEventListener('click', handleImageLinkClicked);
+  }
+
+  const actionContainer = document.getElementById('image-overlay-action-container');
+
+  container.addEventListener('click', handleContainerClicked);
+  document.body.addEventListener('keydown', handleKeyDown);
+
+  function handleContainerClicked(evt) {
+    // Only hide the image overlay if actually clicking the background.
+    if (evt.target !== container) {
+      return;
+    }
+
+    // If you clicked anything close to or beneath the action bar, don't hide
+    // the image overlay.
+    const rect = actionContainer.getBoundingClientRect();
+    if (evt.clientY >= rect.top - 40) {
+      return;
+    }
+
+    container.classList.remove('visible');
+  }
+
+  function handleKeyDown(evt) {
+    if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) {
+      container.classList.remove('visible');
+    }
+  }
+}
+
+function handleImageLinkClicked(evt) {
+  if (evt.metaKey || evt.shiftKey || evt.altKey) {
+    return;
+  }
+
+  evt.preventDefault();
+
+  // Don't show the overlay if the image still needs to be revealed.
+  if (evt.target.closest('.reveal:not(.revealed)')) {
+    return;
+  }
+
+  const container = document.getElementById('image-overlay-container');
+  container.classList.add('visible');
+  container.classList.remove('loaded');
+  container.classList.remove('errored');
+
+  const allViewOriginal = document.getElementsByClassName('image-overlay-view-original');
+  const mainImage = document.getElementById('image-overlay-image');
+  const thumbImage = document.getElementById('image-overlay-image-thumb');
+
+  const {href: originalSrc} = evt.target.closest('a');
+
+  const {
+    src: embeddedSrc,
+    dataset: {
+      originalSize: originalFileSize,
+      thumbs: availableThumbList,
+    },
+  } = evt.target.closest('a').querySelector('img');
+
+  updateFileSizeInformation(originalFileSize);
+
+  let mainSrc = null;
+  let thumbSrc = null;
+
+  if (availableThumbList) {
+    const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList);
+    const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList);
+    mainSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`);
+    thumbSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`);
+    // Show the thumbnail size on each <img> element's data attributes.
+    // Y'know, just for debugging convenience.
+    mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`;
+    thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`;
+  } else {
+    mainSrc = originalSrc;
+    thumbSrc = null;
+    mainImage.dataset.displayingThumb = '';
+    thumbImage.dataset.displayingThumb = '';
+  }
+
+  if (thumbSrc) {
+    thumbImage.src = thumbSrc;
+    thumbImage.style.display = null;
+  } else {
+    thumbImage.src = '';
+    thumbImage.style.display = 'none';
+  }
+
+  for (const viewOriginal of allViewOriginal) {
+    viewOriginal.href = originalSrc;
+  }
+
+  mainImage.addEventListener('load', handleMainImageLoaded);
+  mainImage.addEventListener('error', handleMainImageErrored);
+
+  container.style.setProperty('--download-progress', '0%');
+  loadImage(mainSrc, progress => {
+    container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%');
+  }).then(
+    blobUrl => {
+      mainImage.src = blobUrl;
+      container.style.setProperty('--download-progress', '100%');
+    },
+    handleMainImageErrored);
+
+  function handleMainImageLoaded() {
+    mainImage.removeEventListener('load', handleMainImageLoaded);
+    mainImage.removeEventListener('error', handleMainImageErrored);
+    container.classList.add('loaded');
+  }
+
+  function handleMainImageErrored() {
+    mainImage.removeEventListener('load', handleMainImageLoaded);
+    mainImage.removeEventListener('error', handleMainImageErrored);
+    container.classList.add('errored');
+  }
+}
+
+function parseThumbList(availableThumbList) {
+  // Parse all the available thumbnail sizes! These are provided by the actual
+  // content generation on each image.
+  const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250'
+  const availableSizes =
+    (availableThumbList || defaultThumbList)
+      .split(' ')
+      .map(part => part.split(':'))
+      .map(([thumb, length]) => ({thumb, length: parseInt(length)}))
+      .sort((a, b) => a.length - b.length);
+
+  return availableSizes;
+}
+
+function getPreferredThumbSize(availableThumbList) {
+  // Assuming a square, the image will be constrained to the lesser window
+  // dimension. Coefficient here matches CSS dimensions for image overlay.
+  const constrainedLength = Math.floor(Math.min(
+    0.80 * window.innerWidth,
+    0.80 * window.innerHeight));
+
+  // Match device pixel ratio, which is 2x for "retina" displays and certain
+  // device configurations.
+  const visualLength = window.devicePixelRatio * constrainedLength;
+
+  const availableSizes = parseThumbList(availableThumbList);
+
+  // Starting from the smallest dimensions, find (and return) the first
+  // available length which hits a "good enough" threshold - it's got to be
+  // at least that percent of the way to the actual displayed dimensions.
+  const goodEnoughThreshold = 0.90;
+
+  // (The last item is skipped since we'd be falling back to it anyway.)
+  for (const {thumb, length} of availableSizes.slice(0, -1)) {
+    if (Math.floor(visualLength * goodEnoughThreshold) <= length) {
+      return {thumb, length};
+    }
+  }
+
+  // If none of the items in the list were big enough to hit the "good enough"
+  // threshold, just use the largest size available.
+  return availableSizes[availableSizes.length - 1];
+}
+
+function getSmallestThumbSize(availableThumbList) {
+  // Just snag the smallest size. This'll be used for displaying the "preview"
+  // as the bigger one is loading.
+  const availableSizes = parseThumbList(availableThumbList);
+  return availableSizes[0];
+}
+
+function updateFileSizeInformation(fileSize) {
+  const fileSizeWarningThreshold = 8 * 10 ** 6;
+
+  const actionContentWithoutSize = document.getElementById('image-overlay-action-content-without-size');
+  const actionContentWithSize = document.getElementById('image-overlay-action-content-with-size');
+
+  if (!fileSize) {
+    actionContentWithSize.classList.remove('visible');
+    actionContentWithoutSize.classList.add('visible');
+    return;
+  }
+
+  actionContentWithoutSize.classList.remove('visible');
+  actionContentWithSize.classList.add('visible');
+
+  const megabytesContainer = document.getElementById('image-overlay-file-size-megabytes');
+  const kilobytesContainer = document.getElementById('image-overlay-file-size-kilobytes');
+  const megabytesContent = megabytesContainer.querySelector('.image-overlay-file-size-count');
+  const kilobytesContent = kilobytesContainer.querySelector('.image-overlay-file-size-count');
+  const fileSizeWarning = document.getElementById('image-overlay-file-size-warning');
+
+  fileSize = parseInt(fileSize);
+  const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10;
+
+  if (fileSize > fileSizeWarningThreshold) {
+    fileSizeWarning.classList.add('visible');
+  } else {
+    fileSizeWarning.classList.remove('visible');
+  }
+
+  if (fileSize > 10 ** 6) {
+    megabytesContainer.classList.add('visible');
+    kilobytesContainer.classList.remove('visible');
+    megabytesContent.innerText = round(6);
+  } else {
+    megabytesContainer.classList.remove('visible');
+    kilobytesContainer.classList.add('visible');
+    kilobytesContent.innerText = round(3);
+  }
+
+  void fileSizeWarning;
+}
+
+addImageOverlayClickHandlers();
+
+/**
+ * Credits: Parziphal, Feb 13, 2017
+ * https://stackoverflow.com/a/42196770
+ *
+ * Loads an image with progress callback.
+ *
+ * The `onprogress` callback will be called by XMLHttpRequest's onprogress
+ * event, and will receive the loading progress ratio as an whole number.
+ * However, if it's not possible to compute the progress ratio, `onprogress`
+ * will be called only once passing -1 as progress value. This is useful to,
+ * for example, change the progress animation to an undefined animation.
+ *
+ * @param  {string}   imageUrl   The image to load
+ * @param  {Function} onprogress
+ * @return {Promise}
+ */
+function loadImage(imageUrl, onprogress) {
+  return new Promise((resolve, reject) => {
+    var xhr = new XMLHttpRequest();
+    var notifiedNotComputable = false;
+
+    xhr.open('GET', imageUrl, true);
+    xhr.responseType = 'arraybuffer';
+
+    xhr.onprogress = function(ev) {
+      if (ev.lengthComputable) {
+        onprogress(parseInt((ev.loaded / ev.total) * 1000) / 10);
+      } else {
+        if (!notifiedNotComputable) {
+          notifiedNotComputable = true;
+          onprogress(-1);
+        }
+      }
+    }
+
+    xhr.onloadend = function() {
+      if (!xhr.status.toString().match(/^2/)) {
+        reject(xhr);
+      } else {
+        if (!notifiedNotComputable) {
+          onprogress(100);
+        }
+
+        var options = {}
+        var headers = xhr.getAllResponseHeaders();
+        var m = headers.match(/^Content-Type:\s*(.*?)$/mi);
+
+        if (m && m[1]) {
+          options.type = m[1];
+        }
+
+        var blob = new Blob([this.response], options);
+
+        resolve(window.URL.createObjectURL(blob));
+      }
+    }
+
+    xhr.send();
+  });
+}
+
+// "Additional names" box ---------------------------------
+
+const additionalNamesBoxInfo = initInfo('additionalNamesBox', {
+  box: null,
+  links: null,
+  mainContentContainer: null,
+
+  state: {
+    visible: false,
+  },
+});
+
+function getAdditionalNamesBoxReferences() {
+  const info = additionalNamesBoxInfo;
+
+  info.box =
+    document.getElementById('additional-names-box');
+
+  info.links =
+    document.querySelectorAll('a[href="#additional-names-box"]');
+
+  info.mainContentContainer =
+    document.querySelector('#content .main-content-container');
+}
+
+function addAdditionalNamesBoxInternalListeners() {
+  const info = additionalNamesBoxInfo;
+
+  hashLinkInfo.event.beforeHashLinkScrolls.push(({target}) => {
+    if (target === info.box) {
+      return false;
+    }
+  });
+}
+
+function addAdditionalNamesBoxListeners() {
+  const info = additionalNamesBoxInfo;
+
+  for (const link of info.links) {
+    link.addEventListener('click', domEvent => {
+      handleAdditionalNamesBoxLinkClicked(domEvent);
+    });
+  }
+}
+
+function handleAdditionalNamesBoxLinkClicked(domEvent) {
+  const info = additionalNamesBoxInfo;
+  const {state} = info;
+
+  domEvent.preventDefault();
+
+  if (!info.box || !info.mainContentContainer) return;
+
+  const margin =
+    +(cssProp(info.box, 'scroll-margin-top').replace('px', ''));
+
+  const {top} =
+    (state.visible
+      ? info.box.getBoundingClientRect()
+      : info.mainContentContainer.getBoundingClientRect());
+
+  if (top + 20 < margin || top > 0.4 * window.innerHeight) {
+    if (!state.visible) {
+      toggleAdditionalNamesBox();
+    }
+
+    window.scrollTo({
+      top: window.scrollY + top - margin,
+      behavior: 'smooth',
+    });
+  } else {
+    toggleAdditionalNamesBox();
+  }
+}
+
+function toggleAdditionalNamesBox() {
+  const info = additionalNamesBoxInfo;
+  const {state} = info;
+
+  state.visible = !state.visible;
+  info.box.style.display =
+    (state.visible
+      ? 'block'
+      : 'none');
+}
+
+clientSteps.getPageReferences.push(getAdditionalNamesBoxReferences);
+clientSteps.addInternalListeners.push(addAdditionalNamesBoxInternalListeners);
+clientSteps.addPageListeners.push(addAdditionalNamesBoxListeners);
+
+// Group contributions table ------------------------------
+
+// TODO: Update to clientSteps style.
+
+const groupContributionsTableInfo =
+  Array.from(document.querySelectorAll('#content dl'))
+    .filter(dl => dl.querySelector('a.group-contributions-sort-button'))
+    .map(dl => ({
+      sortingByCountLink: dl.querySelector('dt.group-contributions-sorted-by-count a.group-contributions-sort-button'),
+      sortingByDurationLink: dl.querySelector('dt.group-contributions-sorted-by-duration a.group-contributions-sort-button'),
+      sortingByCountElements: dl.querySelectorAll('.group-contributions-sorted-by-count'),
+      sortingByDurationElements: dl.querySelectorAll('.group-contributions-sorted-by-duration'),
+    }));
+
+function sortGroupContributionsTableBy(info, sort) {
+  const [showThese, hideThese] =
+    (sort === 'count'
+      ? [info.sortingByCountElements, info.sortingByDurationElements]
+      : [info.sortingByDurationElements, info.sortingByCountElements]);
+
+  for (const element of showThese) element.classList.add('visible');
+  for (const element of hideThese) element.classList.remove('visible');
+}
+
+for (const info of groupContributionsTableInfo) {
+  info.sortingByCountLink.addEventListener('click', evt => {
+    evt.preventDefault();
+    sortGroupContributionsTableBy(info, 'duration');
+  });
+
+  info.sortingByDurationLink.addEventListener('click', evt => {
+    evt.preventDefault();
+    sortGroupContributionsTableBy(info, 'count');
+  });
+}
+
+// Generic links with tooltips ----------------------------
+
+const textWithTooltipInfo = initInfo('textWithTooltipInfo', {
+  hoverables: null,
+  tooltips: null,
+});
+
+function getTextWithTooltipReferences() {
+  const info = textWithTooltipInfo;
+
+  const spans =
+    Array.from(document.querySelectorAll('.text-with-tooltip'));
+
+  info.hoverables =
+    spans.map(span => span.children[0]);
+
+  info.tooltips =
+    spans.map(span => span.children[1]);
+}
+
+function addTextWithTooltipPageListeners() {
+  const info = textWithTooltipInfo;
+
+  for (const {hoverable, tooltip} of stitchArrays({
+    hoverable: info.hoverables,
+    tooltip: info.tooltips,
+  })) {
+    registerTooltipElement(tooltip);
+    registerTooltipHoverableElement(hoverable, tooltip);
+  }
+}
+
+clientSteps.getPageReferences.push(getTextWithTooltipReferences);
+clientSteps.addPageListeners.push(addTextWithTooltipPageListeners);
+
+// Datetimestamp tooltips ---------------------------------
+
+const datetimestampTooltipInfo = initInfo('datetimestampTooltipInfo', {
+  hoverables: null,
+  tooltips: null,
+});
+
+function getDatestampTooltipReferences() {
+  const info = datetimestampTooltipInfo;
+
+  const spans =
+    Array.from(document.querySelectorAll('span.datetimestamp.has-tooltip'));
+
+  info.hoverables =
+    spans.map(span => span.querySelector('time'));
+
+  info.tooltips =
+    spans.map(span => span.querySelector('span.datetimestamp-tooltip'));
+}
+
+function addDatestampTooltipPageListeners() {
+  const info = datetimestampTooltipInfo;
+
+  for (const {hoverable, tooltip} of stitchArrays({
+    hoverable: info.hoverables,
+    tooltip: info.tooltips,
+  })) {
+    registerTooltipElement(tooltip);
+    registerTooltipHoverableElement(hoverable, tooltip);
+  }
+}
+
+clientSteps.getPageReferences.push(getDatestampTooltipReferences);
+clientSteps.addPageListeners.push(addDatestampTooltipPageListeners);
+
+// Artist external link tooltips --------------------------
+
+// These don't need to have tooltip events specially added as
+// they're implemented with "text with tooltip" components.
+
+const artistExternalLinkTooltipInfo = initInfo('artistExternalLinkTooltipInfo', {
+  tooltips: null,
+  tooltipRows: null,
+
+  settings: {
+    // This is the maximum distance, in CSS pixels, that the mouse
+    // can appear to be moving per second while still considered
+    // "idle". A greater value means higher tolerance for small
+    // movements.
+    maximumIdleSpeed: 40,
+
+    // Leaving the mouse idle for this amount of time, over a single
+    // row of the tooltip, will cause a column of supplemental info
+    // to display.
+    mouseIdleShowInfoDelay: 1000,
+
+    // If none of these tooltips are visible for this amount of time,
+    // the supplemental info column is hidden. It'll never disappear
+    // while a tooltip is actually visible.
+    hideInfoAfterTooltipHiddenDelay: 2250,
+  },
+
+  state: {
+    // This is shared by all tooltips.
+    showingTooltipInfo: false,
+
+    mouseIdleTimeout: null,
+    hideInfoTimeout: null,
+
+    mouseMovementPositions: [],
+    mouseMovementTimestamps: [],
+  },
+});
+
+function getArtistExternalLinkTooltipPageReferences() {
+  const info = artistExternalLinkTooltipInfo;
+
+  info.tooltips =
+    Array.from(document.getElementsByClassName('icons-tooltip'));
+
+  info.tooltipRows =
+    info.tooltips.map(tooltip =>
+      Array.from(tooltip.getElementsByClassName('icon')));
+}
+
+function addArtistExternalLinkTooltipInternalListeners() {
+  const info = artistExternalLinkTooltipInfo;
+
+  hoverableTooltipInfo.event.whenTooltipShows.push(({tooltip}) => {
+    const {state} = info;
+
+    if (info.tooltips.includes(tooltip)) {
+      clearTimeout(state.hideInfoTimeout);
+      state.hideInfoTimeout = null;
+    }
+  });
+
+  hoverableTooltipInfo.event.whenTooltipHides.push(() => {
+    const {settings, state} = info;
+
+    if (state.showingTooltipInfo) {
+      state.hideInfoTimeout =
+        setTimeout(() => {
+          state.hideInfoTimeout = null;
+          hideArtistExternalLinkTooltipInfo();
+        }, settings.hideInfoAfterTooltipHiddenDelay);
+    } else {
+      clearTimeout(state.mouseIdleTimeout);
+      state.mouseIdleTimeout = null;
+    }
+  });
+}
+
+function addArtistExternalLinkTooltipPageListeners() {
+  const info = artistExternalLinkTooltipInfo;
+
+  for (const tooltip of info.tooltips) {
+    tooltip.addEventListener('mousemove', domEvent => {
+      handleArtistExternalLinkTooltipMouseMoved(domEvent);
+    });
+
+    tooltip.addEventListener('mouseout', () => {
+      const {state} = info;
+
+      clearTimeout(state.mouseIdleTimeout);
+      state.mouseIdleTimeout = null;
+    });
+  }
+
+  for (const tooltipRow of info.tooltipRows.flat()) {
+    tooltipRow.addEventListener('mouseover', () => {
+      const {state} = info;
+
+      clearTimeout(state.mouseIdleTimeout);
+      state.mouseIdleTimeout = null;
+    });
+  }
+}
+
+function handleArtistExternalLinkTooltipMouseMoved(domEvent) {
+  const info = artistExternalLinkTooltipInfo;
+  const {settings, state} = info;
+
+  if (state.showingTooltipInfo) {
+    return;
+  }
+
+  // Clean out expired mouse movements
+
+  const expiryTime = 1000;
+
+  if (!empty(state.mouseMovementTimestamps)) {
+    const firstRecentMovementIndex =
+      state.mouseMovementTimestamps
+        .findIndex(value => Date.now() - value <= expiryTime);
+
+    if (firstRecentMovementIndex === -1) {
+      state.mouseMovementTimestamps.splice(0);
+      state.mouseMovementPositions.splice(0);
+    } else if (firstRecentMovementIndex > 0) {
+      state.mouseMovementTimestamps.splice(0, firstRecentMovementIndex - 1);
+      state.mouseMovementPositions.splice(0, firstRecentMovementIndex - 1);
+    }
+  }
+
+  const currentMovementDistance =
+    Math.sqrt(domEvent.movementX ** 2 + domEvent.movementY ** 2);
+
+  state.mouseMovementTimestamps.push(Date.now());
+  state.mouseMovementPositions.push([domEvent.screenX, domEvent.screenY]);
+
+  // We can't really compute speed without having
+  // at least two data points!
+  if (state.mouseMovementPositions.length < 2) {
+    return;
+  }
+
+  const movementTravelDistances =
+    state.mouseMovementPositions.map((current, index, array) => {
+      if (index === 0) return 0;
+
+      const previous = array[index - 1];
+      const deltaX = current[0] - previous[0];
+      const deltaY = current[1] - previous[1];
+      return Math.sqrt(deltaX ** 2 + deltaY ** 2);
+    });
+
+  const totalTravelDistance =
+    accumulateSum(movementTravelDistances);
+
+  // In seconds rather than milliseconds.
+  const timeSinceFirstMovement =
+    (Date.now() - state.mouseMovementTimestamps[0]) / 1000;
+
+  const averageSpeed =
+    Math.floor(totalTravelDistance / timeSinceFirstMovement);
+
+  if (averageSpeed > settings.maximumIdleSpeed) {
+    clearTimeout(state.mouseIdleTimeout);
+    state.mouseIdleTimeout = null;
+  }
+
+  if (state.mouseIdleTimeout) {
+    return;
+  }
+
+  state.mouseIdleTimeout =
+    setTimeout(() => {
+      state.mouseIdleTimeout = null;
+      showArtistExternalLinkTooltipInfo();
+    }, settings.mouseIdleShowInfoDelay);
+}
+
+function showArtistExternalLinkTooltipInfo() {
+  const info = artistExternalLinkTooltipInfo;
+  const {state} = info;
+
+  state.showingTooltipInfo = true;
+
+  for (const tooltip of info.tooltips) {
+    tooltip.classList.add('show-info');
+  }
+}
+
+function hideArtistExternalLinkTooltipInfo() {
+  const info = artistExternalLinkTooltipInfo;
+  const {state} = info;
+
+  state.showingTooltipInfo = false;
+
+  for (const tooltip of info.tooltips) {
+    tooltip.classList.remove('show-info');
+  }
+}
+
+clientSteps.getPageReferences.push(getArtistExternalLinkTooltipPageReferences);
+clientSteps.addInternalListeners.push(addArtistExternalLinkTooltipInternalListeners);
+clientSteps.addPageListeners.push(addArtistExternalLinkTooltipPageListeners);
+
+// Sticky commentary sidebar ------------------------------
+
+const albumCommentarySidebarInfo = initInfo('albumCommentarySidebarInfo', {
+  sidebar: null,
+  sidebarHeading: null,
+
+  sidebarTrackLinks: null,
+  sidebarTrackDirectories: null,
+
+  sidebarTrackSections: null,
+  sidebarTrackSectionStartIndices: null,
+
+  state: {
+    currentTrackSection: null,
+    currentTrackLink: null,
+    justChangedTrackSection: false,
+  },
+});
+
+function getAlbumCommentarySidebarReferences() {
+  const info = albumCommentarySidebarInfo;
+
+  info.sidebar =
+    document.getElementById('sidebar-left');
+
+  info.sidebarHeading =
+    info.sidebar.querySelector('h1');
+
+  info.sidebarTrackLinks =
+    Array.from(info.sidebar.querySelectorAll('li a'));
+
+  info.sidebarTrackDirectories =
+    info.sidebarTrackLinks
+      .map(el => el.getAttribute('href')?.slice(1) ?? null);
+
+  info.sidebarTrackSections =
+    Array.from(info.sidebar.getElementsByTagName('details'));
+
+  info.sidebarTrackSectionStartIndices =
+    info.sidebarTrackSections
+      .map(details => details.querySelector('ol, ul'))
+      .reduce(
+        (accumulator, _list, index, array) =>
+          (empty(accumulator)
+            ? [0]
+            : [
+              ...accumulator,
+              (accumulator[accumulator.length - 1] +
+                array[index - 1].querySelectorAll('li a').length),
+            ]),
+        []);
+}
+
+function scrollAlbumCommentarySidebar() {
+  const info = albumCommentarySidebarInfo;
+  const {state} = info;
+  const {currentTrackLink, currentTrackSection} = state;
+
+  if (!currentTrackLink) {
+    return;
+  }
+
+  const {sidebar, sidebarHeading} = info;
+
+  const scrollTop = sidebar.scrollTop;
+
+  const headingRect = sidebarHeading.getBoundingClientRect();
+  const sidebarRect = sidebar.getBoundingClientRect();
+
+  const stickyPadding = headingRect.height;
+  const sidebarViewportHeight = sidebarRect.height - stickyPadding;
+
+  const linkRect = currentTrackLink.getBoundingClientRect();
+  const sectionRect = currentTrackSection.getBoundingClientRect();
+
+  const sectionTopEdge =
+    sectionRect.top - (sidebarRect.top - scrollTop);
+
+  const sectionHeight =
+    sectionRect.height;
+
+  const sectionScrollTop =
+    sectionTopEdge - stickyPadding - 10;
+
+  const linkTopEdge =
+    linkRect.top - (sidebarRect.top - scrollTop);
+
+  const linkBottomEdge =
+    linkRect.bottom - (sidebarRect.top - scrollTop);
+
+  const linkScrollTop =
+    linkTopEdge - stickyPadding - 5;
+
+  const linkVisibleFromTopOfSection =
+    linkBottomEdge - sectionTopEdge > sidebarViewportHeight;
+
+  const linkScrollBottom =
+    linkScrollTop - sidebarViewportHeight + linkRect.height + 20;
+
+  const maxScrollInViewport =
+    scrollTop + stickyPadding + sidebarViewportHeight;
+
+  const minScrollInViewport =
+    scrollTop + stickyPadding;
+
+  if (linkBottomEdge > maxScrollInViewport) {
+    if (linkVisibleFromTopOfSection) {
+      sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'});
+    } else {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  } else if (linkTopEdge < minScrollInViewport) {
+    if (linkVisibleFromTopOfSection) {
+      sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'});
+    } else {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  } else if (state.justChangedTrackSection) {
+    if (sectionHeight < sidebarViewportHeight) {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  }
+}
+
+function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) {
+  const info = albumCommentarySidebarInfo;
+  const {state} = info;
+
+  const trackIndex =
+    (trackDirectory
+      ? info.sidebarTrackDirectories
+          .indexOf(trackDirectory)
+      : -1);
+
+  const sectionIndex =
+    (trackIndex >= 0
+      ? info.sidebarTrackSectionStartIndices
+          .findIndex((start, index, array) =>
+            (index === array.length - 1
+              ? true
+              : trackIndex < array[index + 1]))
+      : -1);
+
+  const sidebarTrackLink =
+    (trackIndex >= 0
+      ? info.sidebarTrackLinks[trackIndex]
+      : null);
+
+  const sidebarTrackSection =
+    (sectionIndex >= 0
+      ? info.sidebarTrackSections[sectionIndex]
+      : null);
+
+  state.currentTrackLink?.classList?.remove('current');
+  state.currentTrackLink = sidebarTrackLink;
+  state.currentTrackLink?.classList?.add('current');
+
+  if (sidebarTrackSection !== state.currentTrackSection) {
+    if (sidebarTrackSection && !sidebarTrackSection.open) {
+      if (state.currentTrackSection) {
+        state.currentTrackSection.open = false;
+      }
+
+      sidebarTrackSection.open = true;
+    }
+
+    state.currentTrackSection?.classList?.remove('current');
+    state.currentTrackSection = sidebarTrackSection;
+    state.currentTrackSection?.classList?.add('current');
+    state.justChangedTrackSection = true;
+  } else {
+    state.justChangedTrackSection = false;
+  }
+}
+
+function addAlbumCommentaryInternalListeners() {
+  const info = albumCommentarySidebarInfo;
+
+  const mainContentIndex =
+    (stickyHeadingInfo.contentContainers ?? [])
+      .findIndex(({id}) => id === 'content');
+
+  if (mainContentIndex === -1) return;
+
+  stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => {
+    if (index !== mainContentIndex) return;
+    if (hashLinkInfo.state.scrollingAfterClick) return;
+
+    const trackDirectory =
+      (newHeading
+        ? newHeading.id
+        : null);
+
+    markDirectoryAsCurrentForAlbumCommentary(trackDirectory);
+    scrollAlbumCommentarySidebar();
+  });
+
+  hashLinkInfo.event.whenHashLinkClicked.push(({link}) => {
+    const hash = link.getAttribute('href').slice(1);
+    if (!info.sidebarTrackDirectories.includes(hash)) return;
+    markDirectoryAsCurrentForAlbumCommentary(hash);
+  });
+}
+
+if (document.documentElement.dataset.urlKey === 'localized.albumCommentary') {
+  clientSteps.getPageReferences.push(getAlbumCommentarySidebarReferences);
+  clientSteps.addInternalListeners.push(addAlbumCommentaryInternalListeners);
+}
+
+// Run setup steps ----------------------------------------
+
+for (const [key, steps] of Object.entries(clientSteps)) {
+  for (const step of steps) {
+    try {
+      step();
+    } catch (error) {
+      console.warn(`During ${key}, failed to run ${step.name}`);
+      console.debug(error);
+    }
+  }
+}
diff --git a/src/static/icons.svg b/src/static/icons.svg
index 1e4351b..8c9a80a 100644
--- a/src/static/icons.svg
+++ b/src/static/icons.svg
@@ -1,11 +1,43 @@
 <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>
+  <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-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-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/lazy-loading.js b/src/static/lazy-loading.js
index a403d7c..1df56f0 100644
--- a/src/static/lazy-loading.js
+++ b/src/static/lazy-loading.js
@@ -1,3 +1,5 @@
+/* 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
@@ -7,45 +9,46 @@
 var observer;
 
 function loadImage(image) {
-    image.src = image.dataset.original;
+  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);
-        }
+  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');
-    }
+  // 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);
-        }
+  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/site-basic.css b/src/static/site-basic.css
index d26584a..586f37b 100644
--- a/src/static/site-basic.css
+++ b/src/static/site-basic.css
@@ -4,16 +4,16 @@
  */
 
 html {
-    background-color: #222222;
-    color: white;
+  background-color: #222222;
+  color: white;
 }
 
 body {
-    padding: 15px;
+  padding: 15px;
 }
 
 main {
-    background-color: rgba(0, 0, 0, 0.6);
-    border: 1px dotted white;
-    padding: 20px;
+  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 53a2ad9..0000000
--- a/src/static/site.css
+++ /dev/null
@@ -1,986 +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;
-}
-
-.footer-localization-links > span:not(:last-child)::after {
-    content: " \00b7 ";
-    font-weight: 800;
-}
-
-.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: anywhere;
-}
-
-.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%;
-    position: relative;
-}
-
-.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);
-}
-
-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: flex-start;
-}
-
-.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 {
-    display: block;
-}
-
-.grid-item > span:not(:first-child) {
-    margin-top: 2px;
-}
-
-.grid-item > span:first-of-type {
-    margin-top: 6px;
-}
-
-.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 {
-    display: inline-block;
-    font-style: oblique;
-}
-
-li .by a {
-    display: inline-block;
-}
-
-p code {
-    font-size: 1em;
-    font-family: 'courier new';
-    font-weight: 800;
-}
-
-blockquote {
-    margin-left: 40px;
-    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: 40px;
-    max-width: 600px;
-}
-
-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;
-    }
-}
-
-/* important easter egg mode */
-
-html[data-language-code=preview-en][data-url-key="localized.home"] body {
-    background-color: #ff00a2;
-    animation: preview-rainbow 30s infinite;
-    background: linear-gradient(rgba(255,0,0,1) 0%, rgba(255,154,0,1) 10%, rgba(208,222,33,1) 20%, rgba(79,220,74,1) 30%, rgba(63,218,216,1) 40%, rgba(47,201,226,1) 50%, rgba(28,127,238,1) 60%, rgba(95,21,242,1) 70%, rgba(186,12,248,1) 80%, rgba(251,7,217,1) 90%, rgba(255,0,0,1) 100%) 0 0/100% 200%;
-}
-
-@keyframes preview-rainbow {
-    to {
-        background-position: 0 -200%;
-    }
-}
-
-html[data-language-code=preview-en][data-url-key="localized.home"] #content h1::after {
-    font-family: cursive;
-    display: block;
-    content: "(Preview Build)";
-}
diff --git a/src/static/site6.css b/src/static/site6.css
new file mode 100644
index 0000000..7372195
--- /dev/null
+++ b/src/static/site6.css
@@ -0,0 +1,2401 @@
+/* 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 {
+  margin: 10px;
+  overflow-y: scroll;
+}
+
+body::before {
+  content: "";
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  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: 10px auto 50px;
+  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-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 */
+
+body {
+  background: black;
+}
+
+body::before {
+  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.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;
+}
+
+#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 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;
+}
+
+summary > span:hover {
+  cursor: pointer;
+  text-decoration: underline;
+  text-decoration-color: var(--primary-color);
+}
+
+summary .group-name {
+  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;
+}
+
+#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:not([href]) {
+  cursor: default;
+}
+
+a:not([href]):hover {
+  text-decoration: none;
+}
+
+.external-link:not(.from-content) {
+  white-space: nowrap;
+}
+
+.external-link.indicate-external::after {
+  content: '\00a0➚';
+}
+
+.external-link.indicate-external:hover::after {
+  color: white;
+}
+
+.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.has-divider::before,
+.nav-links-groups .nav-link.has-divider::before {
+  content: "\0020\00b7\0020";
+  font-weight: 800;
+}
+
+.nav-links-hierarchical .nav-link.has-divider::before {
+  content: "\0020/\0020";
+}
+
+#header .chronology .heading,
+#header .chronology .buttons {
+  white-space: nowrap;
+}
+
+#secondary-nav {
+  text-align: center;
+}
+
+.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 {
+  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;
+}
+
+.tooltip {
+  position: absolute;
+  z-index: 3;
+  left: -10px;
+  top: calc(1em + 1px);
+  display: none;
+}
+
+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;
+}
+
+.icons-tooltip {
+  padding: 3px 6px 6px 6px;
+  left: -34px;
+}
+
+.datetimestamp-tooltip,
+.missing-duration-tooltip {
+  padding: 3px 4px 2px 2px;
+  left: -10px;
+}
+
+.thing-name-tooltip {
+  padding: 3px 4px 2px 2px;
+  left: -6px !important;
+
+  /* Terrifying?
+   * https://stackoverflow.com/a/64424759/4633828
+   */
+  margin-right: -120px;
+}
+
+.icons-tooltip .tooltip-content {
+  padding: 6px 2px 2px 2px;
+
+  -webkit-user-select: none;
+          user-select: none;
+
+  cursor: default;
+
+  display: grid;
+
+  grid-template-columns:
+    [icon-start] auto [icon-end domain-start] auto [domain-end];
+}
+
+.icons-tooltip .icon {
+  grid-column-start: icon-start;
+  grid-column-end: icon-end;
+}
+
+.icons-tooltip .icon-platform {
+  display: none;
+
+  grid-column-start: domain-start;
+  grid-column-end: domain-end;
+
+  --icon-platform-opacity: 0.8;
+  padding-right: 4px;
+  opacity: 0.8;
+}
+
+.icons-tooltip.show-info .icon-platform {
+  display: inline;
+  animation: icon-platform 0.2s forwards linear;
+}
+
+@keyframes icon-platform {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: var(--icon-platform-opacity);
+  }
+}
+
+.icons-tooltip .icon:hover + .icon-platform {
+  --icon-platform-opacity: 1;
+  text-decoration: underline;
+  text-decoration-color: #ffffffaa;
+}
+
+.datetimestamp-tooltip .tooltip-content,
+.missing-duration-tooltip .tooltip-content {
+  padding: 5px 6px;
+  white-space: nowrap;
+  font-size: 0.9em;
+}
+
+.thing-name-tooltip .tooltip-content {
+  padding: 3px 4.5px;
+}
+
+.icons {
+  font-style: normal;
+  white-space: nowrap;
+}
+
+.icons a:hover {
+  filter: brightness(1.4);
+}
+
+.icons a {
+  padding: 0 3px;
+}
+
+.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);
+}
+
+.icon.has-text {
+  display: block;
+  width: unset;
+  height: 1.4em;
+}
+
+.icon.has-text > svg {
+  width: 18px;
+  height: 18px;
+  top: -0.1em;
+}
+
+.icon.has-text > .icon-text {
+  margin-left: 24px;
+  padding-right: 8px;
+}
+
+.rerelease,
+.other-group-accent {
+  opacity: 0.7;
+  font-style: oblique;
+}
+
+.other-group-accent {
+  white-space: nowrap;
+}
+
+.other-group-accent a {
+  color: var(--page-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;
+}
+
+#cover-art-container {
+  font-size: 0.8em;
+  border: 2px solid var(--primary-color);
+  box-shadow:
+    0 2px 14px -6px var(--primary-color),
+    0 0 12px 12px #00000080;
+
+  border-radius: 0 0 4px 4px;
+  background: var(--bg-black-color);
+
+  -webkit-backdrop-filter: blur(3px);
+          backdrop-filter: blur(3px);
+}
+
+#cover-art-container:has(.image-details),
+#cover-art-container.has-image-details {
+  border-radius: 0 0 6px 6px;
+}
+
+#cover-art-container:not(:has(.image-details)),
+#cover-art-container:not(.has-image-details) {
+  /* Hacky: `overflow: hidden` hides tag tooltips, so it can't be applied
+   * if we've got tags/details visible. But it's okay, because we only
+   * need to apply it if it *doesn't* - that's when the rounded border
+   * of #cover-art-container needs to cut off its child image-container
+   * (which has a background that otherwise causes sharp corners).
+   */
+  overflow: hidden;
+}
+
+#cover-art-container .image-container {
+  /* Border is handled on the cover-art-container. */
+  border: none;
+  border-radius: 0;
+}
+
+#cover-art-container .image-details {
+  border-top-color: var(--deep-color);
+}
+
+#cover-art-container .image {
+  display: block;
+  width: 100%;
+  height: 100%;
+}
+
+.image-details {
+  display: block;
+
+  padding: 6px 9px 4px 9px;
+  margin-top: 0;
+  margin-bottom: 0;
+  border-top: 1px dashed var(--dim-color);
+}
+
+ul.image-details li {
+  display: inline-block;
+  margin: 0;
+}
+
+#cover-art-container ul li:not(:last-child)::after {
+  content: " \00b7 ";
+}
+
+.commentary-entry-heading {
+  margin-left: 15px;
+  padding-left: 5px;
+  max-width: 625px;
+  padding-bottom: 0.2em;
+  border-bottom: 1px dotted var(--primary-color);
+}
+
+.commentary-entry-accent {
+  font-style: oblique;
+}
+
+.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;
+}
+
+.js-hide,
+.js-show-once-data,
+.js-hide-once-data {
+  display: none;
+}
+
+.content-image-container {
+  margin-top: 1em;
+  margin-bottom: 1em;
+}
+
+.content-image-container.align-center {
+  text-align: center;
+  margin-top: 1.5em;
+  margin-bottom: 1.5em;
+}
+
+.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;
+}
+
+html[data-url-key="localized.home"] #content h1 {
+  text-align: center;
+  font-size: 2.5em;
+}
+
+#content.flash-index h2 {
+  text-align: center;
+  font-size: 2.5em;
+  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;
+}
+
+.carousel-container + .quick-info {
+  margin-top: 25px;
+}
+
+#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;
+}
+
+main.long-content {
+  --long-content-padding-ratio: 0.10;
+}
+
+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 {
+  margin-bottom: 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 {
+  font-style: oblique;
+  padding-left: 0;
+}
+
+.album-group-list dd {
+  margin-left: 0;
+}
+
+.group-chronology-link {
+  font-style: oblique;
+}
+
+#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.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;
+  padding: 15px 20px 10px 20px;
+  width: max-content;
+  max-width: min(60vw, 600px);
+
+  border: 1px dotted var(--primary-color);
+  border-radius: 6px;
+
+  background:
+    linear-gradient(var(--bg-color), var(--bg-color)),
+    linear-gradient(#000000bb, #000000bb),
+    var(--primary-color);
+
+  box-shadow: 0 -2px 6px -1px var(--dim-color) inset;
+
+  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;
+}
+
+/* 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;
+}
+
+.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 {
+  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;
+  background-image: url("warning.svg");
+}
+
+.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;
+}
+
+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-container {
+  position: sticky;
+  top: 0;
+
+  margin: calc(-1 * var(--content-padding));
+  margin-bottom: calc(0.5 * var(--content-padding));
+
+  transform: translateY(-5px);
+}
+
+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-row h1 {
+  margin: 0;
+  padding-right: 20px;
+}
+
+.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 .image-container {
+  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.sidebar.sticky-column,
+.sidebar-column.sidebar.sticky-last,
+.sidebar-multiple.sticky-last > .sidebar:last-child,
+.sidebar-multiple.sticky-column {
+  position: sticky;
+  top: 10px;
+}
+
+.sidebar-multiple.sticky-last {
+  align-self: stretch;
+}
+
+.sidebar-multiple.sticky-column {
+  align-self: flex-start;
+}
+
+.sidebar-column.sidebar.sticky-column {
+  max-height: calc(100vh - 20px);
+  align-self: start;
+  padding-bottom: 0;
+  box-sizing: border-box;
+  flex-basis: 275px;
+  padding-top: 0;
+  overflow-y: scroll;
+  scrollbar-width: thin;
+  scrollbar-color: var(--dark-color);
+}
+
+.sidebar-column.sidebar.sticky-column::-webkit-scrollbar {
+  background: var(--dark-color);
+  width: 12px;
+}
+
+.sidebar-column.sidebar.sticky-column::-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;
+
+  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-container {
+  display: block;
+  position: relative;
+  overflow: hidden;
+  width: 80vmin;
+  height: 80vmin;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+#image-overlay-image,
+#image-overlay-image-thumb {
+  display: inline-block;
+  object-fit: contain;
+  width: 100%;
+  height: 100%;
+  background: rgba(0, 0, 0, 0.65);
+}
+
+#image-overlay-image {
+  position: absolute;
+  top: 3px;
+  left: 3px;
+  width: calc(100% - 6px);
+  height: calc(100% - 4px);
+}
+
+#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-container::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-container::after {
+  width: 100%;
+  background: white;
+  opacity: 0;
+}
+
+#image-overlay-container.errored #image-overlay-image-container::after {
+  width: 100%;
+  background: red;
+}
+
+#image-overlay-container:not(.visible) #image-overlay-image-container::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;
+}
+
+/* important easter egg mode */
+
+html[data-language-code="preview-en"][data-url-key="localized.home"] #content
+  h1::after {
+  font-family: cursive;
+  display: block;
+  content: "(Preview Build)";
+  font-size: 0.8em;
+}
+
+/* Layout - Wide (most computers) */
+
+@media (min-width: 900px) {
+  #page-container:not(.has-zero-sidebars) #secondary-nav {
+    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:not(.has-zero-sidebars) main.long-content {
+    --long-content-padding-ratio: 0.06;
+  }
+}
+
+/* Layout - Wide or Medium */
+
+@media (min-width: 600px) {
+  .content-sticky-heading-container {
+    /* 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.
+   */
+  #cover-art-container {
+    float: right;
+    width: 40%;
+    max-width: 400px;
+    margin: -60px 0 10px 20px;
+
+    position: relative;
+    z-index: 2;
+  }
+
+  html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:not(:nth-child(n+7)) {
+    flex-basis: 23%;
+    margin: 15px;
+  }
+
+  html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:nth-child(n+7) {
+    flex-basis: 18%;
+    margin: 10px;
+  }
+}
+
+/* Layout - Medium or Thin */
+
+@media (max-width: 899.98px) {
+  .sidebar-column:not(.no-hide) {
+    display: none;
+  }
+
+  #secondary-nav {
+    display: block;
+  }
+
+  .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;
+    width: 100%;
+  }
+
+  .sidebar .news-entry:not(.first-news-entry) {
+    display: none;
+  }
+
+  .grid-listing > .grid-item {
+    flex-basis: 40%;
+  }
+}
+
+/* Layout - Thin (phones) */
+
+@media (max-width: 600px) {
+  .content-columns {
+    columns: 1;
+  }
+
+  main.long-content {
+    --long-content-padding-ratio: 0.02;
+  }
+
+  #cover-art-container {
+    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-container {
+    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/warning.svg b/src/static/warning.svg
new file mode 100644
index 0000000..92e5577
--- /dev/null
+++ b/src/static/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/strings-default.json b/src/strings-default.json
deleted file mode 100644
index b607b06..0000000
--- a/src/strings-default.json
+++ /dev/null
@@ -1,372 +0,0 @@
-{
-    "meta.languageCode": "en",
-    "meta.languageName": "English",
-    "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": "_:__",
-    "count.fileSize.terabytes": "{TERABYTES} TB",
-    "count.fileSize.gigabytes": "{GIGABYTES} GB",
-    "count.fileSize.megabytes": "{MEGABYTES} MB",
-    "count.fileSize.kilobytes": "{KILOBYTES} kB",
-    "count.fileSize.bytes": "{BYTES} bytes",
-    "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.additionalFiles.shortcut": "{ANCHOR_LINK} {TITLES}",
-    "releaseInfo.additionalFiles.shortcut.anchorLink": "Additional files:",
-    "releaseInfo.additionalFiles.heading": "Has {FILE_COUNT} additional files:",
-    "releaseInfo.additionalFiles.entry": "{TITLE}",
-    "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}",
-    "releaseInfo.additionalFiles.file": "{FILE}",
-    "releaseInfo.additionalFiles.file.withSize": "{FILE} ({SIZE})",
-    "releaseInfo.note": "Note:",
-    "trackList.section.withDuration": "{SECTION} ({DURATION}):",
-    "trackList.group": "{GROUP}:",
-    "trackList.group.other": "Other",
-    "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.pageTitle": "{TITLE}",
-    "misc.pageTitle.withWikiName": "{TITLE} | {WIKI_NAME}",
-    "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.socialEmbed.heading": "{WIKI_NAME} | {HEADING}",
-    "misc.jumpTo": "Jump to:",
-    "misc.jumpTo.withLinks": "Jump to: {LINKS}.",
-    "misc.contentWarnings": "cw: {WARNINGS}",
-    "misc.contentWarnings.reveal": "click to show",
-    "misc.albumGrid.details": "({TRACKS}, {TIME})",
-    "misc.albumGrid.noCoverArt": "{ALBUM}",
-    "misc.uiLanguage": "UI Language: {LANGUAGES}",
-    "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.withDuration": "{ALBUM} ({DURATION})",
-    "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.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.withoutYear": "{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",
-    "trackPage.socialEmbed.heading": "{ALBUM}",
-    "trackPage.socialEmbed.title": "{TRACK}",
-    "trackPage.socialEmbed.body.withArtists.withCoverArtists": "By {ARTISTS}; art by {COVER_ARTISTS}.",
-    "trackPage.socialEmbed.body.withArtists": "By {ARTISTS}.",
-    "trackPage.socialEmbed.body.withCoverArtists": "Art by {COVER_ARTISTS}."
-}
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
new file mode 100644
index 0000000..4b38b60
--- /dev/null
+++ b/src/strings-default.yaml
@@ -0,0 +1,1850 @@
+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"
+
+  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"
+
+  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}."
+  from: "From {ALBUM}."
+
+  coverArtBy: "Cover art by {ARTISTS}."
+  wallpaperArtBy: "Wallpaper art by {ARTISTS}."
+  bannerArtBy: "Banner art by {ARTISTS}."
+
+  released: "Released {DATE}."
+  artReleased: "Art released {DATE}."
+  addedToWiki: "Added to wiki {DATE}."
+
+  duration: "Duration: {DURATION}."
+
+  contributors: "Contributors:"
+  lyrics: "Lyrics:"
+  note: "Context notes:"
+
+  alsoReleasedAs:
+    _: "Also released as:"
+
+    item:
+      _: "{TRACK} ({ALBUM})"
+      withYear: "({YEAR}) {TRACK} ({ALBUM})"
+
+  tracksReferenced: "Tracks that {TRACK} references:"
+  tracksThatReference: "Tracks that reference {TRACK}:"
+  tracksSampled: "Tracks that {TRACK} samples:"
+  tracksThatSample: "Tracks that sample {TRACK}:"
+
+  flashesThatFeature:
+    _: "Flashes & games that feature {TRACK}:"
+    item:
+      _: "{FLASH}"
+      asDifferentRelease: "{FLASH} (as {TRACK})"
+
+  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"
+
+  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 {ANCHOR_LINK}: {TITLES}"
+      anchorLink: "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})"
+
+  group:
+    _: "From {GROUP}:"
+    fromOther: "From somewhere else:"
+
+  item:
+    _: "{TRACK}"
+    withDuration: "{DURATION} {TRACK}"
+    withArtists: "{TRACK} {BY}"
+    withDuration.withArtists: "{DURATION} {TRACK} {BY}"
+
+    withDuration.duration:
+      _: "({DURATION})"
+      missing: "_:__"
+      missing.info: "no duration provided; treated as zero seconds long"
+
+    withArtists.by: "by {ARTISTS}"
+
+    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})"
+        withAlbums: "(on {ALBUMS})"
+        withAnnotations.withAlbums: "({ANNOTATION}; on {ALBUMS})"
+
+  # 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})"
+          withDate: ({DATE})"
+          withAnnotation.withDate: "({ANNOTATION}, {DATE})"
+
+      seeOriginalRelease: "See {ORIGINAL}!"
+
+  # 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})"
+
+    # External links to visit the artist's own websites or profiles.
+    withExternalLinks: "{ARTIST} ({LINKS})"
+
+    # Combination of above.
+    withContribution.withExternalLinks: "{ARTIST} ({CONTRIB}) ({LINKS})"
+
+    # Displayed in an artist's tooltip, if one of their URLs
+    # isn't a specially detected web platform.
+    noExternalLinkPlatformName: "Other"
+
+  # chronology:
+  #
+  #   "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}"
+
+  # 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"
+
+    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"
+    patreon: "Patreon"
+    poetryFoundation: "Poetry Foundation"
+    soundcloud: "SoundCloud"
+    spotify: "Spotify"
+    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)"
+
+  # 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}"
+    withWikiName: "{TITLE} | {WIKI_NAME}"
+
+  # 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 artist info page.
+
+    tracks: "Tracks"
+    artworks: "Artworks"
+    flashes: "Flashes & Games"
+
+    # 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.
+
+    commentary: "Commentary"
+
+    artistCommentary: "Commentary"
+    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"
+
+  # albumGrid:
+  #   Generic strings for various sorts of gallery grids, displayed
+  #   on the homepage, album galleries, artist artwork galleries, and
+  #   so on. These get the name of the thing being represented and,
+  #   often, a bit of text providing pertinent extra details about
+  #   that thing.
+
+  albumGrid:
+    noCoverArt: "{ALBUM}"
+
+    details:
+      _: "({TRACKS}, {TIME})"
+      coverArtists: "(Illust. {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})"
+
+  # 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}"
+
+#
+# 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}"
+    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.
+
+#
+# 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}"
+
+  infoLine: >-
+    {WORDS} across {ENTRIES}.
+
+  entry:
+    title:
+      albumCommentary:
+        _: "{ALBUM}"
+        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}"
+
+  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)"
+
+      # 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"
+
+#
+# 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}.
+
+#
+# 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}"
+
+#
+# groupInfoPage:
+#   The group info page shows visiting links, the group's full
+#   description, and a list of albums from the group.
+#
+groupInfoPage:
+  title: "{GROUP}"
+
+  viewAlbumGallery:
+    _: "View {LINK}! Or browse the list:"
+    link: "album gallery"
+
+  # 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"
+
+    item:
+      _: >-
+        {ALBUM}
+
+      withYear: >-
+        {YEAR_ACCENT} {ALBUM}
+
+      withOtherGroup: >-
+        {ALBUM} {OTHER_GROUP_ACCENT}
+
+      withYear.withOtherGroup: >-
+        {YEAR_ACCENT} {ALBUM} {OTHER_GROUP_ACCENT}
+
+      yearAccent: "({YEAR})"
+      otherGroupAccent:  "(from {GROUPS})"
+
+#
+# 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}"
+
+  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}"
+
+  listTags:
+
+    # listTags.byName:
+    #   List art tags alphabetically without sorting or chunking by
+    #   any other criteria. Also displays the number of times each
+    #   art tag has been featured.
+
+    byName:
+      title: "Tags - by Name"
+      title.short: "...by Name"
+      item: "{TAG} ({TIMES_USED})"
+
+    # listTags.byUses:
+    #   List art tags by number of times used, falling back to an
+    #   alphabetical sort if two art tags have been featured the same
+    #   number of times. Art tags which haven't haven't been featured
+    #   at all yet are totally excluded from the list.
+
+    byUses:
+      title: "Tags - by Uses"
+      title.short: "...by Uses"
+      item: "{TAG} ({TIMES_USED})"
+
+  other:
+
+    # other.allSheetMusic:
+    #   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}.
+
+#
+# tagPage:
+#   The tag gallery page displays all the artworks that a tag has
+#   been featured in, in one neat grid, with each artwork displaying
+#   its illustrators, as well as a short info line that indicates
+#   how many artworks the tag's part of.
+#
+tagPage:
+  title: "{TAG}"
+
+  nav:
+    tag: "Tag: {TAG}"
+
+  infoLine: >-
+    Appears in {COVER_ARTS}.
+
+#
+# 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"
+
+    track:
+      _: "{TRACK}"
+      withNumber: "{NUMBER}. {TRACK}"
+
+  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/upd8.js b/src/upd8.js
index ba59068..6bd52da 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -31,1719 +31,1640 @@
 // Oh yeah, like. Just run this through some relatively recent version of
 // node.js and you'll 8e fine. ...Within the project root. O8viously.
 
-import * as path from 'path';
-import { promisify } from 'util';
-import { fileURLToPath } from 'url';
+import {execSync} from 'node:child_process';
+import {readdir, readFile} from 'node:fs/promises';
+import * as path from 'node:path';
+import {fileURLToPath} from 'node: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.
+import wrap from 'word-wrap';
 
-// It stands for "HTML Entities", apparently. Cursed.
-import he from 'he';
+// Due to import time shenanigans, these imports have to come in the specified
+// order. This obviously needs fixing up.
 
+/* precede #find */
 import {
-    copyFile,
-    mkdir,
-    readFile,
-    stat,
-    symlink,
-    writeFile,
-    unlink,
-} from 'fs/promises';
+  filterReferenceErrors,
+  reportDuplicateDirectories,
+  reportContentTextErrors,
+} from '#data-checks';
+
+import {bindFind, getAllFindSpecs} from '#find';
+
+// End of import time shenanigans (hopefully)
+
+import {showAggregate} from '#aggregate';
+import CacheableObject from '#cacheable-object';
+import {displayCompositeCacheAnalysis} from '#composite';
+import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile}
+  from '#language';
+import {isMain, traverse} from '#node-utils';
+import {sortByName} from '#sort';
+import {empty, withEntries} from '#sugar';
+import {generateURLs, urlSpec} from '#urls';
+import {linkWikiDataArrays, loadAndProcessDataDocuments, sortWikiDataArrays}
+  from '#yaml';
 
-import { inspect as nodeInspect } from 'util';
+import {
+  colors,
+  decorateTime,
+  fileIssue,
+  logWarn,
+  logInfo,
+  logError,
+  parseOptions,
+  progressCallAll,
+} from '#cli';
+
+import genThumbs, {
+  CACHE_FILE as thumbsCacheFile,
+  defaultMagickThreads,
+  determineMediaCachePath,
+  isThumb,
+  migrateThumbsIntoDedicatedCacheDirectory,
+  verifyImagePaths,
+} from '#thumbs';
 
-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 FileSizePreloader from './file-size-preloader.js';
+import {listingSpec, listingTargetSpec} from './listing-spec.js';
+import * as buildModes from './write/build-modes/index.js';
 
-import find, { bindFind } from './util/find.js';
-import * as html from './util/html.js';
-import unbound_link, {getLinkThemeString} from './util/link.js';
-import { findFiles } from './util/io.js';
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
-import CacheableObject from './data/cacheable-object.js';
+const CACHEBUST = 23;
 
-import { serializeThings } from './data/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 {
-    Language,
-} from './data/things.js';
+const BUILD_TIME = new Date();
 
-import {
-    filterDuplicateDirectories,
-    filterReferenceErrors,
-    linkWikiDataArrays,
-    loadAndProcessDataDocuments,
-    sortWikiDataArrays,
-    WIKI_INFO_FILE,
-} from './data/yaml.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`;
 
-import {
-    fancifyFlashURL,
-    fancifyURL,
-    generateAdditionalFilesShortcut,
-    generateAdditionalFilesList,
-    generateChronologyLinks,
-    generateCoverLink,
-    generateInfoGalleryLinks,
-    generatePreviousNextLinks,
-    generateTrackListDividedByGroups,
-    getAlbumGridHTML,
-    getAlbumStylesheet,
-    getArtistString,
-    getFlashGridHTML,
-    getFooterLocalizationLinks,
-    getGridHTML,
-    getRevealStringFromTags,
-    getRevealStringFromWarnings,
-    getThemeString,
-    iconifyURL
-} from './misc-templates.js';
+const defaultStepStatus = {status: STATUS_NOT_STARTED, annotation: null};
 
-import {
-    color,
-    decorateTime,
-    logWarn,
-    logInfo,
-    logError,
-    parseOptions,
-    progressPromiseAll,
-    ENABLE_COLOR
-} from './util/cli.js';
+// Defined globally for quick access outside the main() function's contents.
+// This will be initialized and mutated over the course of main().
+let stepStatusSummary;
+let showStepStatusSummary = false;
 
-import {
-    validateReplacerSpec,
-    transformInline
-} from './util/replacer.js';
+async function main() {
+  Error.stackTraceLimit = Infinity;
 
-import {
-    chunkByConditions,
-    chunkByProperties,
-    getAlbumCover,
-    getAlbumListTag,
-    getAllTracks,
-    getArtistAvatar,
-    getArtistCommentary,
-    getArtistNumContributions,
-    getFlashCover,
-    getKebabCase,
-    getTotalDuration,
-    getTrackCover,
-    sortByArtDate,
-    sortByDate,
-    sortByName
-} from './util/wiki-data.js';
+  stepStatusSummary = {
+    determineMediaCachePath:
+      {...defaultStepStatus, name: `determine media cache path`},
 
-import {
-    serializeContribs,
-    serializeCover,
-    serializeGroupsForAlbum,
-    serializeGroupsForTrack,
-    serializeImagePaths,
-    serializeLink
-} from './util/serialize.js';
+    migrateThumbnails:
+      {...defaultStepStatus, name: `migrate thumbnails`},
 
-import {
-    bindOpts,
-    decorateErrorWithIndex,
-    filterAggregateAsync,
-    filterEmptyLines,
-    mapAggregate,
-    mapAggregateAsync,
-    openAggregate,
-    queue,
-    showAggregate,
-    splitArray,
-    unique,
-    withAggregate,
-    withEntries
-} from './util/sugar.js';
+    loadThumbnailCache:
+      {...defaultStepStatus, name: `load thumbnail cache file`},
 
-import {
-    generateURLs,
-    thumb
-} from './util/urls.js';
+    generateThumbnails:
+      {...defaultStepStatus, name: `generate thumbnails`},
 
-// Pensive emoji!
-import {
-    FANDOM_GROUP_DIRECTORY,
-    OFFICIAL_GROUP_DIRECTORY
-} from './util/magic-constants.js';
+    loadDataFiles:
+      {...defaultStepStatus, name: `load and process data files`},
 
-import FileSizePreloader from './file-size-preloader.js';
+    linkWikiDataArrays:
+      {...defaultStepStatus, name: `link wiki data arrays`},
 
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
+    precacheCommonData:
+      {...defaultStepStatus, name: `precache common data`},
 
-const CACHEBUST = 8;
+    reportDuplicateDirectories:
+      {...defaultStepStatus, name: `report duplicate directories`},
 
-const DEFAULT_STRINGS_FILE = 'strings-default.json';
+    filterReferenceErrors:
+      {...defaultStepStatus, name: `filter reference errors`},
 
-// 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';
+    reportContentTextErrors:
+      {...defaultStepStatus, name: `report content text errors`},
 
-// 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';
+    sortWikiDataArrays:
+      {...defaultStepStatus, name: `sort wiki data arrays`},
 
-// This exists adjacent to index.html for any page with oEmbed metadata.
-const OEMBED_JSON_FILE = 'oembed.json';
+    precacheAllData:
+      {...defaultStepStatus, name: `precache nearly all data`},
 
-// Automatically copied (if present) from media directory to site root.
-const FAVICON_FILE = 'favicon.ico';
+    // TODO: This should be split into load/watch steps.
+    loadInternalDefaultLanguage:
+      {...defaultStepStatus, name: `load internal default language`},
 
-function inspect(value) {
-    return nodeInspect(value, {colors: ENABLE_COLOR});
-}
+    loadLanguageFiles:
+      {...defaultStepStatus, name: `statically load custom language files`},
 
-// 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;
+    watchLanguageFiles:
+      {...defaultStepStatus, name: `watch custom language files`},
 
-// 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 = {};
+    initializeDefaultLanguage:
+      {...defaultStepStatus, name: `initialize default language`},
 
-let queueSize;
+    verifyImagePaths:
+      {...defaultStepStatus, name: `verify missing/misplaced image paths`},
 
-const urls = generateURLs(urlSpec);
+    preloadFileSizes:
+      {...defaultStepStatus, name: `preload file sizes`},
 
-function splitLines(text) {
-    return text.split(/\r\n|\r|\n/);
-}
+    performBuild:
+      {...defaultStepStatus, name: `perform selected build mode`},
+  };
 
-const replacerSpec = {
-    'album': {
-        find: 'album',
-        link: 'album'
+  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 usingDefaultBuildMode;
+
+  if (empty(selectedBuildModeFlags)) {
+    selectedBuildModeFlag = 'static-build';
+    usingDefaultBuildMode = true;
+  } else if (selectedBuildModeFlags.length > 1) {
+    logError`Building multiple modes (${selectedBuildModeFlags.join(', ')}) at once not supported.`;
+    logError`Please specify a maximum of one build mode.`;
+    return false;
+  } else {
+    selectedBuildModeFlag = selectedBuildModeFlags[0];
+    usingDefaultBuildMode = false;
+  }
+
+  const selectedBuildMode = buildModes[selectedBuildModeFlag];
+
+  // This is about to get a whole lot more stuff put in it.
+  const wikiData = {
+    listingSpec,
+    listingTargetSpec,
+  };
+
+  const buildOptions = selectedBuildMode.getCLIOptions();
+
+  const commonOptions = {
+    'help': {
+      help: `Display usage info and basic information for the \`hsmusic\` command`,
+      type: 'flag',
     },
-    'album-commentary': {
-        find: 'album',
-        link: 'albumCommentary'
+
+    // 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, but may be provided via the HSMUSIC_DATA environment variable instead`,
+      type: 'value',
     },
-    'artist': {
-        find: 'artist',
-        link: 'artist'
+
+    // 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, but may be provided via the HSMUSIC_MEDIA environment variable instead`,
+      type: 'value',
     },
-    'artist-gallery': {
-        find: 'artist',
-        link: 'artistGallery'
+
+    'media-cache-path': {
+      help: `Specify path to media cache directory, including automatically generated thumbnails\n\nThis usually doesn't need to be provided, and will be inferred by adding "-cache" to the end of the media directory`,
+      type: 'value',
     },
-    'commentary-index': {
-        find: null,
-        link: 'commentaryIndex'
+
+    // 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',
     },
-    'date': {
-        find: null,
-        value: ref => new Date(ref),
-        html: (date, {language}) => `<time datetime="${date.toString()}">${language.formatDate(date)}</time>`
+
+    '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',
     },
-    '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;
-            }
-        }
+
+    // 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',
     },
-    'group': {
-        find: 'group',
-        link: 'groupInfo'
+
+    // 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',
     },
-    'group-gallery': {
-        find: 'group',
-        link: 'groupGallery'
+
+    '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',
     },
-    'home': {
-        find: null,
-        link: 'home'
+
+    'skip-file-sizes': {
+      help: `Skips preloading file sizes for images and additional files, which will be left blank in the build`,
+      type: 'flag',
     },
-    'listing-index': {
-        find: null,
-        link: 'listingIndex'
+
+    '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',
     },
-    'listing': {
-        find: 'listing',
-        link: 'listing'
+
+    // 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',
     },
-    'media': {
-        find: null,
-        link: 'media'
+
+    'no-input': {
+      help: `Don't wait on input from stdin - assume the device is headless`,
+      type: 'flag',
     },
-    'news-index': {
-        find: null,
-        link: 'newsIndex'
+
+    'no-language-reloading': {
+      help: `Don't reload language files while the build is running\n\nApplied by default for --static-build`,
+      type: 'flag',
     },
-    'news-entry': {
-        find: 'newsEntry',
-        link: 'newsEntry'
+
+    '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',
     },
-    'root': {
-        find: null,
-        link: 'root'
+
+    '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',
     },
-    'site': {
-        find: null,
-        link: 'site'
+
+    '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;
+      },
     },
-    'static': {
-        find: 'staticPage',
-        link: 'staticPage'
+    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;
+      }
     },
-    'string': {
-        find: null,
-        value: ref => ref,
-        html: (ref, {language, args}) => language.$(ref, args)
+    magick: {alias: 'magick-threads'},
+
+    // This option is super slow and has the potential for bugs! It puts
+    // CacheableObject in a mode where every instance is a Proxy which will
+    // keep track of invalid property accesses.
+    'show-invalid-property-accesses': {
+      help: `Report accesses at runtime to nonexistant properties on wiki data objects, at a dramatic performance cost\n(Internal/development use only)`,
+      type: 'flag',
     },
-    'tag': {
-        find: 'artTag',
-        link: 'tag'
+
+    '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';
+      },
     },
-    'track': {
-        find: 'track',
-        link: 'track'
-    }
-};
+  };
 
-if (!validateReplacerSpec(replacerSpec, {find, link: unbound_link})) {
-    process.exit();
-}
+  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,
 
-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;
+    ...commonOptions,
+    ...buildOptions,
+  });
+
+  if (cliOptions['help']) {
+    const indentWrap = (spaces, str) => wrap(str, {width: 60 - spaces, indent: ' '.repeat(spaces)});
+
+    const showOptions = (msg, options) => {
+      console.log(colors.bright(msg));
+
+      const entries = Object.entries(options);
+      const sortedOptions = sortByName(entries
+        .map(([name, descriptor]) => ({name, descriptor})));
+
+      if (!sortedOptions.length) {
+        console.log(`(No options available)`)
+      }
+
+      let justInsertedPaddingLine = false;
+
+      for (const {name, descriptor} of sortedOptions) {
+        if (descriptor.alias) {
+          continue;
         }
-    };
 
-    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;
+        const aliases = entries
+          .filter(([_name, {alias}]) => alias === name)
+          .map(([name]) => name);
+
+        let wrappedHelp, wrappedHelpLines = 0;
+        if (descriptor.help) {
+          wrappedHelp = indentWrap(4, descriptor.help);
+          wrappedHelpLines = wrappedHelp.split('\n').length;
+        }
+
+        if (wrappedHelpLines > 0 && !justInsertedPaddingLine) {
+          console.log('');
         }
-    }
-    return Object.fromEntries(Object.entries(attributes).map(([ key, val ]) => [
-        key,
-        val === 'true' ? true :
-        val === 'false' ? false :
-        val === key ? true :
-        val
-    ]));
-}
 
-function joinLineBreaks(sourceLines) {
-    const outLines = [];
+        console.log(colors.bright(` --` + name) +
+          (aliases.length
+            ? ` (or: ${aliases.map(alias => colors.bright(`--` + alias)).join(', ')})`
+            : '') +
+          (descriptor.help
+            ? ''
+            : colors.dim('  (no help provided)')));
 
-    let lineSoFar = '';
-    for (let i = 0; i < sourceLines.length; i++) {
-        const line = sourceLines[i];
-        lineSoFar += line;
-        if (!line.endsWith('<br>')) {
-            outLines.push(lineSoFar);
-            lineSoFar = '';
+        if (wrappedHelp) {
+          console.log(wrappedHelp);
         }
-    }
 
-    if (lineSoFar) {
-        outLines.push(lineSoFar);
+        if (wrappedHelpLines > 1) {
+          console.log('');
+          justInsertedPaddingLine = true;
+        } else {
+          justInsertedPaddingLine = false;
+        }
+      }
+
+      if (!justInsertedPaddingLine) {
+        console.log(``);
+      }
+    };
+
+    console.log(
+      colors.bright(`hsmusic (aka. Homestuck Music Wiki)\n`) +
+      `static wiki software cataloguing collaborative creation\n`);
+
+    console.log(indentWrap(0,
+      `The \`hsmusic\` command provides basic control over all parts of generating user-visible HTML pages and website content/structure from provided data, media, and language directories.\n` +
+      `\n` +
+      `CLI options are divided into three groups:\n`));
+    console.log(` 1) ` + indentWrap(4,
+      `Common options: These are shared by all build modes and always have the same essential behavior`).trim());
+    console.log(` 2) ` + indentWrap(4,
+      `Build mode selection: One build mode may be selected (or else the default, --static-build, is used), and it decides which entire set of behavior to use for providing site content to the user`).trim());
+    console.log(` 3) ` + indentWrap(4,
+      `Build options: Each build mode has a set of unique options which customize behavior for that build mode`).trim());
+    console.log(``);
+
+    showOptions(`Common options`, commonOptions);
+    showOptions(`Build mode selection`, buildModeFlagOptions);
+
+    if (buildOptions) {
+      showOptions(`Build options for --${selectedBuildModeFlag} (${
+        usingDefaultBuildMode ? 'default' : 'selected'
+      })`, buildOptions);
     }
 
-    return outLines;
-}
+    return true;
+  }
 
-function transformMultiline(text, {
-    parseAttributes,
-    transformInline
-}) {
-    // Heck yes, HTML magics.
+  const dataPath = cliOptions['data-path'] || process.env.HSMUSIC_DATA;
+  const mediaPath = cliOptions['media-path'] || process.env.HSMUSIC_MEDIA;
+  const langPath = cliOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset!
 
-    text = transformInline(text.trim());
+  const thumbsOnly = cliOptions['thumbs-only'] ?? false;
+  const noInput = cliOptions['no-input'] ?? false;
 
-    const outLines = [];
+  showStepStatusSummary = cliOptions['show-step-summary'] ?? false;
 
-    const indentString = ' '.repeat(4);
+  const showAggregateTraces = cliOptions['show-traces'] ?? 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>';
+  const precacheMode = cliOptions['precache-mode'] ?? 'common';
+  const showInvalidPropertyAccesses = cliOptions['show-invalid-property-accesses'] ?? 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 (!dataPath || !mediaPath) {
+    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`,
+    });
+  } else {
+    if (usingDefaultBuildMode) {
+      logInfo`No build mode specified, will use default: ${selectedBuildModeFlag}`;
+    } else {
+      logInfo`Will use specified build mode: ${selectedBuildModeFlag}`;
+    }
+  }
+
+  // Finish setting up defaults by combining information from all options.
+
+  const _fallbackStep = (stepKey, {
+    default: defaultValue,
+
+    cli: {
+      flag: cliFlag = null,
+      negate: cliFlagNegates = false,
+      warn: cliFlagWarning = null,
+    } = {},
+
+    buildConfig: buildConfigKey,
+  }) => {
+    const {[buildConfigKey]: buildConfig} = selectedBuildMode.config;
+    const {[stepKey]: step} = stepStatusSummary;
+
+    if (cliFlag && cliOptions[cliFlag]) {
+      const cliPart = `--` + cliFlag;
+      const modePart = `--` + selectedBuildModeFlag;
+      if (buildConfig?.applicable === false) {
+        if (cliFlagNegates) {
+          logWarn`${cliPart} provided, but ${modePart} already skips this step`;
+          logWarn`Redundant option ${cliPart}`;
         } 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>');
+          logWarn`${cliPart} provided, but this step isn't applicable for ${modePart}`;
+          logWarn`Ignoring option ${cliPart}`;
         }
-        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>');
+      } else if (buildConfig?.required === true) {
+        if (cliFlagNegates) {
+          logWarn`${cliPart} provided, but ${modePart} requires this step`;
+          logWarn`Ignoring option ${cliPart}`;
         } else {
-            // closing the final list level! no need for indent here
-            outLines.push('</ul>');
+          logWarn`${cliPart} provided, but ${modePart} already requires this step`;
+          logWarn`Redundant option ${cliPart}`;
         }
-    };
-
-    // 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;
-
-    let lines = splitLines(text);
-    lines = joinLineBreaks(lines);
-    for (let line of lines) {
-        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';
-        } 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>');
-            }
-
-            // let some escaped symbols display as the normal symbol, since the
-            // point of escaping them is just to avoid having them be treated as
-            // syntax markers!
-            if (lineContent.match(/( *)\\-/)) {
-                lineContent = lineContent.replace('\\-', '-');
-            } else if (lineContent.match(/( *)\\>/)) {
-                lineContent = lineContent.replace('\\>', '>');
-            }
+      } else {
+        if (cliFlagNegates) {
+          step.status = STATUS_NOT_APPLICABLE;
+          step.annotation = `--${cliFlag} provided`;
         }
-
-        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 = '';
-            }
+        if (cliFlagWarning) {
+          for (const line of cliFlagWarning.split('\n')) {
+            logWarn(line);
+          }
         }
+      }
+    }
 
-        let pushString = indentString.repeat(indentThisLine);
-        if (lineTag) {
-            pushString += `<${lineTag}>${lineContent}</${lineTag}>`;
-        } else {
-            pushString += lineContent;
-        }
-        outLines.push(pushString);
+    if (buildConfig?.applicable === false) {
+      step.status = STATUS_NOT_APPLICABLE;
+      step.annotation = `N/A for --${selectedBuildModeFlag}`;
+      return;
+    }
+
+    if (buildConfig?.default === 'skip') {
+      step.status = STATUS_NOT_APPLICABLE;
+      step.annotation = `default for --${selectedBuildModeFlag}`;
+      return;
     }
 
-    // after processing all lines...
+    switch (defaultValue) {
+      case 'skip':
+        step.status = STATUS_NOT_APPLICABLE;
+        if (cliFlag && !cliFlagNegates) {
+          step.annotation = `--${cliFlag} not provided`;
+        }
+        break;
 
-    // if still in a list, close all levels
-    while (levelIndents.length) closeLevel();
+      case 'perform':
+        break;
 
-    // if still in a blockquote, close its tag
-    if (inBlockquote) {
-        inBlockquote = false;
-        outLines.push('</blockquote>');
+      default:
+        throw new Error(`Invalid default step status ${defaultValue}`);
     }
+  };
 
-    return outLines.join('\n');
-}
+  {
+    let errored = false;
 
-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);
-    }
+    const fallbackStep = (stepKey, options) => {
+      try {
+        _fallbackStep(stepKey, options);
+      } catch (error) {
+        logError`Error determining fallback for step ${stepKey}`;
+        showAggregate(error);
+        errored = true;
+      }
+    };
 
-    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 (buildLine.length) {
-        addLine();
-    }
-    return outLines.join('\n');
-}
+    fallbackStep('filterReferenceErrors', {
+      default: 'perform',
+      buildConfig: null,
+      cli: {
+        flag: 'skip-reference-validation',
+        negate: true,
+        warn:
+          `Skipping reference validation. If any reference errors are present\n` +
+          `in data, they will be silently passed along to the build.`,
+      }
+    });
 
-function stringifyThings(thingData) {
-    return JSON.stringify(serializeThings(thingData));
-}
+    fallbackStep('generateThumbnails', {
+      default: 'perform',
+      buildConfig: 'thumbs',
+      cli: {
+        flag: 'skip-thumbs',
+        negate: true,
+      },
+    });
 
-function img({
-    src,
-    alt,
-    noSrcText = '',
-    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 = src && (thumbKey ? thumb[thumbKey](src) : src);
-
-    const imgAttributes = html.attributes({
-        id: link ? '' : id,
-        class: className,
-        alt,
-        width,
-        height
+    fallbackStep('migrateThumbnails', {
+      default: 'skip',
+      buildConfig: null,
+      cli: {
+        flag: 'migrate-thumbs',
+      },
     });
 
-    const noSrcHTML = !src && wrap(`<div class="image-text-area">${noSrcText}</div>`);
-    const nonlazyHTML = src && wrap(`<img src="${thumbSrc}" ${imgAttributes}>`);
-    const lazyHTML = src && lazy && wrap(`<img class="lazy" data-original="${thumbSrc}" ${imgAttributes}>`, true);
-
-    if (!src) {
-        return noSrcHTML;
-    } else if (lazy) {
-        return fixWS`
-            <noscript>${nonlazyHTML}</noscript>
-            ${lazyHTML}
-        `;
-    } else {
-        return nonlazyHTML;
-    }
+    fallbackStep('preloadFileSizes', {
+      default: 'perform',
+      buildConfig: 'fileSizes',
+      cli: {
+        flag: 'skip-file-sizes',
+        negate: true,
+      },
+    });
 
-    function wrap(input, hide = false) {
-        let wrapped = input;
+    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.`,
+      },
+    });
 
-        wrapped = `<div class="image-inner-area">${wrapped}</div>`;
-        wrapped = `<div class="image-container">${wrapped}</div>`;
+    fallbackStep('watchLanguageFiles', {
+      default: 'perform',
+      buildConfig: 'languageReloading',
+      cli: {
+        flag: 'no-language-reloading',
+        negate: true,
+      },
+    });
 
-        if (reveal) {
-            wrapped = fixWS`
-                <div class="reveal">
-                    ${wrapped}
-                    <span class="reveal-text">${reveal}</span>
-                </div>
-            `;
-        }
+    if (errored) {
+      return false;
+    }
+  }
 
-        if (willSquare) {
-            wrapped = html.tag('div', {class: 'square-content'}, wrapped);
-            wrapped = html.tag('div', {class: ['square', hide && !willLink && 'js-hide']}, wrapped);
-        }
+  if (stepStatusSummary.generateThumbnails.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.loadThumbnailCache, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `using cache from thumbnail generation`,
+    });
+  }
 
-        if (willLink) {
-            wrapped = html.tag('a', {
-                id,
-                class: ['box', hide && 'js-hide'],
-                href: typeof link === 'string' ? link : originalSrc
-            }, wrapped);
-        }
+  if (stepStatusSummary.watchLanguageFiles.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.loadLanguageFiles, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `watching for changes instead`,
+    });
+  }
+
+  switch (precacheMode) {
+    case 'common':
+      Object.assign(stepStatusSummary.precacheAllData, {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `--precache-mode is common, not all`,
+      });
+
+      break;
+
+    case 'all':
+      Object.assign(stepStatusSummary.precacheCommonData, {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `--precache-mode is all, not common`,
+      });
+
+      break;
+
+    case 'none':
+      Object.assign(stepStatusSummary.precacheCommonData, {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `--precache-mode is none`,
+      });
+
+      Object.assign(stepStatusSummary.precacheAllData, {
+        status: STATUS_NOT_APPLICABLE,
+        annotation: `--precache-mode is none`,
+      });
+
+      break;
+  }
+
+  if (!langPath) {
+    Object.assign(stepStatusSummary.loadLanguageFiles, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `neither --lang-path nor HSMUSIC_LANG provided`,
+    });
 
-        return wrapped;
-    }
-}
+    Object.assign(stepStatusSummary.watchLanguageFiles, {
+      status: STATUS_NOT_APPLICABLE,
+      annotation: `neither --lang-path nor HSMUSIC_LANG provided`,
+    });
+  }
+
+  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;
+  }
+
+  Object.assign(stepStatusSummary.determineMediaCachePath, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
+
+  const {mediaCachePath, annotation: mediaCachePathAnnotation} =
+    await determineMediaCachePath({
+      mediaPath,
+      providedMediaCachePath:
+        cliOptions['media-cache-path'] || process.env.HSMUSIC_MEDIA_CACHE,
+      disallowDoubling:
+        stepStatusSummary.migrateThumbnails.status === STATUS_NOT_STARTED,
+    });
 
-function validateWritePath(path, urlGroup) {
-    if (!Array.isArray(path)) {
-        return {error: `Expected array, got ${path}`};
+  if (!mediaCachePath) {
+    logError`Couldn't determine a media cache path. (${mediaCachePathAnnotation})`;
+
+    switch (mediaCachePathAnnotation) {
+      case 'inferred path does not have cache':
+        logError`If you're certain this is the right path, you can provide it via`;
+        logError`${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}, and it should work.`;
+        break;
+
+      case 'inferred path not readable':
+        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 */
+        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;
     }
 
-    const { paths } = urlGroup;
+    Object.assign(stepStatusSummary.determineMediaCachePath, {
+      status: STATUS_FATAL_ERROR,
+      annotation: mediaCachePathAnnotation,
+      timeEnd: Date.now(),
+    });
 
-    const definedKeys = Object.keys(paths);
-    const specifiedKey = path[0];
+    return false;
+  }
 
-    if (!definedKeys.includes(specifiedKey)) {
-        return {error: `Specified key ${specifiedKey} isn't defined`};
-    }
+  logInfo`Using media cache at: ${mediaCachePath} (${mediaCachePathAnnotation})`;
 
-    const expectedArgs = paths[specifiedKey].match(/<>/g)?.length ?? 0;
-    const specifiedArgs = path.length - 1;
+  Object.assign(stepStatusSummary.determineMediaCachePath, {
+    status: STATUS_DONE_CLEAN,
+    annotation: mediaCachePathAnnotation,
+    timeEnd: Date.now(),
+  });
 
-    if (specifiedArgs !== expectedArgs) {
-        return {error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`};
-    }
+  if (stepStatusSummary.migrateThumbnails.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.migrateThumbnails, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-    return {success: true};
-}
+    const result = await migrateThumbsIntoDedicatedCacheDirectory({
+      mediaPath,
+      mediaCachePath,
+      queueSize,
+    });
 
-function validateWriteObject(obj) {
-    if (typeof obj !== 'object') {
-        return {error: `Expected object, got ${typeof obj}`};
-    }
+    if (result.succses) {
+      Object.assign(stepStatusSummary.migrateThumbnails, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+      });
 
-    if (typeof obj.type !== 'string') {
-        return {error: `Expected type to be string, got ${obj.type}`};
+      return false;
     }
 
-    switch (obj.type) {
-        case 'legacy': {
-            if (typeof obj.write !== 'function') {
-                return {error: `Expected write to be string, got ${obj.write}`};
-            }
+    logInfo`Good to go! Run hsmusic again without ${'--migrate-thumbs'} to start`;
+    logInfo`using the migrated media cache.`;
 
-            break;
-        }
+    Object.assign(stepStatusSummary.migrateThumbnails, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
 
-        case 'page': {
-            const path = validateWritePath(obj.path, urlSpec.localized);
-            if (path.error) {
-                return {error: `Path validation failed: ${path.error}`};
-            }
+    return true;
+  }
 
-            if (typeof obj.page !== 'function') {
-                return {error: `Expected page to be function, got ${obj.content}`};
-            }
+  const niceShowAggregate = (error, ...opts) => {
+    showAggregate(error, {
+      showTraces: showAggregateTraces,
+      pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
+      ...opts,
+    });
+  };
 
-            break;
-        }
+  if (
+    stepStatusSummary.loadThumbnailCache.status === STATUS_NOT_STARTED &&
+    stepStatusSummary.generateThumbnails.status === STATUS_NOT_STARTED
+  ) {
+    throw new Error(`Unable to continue with both loadThumbnailCache and generateThumbnails`);
+  }
 
-        case 'data': {
-            const path = validateWritePath(obj.path, urlSpec.data);
-            if (path.error) {
-                return {error: `Path validation failed: ${path.error}`};
-            }
+  let thumbsCache;
 
-            if (typeof obj.data !== 'function') {
-                return {error: `Expected data to be function, got ${obj.data}`};
-            }
+  if (stepStatusSummary.loadThumbnailCache.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.loadThumbnailCache, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-            break;
-        }
+    const thumbsCachePath = path.join(mediaCachePath, thumbsCacheFile);
 
-        case 'redirect': {
-            const fromPath = validateWritePath(obj.fromPath, urlSpec.localized);
-            if (fromPath.error) {
-                return {error: `Path (fromPath) validation failed: ${fromPath.error}`};
-            }
+    try {
+      thumbsCache = JSON.parse(await readFile(thumbsCachePath));
+    } catch (error) {
+      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.loadThumbnailCache, {
+          status: STATUS_FATAL_ERROR,
+          annotation: `cache does not exist`,
+          timeEnd: Date.now(),
+        });
 
-            const toPath = validateWritePath(obj.toPath, urlSpec.localized);
-            if (toPath.error) {
-                return {error: `Path (toPath) validation failed: ${toPath.error}`};
-            }
+        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.loadThumbnailCache, {
+          status: STATUS_FATAL_ERROR,
+          annotation: `cache malformed or unreadable`,
+          timeEnd: Date.now(),
+        });
 
-            if (typeof obj.title !== 'function') {
-                return {error: `Expected title to be function, got ${obj.title}`};
-            }
+        return false;
+      }
+    }
 
-            break;
-        }
+    logInfo`Thumbnail cache file successfully read.`;
 
-        default: {
-            return {error: `Unknown type: ${obj.type}`};
-        }
-    }
+    Object.assign(stepStatusSummary.loadThumbnailCache, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
 
-    return {success: true};
-}
+    logInfo`Skipping thumbnail generation.`;
+  } else if (stepStatusSummary.generateThumbnails.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.generateThumbnails, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-/*
-async function writeData(subKey, directory, data) {
-    const paths = writePage.paths('', 'data.' + subKey, directory, {file: 'data.json'});
-    await writePage.write(JSON.stringify(data), {paths});
-}
-*/
-
-// 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;
-
-    let from;
-    let 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;
-    }
+    logInfo`Begin thumbnail generation... -----+`;
 
-    path += urls.from(from).to(to, ...args);
-
-    return path;
-};
-
-writePage.html = (pageInfo, {
-    defaultLanguage,
-    language,
-    languages,
-    localizedPaths,
-    paths,
-    oEmbedJSONHref,
-    to,
-    transformMultiline,
-    wikiData
-}) => {
-    const { wikiInfo } = wikiData;
-
-    let {
-        title = '',
-        meta = {},
-        theme = '',
-        stylesheet = '',
-
-        showWikiNameInTitle = true,
-
-        // missing properties are auto-filled, see below!
-        body = {},
-        banner = {},
-        main = {},
-        sidebarLeft = {},
-        sidebarRight = {},
-        nav = {},
-        footer = {},
-        socialEmbed = {},
-    } = pageInfo;
-
-    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;
-    }
+    const result = await genThumbs({
+      mediaPath,
+      mediaCachePath,
 
-    nav.classes ??= [];
-    nav.content ??= '';
-    nav.links ??= [];
+      queueSize,
+      magickThreads,
+      quiet: !thumbsOnly,
+    });
+
+    logInfo`Done thumbnail generation! --------+`;
 
-    footer.classes ??= [];
-    footer.content ??= (wikiInfo.footerContent ? transformMultiline(wikiInfo.footerContent) : '');
+    if (!result.success) {
+      Object.assign(stepStatusSummary.generateThumbnails, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+      });
 
-    footer.content += '\n' + getFooterLocalizationLinks(paths.pathname, {
-        defaultLanguage, languages, paths, language, to
+      return false;
+    }
+
+    Object.assign(stepStatusSummary.generateThumbnails, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
     });
 
-    const canonical = (wikiInfo.canonicalBase
-        ? wikiInfo.canonicalBase + (paths.pathname === '/' ? '' : paths.pathname)
-        : '');
-
-    const localizedCanonical = (wikiInfo.canonicalBase
-        ? Object.entries(localizedPaths).map(([ code, { pathname } ]) => ({
-            lang: code,
-            href: wikiInfo.canonicalBase + (pathname === '/' ? '' : 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}
-        ];
+    if (thumbsOnly) {
+      return true;
     }
 
-    const links = (nav.links || []).filter(Boolean);
+    thumbsCache = result.cache;
+  } else {
+    thumbsCache = {};
+  }
 
-    const navLinkParts = [];
-    for (let i = 0; i < links.length; i++) {
-        let cur = links[i];
-        const prev = links[i - 1];
-        const next = links[i + 1];
+  if (showInvalidPropertyAccesses) {
+    CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true;
+  }
 
-        let { title: linkTitle } = cur;
+  Object.assign(stepStatusSummary.loadDataFiles, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
-        if (cur.toHome) {
-            linkTitle ??= wikiInfo.nameShort;
-        } else if (cur.toCurrentPage) {
-            linkTitle ??= title;
-        }
+  let processDataAggregate, wikiDataResult;
 
-        let part = prev && (cur.divider ?? true) ? '/ ' : '';
+  try {
+    ({aggregate: processDataAggregate, result: wikiDataResult} =
+        await loadAndProcessDataDocuments({dataPath}));
+  } catch (error) {
+    console.error(error);
 
-        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 ? (() => {
-                        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);
-    }
+    logError`There was a JavaScript error loading data files.`;
+    fileIssue();
 
-    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>', {language})
-                        })}
-                    </div>
-                    <h1 class="info-card-name"><a></a></h1>
-                    <p class="info-card-album">${language.$('releaseInfo.from', {album: '<a></a>'})}</p>
-                    <p class="info-card-artists">${language.$('releaseInfo.by', {artists: '<span></span>'})}</p>
-                    <p class="info-card-cover-artists">${language.$('releaseInfo.coverArtBy', {artists: '<span></span>'})}</p>
-                </div>
-            </div>
-        </div>
-    `;
-
-    const socialEmbedHTML = [
-        socialEmbed.title && html.tag('meta', {property: 'og:title', content: socialEmbed.title}),
-        socialEmbed.description && html.tag('meta', {property: 'og:description', content: socialEmbed.description}),
-        socialEmbed.image && html.tag('meta', {property: 'og:image', content: socialEmbed.image}),
-        socialEmbed.color && html.tag('meta', {name: 'theme-color', content: socialEmbed.color}),
-        oEmbedJSONHref && html.tag('link', {type: 'application/json+oembed', href: oEmbedJSONHref}),
-    ].filter(Boolean).join('\n');
-
-    return filterEmptyLines(fixWS`
-        <!DOCTYPE html>
-        <html ${html.attributes({
-            lang: language.intlCode,
-            'data-language-code': language.code,
-            'data-url-key': paths.toPath[0],
-            ...Object.fromEntries(paths.toPath.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-data': to('data.root')
-        })}>
-            <head>
-                <title>${(showWikiNameInTitle
-                    ? language.formatString('misc.pageTitle.withWikiName', {
-                        title,
-                        wikiName: wikiInfo.nameShort
-                    })
-                    : language.formatString('misc.pageTitle', {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}">`}
-                ${localizedCanonical.map(({ lang, href }) => `<link rel="alternate" hreflang="${lang}" href="${href}">`).join('\n')}
-                ${socialEmbedHTML}
-                <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', language.$('misc.skippers.skipToContent')],
-                                sidebarLeftHTML && ['#sidebar-left', (sidebarRightHTML
-                                    ? language.$('misc.skippers.skipToSidebar.left')
-                                    : language.$('misc.skippers.skipToSidebar'))],
-                                sidebarRightHTML && ['#sidebar-right', (sidebarLeftHTML
-                                    ? language.$('misc.skippers.skipToSidebar.right')
-                                    : language.$('misc.skippers.skipToSidebar'))],
-                                footerHTML && ['#footer', language.$('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.oEmbedJSON = (pageInfo, {
-    language,
-    wikiData,
-}) => {
-    const { socialEmbed } = pageInfo;
-    const { wikiInfo } = wikiData;
-    const { canonicalBase, nameShort } = wikiInfo;
-
-    if (!socialEmbed) return '';
-
-    const entries = [
-        socialEmbed.heading && ['author_name',
-            language.$('misc.socialEmbed.heading', {
-                wikiName: nameShort,
-                heading: socialEmbed.heading
-            })],
-        socialEmbed.headingLink && canonicalBase && ['author_url',
-            canonicalBase.replace(/\/$/, '') + '/' +
-            socialEmbed.headingLink.replace(/^\//, '')],
-    ].filter(Boolean);
-
-    if (!entries.length) return '';
-
-    return JSON.stringify(Object.fromEntries(entries));
-};
-
-writePage.write = async ({
-    html,
-    oEmbedJSON = '',
-    paths,
-}) => {
-    await mkdir(paths.outputDirectory, {recursive: true});
-    await Promise.all([
-        writeFile(paths.outputFile, html),
-        oEmbedJSON && writeFile(paths.oEmbedJSONFile, oEmbedJSON)
-    ].filter(Boolean));
-};
-
-// 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').toDevice('localizedWithBaseDirectory.' + subKey, baseDirectory, directory)
-        : urls.from('shared.root').toDevice(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);
-    const oEmbedJSONFile = path.join(outputDirectory, OEMBED_JSON_FILE);
-
-    return {
-        toPath: [fullKey, directory],
-        pathname,
-        subdirectoryPrefix,
-        outputDirectory, outputFile,
-        oEmbedJSONFile,
-    };
-};
+    Object.assign(stepStatusSummary.loadDataFiles, {
+      status: STATUS_FATAL_ERROR,
+      annotation: `javascript error - view log for details`,
+      timeEnd: Date.now(),
+    });
+
+    return false;
+  }
+
+  Object.assign(wikiData, wikiDataResult);
+
+  {
+    const logThings = (prop, label) => {
+      const array =
+        (Array.isArray(prop)
+          ? prop
+          : wikiData[prop]);
+
+      logInfo` - ${array?.length ?? colors.red('(Missing!)')} ${colors.normal(colors.dim(label))}`;
+    }
 
-async function writeFavicon() {
     try {
-        await stat(path.join(mediaPath, FAVICON_FILE));
+      logInfo`Loaded data and processed objects:`;
+      logThings('albumData', 'albums');
+      logThings('trackData', 'tracks');
+      logThings(wikiData.artistData.filter(artist => !artist.isAlias), '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');
+      if (wikiData.homepageLayout) {
+        logInfo` - ${1} homepage layout (${
+          wikiData.homepageLayout.rows.length
+        } rows)`;
+      }
+      if (wikiData.wikiInfo) {
+        logInfo` - ${1} wiki config file`;
+      }
     } catch (error) {
-        return;
+      console.error(`Error showing data summary:`, error);
     }
 
+    let errorless = true;
     try {
-        await copyFile(
-            path.join(mediaPath, FAVICON_FILE),
-            path.join(outputPath, FAVICON_FILE)
-        );
+      processDataAggregate.close();
     } catch (error) {
-        logWarn`Failed to copy favicon! ${error.message}`;
-        return;
+      niceShowAggregate(error);
+      logWarn`The above errors were detected while processing data files.`;
+      errorless = false;
     }
 
-    logInfo`Copied favicon to site root.`;
-}
+    if (!wikiData.wikiInfo) {
+      logError`Can't proceed without wiki info file successfully loading`;
 
-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').toDevice(urlKey);
-        const file = path.join(outputPath, pathname);
-        try {
-            await unlink(file);
-        } catch (error) {
-            if (error.code !== 'ENOENT') {
-                throw error;
-            }
-        }
-        try {
-            await symlink(path.resolve(directory), file);
-        } catch (error) {
-            if (error.code === 'EPERM') {
-                await symlink(path.resolve(directory), file, 'junction');
-            }
-        }
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `wiki info object not available`,
+        timeEnd: Date.now(),
+      });
+
+      return false;
     }
-}
 
-function writeSharedFilesAndPages({language, wikiData}) {
-    const { groupData, wikiInfo } = wikiData;
+    if (errorless) {
+      logInfo`All data files processed without any errors - nice!`;
 
-    const redirect = async (title, from, urlKey, directory) => {
-        const target = path.relative(from, urls.from('shared.root').to(urlKey, directory));
-        const content = generateRedirectPage(title, target, {language});
-        await mkdir(path.join(outputPath, from), {recursive: true});
-        await writeFile(path.join(outputPath, from, 'index.html'), content);
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
+    } else {
+      logWarn`If the remaining valid data is complete enough, the wiki will`;
+      logWarn`still build - but all errored data will be skipped.`;
+      logWarn`(Resolve errors for more complete output!)`;
+
+      Object.assign(stepStatusSummary.loadDataFiles, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+      });
+    }
+  }
+
+  // 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);
+
+  Object.assign(stepStatusSummary.linkWikiDataArrays, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+  });
+
+  if (precacheMode === 'common') {
+    Object.assign(stepStatusSummary.precacheCommonData, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    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',
+      ]),
     };
 
-    return progressPromiseAll(`Writing files & pages shared across languages.`, [
-        groupData?.some(group => group.directory === 'fandom') &&
-        redirect('Fandom - Gallery', 'albums/fandom', 'localized.groupGallery', 'fandom'),
+    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];
+        }
+      }
+    }
 
-        groupData?.some(group => group.directory === 'official') &&
-        redirect('Official - Gallery', 'albums/official', 'localized.groupGallery', 'official'),
+    Object.assign(stepStatusSummary.precacheCommonData, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
+  }
 
-        wikiInfo.enableListings &&
-        redirect('Album Commentary', 'list/all-commentary', 'localized.commentaryIndex', ''),
+  // Filter out any things with duplicate directories throughout the data,
+  // warning about them too.
 
-        writeFile(path.join(outputPath, 'data.json'), fixWS`
-            {
-                "albumData": ${stringifyThings(wikiData.albumData)},
-                ${wikiInfo.enableFlashesAndGames && `"flashData": ${stringifyThings(wikiData.flashData)},`}
-                "artistData": ${stringifyThings(wikiData.artistData)}
-            }
-        `)
-    ].filter(Boolean));
-}
+  Object.assign(stepStatusSummary.reportDuplicateDirectories, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
-function generateRedirectPage(title, target, {language}) {
-    return fixWS`
-        <!DOCTYPE html>
-        <html>
-            <head>
-                <title>${language.$('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>${language.$('redirectPage.title', {title})}</h1>
-                    <p>${language.$('redirectPage.infoLine', {
-                        target: `<a href="${target}">${target}</a>`
-                    })}</p>
-                </main>
-            </body>
-        </html>
-    `;
-}
+  try {
+    reportDuplicateDirectories(wikiData, {getAllFindSpecs});
+    logInfo`No duplicate directories found - nice!`;
 
-// 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'
-    )
-}
+    Object.assign(stepStatusSummary.reportDuplicateDirectories, {
+      status: STATUS_DONE_CLEAN,
+      timeEnd: Date.now(),
+    });
+  } catch (aggregate) {
+    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.`;
+
+    Object.assign(stepStatusSummary.reportDuplicateDirectories, {
+      status: STATUS_FATAL_ERROR,
+      annotation: `duplicate directories found`,
+      timeEnd: Date.now(),
+    });
 
-async function processLanguageFile(file) {
-    const contents = await readFile(file, 'utf-8');
-    const json = JSON.parse(contents);
+    return false;
+  }
 
-    const code = json['meta.languageCode'];
-    if (!code) {
-        throw new Error(`Missing language code (file: ${file})`);
-    }
-    delete json['meta.languageCode'];
+  // Filter out any reference errors throughout the data, warning about them
+  // too.
 
-    const intlCode = json['meta.languageIntlCode'] ?? null;
-    delete json['meta.languageIntlCode'];
+  if (stepStatusSummary.filterReferenceErrors.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.filterReferenceErrors, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-    const name = json['meta.languageName'];
-    if (!name) {
-        throw new Error(`Missing language name (${code})`);
-    }
-    delete json['meta.languageName'];
+    const filterReferenceErrorsAggregate =
+      filterReferenceErrors(wikiData, {bindFind});
+
+    try {
+      filterReferenceErrorsAggregate.close();
+
+      logInfo`All references validated without any errors - nice!`;
 
-    const hidden = json['meta.hidden'] ?? false;
-    delete json['meta.hidden'];
+      Object.assign(stepStatusSummary.filterReferenceErrors, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
+    } catch (error) {
+      niceShowAggregate(error);
 
-    if (json['meta.baseDirectory']) {
-        logWarn`(${code}) Language JSON still has unused meta.baseDirectory`;
-        delete json['meta.baseDirectory'];
+      logWarn`The above errors were detected while validating references in data files.`;
+      logWarn`The wiki will still build, but these connections between data objects`;
+      logWarn`will be completely skipped. Resolve the errors for more complete output.`;
+
+      Object.assign(stepStatusSummary.filterReferenceErrors, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+      });
     }
+  }
 
-    const language = new Language();
-    language.code = code;
-    language.intlCode = intlCode;
-    language.name = name;
-    language.hidden = hidden;
-    language.escapeHTML = string => he.encode(string, {useNamedReferences: true});
-    language.strings = json;
-    return language;
-}
+  if (stepStatusSummary.reportContentTextErrors.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.reportContentTextErrors, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
-// 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);
+    try {
+      reportContentTextErrors(wikiData, {bindFind});
+      logInfo`All content text validated without any errors - nice!`;
 
-    const entries = Object.entries(languagesToRun)
-        .filter(([ key ]) => key !== 'default');
+      Object.assign(stepStatusSummary.reportContentTextErrors, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
+    } catch (error) {
+      niceShowAggregate(error);
 
-    for (let i = 0; i < entries.length; i++) {
-        const [ key, language ] = entries[i];
+      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.`;
 
-        await fn(language, i, entries);
+      Object.assign(stepStatusSummary.reportContentTextErrors, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `view log for details`,
+        timeEnd: Date.now(),
+      });
     }
-}
+  }
 
-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'
-        },
-
-        // 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': {
-            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': {
-            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': {
-            type: 'flag'
-        },
-
-        // 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': {
-            type: 'flag'
-        },
-
-        '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'},
-
-        // This option is super slow and has the potential for bugs! It puts
-        // CacheableObject in a mode where every instance is a Proxy which will
-        // keep track of invalid property accesses.
-        'show-invalid-property-accesses': {
-            type: 'flag'
-        },
-
-        [parseOptions.handleUnknown]: () => {}
+  // Sort data arrays so that they're all in order! This may use properties
+  // which are only available after the initial linking.
+
+  Object.assign(stepStatusSummary.sortWikiDataArrays, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
+
+  sortWikiDataArrays(wikiData);
+
+  Object.assign(stepStatusSummary.sortWikiDataArrays, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+  });
+
+  if (precacheMode === 'all') {
+    Object.assign(stepStatusSummary.precacheAllData, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
     });
 
-    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;
+    // 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(),
+    });
+  }
 
-    const writeOneLanguage = miscOptions['lang'];
+  if (stepStatusSummary.performBuild.status === STATUS_NOT_APPLICABLE) {
+    displayCompositeCacheAnalysis();
 
-    {
-        let errored = false;
-        const error = (cond, msg) => {
-            if (cond) {
-                console.error(`\x1b[31;1m${msg}\x1b[0m`);
-                errored = true;
-            }
-        };
-        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;
-        }
+    if (precacheMode === 'all') {
+      return true;
     }
+  }
 
-    const appendIndexHTML = miscOptions['append-index-html'] ?? false;
-    if (appendIndexHTML) {
-        logWarn`Appending index.html to link hrefs. (Note: not recommended for production release!)`;
-        unbound_link.globalOptions.appendIndexHTML = true;
-    }
+  const languageReloading =
+    stepStatusSummary.watchLanguageFiles.status === STATUS_NOT_STARTED;
 
-    const skipThumbs = miscOptions['skip-thumbs'] ?? false;
-    const thumbsOnly = miscOptions['thumbs-only'] ?? false;
-    const noBuild = miscOptions['no-build'] ?? false;
-    const showAggregateTraces = miscOptions['show-traces'] ?? false;
+  Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
 
-    const niceShowAggregate = (error, ...opts) => {
-        showAggregate(error, {
-            showTraces: showAggregateTraces,
-            pathToFile: f => path.relative(__dirname, f),
-            ...opts
-        });
-    };
+  let internalDefaultLanguage;
+  let internalDefaultLanguageWatcher;
 
-    if (skipThumbs && thumbsOnly) {
-        logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`;
-        return;
-    }
+  let errorLoadingInternalDefaultLanguage = false;
 
-    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;
-    }
+  if (languageReloading) {
+    internalDefaultLanguageWatcher = watchLanguageFile(internalDefaultStringsFile);
 
-    const showInvalidPropertyAccesses = miscOptions['show-invalid-property-accesses'] ?? false;
+    try {
+      await new Promise((resolve, reject) => {
+        const watcher = internalDefaultLanguageWatcher;
 
-    if (showInvalidPropertyAccesses) {
-        CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true;
-    }
+        const onReady = () => {
+          watcher.removeListener('ready', onReady);
+          watcher.removeListener('error', onError);
+          resolve();
+        };
 
-    const {
-        aggregate: processDataAggregate,
-        result: wikiDataResult
-    } = await loadAndProcessDataDocuments({dataPath});
-
-    Object.assign(wikiData, wikiDataResult);
-
-    {
-        const logThings = (thingDataProp, label) => logInfo` - ${wikiData[thingDataProp]?.length ?? color.red('(Missing!)')} ${color.normal(color.dim(label))}`;
-        try {
-            logInfo`Loaded data and processed objects:`;
-            logThings('albumData', 'albums');
-            logThings('trackData', 'tracks');
-            logThings('artistData', 'artists');
-            if (wikiData.flashData) {
-                logThings('flashData', 'flashes');
-                logThings('flashActData', 'flash acts');
-            }
-            logThings('groupData', 'groups');
-            logThings('groupCategoryData', 'group categories');
-            logThings('artTagData', 'art tags');
-            if (wikiData.newsData) {
-                logThings('newsData', 'news entries');
-            }
-            logThings('staticPageData', 'static pages');
-            if (wikiData.homepageLayout) {
-                logInfo` - ${1} homepage layout (${wikiData.homepageLayout.rows.length} rows)`;
-            }
-            if (wikiData.wikiInfo) {
-                logInfo` - ${1} wiki config file`;
-            }
-        } catch (error) {
-            console.error(`Error showing data summary:`, error);
-        }
+        const onError = error => {
+          watcher.removeListener('ready', onReady);
+          watcher.removeListener('error', onError);
+          watcher.close();
+          reject(error);
+        };
 
-        let errorless = true;
-        try {
-            processDataAggregate.close();
-        } catch (error) {
-            niceShowAggregate(error);
-            logWarn`The above errors were detected while processing data files.`;
-            logWarn`If the remaining valid data is complete enough, the wiki will`;
-            logWarn`still build - but all errored data will be skipped.`;
-            logWarn`(Resolve errors for more complete output!)`;
-            errorless = false;
-        }
+        watcher.on('ready', onReady);
+        watcher.on('error', onError);
+      });
 
-        if (errorless) {
-            logInfo`All data processed without any errors - nice!`;
-            logInfo`(This means all source files will be fully accounted for during page generation.)`;
-        }
+      internalDefaultLanguage = internalDefaultLanguageWatcher.language;
+    } catch (_error) {
+      // No need to display the error here - it's already printed by
+      // watchLanguageFile.
+      errorLoadingInternalDefaultLanguage = true;
     }
+  } else {
+    internalDefaultLanguageWatcher = null;
 
-    if (!WD.wikiInfo) {
-        logError`Can't proceed without wiki info file (${WIKI_INFO_FILE}) successfully loading`;
-        return;
+    try {
+      internalDefaultLanguage = await processLanguageFile(internalDefaultStringsFile);
+    } catch (error) {
+      niceShowAggregate(error);
+      errorLoadingInternalDefaultLanguage = true;
     }
+  }
 
-    let duplicateDirectoriesErrored = false;
-
-    function filterAndShowDuplicateDirectories() {
-        const aggregate = filterDuplicateDirectories(wikiData);
-        let errorless = true;
-        try {
-            aggregate.close();
-        } catch (aggregate) {
-            niceShowAggregate(aggregate);
-            logWarn`The above duplicate directories were detected while reviewing data files.`;
-            logWarn`Each thing listed above will been totally excempt from this build of the site!`;
-            logWarn`Specify unique 'Directory' fields in data entries to resolve these.`;
-            logWarn`${`Note:`} This will probably result in reference errors below.`;
-            logWarn`${`. . .`} You should fix duplicate directories first!`;
-            logWarn`(Resolve errors for more complete output!)`;
-            duplicateDirectoriesErrored = true;
-            errorless = false;
-        }
-        if (errorless) {
-            logInfo`No duplicate directories found - nice!`;
-        }
-    }
+  if (errorLoadingInternalDefaultLanguage) {
+    logError`There was an error reading the internal language file.`;
+    fileIssue();
 
-    function filterAndShowReferenceErrors() {
-        const aggregate = filterReferenceErrors(wikiData);
-        let errorless = true;
-        try {
-            aggregate.close();
-        } catch (error) {
-            niceShowAggregate(error);
-            logWarn`The above errors were detected while validating references in data files.`;
-            logWarn`If the remaining valid data is complete enough, the wiki will still build -`;
-            logWarn`but all errored references will be skipped.`;
-            if (duplicateDirectoriesErrored) {
-                logWarn`${`Note:`} Duplicate directories were found as well. Review those first,`;
-                logWarn`${`. . .`} as they may have caused some of the errors detected above.`;
-            }
-            logWarn`(Resolve errors for more complete output!)`;
-            errorless = false;
-        }
-        if (errorless) {
-            logInfo`All references validated without any errors - nice!`;
-            logInfo`(This means all references between things, such as leitmotif references`
-            logInfo` and artist credits, will be fully accounted for during page generation.)`;
-        }
+    Object.assign(stepStatusSummary.loadInternalDefaultLanguage, {
+      status: STATUS_FATAL_ERROR,
+      annotation: `see log for details`,
+      timeEnd: Date.now(),
+    });
+
+    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(),
+  });
+
+  let customLanguageWatchers;
+  let languages;
+
+  if (langPath) {
+    if (languageReloading) {
+      Object.assign(stepStatusSummary.watchLanguageFiles, {
+        status: STATUS_STARTED_NOT_DONE,
+        timeStart: Date.now(),
+      });
+    } else {
+      Object.assign(stepStatusSummary.loadLanguageFiles, {
+        status: STATUS_STARTED_NOT_DONE,
+        timeStart: Date.now(),
+      });
     }
 
-    // Link data arrays so that all essential references between objects are
-    // complete, so properties (like dates!) are inherited where that's
-    // appropriate.
-    linkWikiDataArrays(wikiData);
+    const languageDataFiles =
+      (await readdir(langPath))
+        .filter(name => ['.json', '.yaml'].includes(path.extname(name)))
+        .map(name => path.join(langPath, name));
 
-    // Filter out any things with duplicate directories throughout the data,
-    // warning about them too.
-    filterAndShowDuplicateDirectories();
+    let errorLoadingCustomLanguages = false;
 
-    // Filter out any reference errors throughout the data, warning about them
-    // too.
-    filterAndShowReferenceErrors();
+    if (languageReloading) watchCustomLanguages: {
+      Object.assign(stepStatusSummary.watchLanguageFiles, {
+        status: STATUS_STARTED_NOT_DONE,
+        timeStart: Date.now(),
+      });
 
-    // Sort data arrays so that they're all in order! This may use properties
-    // which are only available after the initial linking.
-    sortWikiDataArrays(wikiData);
+      customLanguageWatchers =
+        languageDataFiles.map(file => {
+          const watcher = watchLanguageFile(file);
 
-    const internalDefaultLanguage = await processLanguageFile(path.join(__dirname, DEFAULT_STRINGS_FILE));
+          // Bypass node.js special-case handling for uncaught error events
+          watcher.on('error', () => {});
 
-    let languages;
-    if (langPath) {
-        const languageDataFiles = await findFiles(langPath, {
-            filter: f => path.extname(f) === '.json'
+          return watcher;
         });
 
-        const results = await progressPromiseAll(`Reading & processing language files.`, languageDataFiles
-            .map(file => processLanguageFile(file)));
+      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(),
+          });
+
+          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]));
 
-        languages = Object.fromEntries(results.map(language => [language.code, language]));
+      Object.assign(stepStatusSummary.watchLanguageFiles, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
     } else {
-        languages = {};
-    }
+      languages = {};
+
+      const results =
+        await Promise.allSettled(
+          languageDataFiles
+            .map(file => processLanguageFile(file)));
 
-    const customDefaultLanguage = languages[WD.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code];
-    let finalDefaultLanguage;
-
-    if (customDefaultLanguage) {
-        logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`;
-        customDefaultLanguage.inheritedStrings = internalDefaultLanguage.strings;
-        finalDefaultLanguage = customDefaultLanguage;
-    } else if (WD.wikiInfo.defaultLanguage) {
-        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}?`;
+      for (const {status, value: language, reason: error} of results) {
+        if (status === 'rejected') {
+          errorLoadingCustomLanguages = true;
+          niceShowAggregate(error);
         } else {
-            logError`Be sure to specify ${'--lang'} or ${'HSMUSIC_LANG'} with the path to language files.`;
+          languages[language.code] = language;
         }
-        return;
-    } else {
-        languages[internalDefaultLanguage.code] = internalDefaultLanguage;
-        finalDefaultLanguage = internalDefaultLanguage;
+      }
+
+      if (errorLoadingCustomLanguages) {
+        Object.assign(stepStatusSummary.loadLanguageFiles, {
+          status: STATUS_FATAL_ERROR,
+          annotation: `see log for details`,
+          timeEnd: Date.now(),
+        });
+      } else {
+        Object.assign(stepStatusSummary.loadLanguageFiles, {
+          status: STATUS_DONE_CLEAN,
+          timeEnd: Date.now(),
+        });
+      }
     }
 
-    for (const language of Object.values(languages)) {
-        if (language === finalDefaultLanguage) {
-            continue;
-        }
+    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 = {};
+  }
+
+  Object.assign(stepStatusSummary.initializeDefaultLanguage, {
+    status: STATUS_STARTED_NOT_DONE,
+    timeStart: Date.now(),
+  });
+
+  let finalDefaultLanguage;
+  let finalDefaultLanguageWatcher;
+  let finalDefaultLanguageAnnotation;
+
+  if (wikiData.wikiInfo.defaultLanguage) {
+    const customDefaultLanguage = languages[wikiData.wikiInfo.defaultLanguage];
+
+    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(),
+      });
+
+      return false;
+    }
+
+    logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`;
+
+    finalDefaultLanguage = customDefaultLanguage;
+    finalDefaultLanguageAnnotation = `using wiki-specified custom default language`;
 
-        language.inheritedStrings = finalDefaultLanguage.strings;
+    if (languageReloading) {
+      finalDefaultLanguageWatcher =
+        customLanguageWatchers
+          .find(({language}) => language === customDefaultLanguage);
     }
+  } else if (languages[internalDefaultLanguage.code]) {
+    const customDefaultLanguage = languages[internalDefaultLanguage.code];
 
-    logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
+    finalDefaultLanguage = customDefaultLanguage;
+    finalDefaultLanguageAnnotation = `using inferred custom default language`;
 
-    if (noBuild) {
-        logInfo`Not generating any site or page files this run (--no-build passed).`;
-    } else 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.`;
-    } else {
-        logInfo`Writing all languages.`;
+    if (languageReloading) {
+      finalDefaultLanguageWatcher =
+        customLanguageWatchers
+          .find(({language}) => language === customDefaultLanguage);
     }
+  } else {
+    languages[internalDefaultLanguage.code] = internalDefaultLanguage;
 
-    {
-        const tagRefs = new Set([...WD.trackData, ...WD.albumData].flatMap(thing => thing.artTagsByRef ?? []));
+    finalDefaultLanguage = internalDefaultLanguage;
+    finalDefaultLanguageAnnotation = `no custom default language specified`;
 
-        for (const ref of tagRefs) {
-            if (find.artTag(ref, WD.artTagData)) {
-                tagRefs.delete(ref);
-            }
-        }
+    if (languageReloading) {
+      finalDefaultLanguageWatcher = internalDefaultLanguageWatcher;
+    }
+  }
+
+  const closeLanguageWatchers = () => {
+    if (languageReloading) {
+      for (const watcher of [
+        internalDefaultLanguageWatcher,
+        ...customLanguageWatchers,
+      ]) {
+        watcher.close();
+      }
+    }
+  };
+
+  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});
+  };
+
+  const inheritStringsFromDefaultLanguage = () => {
+    const {strings: inheritedStrings} = finalDefaultLanguage;
+    for (const language of Object.values(languages)) {
+      if (language === finalDefaultLanguage) continue;
+      Object.assign(language, {inheritedStrings});
+    }
+  };
 
-        if (tagRefs.size) {
-            for (const ref of Array.from(tagRefs).sort()) {
-                console.log(`\x1b[33;1m- Missing tag: "${ref}"\x1b[0m`);
-            }
-            return;
-        }
+  if (finalDefaultLanguage !== internalDefaultLanguage) {
+    inheritStringsFromInternalLanguage();
+  }
+
+  inheritStringsFromDefaultLanguage();
+
+  if (languageReloading) {
+    if (finalDefaultLanguage !== internalDefaultLanguage) {
+      internalDefaultLanguageWatcher.on('update', () => {
+        inheritStringsFromInternalLanguage();
+        inheritStringsFromDefaultLanguage();
+      });
     }
 
-    WD.justEverythingMan = sortByDate([...WD.albumData, ...WD.trackData, ...(WD.flashData || [])]);
-    WD.justEverythingSortedByArtDateMan = sortByArtDate(WD.justEverythingMan.slice());
-    // console.log(JSON.stringify(justEverythingSortedByArtDateMan.map(toAnythingMan), null, 2));
+    finalDefaultLanguageWatcher.on('update', () => {
+      inheritStringsFromDefaultLanguage();
+    });
+  }
+
+  logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
+
+  Object.assign(stepStatusSummary.initializeDefaultLanguage, {
+    status: STATUS_DONE_CLEAN,
+    annotation: finalDefaultLanguageAnnotation,
+    timeEnd: 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));
+  const urls = generateURLs(urlSpec);
+
+  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(),
+    });
+
+    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(),
+      });
+    } else if (empty(missingImagePaths)) {
+      Object.assign(stepStatusSummary.verifyImagePaths, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `misplaced images detected`,
+        timeEnd: Date.now(),
+      });
+    } else if (empty(misplacedImagePaths)) {
+      Object.assign(stepStatusSummary.verifyImagePaths, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `missing images detected`,
+        timeEnd: Date.now(),
+      });
+    } else {
+      Object.assign(stepStatusSummary.verifyImagePaths, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `missing and misplaced images detected`,
+        timeEnd: Date.now(),
+      });
+    }
+  }
+
+  let getSizeOfAdditionalFile;
+  let getSizeOfImagePath;
+
+  if (stepStatusSummary.preloadFileSizes.status === STATUS_NOT_APPLICABLE) {
+    getSizeOfAdditionalFile = () => null;
+    getSizeOfImagePath = () => null;
+  } else if (stepStatusSummary.preloadFileSizes.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.preloadFileSizes, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
 
     const fileSizePreloader = new FileSizePreloader();
 
@@ -1755,479 +1676,338 @@ async function main() {
     // function between them so that when site code requests a site path,
     // it'll get the size of the file at the corresponding device path.
     const additionalFilePaths = [
-        ...WD.albumData.flatMap(album => (
-            [
-                ...album.additionalFiles ?? [],
-                ...album.tracks.flatMap(track => track.additionalFiles ?? [])
-            ]
-            .flatMap(fileGroup => fileGroup.files)
-            .map(file => ({
-                device: (path.join(mediaPath, urls
-                    .from('media.root')
-                    .toDevice('media.albumAdditionalFile', album.directory, file))),
-                media: (urls
-                    .from('media.root')
-                    .to('media.albumAdditionalFile', album.directory, file))
-            })))),
+      ...wikiData.albumData.flatMap((album) =>
+        [
+          ...(album.additionalFiles ?? []),
+          ...album.tracks.flatMap((track) => [
+            ...(track.additionalFiles ?? []),
+            ...(track.sheetMusicFiles ?? []),
+            ...(track.midiProjectFiles ?? []),
+          ]),
+        ]
+          .flatMap((fileGroup) => fileGroup.files ?? [])
+          .map((file) => ({
+            device: path.join(
+              mediaPath,
+              urls
+                .from('media.root')
+                .toDevice('media.albumAdditionalFile', album.directory, file)
+            ),
+            media: urls
+              .from('media.root')
+              .to('media.albumAdditionalFile', album.directory, file),
+          }))
+      ),
     ];
 
-    const getSizeOfAdditionalFile = mediaPath => {
-        const { device = null } = additionalFilePaths.find(({ media }) => media === mediaPath) || {};
-        if (!device) return null;
-        return fileSizePreloader.getSizeOfPath(device);
+    // Same dealio for images. Since just about any image can be embedded and
+    // we can't super easily know which ones are referenced at runtime, just
+    // cheat and get file sizes for all images under media. (This includes
+    // additional files which are images.)
+    const imageFilePaths =
+      await traverse(mediaPath, {
+        pathStyle: 'device',
+        filterDir: dir => dir !== '.git',
+        filterFile: file =>
+          ['.png', '.gif', '.jpg'].includes(path.extname(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('/')),
+          })));
+
+    const getSizeOfMediaFileHelper = paths => (mediaPath) => {
+      const pair = paths.find(({media}) => media === mediaPath);
+      if (!pair) return null;
+      return fileSizePreloader.getSizeOfPath(pair.device);
     };
 
+    getSizeOfAdditionalFile = getSizeOfMediaFileHelper(additionalFilePaths);
+    getSizeOfImagePath = getSizeOfMediaFileHelper(imageFilePaths);
+
     logInfo`Preloading filesizes for ${additionalFilePaths.length} additional files...`;
 
-    fileSizePreloader.loadPaths(...additionalFilePaths.map(path => path.device));
+    fileSizePreloader.loadPaths(...additionalFilePaths.map((path) => path.device));
+    await fileSizePreloader.waitUntilDoneLoading();
+
+    logInfo`Preloading filesizes for ${imageFilePaths.length} full-resolution images...`;
+
+    fileSizePreloader.loadPaths(...imageFilePaths.map((path) => path.device));
     await fileSizePreloader.waitUntilDoneLoading();
 
-    logInfo`Done preloading filesizes!`;
+    if (fileSizePreloader.hasErrored) {
+      logWarn`Some media files couldn't be read for preloading filesizes.`;
+      logWarn`This means the wiki won't display file sizes for these files.`;
+      logWarn`Investigate missing or unreadable files to get that fixed!`;
 
-    if (noBuild) return;
+      Object.assign(stepStatusSummary.preloadFileSizes, {
+        status: STATUS_HAS_WARNINGS,
+        annotation: `see log for details`,
+        timeEnd: Date.now(),
+      });
+    } else {
+      logInfo`Done preloading filesizes without any errors - nice!`;
 
-    // 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);
+      Object.assign(stepStatusSummary.preloadFileSizes, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+      });
+    }
+  }
+
+  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;
+
+  try {
+    buildModeResult = await selectedBuildMode.go({
+      cliOptions,
+      dataPath,
+      mediaPath,
+      mediaCachePath,
+      queueSize,
+      srcRootPath: __dirname,
+
+      defaultLanguage: finalDefaultLanguage,
+      languages,
+      missingImagePaths,
+      thumbsCache,
+      urls,
+      urlSpec,
+      wikiData,
+
+      cachebust: '?' + CACHEBUST,
+      closeLanguageWatchers,
+      developersComment,
+      getSizeOfAdditionalFile,
+      getSizeOfImagePath,
+      niceShowAggregate,
+    });
+  } catch (error) {
+    console.error(error);
 
-    const buildDictionary = pageSpecs;
+    logError`There was a JavaScript error performing the build.`;
+    fileIssue();
 
-    // 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.performBuild, {
+      status: STATUS_FATAL_ERROR,
+      message: `javascript error - view log for details`,
+      timeEnd: Date.now(),
+    });
 
-        // Kinda a hack t8h!
-        ...Object.fromEntries(Object.keys(buildDictionary)
-            .map(key => [key, {type: 'flag'}])),
+    return false;
+  }
 
-        [parseOptions.handleUnknown]: () => {}
+  if (buildModeResult !== true) {
+    Object.assign(stepStatusSummary.performBuild, {
+      status: STATUS_HAS_WARNINGS,
+      annotation: `may not have completed - view log for details`,
+      timeEnd: Date.now(),
     });
 
-    const writeAll = !Object.keys(writeFlags).length || writeFlags.all;
+    return false;
+  }
 
-    logInfo`Writing site pages: ${writeAll ? 'all' : Object.keys(writeFlags).join(', ')}`;
+  Object.assign(stepStatusSummary.performBuild, {
+    status: STATUS_DONE_CLEAN,
+    timeEnd: Date.now(),
+  });
 
-    await writeFavicon();
-    await writeSymlinks();
-    await writeSharedFilesAndPages({language: finalDefaultLanguage, wikiData});
+  return true;
+}
 
-    const buildSteps = (writeAll
-        ? Object.entries(buildDictionary)
-        : (Object.entries(buildDictionary)
-            .filter(([ flag ]) => writeFlags[flag])));
+// 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 writes;
-    {
-        let error = false;
+    const totalTimeStart = Date.now();
 
-        const buildStepsWithTargets = buildSteps.map(([ flag, pageSpec ]) => {
-            // Condition not met: skip this build step altogether.
-            if (pageSpec.condition && !pageSpec.condition({wikiData})) {
-                return null;
-            }
+    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);
+      }
+    }
 
-            // May still call writeTargetless if present.
-            if (!pageSpec.targets) {
-                return {flag, pageSpec, targets: []};
-            }
+    const totalTimeEnd = Date.now();
 
-            if (!pageSpec.write) {
-                logError`${flag + '.targets'} is specified, but ${flag + '.write'} is missing!`;
-                error = true;
-                return null;
-            }
+    const formatDuration = timeDelta => {
+      const seconds = timeDelta / 1000;
 
-            const targets = pageSpec.targets({wikiData});
-            if (!Array.isArray(targets)) {
-                logError`${flag + '.targets'} was called, but it didn't return an array! (${typeof targets})`;
-                error = true;
-                return null;
-            }
+      if (seconds > 90) {
+        const modSeconds = Math.floor(seconds % 60);
+        const minutes = Math.floor(seconds - seconds % 60) / 60;
+        return `${minutes}m${modSeconds}s`;
+      }
 
-            return {flag, pageSpec, targets};
-        }).filter(Boolean);
+      if (seconds < 0.1) {
+        return 'instant';
+      }
 
-        if (error) {
-            return;
-        }
+      const precision = (seconds > 1 ? 3 : 2);
+      return `${seconds.toPrecision(precision)}s`;
+    };
 
-        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;
-        };
+    if (showStepStatusSummary) {
+      const totalDuration = formatDuration(totalTimeEnd - totalTimeStart);
 
-        // return;
+      console.error(colors.bright(`Step summary:`));
 
-        writes = buildStepsWithTargets.flatMap(({ flag, pageSpec, targets }) => {
-            const writes = targets.flatMap(target =>
-                pageSpec.write(target, {wikiData})?.slice() || []);
+      const longestNameLength =
+        Math.max(...
+          Object.values(stepStatusSummary)
+            .map(({name}) => name.length));
 
-            if (!validateWrites(writes, flag + '.write')) {
-                return [];
-            }
+      const stepsNotClean =
+        Object.values(stepStatusSummary)
+          .map(({status}) =>
+            status === STATUS_HAS_WARNINGS ||
+            status === STATUS_FATAL_ERROR ||
+            status === STATUS_STARTED_NOT_DONE);
 
-            if (pageSpec.writeTargetless) {
-                const writes2 = pageSpec.writeTargetless({wikiData});
+      const anyStepsNotClean =
+        stepsNotClean.includes(true);
 
-                if (!validateWrites(writes2, flag + '.writeTargetless')) {
-                    return [];
-                }
+      const stepDetails = Object.values(stepStatusSummary);
 
-                writes.push(...writes2);
-            }
+      const stepDurations =
+        stepDetails.map(({status, timeStart, timeEnd}) => {
+          if (
+            status === STATUS_NOT_APPLICABLE ||
+            status === STATUS_NOT_STARTED ||
+            status === STATUS_STARTED_NOT_DONE
+          ) {
+            return '-';
+          }
 
-            return writes;
-        });
+          if (typeof timeStart !== 'number' || typeof timeEnd !== 'number') {
+            return 'unknown';
+          }
 
-        if (error) {
-            return;
-        }
-    }
+          return formatDuration(timeEnd - timeStart);
+        });
 
-    const pageWrites = writes.filter(({ type }) => type === 'page');
-    const dataWrites = writes.filter(({ type }) => type === 'data');
-    const redirectWrites = writes.filter(({ type }) => type === 'redirect');
+      const longestDurationLength =
+        Math.max(...stepDurations.map(duration => duration.length));
 
-    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;
-    }
+      for (let index = 0; index < stepDetails.length; index++) {
+        const {name, status, annotation} = stepDetails[index];
+        const duration = stepDurations[index];
 
-    /*
-    await progressPromiseAll(`Writing data files shared across languages.`, queue(
-        dataWrites.map(({path, data}) => () => {
-            const bound = {};
+        let message =
+          (stepsNotClean[index]
+            ? `!! `
+            : ` - `);
 
-            bound.serializeLink = bindOpts(serializeLink, {});
+        message += `(${duration})`.padStart(longestDurationLength + 2, ' ');
+        message += ` `;
+        message += `${name}: `.padEnd(longestNameLength + 4, '.');
+        message += ` `;
+        message += status;
 
-            bound.serializeContribs = bindOpts(serializeContribs, {});
+        if (annotation) {
+          message += ` (${annotation})`;
+        }
 
-            bound.serializeImagePaths = bindOpts(serializeImagePaths, {
-                thumb
-            });
+        switch (status) {
+          case STATUS_DONE_CLEAN:
+            console.error(colors.green(message));
+            break;
 
-            bound.serializeCover = bindOpts(serializeCover, {
-                [bindOpts.bindIndex]: 2,
-                serializeImagePaths: bound.serializeImagePaths,
-                urls
-            });
+          case STATUS_NOT_STARTED:
+          case STATUS_NOT_APPLICABLE:
+            console.error(colors.dim(message));
+            break;
 
-            bound.serializeGroupsForAlbum = bindOpts(serializeGroupsForAlbum, {
-                serializeLink
-            });
+          case STATUS_HAS_WARNINGS:
+          case STATUS_STARTED_NOT_DONE:
+            console.error(colors.yellow(message));
+            break;
 
-            bound.serializeGroupsForTrack = bindOpts(serializeGroupsForTrack, {
-                serializeLink
-            });
+          case STATUS_FATAL_ERROR:
+            console.error(colors.red(message));
+            break;
 
-            // TODO: This only supports one <>-style argument.
-            return writeData(path[0], path[1], data({
-                ...bound
-            }));
-        }),
-        queueSize
-    ));
-    */
-
-    const perLanguageFn = async (language, i, entries) => {
-        const baseDirectory = (language === finalDefaultLanguage ? '' : 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(({type, ...props}) => () => {
-                const { path, page } = props;
-
-                // TODO: This only supports one <>-style argument.
-                const pageSubKey = path[0];
-                const directory = path[1];
-
-                const localizedPaths = Object.fromEntries(Object.entries(languages)
-                    .filter(([ key, language ]) => key !== 'default' && !language.hidden)
-                    .map(([ key, language ]) => [language.code, writePage.paths(
-                        (language === finalDefaultLanguage ? '' : language.code),
-                        'localized.' + pageSubKey,
-                        directory
-                    )]));
-
-                const paths = writePage.paths(
-                    baseDirectory,
-                    'localized.' + pageSubKey,
-                    directory
-                );
-
-                const to = writePage.to({
-                    baseDirectory,
-                    pageSubKey,
-                    paths
-                });
-
-                const absoluteTo = (targetFullKey, ...args) => {
-                    const [ groupKey, subKey ] = targetFullKey.split('.');
-                    const from = urls.from('shared.root');
-                    return '/' + (groupKey === 'localized' && baseDirectory
-                        ? from.to('localizedWithBaseDirectory.' + subKey, baseDirectory, ...args)
-                        : from.to(targetFullKey, ...args));
-                };
-
-                // 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.find = bindFind(wikiData, {mode: 'warn'});
-
-                bound.transformInline = bindOpts(transformInline, {
-                    find: bound.find,
-                    link: bound.link,
-                    replacerSpec,
-                    language,
-                    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, {
-                    language,
-                    to
-                });
-
-                bound.fancifyURL = bindOpts(fancifyURL, {
-                    language
-                });
-
-                bound.fancifyFlashURL = bindOpts(fancifyFlashURL, {
-                    [bindOpts.bindIndex]: 2,
-                    language
-                });
-
-                bound.getLinkThemeString = getLinkThemeString;
-
-                bound.getThemeString = getThemeString;
-
-                bound.getArtistString = bindOpts(getArtistString, {
-                    iconifyURL: bound.iconifyURL,
-                    link: bound.link,
-                    language
-                });
-
-                bound.getAlbumCover = bindOpts(getAlbumCover, {
-                    to
-                });
-
-                bound.getTrackCover = bindOpts(getTrackCover, {
-                    to
-                });
-
-                bound.getFlashCover = bindOpts(getFlashCover, {
-                    to
-                });
-
-                bound.getArtistAvatar = bindOpts(getArtistAvatar, {
-                    to
-                });
-
-                bound.generateAdditionalFilesShortcut = bindOpts(generateAdditionalFilesShortcut, {
-                    language
-                });
-
-                bound.generateAdditionalFilesList = bindOpts(generateAdditionalFilesList, {
-                    language
-                });
-
-                bound.generateChronologyLinks = bindOpts(generateChronologyLinks, {
-                    link: bound.link,
-                    linkAnythingMan: bound.linkAnythingMan,
-                    language,
-                    wikiData
-                });
-
-                bound.generateCoverLink = bindOpts(generateCoverLink, {
-                    [bindOpts.bindIndex]: 0,
-                    img,
-                    link: bound.link,
-                    language,
-                    to,
-                    wikiData
-                });
-
-                bound.generateInfoGalleryLinks = bindOpts(generateInfoGalleryLinks, {
-                    [bindOpts.bindIndex]: 2,
-                    link: bound.link,
-                    language
-                });
-
-                bound.generatePreviousNextLinks = bindOpts(generatePreviousNextLinks, {
-                    link: bound.link,
-                    language
-                });
-
-                bound.generateTrackListDividedByGroups = bindOpts(generateTrackListDividedByGroups, {
-                    language,
-                    wikiData,
-                });
-
-                bound.getGridHTML = bindOpts(getGridHTML, {
-                    [bindOpts.bindIndex]: 0,
-                    img,
-                    language
-                });
-
-                bound.getAlbumGridHTML = bindOpts(getAlbumGridHTML, {
-                    [bindOpts.bindIndex]: 0,
-                    getAlbumCover: bound.getAlbumCover,
-                    getGridHTML: bound.getGridHTML,
-                    link: bound.link,
-                    language
-                });
-
-                bound.getFlashGridHTML = bindOpts(getFlashGridHTML, {
-                    [bindOpts.bindIndex]: 0,
-                    getFlashCover: bound.getFlashCover,
-                    getGridHTML: bound.getGridHTML,
-                    link: bound.link
-                });
-
-                bound.getRevealStringFromTags = bindOpts(getRevealStringFromTags, {
-                    language
-                });
-
-                bound.getRevealStringFromWarnings = bindOpts(getRevealStringFromWarnings, {
-                    language
-                });
-
-                bound.getAlbumStylesheet = bindOpts(getAlbumStylesheet, {
-                    to
-                });
-
-                const pageInfo = page({
-                    ...bound,
-
-                    language,
-
-                    absoluteTo,
-                    relativeTo: to,
-                    to,
-                    urls,
-
-                    getSizeOfAdditionalFile,
-                });
-
-                const oEmbedJSON = writePage.oEmbedJSON(pageInfo, {
-                    language,
-                    wikiData,
-                });
-
-                const oEmbedJSONHref = (oEmbedJSON && wikiData.wikiInfo.canonicalBase) && (
-                    wikiData.wikiInfo.canonicalBase + urls.from('shared.root').to('shared.path', paths.pathname + OEMBED_JSON_FILE));
-
-                const html = writePage.html(pageInfo, {
-                    defaultLanguage: finalDefaultLanguage,
-                    language,
-                    languages,
-                    localizedPaths,
-                    oEmbedJSONHref,
-                    paths,
-                    to,
-                    transformMultiline: bound.transformMultiline,
-                    wikiData
-                });
-
-                return writePage.write({
-                    html,
-                    oEmbedJSON,
-                    paths,
-                });
-            }),
-            ...redirectWrites.map(({fromPath, toPath, title: titleFn}) => () => {
-                const title = titleFn({
-                    language
-                });
-
-                // 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 html = generateRedirectPage(title, target, {language});
-                return writePage.write({html, paths: fromPaths});
-            })
-        ], queueSize));
-    };
+          default:
+            console.error(message);
+            break;
+        }
+      }
 
-    await wrapLanguages(perLanguageFn, {
-        languages,
-        writeOneLanguage,
-    });
+      console.error(colors.bright(`Done in ${totalDuration}.`));
 
-    // The single most important step.
-    logInfo`Written!`;
-}
+      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}).`));
+      }
+    }
 
-main().catch(error => {
-    if (error instanceof AggregateError) {
-        showAggregate(error);
-    } else {
-        console.error(error);
+    if (result !== true) {
+      process.exit(1);
+      return;
     }
-}).then(() => {
+
     decorateTime.displayTime();
     CacheableObject.showInvalidAccesses();
-});
+
+    process.exit(0);
+  })();
+}
diff --git a/src/url-spec.js b/src/url-spec.js
index c1ed1eb..ea5337a 100644
--- a/src/url-spec.js
+++ b/src/url-spec.js
@@ -1,93 +1,114 @@
-import {withEntries} from './util/sugar.js';
+import {withEntries} from '#sugar';
 
 const urlSpec = {
-    data: {
-        prefix: 'data/',
+  data: {
+    prefix: 'data/',
 
-        paths: {
-            root: '',
-            path: '<>',
+    paths: {
+      root: '',
+      path: '<>',
 
-            album: 'album/<>',
-            artist: 'artist/<>',
-            track: 'track/<>'
-        }
+      album: 'album/<>',
+      artist: 'artist/<>',
+      track: 'track/<>',
     },
+  },
 
-    localized: {
-        // TODO: Implement this.
-        // prefix: '_languageCode',
+  localized: {
+    // TODO: Implement this.
+    // prefix: '_languageCode',
 
-        paths: {
-            root: '',
-            path: '<>',
+    paths: {
+      root: '',
+      path: '<>',
+      page: '<>/',
 
-            home: '/',
+      home: '',
 
-            album: 'album/<>/',
-            albumCommentary: 'commentary/album/<>/',
+      album: 'album/<>/',
+      albumCommentary: 'commentary/album/<>/',
+      albumGallery: 'album/<>/gallery/',
 
-            artist: 'artist/<>/',
-            artistGallery: 'artist/<>/gallery/',
+      artist: 'artist/<>/',
+      artistGallery: 'artist/<>/gallery/',
 
-            commentaryIndex: 'commentary/',
+      commentaryIndex: 'commentary/',
 
-            flashIndex: 'flash/',
-            flash: 'flash/<>/',
+      flashIndex: 'flash/',
 
-            groupInfo: 'group/<>/',
-            groupGallery: 'group/<>/gallery/',
+      flash: 'flash/<>/',
 
-            listingIndex: 'list/',
-            listing: 'list/<>/',
+      flashActGallery: 'flash-act/<>/',
 
-            newsIndex: 'news/',
-            newsEntry: 'news/<>/',
+      groupInfo: 'group/<>/',
+      groupGallery: 'group/<>/gallery/',
 
-            staticPage: '<>/',
-            tag: 'tag/<>/',
-            track: 'track/<>/'
-        }
+      listingIndex: 'list/',
+
+      listing: 'list/<>/',
+
+      newsIndex: 'news/',
+
+      newsEntry: 'news/<>/',
+
+      staticPage: '<>/',
+
+      tag: 'tag/<>/',
+
+      track: 'track/<>/',
+    },
+  },
+
+  shared: {
+    paths: {
+      root: '',
+      path: '<>',
+
+      utilityRoot: 'util',
+      staticRoot: 'static',
+
+      utilityFile: 'util/<>',
+      staticFile: 'static/<>?<>',
+
+      staticIcon: 'static/icons.svg#icon-<>',
     },
+  },
+
+  media: {
+    prefix: 'media/',
 
-    shared: {
-        paths: {
-            root: '',
-            path: '<>',
+    paths: {
+      root: '',
+      path: '<>',
 
-            utilityRoot: 'util',
-            staticRoot: 'static',
+      albumAdditionalFile: 'album-additional/<>/<>',
+      albumBanner: 'album-art/<>/banner.<>',
+      albumCover: 'album-art/<>/cover.<>',
+      albumWallpaper: 'album-art/<>/bg.<>',
 
-            utilityFile: 'util/<>',
-            staticFile: 'static/<>'
-        }
+      artistAvatar: 'artist-avatar/<>.<>',
+
+      flashArt: 'flash-art/<>.<>',
+
+      trackCover: 'album-art/<>/<>.<>',
     },
+  },
 
-    media: {
-        prefix: 'media/',
-
-        paths: {
-            root: '',
-            path: '<>',
-
-            albumCover: 'album-art/<>/cover.<>',
-            albumWallpaper: 'album-art/<>/bg.<>',
-            albumBanner: 'album-art/<>/banner.<>',
-            trackCover: 'album-art/<>/<>.<>',
-            artistAvatar: 'artist-avatar/<>.<>',
-            flashArt: 'flash-art/<>.<>',
-            albumAdditionalFile: 'album-additional/<>/<>',
-        }
-    }
+  thumb: {
+    prefix: 'thumb/',
+
+    paths: {
+      root: '',
+      path: '<>',
+    },
+  },
 };
 
 // This gets automatically switched in place when working from a baseDirectory,
 // so it should never be referenced manually.
 urlSpec.localizedWithBaseDirectory = {
-    paths: withEntries(
-        urlSpec.localized.paths,
-        entries => entries.map(([key, path]) => [key, '<>/' + path])
-    )
+  paths: withEntries(urlSpec.localized.paths, (entries) =>
+    entries.map(([key, path]) => [key, '<>/' + path])),
 };
 
 export default urlSpec;
diff --git a/src/util/aggregate.js b/src/util/aggregate.js
new file mode 100644
index 0000000..f002335
--- /dev/null
+++ b/src/util/aggregate.js
@@ -0,0 +1,647 @@
+import {colors} from './cli.js';
+import {empty, typeAppearance} from './sugar.js';
+
+// 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.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, aggregateOpts) and (aggregateOpts, fn)
+// in aggregate utilities.
+function _reorganizeAggregateArguments(arg1, arg2) {
+  if (typeof arg1 === 'function') {
+    return {fn: arg1, opts: arg2 ?? {}};
+  } else if (typeof arg2 === 'function') {
+    return {fn: arg2, opts: arg1 ?? {}};
+  } else {
+    throw new Error(`Expected a function`);
+  }
+}
+
+// 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);
+  return _mapAggregate('sync', null, array, fn, opts);
+}
+
+export function mapAggregateAsync(array, arg1, arg2) {
+  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  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);
+  return _filterAggregate('sync', null, array, fn, opts);
+}
+
+export async function filterAggregateAsync(array, arg1, arg2) {
+  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  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);
+  return _withAggregate('sync', opts, fn);
+}
+
+export function withAggregateAsync(arg1, arg2) {
+  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  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/,
+  /aggregate/,
+  /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;
+      }
+    }
+
+    return determineCauseHelper(cause.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);
+    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:
+        (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 subApparentSiblings =
+      (cause && errors
+        ? [cause, ...errors]
+     : cause
+        ? [cause]
+     : errors
+        ? 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 =
+      (cause
+        ? recursive(cause, 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 =
+      (errors
+        ? errors
+            .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/util/cli.js b/src/util/cli.js
index 0bbf3af..ce513f0 100644
--- a/src/util/cli.js
+++ b/src/util/cli.js
@@ -3,49 +3,54 @@
 // A 8unch of these depend on process.stdout 8eing availa8le, so they won't
 // work within the 8rowser.
 
-const { process } = globalThis;
-
-export const ENABLE_COLOR = process && (
-    (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 color = {
-    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 {process} = globalThis;
+
+export const ENABLE_COLOR =
+  process &&
+  ((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);
+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`);
-        }
+      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);
@@ -53,205 +58,343 @@ 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);
+  // 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', 'artichoke'] -> (error)
+  // ['--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;
             }
-        } else if (handleDashless) {
-            handleDashless(option);
+          }
+
+          if (!value) {
+            console.error(`Expected a value for --${name}`);
+            process.exit(1);
+          }
+
+          result[name] = value;
+          break;
         }
+
+        case '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;
+          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;
+  }
+  return result;
 }
 
 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 averageTime = meta.timeSpent / meta.timesCalled;
-            console.log(`\x1b[1m${typeof id === 'symbol' ? id.description : id}(...):\x1b[0m ${meta.timeSpent} ms / ${meta.timesCalled} calls \x1b[2m(avg: ${averageTime} ms)\x1b[0m`);
-        }
-    };
+  const [id, functionToBeWrapped] =
+    typeof arg1 === 'string' || typeof arg1 === 'symbol'
+      ? [arg1, arg2]
+      : [Symbol(arg1.name), arg1];
 
-    decorateTime.idMetaMap[id] = meta;
+  const meta = decorateTime.idMetaMap[id] ?? {
+    wrappedName: functionToBeWrapped.name,
+    timeSpent: 0,
+    timesCalled: 0,
+    displayTime() {
+      const align1 = 48;
+      const align2 = 22;
 
-    const fn = function(...args) {
-        const start = Date.now();
-        const ret = functionToBeWrapped(...args);
-        const end = Date.now();
-        meta.timeSpent += end - start;
-        meta.timesCalled++;
-        return ret;
-    };
+      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)`;
 
-    fn.displayTime = meta.displayTime;
+      const alignPart1 =
+        (idPart.length >= align1
+          ? ' '
+          : ' ' + '.'.repeat(Math.max(0, align1 - 2 - idPart.length)) + ' ');
 
-    return fn;
+      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;
+decorateTime.displayTime = function () {
+  const map = decorateTime.idMetaMap;
 
-    const keys = [
-        ...Object.getOwnPropertySymbols(map),
-        ...Object.getOwnPropertyNames(map)
-    ];
+  const keys = [
+    ...Object.getOwnPropertySymbols(map),
+    ...Object.getOwnPropertyNames(map),
+  ];
 
-    if (keys.length) {
-        console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m');
-        for (const key of keys) {
-            map[key].displayTime();
-        }
-    }
+  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([]);
-    }
+  if (!array.length) {
+    return Promise.resolve([]);
+  }
 
-    const msgFn = (typeof msgOrMsgFn === 'function'
-        ? msgOrMsgFn
-        : () => msgOrMsgFn);
+  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 => {
+  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, ' ');
+        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`)
+          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}] `);
+          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);
 }
diff --git a/src/util/colors.js b/src/util/colors.js
index f568557..50339cd 100644
--- a/src/util/colors.js
+++ b/src/util/colors.js
@@ -1,25 +1,42 @@
 // 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(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 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(),
+
+    bg: bg.hex(),
+    bgBlack: bgBlack.hex(),
+    shadow: shadow.hex(),
 
-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,
-        rgb: [r, g, b],
-        hsl: [h, s, l],
-    };
+    rgb: primary.rgb(),
+    hsl,
+  };
 }
diff --git a/src/util/external-links.js b/src/util/external-links.js
new file mode 100644
index 0000000..3b779af
--- /dev/null
+++ b/src/util/external-links.js
@@ -0,0 +1,998 @@
+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: {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: '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: '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: {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/util/find.js b/src/util/find.js
deleted file mode 100644
index 7cedb3d..0000000
--- a/src/util/find.js
+++ /dev/null
@@ -1,155 +0,0 @@
-import {
-    color,
-    logError,
-    logWarn
-} from './cli.js';
-
-import { inspect } from 'util';
-
-function warnOrThrow(mode, message) {
-    switch (mode) {
-        case 'error':
-            throw new Error(message);
-        case 'warn':
-            logWarn(message);
-        default:
-            return null;
-    }
-}
-
-function findHelper(keys, findFns = {}) {
-    // 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();
-
-    const byDirectory = findFns.byDirectory || matchDirectory;
-    const byName = findFns.byName || matchName;
-
-    const keyRefRegex = new RegExp(String.raw`^(?:(${keys.join('|')}):(?=\S))?(.*)$`);
-
-    // 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 Error(`Got a reference that is ${typeof fullRef}, not string: ${fullRef}`);
-        }
-
-        if (!data) {
-            throw new Error(`Expected data to be present`);
-        }
-
-        if (!Array.isArray(data) && data.wikiData) {
-            throw new Error(`Old {wikiData: {...}} format provided`);
-        }
-
-        let cacheForThisData = cache.get(data);
-        const cachedValue = cacheForThisData?.[fullRef];
-        if (cachedValue) {
-            globalThis.NUM_CACHE = (globalThis.NUM_CACHE || 0) + 1;
-            return cachedValue;
-        }
-        if (!cacheForThisData) {
-            cacheForThisData = Object.create(null);
-            cache.set(data, cacheForThisData);
-        }
-
-        const match = fullRef.match(keyRefRegex);
-        if (!match) {
-            return warnOrThrow(mode, `Malformed link reference: "${fullRef}"`);
-        }
-
-        const key = match[1];
-        const ref = match[2];
-
-        const found = (key
-            ? byDirectory(ref, data, mode)
-            : byName(ref, data, mode));
-
-        if (!found) {
-            warnOrThrow(mode, `Didn't match anything for ${color.bright(fullRef)}`);
-        }
-
-        cacheForThisData[fullRef] = found;
-
-        return found;
-    };
-}
-
-function matchDirectory(ref, data, mode) {
-    return data.find(({ directory }) => directory === ref);
-}
-
-function matchName(ref, data, mode) {
-    const matches = data.filter(({ name }) => name.toLowerCase() === ref.toLowerCase());
-
-    if (matches.length > 1) {
-        return warnOrThrow(mode,
-            `Multiple matches for reference "${ref}". Please resolve:\n` +
-            matches.map(match => `- ${inspect(match)}\n`).join('') +
-            `Returning null for this reference.`);
-    }
-
-    if (matches.length === 0) {
-        return null;
-    }
-
-    const thing = matches[0];
-
-    if (ref !== thing.name) {
-        warnOrThrow(mode, `Bad capitalization: ${color.red(ref)} -> ${color.green(thing.name)}`);
-    }
-
-    return thing;
-}
-
-function matchTagName(ref, data, quiet) {
-    return matchName(ref.startsWith('cw: ') ? ref.slice(4) : ref, data, quiet);
-}
-
-const find = {
-    album: findHelper(['album', 'album-commentary']),
-    artist: findHelper(['artist', 'artist-gallery']),
-    artTag: findHelper(['tag'], {byName: matchTagName}),
-    flash: findHelper(['flash']),
-    group: findHelper(['group', 'group-gallery']),
-    listing: findHelper(['listing']),
-    newsEntry: findHelper(['news-entry']),
-    staticPage: findHelper(['static']),
-    track: findHelper(['track'])
-};
-
-export default find;
-
-// 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, opts1) {
-    return Object.fromEntries(Object.entries({
-        album: 'albumData',
-        artist: 'artistData',
-        artTag: 'artTagData',
-        flash: 'flashData',
-        group: 'groupData',
-        listing: 'listingSpec',
-        newsEntry: 'newsData',
-        staticPage: 'staticPageData',
-        track: 'trackData',
-    }).map(([ key, value ]) => {
-        const findFn = find[key];
-        const thingData = wikiData[value];
-        return [key, (opts1
-            ? (ref, opts2) => (opts2
-                ? findFn(ref, thingData, {...opts1, ...opts2})
-                : findFn(ref, thingData, opts1))
-            : (ref, opts2) => (opts2
-                ? findFn(ref, thingData, opts2)
-                : findFn(ref, thingData)))];
-    }));
-}
diff --git a/src/util/html.js b/src/util/html.js
index a9b4bb9..d1d509e 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -1,109 +1,1858 @@
-// Some really simple functions for formatting HTML content.
+// 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',
+  'area',
+  'base',
+  'br',
+  'col',
+  'embed',
+  'hr',
+  'img',
+  'input',
+  'link',
+  'meta',
+  'source',
+  'track',
+  'wbr',
 ];
 
-// 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.
+// 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 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();
 
+// 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();
+
+// 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.
+
+  const arrayContent = [];
+  const templateContent = [];
+
+  for (const item of nonStringContent) {
+    if (item instanceof Tag) {
+      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) {
+      if (!template.blank) {
+        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 selfClosing = selfClosingTags.includes(tagName);
+  const lastArg = args.at(-1);
 
-    let openTag;
-    let content;
-    let attrs;
+  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);
+}
 
-    if (typeof args[0] === 'object' && !Array.isArray(args[0])) {
-        attrs = args[0];
-        content = args[1];
+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);
+
+    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 {
-        content = args[0];
+      this.#attributes = new Attributes(attributes);
+    }
+  }
+
+  get attributes() {
+    if (this.#attributes === null) {
+      this.attributes = {};
+    }
+
+    return this.#attributes;
+  }
+
+  set content(value) {
+    if (
+      this.selfClosing &&
+      !(value === null ||
+        value === undefined ||
+        !value ||
+        Array.isArray(value) && value.filter(Boolean).length === 0)
+    ) {
+      throw new Error(`Tag <${this.tagName}> is self-closing but got content`);
     }
 
-    if (selfClosing && content) {
-        throw new Error(`Tag <${tagName}> is self-closing but got 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`);
+      }
     }
 
-    if (attrs?.[onlyIfContent] && !content) {
-        return '';
+    this.#content = contentArray;
+    this.#content.toString = () => this.#stringifyContent();
+  }
+
+  get content() {
+    if (this.#content === null) {
+      this.#content = [];
     }
 
-    if (attrs) {
-        const attrString = attributes(args[0]);
-        if (attrString) {
-            openTag = `${tagName} ${attrString}`;
+    return this.#content;
+  }
+
+  get selfClosing() {
+    if (this.tagName) {
+      return selfClosingTags.includes(this.tagName);
+    } else {
+      return false;
+    }
+  }
+
+  get blank() {
+    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 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 = content;
+    } catch (error) {
+      this.#setAttributeFlag(chunkwrap, false);
+      throw error;
+    }
+  }
+
+  get chunkwrap() {
+    return this.#getAttributeFlag(chunkwrap);
+  }
+
+  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 = '';
+
+    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()) {
+      let itemContent;
+
+      try {
+        itemContent = item.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;
+      }
+
+      const chunkwrapChunks =
+        (typeof item === 'string' && chunkwrapSplitter
+          ? itemContent.split(chunkwrapSplitter)
+          : null);
+
+      const itemIncludesChunkwrapSplit =
+        (chunkwrapChunks
+          ? chunkwrapChunks.length > 1
+          : null);
+
+      if (content) {
+        if (itemIncludesChunkwrapSplit) {
+          if (!seenChunkwrapSplitter) {
+            // The first time we see a chunkwrap splitter, backtrack and wrap
+            // the content *so far* in a chunk.
+            content = `<span class="chunkwrap">` + content;
+          }
+
+          // Close the existing chunk. We'll add the new chunks after the
+          // (normal) joiner.
+          content += `</span>`;
         }
+
+        content += joiner;
+      } else {
+        // We've encountered a chunkwrap split before any other content.
+        // This means there's no content to wrap, no existing chunkwrap
+        // to close, and no reason to add a joiner, but we *do* need to
+        // enter a chunkwrap wrapper *now*, so the first chunk of this
+        // item will be properly wrapped.
+        if (itemIncludesChunkwrapSplit) {
+          content = `<span class="chunkwrap">`;
+        }
+      }
+
+      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 (item instanceof Tag && item.blockwrap && content) {
+        content += `<span class="blockwrap">`;
+        blockwrapClosers += `</span>`;
+      }
+
+      appendItemContent: {
+        if (itemIncludesChunkwrapSplit) {
+          for (const [index, chunk] of chunkwrapChunks.entries()) {
+            if (index === 0) {
+              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 (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]);
     }
 
-    if (!openTag) {
-        openTag = tagName;
+    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 (Array.isArray(content)) {
-        content = content.filter(Boolean).join('\n');
+    if (workingText) {
+      result.push(workingText);
     }
 
-    if (content) {
-        if (content.includes('\n')) {
-            return (
-                `<${openTag}>\n` +
-                content.split('\n').map(line => '    ' + line + '\n').join('') +
-                `</${tagName}>`
-            );
+    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 {
-            return `<${openTag}>${content}</${tagName}>`;
+          string = value.toString();
         }
-    } else {
-        if (selfClosing) {
-            return `<${openTag}>`;
+
+        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 {
-            return `<${openTag}></${tagName}>`;
+          childLines.push(...
+            inspect(child, {depth: nextDepth})
+              .split('\n')
+              .map(line => `  ${line}`));
         }
+
+        lines.push(...childLines);
+      }
     }
+
+    return lines.join('\n');
+  }
 }
 
-export function escapeAttributeValue(value) {
+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) {
+    return attribute in this.#attributes;
+  }
+
+  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
-        .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(' ');
+      .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} = {}) {
+  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));
+}
+
+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,
+    ]);
+
+    clone.setSlots(this.#slotValues);
+
+    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 (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;
+  }
+
+  set content(_value) {
+    throw new Error(`Template content can't be changed after constructed`);
+  }
+
+  get content() {
+    const slots = {};
+
+    for (const slotName of Object.keys(this.description.slots ?? {})) {
+      slots[slotName] = this.getSlotValue(slotName);
+    }
+
+    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;
+  }
+
+  [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/util/io.js b/src/util/io.js
deleted file mode 100644
index 1d74399..0000000
--- a/src/util/io.js
+++ /dev/null
@@ -1,14 +0,0 @@
-// Utility functions for interacting with files and other external data
-// interfacey constructs.
-
-import { readdir } from 'fs/promises';
-import * as path from 'path';
-
-export async function findFiles(dataPath, {
-    filter = f => true,
-    joinParentDirectory = true,
-} = {}) {
-    return (await readdir(dataPath))
-        .filter(file => filter(file))
-        .map(file => joinParentDirectory ? path.join(dataPath, file) : file);
-}
diff --git a/src/util/link.js b/src/util/link.js
deleted file mode 100644
index 6853962..0000000
--- a/src/util/link.js
+++ /dev/null
@@ -1,116 +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 appendIndexHTMLRegex = /^(?!https?:\/\/).+\/$/;
-
-const linkHelper = (hrefFn, {color = true, attr = null} = {}) =>
-    (thing, {
-        to,
-        text = '',
-        attributes = null,
-        class: className = '',
-        color: color2 = true,
-        hash = ''
-    }) => {
-        let href = hrefFn(thing, {to});
-
-        if (link.globalOptions.appendIndexHTML) {
-            if (appendIndexHTMLRegex.test(href)) {
-                href += 'index.html';
-            }
-        }
-
-        if (hash) {
-            href += (hash.startsWith('#') ? '' : '#') + hash;
-        }
-
-        return html.tag('a', {
-            ...attr ? attr(thing) : {},
-            ...attributes ? attributes : {},
-            href,
-            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 = {
-    globalOptions: {
-        // This should usually only 8e used during development! It'll take any
-        // href that ends with `/` and append `index.html` to the returned
-        // value (for to.thing() functions). This is handy when developing
-        // without a local server (i.e. using file:// protocol URLs in your
-        // 8rowser), 8ut isn't guaranteed to 8e 100% 8ug-free.
-        appendIndexHTML: false
-    },
-
-    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'}),
-
-    // TODO: This is a bit hacky. Files are just strings (not objects), so we
-    // have to manually provide the album alongside the file. They also don't
-    // follow the usual {name: whatever} type shape, so we have to provide that
-    // ourselves.
-    _albumAdditionalFileHelper: linkHelper(
-        ((fakeFileObject, { to }) =>
-            to('media.albumAdditionalFile', fakeFileObject.album.directory, fakeFileObject.name)),
-        {color: false}),
-    albumAdditionalFile: ({ file, album }, { to }) => link._albumAdditionalFileHelper({
-        name: file,
-        album
-    }, {to}),
-
-    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 73fdbc6..0000000
--- a/src/util/magic-constants.js
+++ /dev/null
@@ -1,10 +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 OFFICIAL_GROUP_DIRECTORY = 'official';
-export const FANDOM_GROUP_DIRECTORY = 'fandom';
diff --git a/src/util/node-utils.js b/src/util/node-utils.js
index a46d614..345d10a 100644
--- a/src/util/node-utils.js
+++ b/src/util/node-utils.js
@@ -1,31 +1,45 @@
 // Utility functions which are only relevant to particular Node.js constructs.
 
-import { fileURLToPath } from 'url';
+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);
-            }
-        })
-    })
+  // 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
@@ -33,5 +47,56 @@ export function promisifyProcess(proc, showLogging = true) {
 // is great 'cuz (module === require.main) doesn't work without CommonJS
 // modules.
 export function isMain(importMetaURL) {
-    return (process.argv[1] === fileURLToPath(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/util/replacer.js b/src/util/replacer.js
index b29044f..d1b0a26 100644
--- a/src/util/replacer.js
+++ b/src/util/replacer.js
@@ -1,22 +1,150 @@
-import {logError, logWarn} from './cli.js';
-import {escapeRegex} from './sugar.js';
-
-export function validateReplacerSpec(replacerSpec, {find, 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;
-}
+// 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: 'linkAlbum',
+  },
+
+  'album-commentary': {
+    find: 'album',
+    link: 'linkAlbumCommentary',
+  },
+
+  'album-gallery': {
+    find: 'album',
+    link: 'linkAlbumGallery',
+  },
+
+  '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',
+  },
+
+  'group': {
+    find: 'group',
+    link: 'linkGroup',
+  },
+
+  'group-gallery': {
+    find: 'group',
+    link: 'linkGroupGallery',
+  },
+
+  'home': {
+    find: null,
+    link: 'linkWikiHome',
+  },
+
+  '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: 'linkArtTag',
+  },
+
+  'track': {
+    find: 'track',
+    link: 'linkTrackDynamically',
+  },
+};
 
 // Syntax literals.
 const tagBeginning = '[[';
@@ -29,401 +157,554 @@ const tagLabel = '|';
 
 const noPrecedingWhitespace = '(?<!\\s)';
 
-const R_tagBeginning =
-    escapeRegex(tagBeginning);
+const R_tagBeginning = escapeRegex(tagBeginning);
 
-const R_tagEnding =
-    escapeRegex(tagEnding);
+const R_tagEnding = escapeRegex(tagEnding);
 
 const R_tagReplacerValue =
-    noPrecedingWhitespace +
-    escapeRegex(tagReplacerValue);
+  noPrecedingWhitespace + escapeRegex(tagReplacerValue);
 
-const R_tagHash =
-    noPrecedingWhitespace +
-    escapeRegex(tagHash);
+const R_tagHash = noPrecedingWhitespace + escapeRegex(tagHash);
 
-const R_tagArgument =
-    escapeRegex(tagArgument);
+const R_tagArgument = escapeRegex(tagArgument);
 
-const R_tagArgumentValue =
-    escapeRegex(tagArgumentValue);
+const R_tagArgumentValue = escapeRegex(tagArgumentValue);
 
-const R_tagLabel =
-    escapeRegex(tagLabel);
+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}).`);
+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;
+let stopped, stop_iParse, stop_literal;
 
 function parseOneTextNode(input, i, stopAt) {
-    return parseNodes(input, i, stopAt, true)[0];
+  return parseNodes(input, i, stopAt, true)[0];
 }
 
 function parseNodes(input, i, stopAt, textOnly) {
-    let nodes = [];
-    let escapeNext = false;
-    let string = '';
-    let iString = 0;
+  let nodes = [];
+  let string = '';
+  let iString = 0;
 
-    stopped = false;
+  stopped = false;
 
-    const pushTextNode = (isLast) => {
-        string = input.slice(iString, i);
+  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 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;
+    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);
 
-    // 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;
+    if (!match) {
+      iString = i;
+      i = input.length;
+      pushTextNode(true);
+      break;
     }
 
-    i += whitespaceOffset;
+    const closestMatch = match[0];
+    const closestMatchIndex = i + match.index;
 
-    while (i < input.length) {
-        const match = input.slice(i).match(regexp);
+    if (textOnly && closestMatch === tagBeginning)
+      throw makeError(i, `Unexpected [[tag]] - expected only text here.`);
 
-        if (!match) {
-            iString = i;
-            i = input.length;
-            pushTextNode(true);
-            break;
-        }
+    const stopHere = closestMatch !== tagBeginning;
+
+    iString = i;
+    i = closestMatchIndex;
+    pushTextNode(stopHere);
+
+    i += closestMatch.length;
+
+    if (stopHere) {
+      stopped = true;
+      stop_iParse = i;
+      stop_literal = closestMatch;
+      break;
+    }
 
-        const closestMatch = match[0];
-        const closestMatchIndex = i + match.index;
+    if (closestMatch === tagBeginning) {
+      const iTag = closestMatchIndex;
 
-        if (textOnly && closestMatch === tagBeginning)
-            throw makeError(i, `Unexpected [[tag]] - expected only text here.`);
+      let N;
 
-        const stopHere = (closestMatch !== tagBeginning);
+      // Replacer key (or value)
 
-        iString = i;
-        i = closestMatchIndex;
-        pushTextNode(stopHere);
+      N = parseOneTextNode(input, i, [
+        R_tagReplacerValue,
+        R_tagHash,
+        R_tagArgument,
+        R_tagLabel,
+        R_tagEnding,
+      ]);
 
-        i += closestMatch.length;
+      if (!stopped) throw endOfInput(i, `reading replacer key`);
 
-        if (stopHere) {
-            stopped = true;
-            stop_iMatch = closestMatchIndex;
-            stop_iParse = i;
-            stop_literal = closestMatch;
-            break;
+      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;
 
-        if (closestMatch === tagBeginning) {
-            const iTag = closestMatchIndex;
+      // Replacer value (if explicit)
 
-            let N;
+      let replacerSecond;
 
-            // Replacer key (or value)
+      if (stop_literal === tagReplacerValue) {
+        N = parseNodes(input, i, [
+          R_tagHash,
+          R_tagArgument,
+          R_tagLabel,
+          R_tagEnding,
+        ]);
 
-            N = parseOneTextNode(input, i, [R_tagReplacerValue, 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).`);
 
-            if (!stopped) throw endOfInput(i, `reading replacer key`);
+        replacerSecond = N;
+        i = stop_iParse;
+      }
 
-            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).`);
-                }
-            }
+      // Assign first & second to replacer key/value
 
-            const replacerFirst = N;
-            i = stop_iParse;
+      let replacerKey, replacerValue;
 
-            // Replacer value (if explicit)
+      // 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];
+      }
 
-            let replacerSecond;
+      // Hash
 
-            if (stop_literal === tagReplacerValue) {
-                N = parseNodes(input, i, [R_tagHash, R_tagArgument, R_tagLabel, R_tagEnding]);
+      let hash;
 
-                if (!stopped) throw endOfInput(i, `reading replacer value`);
-                if (!N.length) throw makeError(i, `Expected content (replacer value).`);
+      if (stop_literal === tagHash) {
+        N = parseOneTextNode(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
 
-                replacerSecond = N;
-                i = stop_iParse
-            }
+        if (!stopped) throw endOfInput(i, `reading hash`);
+        if (!N) throw makeError(i, `Expected text (hash).`);
 
-            // Assign first & second to replacer key/value
+        hash = N;
+        i = stop_iParse;
+      }
 
-            let replacerKey,
-                replacerValue;
+      // Arguments
 
-            // 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];
-            }
+      const args = [];
 
-            // Hash
+      while (stop_literal === tagArgument) {
+        N = parseOneTextNode(input, i, [
+          R_tagArgumentValue,
+          R_tagArgument,
+          R_tagLabel,
+          R_tagEnding,
+        ]);
 
-            let hash;
+        if (!stopped) throw endOfInput(i, `reading argument key`);
 
-            if (stop_literal === tagHash) {
-                N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
+        if (stop_literal !== tagArgumentValue)
+          throw makeError(
+            i,
+            `Expected ${tagArgumentValue.literal} (tag argument).`
+          );
 
-                if (!stopped) throw endOfInput(i, `reading hash`);
+        if (!N) throw makeError(i, `Expected text (argument key).`);
 
-                if (!N)
-                    throw makeError(i, `Expected content (hash).`);
+        const key = N;
+        i = stop_iParse;
 
-                hash = N;
-                i = stop_iParse;
-            }
+        N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
 
-            // Arguments
+        if (!stopped) throw endOfInput(i, `reading argument value`);
+        if (!N.length) throw makeError(i, `Expected content (argument value).`);
 
-            const args = [];
+        const value = N;
+        i = stop_iParse;
 
-            while (stop_literal === tagArgument) {
-                N = parseOneTextNode(input, i, [R_tagArgumentValue, R_tagArgument, R_tagLabel, R_tagEnding]);
+        args.push({key, value});
+      }
 
-                if (!stopped) throw endOfInput(i, `reading argument key`);
+      let label;
 
-                if (stop_literal !== tagArgumentValue)
-                    throw makeError(i, `Expected ${tagArgumentValue.literal} (tag argument).`);
+      if (stop_literal === tagLabel) {
+        N = parseOneTextNode(input, i, [R_tagEnding]);
 
-                if (!N)
-                    throw makeError(i, `Expected text (argument key).`);
+        if (!stopped) throw endOfInput(i, `reading label`);
+        if (!N) throw makeError(i, `Expected text (label).`);
 
-                const key = N;
-                i = stop_iParse;
+        label = N;
+        i = stop_iParse;
+      }
 
-                N = parseNodes(input, i, [R_tagArgument, R_tagLabel, R_tagEnding]);
+      nodes.push({
+        i: iTag,
+        iEnd: i,
+        type: 'tag',
+        data: {replacerKey, replacerValue, hash, args, label},
+      });
 
-                if (!stopped) throw endOfInput(i, `reading argument value`);
-                if (!N.length) throw makeError(i, `Expected content (argument value).`);
+      continue;
+    }
+  }
 
-                const value = N;
-                i = stop_iParse;
+  return nodes;
+}
 
-                args.push({key, value});
-            }
+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');
+}
 
-            let label;
+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');
+}
 
-            if (stop_literal === tagLabel) {
-                N = parseOneTextNode(input, i, [R_tagEnding]);
+export function cleanRawText(text) {
+  text = squashBackslashes(text);
+  text = restoreRawHTMLTags(text);
+  return text;
+}
 
-                if (!stopped) throw endOfInput(i, `reading label`);
-                if (!N) throw makeError(i, `Expected text (label).`);
+export function postprocessImages(inputNodes) {
+  const outputNodes = [];
 
-                label = N;
-                i = stop_iParse;
-            }
+  let atStartOfLine = true;
 
-            nodes.push({i: iTag, iEnd: i, type: 'tag', data: {replacerKey, replacerValue, hash, args, label}});
+  const lastNode = inputNodes.at(-1);
 
-            continue;
-        }
+  for (const node of inputNodes) {
+    if (node.type === 'tag') {
+      atStartOfLine = false;
     }
 
-    return nodes;
-};
+    if (node.type === 'text') {
+      const imageRegexp = /<img (.*?)>/g;
 
-export function parseInput(input) {
-    try {
-        return parseNodes(input, 0);
-    } catch (errorNode) {
-        if (errorNode.type !== 'error') {
-            throw errorNode;
-        }
+      let match = null, parseFrom = 0;
+      while (match = imageRegexp.exec(node.data)) {
+        const previousText = node.data.slice(parseFrom, match.index);
 
-        const { i, data: { message } } = errorNode;
+        outputNodes.push({
+          type: 'text',
+          data: previousText,
+          i: node.i + parseFrom,
+          iEnd: node.i + parseFrom + match.index,
+        });
+
+        parseFrom = match.index + match[0].length;
+
+        const imageNode = {type: 'image'};
+        const attributes = html.parseAttributes(match[1]);
+
+        imageNode.src = attributes.get('src');
 
-        let lineStart = input.slice(0, i).lastIndexOf('\n');
-        if (lineStart >= 0) {
-            lineStart += 1;
-        } else {
-            lineStart = 0;
+        if (previousText.endsWith('\n')) {
+          atStartOfLine = true;
+        } else if (previousText.length) {
+          atStartOfLine = false;
         }
 
-        let lineEnd = input.slice(i).indexOf('\n');
-        if (lineEnd >= 0) {
-            lineEnd += i;
-        } else {
-            lineEnd = input.length;
+        imageNode.inline = (() => {
+          // Images can force themselves to be rendered inline using a custom
+          // attribute - this style just works better for certain embeds,
+          // usually jokes or small images.
+          if (attributes.get('inline')) return true;
+
+          // 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 (
+            parseFrom !== node.data.length &&
+            node.data[parseFrom] !== '\n'
+          ) {
+            return true;
+          }
+
+          // If we're at the end of this text node, but this text node
+          // isn't the last node overall, we're inline.
+          if (
+            parseFrom === node.data.length &&
+            node !== lastNode
+          ) {
+            return true;
+          }
+
+          // If no other condition matches, this image is on its own line.
+          return false;
+        })();
+
+        if (attributes.get('link')) imageNode.link = attributes.get('link');
+        if (attributes.get('style')) imageNode.style = attributes.get('style');
+        if (attributes.get('width')) imageNode.width = parseInt(attributes.get('width'));
+        if (attributes.get('height')) imageNode.height = parseInt(attributes.get('height'));
+        if (attributes.get('align')) imageNode.align = attributes.get('align');
+        if (attributes.get('pixelate')) imageNode.pixelate = true;
+
+        if (attributes.get('warning')) {
+          imageNode.warnings =
+            attributes.get('warning').split(', ');
         }
 
-        const line = input.slice(lineStart, lineEnd);
+        outputNodes.push(imageNode);
 
-        const cursor = i - lineStart;
+        // No longer at the start of a line after an image - there will at
+        // least be a text node with only '\n' before the next image that's
+        // on its own line.
+        atStartOfLine = false;
+      }
 
-        throw new SyntaxError(fixWS`
-            Parse error (at pos ${i}): ${message}
-            ${line}
-            ${'-'.repeat(cursor) + '^'}
-        `);
+      if (parseFrom !== node.data.length) {
+        outputNodes.push({
+          type: 'text',
+          data: node.data.slice(parseFrom),
+          i: node.i + parseFrom,
+          iEnd: node.iEnd,
+        });
+      }
+
+      continue;
     }
-}
 
-function evaluateTag(node, opts) {
-    const { find, input, language, link, replacerSpec, to, wikiData } = opts;
+    outputNodes.push(node);
+  }
 
-    const source = input.slice(node.i, node.iEnd);
+  return outputNodes;
+}
 
-    const replacerKeyImplied = !node.data.replacerKey;
-    const replacerKey = (replacerKeyImplied
-        ? 'track'
-        : node.data.replacerKey.data);
+export function postprocessHeadings(inputNodes) {
+  const outputNodes = [];
 
-    if (!replacerSpec[replacerKey]) {
-        logWarn`The link ${source} has an invalid replacer key!`;
-        return source;
+  for (const node of inputNodes) {
+    if (node.type !== 'text') {
+      outputNodes.push(node);
+      continue;
     }
 
-    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]((replacerKeyImplied
-            ? replacerValue
-            : replacerKey + `:` + replacerValue)) :
-        {
-            directory: replacerValue,
-            name: null
-        });
+    const headingRegexp = /<h2 (.*?)>/g;
 
-    if (!value) {
-        logWarn`The link ${source} does not match anything!`;
-        return source;
-    }
+    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 enteredLabel = node.data.label && transformNode(node.data.label, opts);
+      const attributes = html.parseAttributes(match[1]);
+      attributes.push('class', 'content-heading');
 
-    const label = (enteredLabel
-        || transformName && transformName(value.name, node, input)
-        || value.name);
+      // We're only modifying the opening tag here. The remaining content,
+      // including the closing tag, will be pushed as-is.
+      textContent += `<h2 ${attributes}>`;
+    }
 
-    if (!valueFn && !label) {
-        logWarn`The link ${source} requires a label be entered!`;
-        return source;
+    if (parseFrom !== node.data.length) {
+      textContent += node.data.slice(parseFrom);
     }
 
-    const hash = node.data.hash && transformNodes(node.data.hash, opts);
+    outputNodes.push({
+      type: 'text',
+      data: textContent,
+      i: node.i,
+      iEnd: node.iEnd,
+    });
+  }
 
-    const args = node.data.args && Object.fromEntries(node.data.args.map(
-        ({ key, value }) => [
-            transformNode(key, opts),
-            transformNodes(value, opts)
-        ]));
+  return outputNodes;
+}
 
-    const fn = (htmlFn
-        ? htmlFn
-        : link[linkKey]);
+export function postprocessExternalLinks(inputNodes) {
+  const outputNodes = [];
 
-    try {
-        return fn(value, {text: label, hash, args, language, to});
-    } catch (error) {
-        logError`The link ${source} failed to be processed: ${error}`;
-        return source;
+  for (const node of inputNodes) {
+    if (node.type !== 'text') {
+      outputNodes.push(node);
+      continue;
     }
-}
 
-function transformNode(node, opts) {
-    if (!node) {
-        throw new Error('Expected a node!');
+    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 (Array.isArray(node)) {
-        throw new Error('Got an array - use transformNodes here!');
+    if (parseFrom !== node.data.length) {
+      textContent += node.data.slice(parseFrom);
     }
 
-    switch (node.type) {
-        case 'text':
-            return node.data;
-        case 'tag':
-            return evaluateTag(node, opts);
-        default:
-            throw new Error(`Unknown node type ${node.type}`);
+    if (textContent.length) {
+      outputNodes.push({type: 'text', data: textContent});
     }
+  }
+
+  return outputNodes;
 }
 
-function transformNodes(nodes, opts) {
-    if (!nodes || !Array.isArray(nodes)) {
-        throw new Error(`Expected an array of nodes! Got: ${nodes}`);
+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 = postprocessImages(output);
+    output = postprocessHeadings(output);
+    output = postprocessExternalLinks(output);
+    return output;
+  } catch (errorNode) {
+    if (errorNode.type !== 'error') {
+      throw errorNode;
     }
 
-    return nodes.map(node => transformNode(node, opts)).join('');
-}
+    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);
 
-export function transformInline(input, {replacerSpec, find, link, language, to, wikiData}) {
-    if (!replacerSpec) throw new Error('Expected replacerSpec');
-    if (!find) throw new Error('Expected find');
-    if (!link) throw new Error('Expected link');
-    if (!language) throw new Error('Expected language');
-    if (!to) throw new Error('Expected to');
-    if (!wikiData) throw new Error('Expected wikiData');
+    const cursor = i - lineStart;
 
-    const nodes = parseInput(input);
-    return transformNodes(nodes, {input, find, link, replacerSpec, language, to, wikiData});
+    throw new SyntaxError([
+      `Parse error (at pos ${i}): ${message}`,
+      line,
+      '-'.repeat(cursor) + '^',
+    ].join('\n'));
+  }
 }
diff --git a/src/util/serialize.js b/src/util/serialize.js
index e30951f..4992e2b 100644
--- a/src/util/serialize.js
+++ b/src/util/serialize.js
@@ -1,71 +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;
+  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;
-    });
+  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)
-    };
+  return {
+    original,
+    medium: thumb.medium(original),
+    small: thumb.small(original),
+  };
 }
 
 export function serializeCover(thing, pathFunction, {
-    serializeImagePaths,
-    urls
+  serializeImagePaths,
+  urls,
 }) {
-    const coverPath = pathFunction(thing, {
-        to: urls.from('media.root').to
-    });
+  const coverPath = pathFunction(thing, {
+    to: urls.from('media.root').to,
+  });
 
-    const { artTags } = thing;
+  const {artTags} = thing;
 
-    const cwTags = artTags.filter(tag => tag.isContentWarning);
-    const linkTags = artTags.filter(tag => !tag.isContentWarning);
+  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)
-    };
+  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 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,
-    }));
+export function serializeGroupsForTrack(track, {serializeLink}) {
+  return track.album.groups.map((group) => ({
+    link: serializeLink(group),
+    urls: group.urls,
+  }));
 }
+*/
diff --git a/src/util/sort.js b/src/util/sort.js
new file mode 100644
index 0000000..b3a9081
--- /dev/null
+++ b/src/util/sort.js
@@ -0,0 +1,405 @@
+// 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.)
+
+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, undefined, {numeric: true})
+    : al.localeCompare(bl, undefined, {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 sortFlashesChronologically(data, {
+  latestFirst = false,
+  getDate,
+} = {}) {
+  // Group flashes by act...
+  sortByDirectory(data, {
+    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;
+}
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 99f706f..e060f45 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -6,413 +6,738 @@
 // It will likely only do exactly what I want it to, and only in the cases I
 // decided were relevant enough to 8other handling.
 
-import { color } from './cli.js';
+import {colors} from './cli.js';
 
 // Apparently JavaScript doesn't come with a function to split an array into
 // chunks! Weird. Anyway, this is an awesome place to use a generator, even
 // though we don't really make use of the 8enefits of generators any time we
 // 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;
+  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 (typeof array === 'string') return repeat(times, [array]);
+  if (empty(array)) return [];
+  if (times === 0) return [];
+  if (times === 1) return array.slice();
+
+  const out = [];
+  for (let n = 1; n <= times; n++) {
+    out.push(...array);
+  }
+  return out;
+}
 
-export const mapInPlace = (array, fn) => array.splice(0, array.length, ...array.map(fn));
+// 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];
+}
+
+// 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;
+}
+
+// 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 filterEmptyLines = string => string.split('\n').filter(line => line.trim()).join('\n');
+export const mapInPlace = (array, fn) =>
+  array.splice(0, array.length, ...array.map(fn));
 
-export const unique = arr => Array.from(new Set(arr));
+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 const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
+  arr1.length === arr2.length &&
+  (checkOrder
+    ? arr1.every((x, i) => arr2[i] === x)
+    : arr1.every((x) => arr2.includes(x)));
 
 // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice.
-export const withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj)));
+export const withEntries = (obj, fn) =>
+  Object.fromEntries(fn(Object.entries(obj)));
+
+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 queue(array, max = 50) {
-    if (max === 0) {
-        return array.map(fn => fn());
+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;
+}
 
-    const begin = [];
-    let current = 0;
-    const ret = array.map(fn => new Promise((resolve, reject) => {
+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);
+          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()();
-    }
+  for (let i = 0; i < max && begin.length; i++) {
+    begin.shift()();
+  }
 
-    return ret;
+  return ret;
 }
 
 export function delay(ms) {
-    return new Promise(res => setTimeout(res, 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
+// past stage ~~1~~ 2 yet: https://github.com/tc39/proposal-regex-escaping
 export function escapeRegex(string) {
-    return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
+  return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
 }
 
-export function bindOpts(fn, bind) {
-    const bindIndex = bind[bindOpts.bindIndex] ?? 1;
+export function splitKeys(key) {
+  return key.split(/(?<=(?<!\\)(?:\\\\)*)\./);
+}
 
-    const bound = function(...args) {
-        const opts = args[bindIndex] ?? {};
-        return fn(...args.slice(0, bindIndex), {...bind, ...opts});
-    };
+// Follows a key path like 'foo.bar.baz' to get an item nested deeply inside
+// an object.
+export function getNestedProp(obj, key) {
+  const recursive = (o, k) =>
+    (k.length === 1
+      ? o[k[0]]
+      : recursive(o[k[0]], k.slice(1)));
 
-    Object.defineProperty(bound, 'name', {
-        value: (fn.name ? `(options-bound) ${fn.name}` : `(options-bound)`)
-    });
+  return recursive(obj, splitKeys(key));
+}
 
-    return bound;
+// 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;
 }
 
-bindOpts.bindIndex = Symbol();
+// 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;
+  }
+}
 
-// Utility function for providing useful interfaces to the JS AggregateError
-// class.
+// 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;
+  }
+}
+
+// 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.
 //
-// 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
+export function* iterateMultiline(content, iterator, {
+  formatWhere = true,
+  getContainingLine = false,
 } = {}) {
-    const errors = [];
-
-    const aggregate = {};
-
-    aggregate.wrap = fn => (...args) => {
-        try {
-            return fn(...args);
-        } catch (error) {
-            errors.push(error);
-            return (typeof returnOnFail === 'function'
-                ? returnOnFail(...args)
-                : returnOnFail);
-        }
-    };
+  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;
+    }
+  };
 
-    aggregate.wrapAsync = fn => (...args) => {
-        return fn(...args).then(
-            value => value,
-            error => {
-                errors.push(error);
-                return (typeof returnOnFail === 'function'
-                    ? returnOnFail(...args)
-                    : returnOnFail);
-            });
-    };
+  for (const result of iterator) {
+    const {index, length} = result;
 
-    aggregate.call = (fn, ...args) => {
-        return aggregate.wrap(fn)(...args);
-    };
+    countLineBreaks(previousIndex, index - previousIndex);
 
-    aggregate.callAsync = (fn, ...args) => {
-        return aggregate.wrapAsync(fn)(...args);
-    };
+    const matchStartOfLine = startOfLine;
 
-    aggregate.nest = (...args) => {
-        return aggregate.call(() => withAggregate(...args));
-    };
+    previousIndex = index + length;
 
-    aggregate.nestAsync = (...args) => {
-        return aggregate.callAsync(() => withAggregateAsync(...args));
-    };
+    const columnNumber = index - startOfLine;
 
-    aggregate.map = (...args) => {
-        const parent = aggregate;
-        const { result, aggregate: child } = mapAggregate(...args);
-        parent.call(child.close);
-        return result;
-    };
+    let where = null;
+    if (formatWhere) {
+      where =
+        colors.yellow(
+          (isMultiline
+            ? `line: ${lineNumber + 1}, col: ${columnNumber + 1}`
+            : `pos: ${index + 1}`));
+    }
 
-    aggregate.mapAsync = async (...args) => {
-        const parent = aggregate;
-        const { result, aggregate: child } = await mapAggregateAsync(...args);
-        parent.call(child.close);
-        return result;
-    };
+    countLineBreaks(index, length);
 
-    aggregate.filter = (...args) => {
-        const parent = aggregate;
-        const { result, aggregate: child } = filterAggregate(...args);
-        parent.call(child.close);
-        return result;
-    };
+    let containingLine = null;
+    if (getContainingLine) {
+      const nextLineResult =
+        content
+          .slice(previousIndex)
+          .matchAll(lineRegexp)
+          .next();
 
-    aggregate.throws = aggregateThrows;
+      const nextStartOfLine =
+        (nextLineResult.done
+          ? content.length
+          : previousIndex + nextLineResult.value.index);
 
-    aggregate.close = () => {
-        if (errors.length) {
-            throw Reflect.construct(errorClass, [errors, message]);
-        }
-    };
+      containingLine =
+        content.slice(matchStartOfLine, nextStartOfLine);
+    }
 
-    return aggregate;
+    yield {
+      ...result,
+      lineNumber,
+      columnNumber,
+      where,
+      containingLine,
+    };
+  }
 }
 
-openAggregate.errorClassSymbol = Symbol('error class');
+// 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,
+        };
+      }
+    })();
 
-// Utility function for providing {errorClass} parameter to aggregate functions.
-export function aggregateThrows(errorClass) {
-    return {[openAggregate.errorClassSymbol]: errorClass};
+  const multilineIterator =
+    iterateMultiline(content, cleanMatchAllIterator, options);
+
+  yield* multilineIterator;
 }
 
-// Performs an ordinary array map with the given function, collating into a
-// results array (with errored inputs filtered out) and an error aggregate.
+// 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.
 //
-// Optionally, override returnOnFail to disable filtering and map errored inputs
-// to a particular output.
+// 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).
 //
-// 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) {
-    return _mapAggregate('sync', null, array, fn, aggregateOpts);
+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;
 }
 
-export function mapAggregateAsync(array, fn, {
-    promiseAll = Promise.all.bind(Promise),
-    ...aggregateOpts
-} = {}) {
-    return _mapAggregate('async', promiseAll, array, fn, aggregateOpts);
-}
+bindOpts.bindIndex = Symbol();
 
-// Helper function for mapAggregate which holds code common between sync and
-// async versions.
-export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) {
-    const failureSymbol = 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]);
+    }
 
-    const aggregate = openAggregate({
-        returnOnFail: failureSymbol,
-        ...aggregateOpts
-    });
+    return fn(...args);
+  });
 
-    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};
-        });
-    }
+  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;
 }
 
-// Performs an ordinary array filter with the given function, collating into a
-// results array (with errored inputs filtered out) and an error aggregate.
+// Filters multiple arrays by an arbitrary function (which is the last argument).
+// Values from each array are provided to the callback sequentially:
 //
-// Optionally, override returnOnFail to disable filtering errors and map errored
-// inputs to a particular output.
+//   (value_fromFirstArray,
+//    value_fromSecondArray,
+//    value_fromThirdArray,
+//    index,
+//    [firstArray, secondArray, thirdArray]) =>
+//      true or false
 //
-// As with mapAggregate, the returned aggregate property is not yet closed.
-export function filterAggregate(array, fn, aggregateOpts) {
-    return _filterAggregate('sync', null, array, fn, aggregateOpts);
+// 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;
 }
 
-export async function filterAggregateAsync(array, fn, {
-    promiseAll = Promise.all.bind(Promise),
-    ...aggregateOpts
+// 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,
 } = {}) {
-    return _filterAggregate('async', promiseAll, array, fn, aggregateOpts);
+  filterMultipleArrays(data, counts, (data, count) =>
+    count >= min && count <= max);
 }
 
-// Helper function for filterAggregate which holds code common between sync and
-// async versions.
-function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) {
-    const failureSymbol = Symbol();
+// 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;
+}
 
-    const aggregate = openAggregate({
-        returnOnFail: failureSymbol,
-        ...aggregateOpts
-    });
+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;
+}
 
-    function filterFunction(value) {
-        // Filter out results which match the failureSymbol, i.e. errored
-        // inputs.
-        if (value === failureSymbol) return false;
+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];
 
-        // Always keep results which match the overridden returnOnFail
-        // value, if provided.
-        if (value === aggregateOpts.returnOnFail) return true;
+      if (a[p] !== b[p]) return true;
 
-        // Otherwise, filter according to the returned value of the wrapped
-        // function.
-        return value.output;
-    }
+      // 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;
 
-    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);
-    }
+      return false;
+    })
+  ).map((chunk) => ({
+    ...Object.fromEntries(properties.map((p) => [p, chunk[0][p]])),
+    chunk,
+  }));
+}
 
-    function wrapperFunction(x, ...rest) {
-        return {
-            input: x,
-            output: fn(x, ...rest)
-        };
-    }
+export function chunkMultipleArrays(...args) {
+  const arrays = args.slice(0, -1);
+  const fn = args.at(-1);
 
-    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);
+  if (arrays[0].length === 0) {
+    return [];
+  }
 
-        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};
-        });
+  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);
     }
-}
 
-// 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) {
-    return _withAggregate('sync', aggregateOpts, fn);
-}
+    if (fn(...args)) {
+      results.push(newChunk(i));
+      continue;
+    }
 
-export function withAggregateAsync(aggregateOpts, fn) {
-    return _withAggregate('async', aggregateOpts, fn);
+    for (let j = 0; j < arrays.length; j++) {
+      current[j].push(arrays[j][i]);
+    }
+  }
+
+  return results;
 }
 
-export function _withAggregate(mode, aggregateOpts, fn) {
-    if (typeof aggregateOpts === 'function') {
-        fn = aggregateOpts;
-        aggregateOpts = {};
+// 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;
     }
+  }
 
-    const aggregate = openAggregate(aggregateOpts);
-
-    if (mode === 'sync') {
-        const result = fn(aggregate);
-        aggregate.close();
-        return result;
+  if (newTrait) {
+    if (trait) {
+      trait += ' #' + newTrait;
     } else {
-        return fn(aggregate).then(result => {
-            aggregate.close();
-            return result;
-        });
+      trait = '#' + newTrait;
     }
-}
+  }
 
-export function showAggregate(topError, {
-    pathToFile = p => p,
-    showTraces = true
-} = {}) {
-    const recursive = (error, {level}) => {
-        let header = (showTraces
-            ? `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'}`
-            : (error instanceof AggregateError
-                ? `[${error.message || '(no message)'}]`
-                : error.message || '(no message)'));
-        if (showTraces) {
-            const stackLines = error.stack?.split('\n');
-            const stackLine = stackLines?.find(line =>
-                line.trim().startsWith('at')
-                && !line.includes('sugar')
-                && !line.includes('node:')
-                && !line.includes('<anonymous>'));
-            const tracePart = (stackLine
-                ? '- ' + stackLine.trim().replace(/file:\/\/(.*\.js)/, (match, pathname) => pathToFile(pathname))
-                : '(no stack trace)');
-            header += ` ${color.dim(tracePart)}`;
-        }
-        const bar = (level % 2 === 0
-            ? '\u2502'
-            : color.dim('\u254e'));
-        const head = (level % 2 === 0
-            ? '\u257f'
-            : color.dim('\u257f'));
-
-        if (error instanceof AggregateError) {
-            return header + '\n' + (error.errors
-                .map(error => recursive(error, {level: level + 1}))
-                .flatMap(str => str.split('\n'))
-                .map((line, i, lines) => (i === 0
-                    ? ` ${head} ${line}`
-                    : ` ${bar} ${line}`))
-                .join('\n'));
-        } else {
-            return header;
-        }
-    };
+  let parenthesesPart;
 
-    console.error(recursive(topError, {level: 0}));
-}
+  if (description && trait) {
+    parenthesesPart = `${description} ${trait}`;
+  } else if (description || trait) {
+    parenthesesPart = description || trait;
+  } else {
+    parenthesesPart = '';
+  }
 
-export function decorateErrorWithIndex(fn) {
-    return (x, index, array) => {
-        try {
-            return fn(x, index, array);
-        } catch (error) {
-            error.message = `(${color.yellow(`#${index + 1}`)}) ${error.message}`;
-            throw error;
-        }
-    }
+  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/util/urls.js b/src/util/urls.js
index e15c018..11b9b8b 100644
--- a/src/util/urls.js
+++ b/src/util/urls.js
@@ -3,122 +3,249 @@
 // 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';
+import * as path from 'node:path';
+
+import {withEntries} from '#sugar';
+
+// This export is only provided for convenience, i.e. to enable the following:
+//
+//   import {urlSpec} from '#urls';
+//
+// It's not actually defined in this module's variable scope, and functions
+// exported here require a urlSpec (whether this default one or another) to be
+// passed directly.
+//
+export {default as urlSpec} from '../url-spec.js';
 
 export 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})`);
-        }
+  const getValueForFullKey = (obj, fullKey) => {
+    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})`);
-        }
+    if (!Object.hasOwn(obj, groupKey)) {
+      throw new Error(`Expected valid group key (got ${groupKey})`);
+    }
 
-        const group = obj[groupKey];
+    const group = obj[groupKey];
 
-        if (!group.hasOwnProperty(subKey)) {
-            throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`);
-        }
+    if (!Object.hasOwn(group, subKey)) {
+      throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`);
+    }
 
-        return {
-            value: group[subKey],
-            group
-        };
+    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;
+  // 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 generateTo = (fromPath, fromGroup) => {
+    const A = trimLeadingSlash(fromPath);
 
-        const rebasePrefix = '../'.repeat((fromGroup.prefix || '').split('/').filter(Boolean).length);
+    const rebasePrefix = '../'
+      .repeat((fromGroup.prefix || '').split('/').filter(Boolean).length);
 
-        const pathHelper = (toPath, toGroup) => {
-            let B = trimLeadingSlash(toPath);
+    const pathHelper = (toPath, toGroup) => {
+      let B = trimLeadingSlash(toPath);
 
-            let argIndex = 0;
-            B = B.replaceAll('<>', () => `<${argIndex++}>`);
+      let argIndex = 0;
+      B = B.replaceAll('<>', () => `<${argIndex++}>`);
 
-            if (toGroup.prefix !== fromGroup.prefix) {
-                // TODO: Handle differing domains in prefixes.
-                B = rebasePrefix + (toGroup.prefix || '') + B;
-            }
-
-            const suffix = (toPath.endsWith('/') ? '/' : '');
+      if (toGroup.prefix !== fromGroup.prefix) {
+        // TODO: Handle differing domains in prefixes.
+        B = rebasePrefix + (toGroup.prefix || '') + B;
+      }
 
-            return {
-                posix: path.posix.relative(A, B) + suffix,
-                device: path.relative(A, B) + suffix
-            };
-        };
+      const suffix = toPath.endsWith('/') ? '/' : '';
 
-        const groupSymbol = Symbol();
+      return {
+        posix: path.posix.relative(A, B) + suffix,
+        device: path.relative(A, B) + suffix,
+      };
+    };
 
-        const groupHelper = urlGroup => ({
-            [groupSymbol]: urlGroup,
-            ...withEntries(urlGroup.paths, entries => entries
-                .map(([key, path]) => [key, pathHelper(path, urlGroup)]))
+    const 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 toHelper =
+      ({device}) =>
+      (key, ...args) => {
+        const {
+          value: {
+            [device ? 'device' : 'posix']: template,
+          },
+        } = getValueForFullKey(relative, key);
+
+        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++;
+          }
         });
 
-        const relative = withEntries(urlSpec, entries => entries
-            .map(([key, urlGroup]) => [key, groupHelper(urlGroup)]));
-
-        const toHelper = (delimiterMode) => (key, ...args) => {
-            const {
-                value: {[delimiterMode]: template}
-            } = getValueForFullKey(relative, key);
-
-            let missing = 0;
-            let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => {
-                if (n < args.length) {
-                    return args[n];
-                } else {
-                    missing++;
-                }
-            });
-
-            if (missing) {
-                throw new Error(`Expected ${missing + args.length} arguments, got ${args.length} (key ${key}, args [${args}])`);
-            }
+        if (missing) {
+          throw new Error(
+            `Expected ${missing + args.length} arguments, got ${
+              args.length
+            } (key ${key}, args [${args}])`
+          );
+        }
 
-            return result;
-        };
+        return result;
+      };
 
-        return {
-            to: toHelper('posix'),
-            toDevice: toHelper('device')
-        };
+    return {
+      to: toHelper({device: false}),
+      toDevice: toHelper({device: true}),
     };
+  };
 
-    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 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;
+    const from = (key) => getValueForFullKey(map, key).value;
 
-        return {from, map};
-    };
+    return {from, map};
+  };
 
-    return generateFrom();
+  return generateFrom();
 }
 
-const thumbnailHelper = name => file =>
-    file.replace(/\.(jpg|png)$/, name + '.jpg');
+const thumbnailHelper = (name) => (file) =>
+  file.replace(/\.(jpg|png)$/, name + '.jpg');
 
 export const thumb = {
-    medium: thumbnailHelper('.medium'),
-    small: thumbnailHelper('.small')
+  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;
+    }
+
+    return (
+      subdirectoryPrefix +
+      urls.from(from).to(to, ...args));
+  };
+}
+
+// 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('.');
+    return (
+      '/' +
+      (groupKey === 'localized' && baseDirectory
+        ? to(
+            'localizedWithBaseDirectory.' + subKey,
+            baseDirectory,
+            ...args
+          )
+        : to(targetFullKey, ...args))
+    );
+  };
+}
+
+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/wiki-data.js b/src/util/wiki-data.js
index b4f7f21..f8ab3ef 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -1,126 +1,119 @@
 // Utility functions for interacting with wiki data.
 
+import {accumulateSum, empty} 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
-        .split(' ')
-        .join('-')
-        .replace(/&/g, 'and')
-        .replace(/[^a-zA-Z0-9\-]/g, '')
-        .replace(/-{2,}/g, '-')
-        .replace(/^-+|-+$/g, '')
-        .toLowerCase();
-}
+  return name
 
-export function chunkByConditions(array, conditions) {
-    if (array.length === 0) {
-        return [];
-    } else if (conditions.length === 0) {
-        return [array];
-    }
+    // Spaces to dashes
+    .split(' ')
+    .join('-')
 
-    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;
-}
+    // Punctuation as words
+    .replace(/&/g, '-and-')
+    .replace(/\+/g, '-plus-')
+    .replace(/%/g, '-percent-')
 
-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];
+    // Punctuation which only divides words, not single characters
+    .replace(/(\b[^\s-.]{2,})\./g, '$1-')
+    .replace(/\.([^\s-.]{2,})\b/g, '-$1')
 
-        if (a[p] !== b[p]) return true;
+    // Punctuation which doesn't divide a number following a non-number
+    .replace(/(?<=[0-9])\^/g, '-')
+    .replace(/\^(?![0-9])/g, '-')
 
-        // 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;
+    // General punctuation which always separates surrounding words
+    .replace(/[/@#$%*()_=,[\]{}|\\;:<>?`~]/g, '-')
 
-        return false;
-    }))
-        .map(chunk => ({
-            ...Object.fromEntries(properties.map(p => [p, chunk[0][p]])),
-            chunk
-        }));
-}
+    // Accented characters
+    .replace(/[áâäàå]/gi, 'a')
+    .replace(/[çč]/gi, 'c')
+    .replace(/[éêëè]/gi, 'e')
+    .replace(/[íîïì]/gi, 'i')
+    .replace(/[óôöò]/gi, 'o')
+    .replace(/[úûüù]/gi, 'u')
 
-// Sorting functions
+    // Strip other characters
+    .replace(/[^a-z0-9-]/gi, '')
 
-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;
-}
+    // Combine consecutive dashes
+    .replace(/-{2,}/g, '-')
 
-// 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(({ [dateKey]: a }, { [dateKey]: 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 && b) {
-            return a - b;
-        } else if (a) {
-            return -1;
-        } else if (b) {
-            return 1;
-        } else {
-            // 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;
-        }
-    });
-}
+    // Trim dashes on boundaries
+    .replace(/^-+|-+$/g, '')
 
-// 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));
+    // 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 commentaryRegexRaw =
+  String.raw`^<i>(?<artistReferences>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?<date>[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4}))?\))?`;
+export const commentaryRegexCaseInsensitive =
+  new RegExp(commentaryRegexRaw, 'gmi');
+export const commentaryRegexCaseSensitive =
+  new RegExp(commentaryRegexRaw, 'gm');
+export const commentaryRegexCaseSensitiveOneShot =
+  new RegExp(commentaryRegexRaw);
+
 export function filterAlbumsByCommentary(albums) {
-    return albums.filter(album => [album, ...album.tracks].some(x => x.commentary));
+  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;
-    }
+  // 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');
+  return album.hasTrackNumbers ? 'ol' : 'ul';
 }
 
 // This gets all the track o8jects defined in every al8um, and sorts them 8y
@@ -141,164 +134,269 @@ export function getAlbumListTag(album) {
 // 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));
+  return sortByDate(albumData.flatMap((album) => album.tracks));
 }
 
 export function getArtistNumContributions(artist) {
-    return (
-        (artist.tracksAsAny?.length ?? 0) +
-        (artist.albumsAsCoverArtist?.length ?? 0) +
-        (artist.flashesAsContributor?.length ?? 0)
-    );
-}
-
-export function getArtistCommentary(artist, {justEverythingMan}) {
-    return justEverythingMan.filter(thing =>
-        (thing?.commentary
-            .replace(/<\/?b>/g, '')
-            .includes('<i>' + artist.name + ':</i>')));
+  return (
+    (artist.tracksAsAny?.length ?? 0) +
+    (artist.albumsAsCoverArtist?.length ?? 0) +
+    (artist.flashesAsContributor?.length ?? 0)
+  );
 }
 
 export function getFlashCover(flash, {to}) {
-    return to('media.flashArt', flash.directory, flash.coverArtFileExtension);
+  return to('media.flashArt', flash.directory, flash.coverArtFileExtension);
 }
 
 export function getFlashLink(flash) {
-    return `https://homestuck.com/story/${flash.page}`;
+  return `https://homestuck.com/story/${flash.page}`;
 }
 
-export function getTotalDuration(tracks) {
-    return tracks.reduce((duration, track) => duration + track.duration, 0);
+export function getTotalDuration(tracks, {
+  originalReleasesOnly = false,
+} = {}) {
+  if (originalReleasesOnly) {
+    tracks = tracks.filter(t => !t.originalReleaseTrack);
+  }
+
+  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.hasCoverArt) {
-        return getAlbumCover(track.album, {to});
-    } else {
-        return to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension);
-    }
+  // 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);
+  return to('media.artistAvatar', artist.directory, artist.avatarFileExtension);
 }
 
 // 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.dateAddedToWiki > b.dateAddedToWiki) return -1;
-        if (a.dateAddedToWiki < b.dateAddedToWiki) 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;
+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);
-            }
+  // 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;
         }
 
-        // 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);
-                }
-            }
+        if (empty(entry)) {
+          groupArray.splice(j, 1);
+        } else {
+          j++;
         }
+      }
     }
+  }
 
-    // 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}));
+  return albums;
 }
 
-export function getNewReleases(numReleases, {wikiData}) {
-    const { albumData } = wikiData;
+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(tag => !tag.isContentWarning))
+    .slice(0, maxCarouselLayoutItems + 1);
+}
 
-    const latestFirst = albumData.filter(album => album.isListedOnHomepage).reverse();
-    const majorReleases = latestFirst.filter(album => album.isMajorRelease);
-    majorReleases.splice(1);
+// Ridiculous caching support nonsense
 
-    const otherReleases = latestFirst
-        .filter(album => !majorReleases.includes(album))
-        .slice(0, numReleases - majorReleases.length);
+export class TupleMap {
+  static maxNestedTupleLength = 25;
 
-    return [
-        ...majorReleases.map(album => ({large: true, item: album})),
-        ...otherReleases.map(album => ({large: false, item: album}))
-    ];
+  #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;
+  }
 }
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
new file mode 100644
index 0000000..3d4ecc7
--- /dev/null
+++ b/src/write/bind-utilities.js
@@ -0,0 +1,73 @@
+// 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,
+  cachebust,
+  defaultLanguage,
+  getSizeOfAdditionalFile,
+  getSizeOfImagePath,
+  language,
+  languages,
+  missingImagePaths,
+  pagePath,
+  thumbsCache,
+  to,
+  urls,
+  wikiData,
+}) {
+  const bound = {};
+
+  Object.assign(bound, {
+    absoluteTo,
+    cachebust,
+    defaultLanguage,
+    getSizeOfAdditionalFile,
+    getSizeOfImagePath,
+    getThumbnailsAvailableForDimensions,
+    html,
+    language,
+    languages,
+    missingImagePaths,
+    pagePath,
+    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 0000000..3ae2cfc
--- /dev/null
+++ b/src/write/build-modes/index.js
@@ -0,0 +1,3 @@
+export * as 'live-dev-server' from './live-dev-server.js';
+export * as 'repl' from './repl.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 0000000..24e1832
--- /dev/null
+++ b/src/write/build-modes/live-dev-server.js
@@ -0,0 +1,505 @@
+import {spawn} from 'node:child_process';
+import * as http from 'node:http';
+import {readFile, stat} from 'node:fs/promises';
+import * as path from 'node:path';
+import {inspect as nodeInspect} from 'node:util';
+
+import {ENABLE_COLOR, 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; reacts to changes in data files, so new reloads will be up-to-date with on-disk YAML data (<- not implemented yet, check back soon!)\n\nIntended for local development ONLY; this custom HTTP server is NOT rigorously tested and almost certainly has security flaws`;
+
+export const config = {
+  fileSizes: {
+    default: true,
+  },
+
+  languageReloading: {
+    default: true,
+  },
+
+  mediaValidation: {
+    default: true,
+  },
+
+  thumbs: {
+    default: 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',
+    },
+  };
+}
+
+export async function go({
+  cliOptions,
+  _dataPath,
+  mediaPath,
+  mediaCachePath,
+
+  defaultLanguage,
+  languages,
+  missingImagePaths,
+  srcRootPath,
+  thumbsCache,
+  urls,
+  wikiData,
+
+  cachebust,
+  developersComment: _developersComment,
+  getSizeOfAdditionalFile,
+  getSizeOfImagePath,
+  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));
+
+  let targetSpecPairs = getPageSpecsWithTargets({wikiData});
+  const pages = progressCallAll(`Computing page data & paths for ${targetSpecPairs.length} targets.`,
+    targetSpecPairs.flatMap(({
+      pageSpec,
+      target,
+      targetless,
+    }) => () => {
+      if (targetless) {
+        const result = pageSpec.pathsTargetless({wikiData});
+        return Array.isArray(result) ? result : [result];
+      } else {
+        return pageSpec.pathsForTarget(target);
+      }
+    })).flat();
+
+  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}`);
+      } catch (error) {
+        response.writeHead(500, contentTypeJSON);
+        response.end(`Internal error serializing wiki JSON`);
+        console.error(`${requestHead} [500] ${pathname}`);
+        showError(error);
+      }
+      return;
+    }
+
+    const {
+      area: localFileArea,
+      path: localFilePath
+    } = pathname.match(/^\/(?<area>static|util|media|thumb)\/(?<path>.*)/)?.groups ?? {};
+
+    if (localFileArea) {
+      // Not security tested, man, this is a dev server!!
+      const safePath = path.posix.resolve('/', localFilePath).replace(/^\//, '');
+
+      let localDirectory;
+      if (localFileArea === 'static' || localFileArea === 'util') {
+        localDirectory = path.join(srcRootPath, localFileArea);
+      } else if (localFileArea === 'media') {
+        localDirectory = mediaPath;
+      } else if (localFileArea === 'thumb') {
+        localDirectory = mediaCachePath;
+      }
+
+      let filePath;
+      try {
+        filePath = path.resolve(localDirectory, decodeURI(safePath.split('/').join(path.sep)));
+      } catch (error) {
+        response.writeHead(404, contentTypePlain);
+        response.end(`No ${localFileArea} file found for: ${safePath}`);
+        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(`No ${localFileArea} file found for: ${safePath}`);
+          console.log(`${requestHead} [404] ${pathname}`);
+          console.log(`ENOENT for stat: ${filePath}`);
+        } else {
+          response.writeHead(500, contentTypePlain);
+          response.end(`Internal error accessing ${localFileArea} file for: ${safePath}`);
+          console.error(`${requestHead} [500] ${pathname}`);
+          showError(error);
+        }
+        return;
+      }
+
+      const extname = path.extname(safePath).slice(1).toLowerCase();
+
+      const contentType = {
+        // BRB covering all my bases
+        'aac': 'audio/aac',
+        'bmp': 'image/bmp',
+        'css': 'text/css',
+        'csv': 'text/csv',
+        'gif': 'image/gif',
+        'ico': 'image/vnd.microsoft.icon',
+        'jpg': 'image/jpeg',
+        'jpeg': 'image/jpeg',
+        'js': 'text/javascript',
+        'mjs': 'text/javascript',
+        'mp3': 'audio/mpeg',
+        'mp4': 'video/mp4',
+        'oga': 'audio/ogg',
+        'ogg': 'audio/ogg',
+        'ogv': 'video/ogg',
+        'opus': 'audio/opus',
+        'png': 'image/png',
+        'pdf': 'application/pdf',
+        'svg': 'image/svg+xml',
+        'ttf': 'font/ttf',
+        'txt': 'text/plain',
+        'wav': 'audio/wav',
+        'weba': 'audio/webm',
+        'webm': 'video/webm',
+        'woff': 'font/woff',
+        'woff2': 'font/woff2',
+        'xml': 'application/xml',
+        'zip': 'application/zip',
+      }[extname];
+
+      try {
+        const {size} = await stat(filePath);
+        const buffer = await readFile(filePath)
+        response.writeHead(200, contentType ? {
+          'Content-Type': contentType,
+          'Content-Length': size,
+        } : {});
+        response.end(buffer);
+        if (loudResponses) console.log(`${requestHead} [200] ${pathname}`);
+      } catch (error) {
+        response.writeHead(500, contentTypePlain);
+        response.end(`Failed during file-to-response pipeline`);
+        console.error(`${requestHead} [500] ${pathname}`);
+        showError(error);
+      }
+      return;
+    }
+
+    // Other routes determined by page and URL specs
+
+    // URL to page map expects trailing slash but no leading slash.
+    const pathnameKey = pathname.replace(/^\//, '') + (pathname.endsWith('/') ? '' : '/');
+
+    if (!Object.hasOwn(urlToPageMap, pathnameKey)) {
+      response.writeHead(404, contentTypePlain);
+      response.end(`No page found for: ${pathnameKey}\n`);
+      if (loudResponses) console.log(`${requestHead} [404] ${pathname}`);
+      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 timeStart = Date.now();
+
+      const bound = bindUtilities({
+        absoluteTo,
+        cachebust,
+        defaultLanguage,
+        getSizeOfAdditionalFile,
+        getSizeOfImagePath,
+        language,
+        languages,
+        missingImagePaths,
+        pagePath: servePath,
+        thumbsCache,
+        to,
+        urls,
+        wikiData,
+      });
+
+      const topLevelResult =
+        quickEvaluate({
+          contentDependencies,
+          extraDependencies: {...bound, appendIndexHTML: false},
+
+          name: page.contentFunction.name,
+          args: page.contentFunction.args ?? [],
+        });
+
+      const {pageHTML} = html.resolve(topLevelResult);
+
+      const timeEnd = Date.now();
+      const timeDelta = timeEnd - timeStart;
+
+      if (showTimings) {
+        const timeString =
+          (timeDelta > 100
+            ? `${(timeDelta / 1000).toFixed(2)}s`
+            : `${timeDelta}ms`);
+
+        console.log(`${requestHead} [200, ${timeString}] ${pathname}`);
+      } else if (loudResponses) {
+        console.log(`${requestHead} [200] ${pathname}`);
+      }
+
+      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 address = `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', () => {
+    logInfo`${'All done!'} Listening at: ${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 0000000..2098559
--- /dev/null
+++ b/src/write/build-modes/repl.js
@@ -0,0 +1,156 @@
+export const description = `Provide command-line interactive access to wiki data objects`;
+
+export const config = {
+  fileSizes: {
+    default: 'skip',
+  },
+
+  languageReloading: {
+    default: true,
+  },
+
+  mediaValidation: {
+    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 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,
+
+  defaultLanguage,
+  languages,
+  missingImagePaths,
+  thumbsCache,
+  urls,
+  wikiData,
+
+  getSizeOfAdditionalFile,
+  getSizeOfImagePath,
+  niceShowAggregate,
+}) {
+  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`;
+  }
+
+  const replContext = {
+    dataPath,
+    mediaPath,
+    mediaCachePath,
+
+    languages,
+    defaultLanguage,
+    language: defaultLanguage,
+
+    missingImagePaths,
+    thumbsCache,
+    urls,
+
+    wikiData,
+    ...wikiData,
+    WD: wikiData,
+
+    ...thingConstructors,
+    CacheableObject,
+    debugComposite,
+
+    ...sort,
+    ...sugar,
+    ...wikiDataUtils,
+
+    serialize,
+    S: serialize,
+
+    _find,
+    find,
+    bindFind,
+
+    getSizeOfAdditionalFile,
+    getSizeOfImagePath,
+    showAggregate: niceShowAggregate,
+  };
+
+  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/static-build.js b/src/write/build-modes/static-build.js
new file mode 100644
index 0000000..a355a00
--- /dev/null
+++ b/src/write/build-modes/static-build.js
@@ -0,0 +1,507 @@
+import * as path from 'node:path';
+
+import {
+  copyFile,
+  mkdir,
+  stat,
+  symlink,
+  writeFile,
+  unlink,
+} from 'node:fs/promises';
+
+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 {
+  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: true,
+  },
+
+  languageReloading: {
+    applicable: false,
+  },
+
+  mediaValidation: {
+    default: true,
+  },
+
+  thumbs: {
+    default: 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,
+  _dataPath,
+  mediaPath,
+  mediaCachePath,
+  queueSize,
+
+  defaultLanguage,
+  languages,
+  missingImagePaths,
+  srcRootPath,
+  thumbsCache,
+  urls,
+  wikiData,
+
+  cachebust,
+  developersComment: _developersComment,
+  getSizeOfAdditionalFile,
+  getSizeOfImagePath,
+  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 writeSymlinks({
+    srcRootPath,
+    mediaPath,
+    mediaCachePath,
+    outputPath,
+    urls,
+  });
+
+  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;
+        }
+
+        const 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
+        }
+
+        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 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({
+          absoluteTo,
+          cachebust,
+          defaultLanguage,
+          getSizeOfAdditionalFile,
+          getSizeOfImagePath,
+          language,
+          languages,
+          missingImagePaths,
+          pagePath,
+          thumbsCache,
+          to,
+          urls,
+          wikiData,
+        });
+
+        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 writeSymlinks({
+  srcRootPath,
+  mediaPath,
+  mediaCachePath,
+  outputPath,
+  urls,
+}) {
+  return progressPromiseAll('Writing site symlinks.', [
+    link(path.join(srcRootPath, 'util'), 'shared.utilityRoot'),
+    link(path.join(srcRootPath, 'static'), 'shared.staticRoot'),
+    link(mediaPath, 'media.root'),
+    link(mediaCachePath, 'thumb.root'),
+  ]);
+
+  async function link(directory, urlKey) {
+    const pathname = urls.from('shared.root').toDevice(urlKey);
+    const file = path.join(outputPath, pathname);
+
+    try {
+      await unlink(file);
+    } catch (error) {
+      if (error.code !== 'ENOENT') {
+        throw error;
+      }
+    }
+
+    try {
+      await symlink(path.resolve(directory), file);
+    } catch (error) {
+      if (error.code === 'EPERM') {
+        await symlink(path.resolve(directory), file, 'junction');
+      } else {
+        throw error;
+      }
+    }
+  }
+}
+
+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 0000000..c9824a4
--- /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)),
+  });
+}
diff --git a/tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs b/tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs
new file mode 100644
index 0000000..e166140
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateAdditionalFilesShortcut.js.test.cjs
@@ -0,0 +1,14 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateAdditionalFilesShortcut.js > TAP > generateAdditionalFilesShortcut (snapshot) > basic behavior 1`] = `
+View <a href="#additional-files">additional files</a>: SBURB Wallpaper, Alternate Covers
+`
+
+exports[`test/snapshot/generateAdditionalFilesShortcut.js > TAP > generateAdditionalFilesShortcut (snapshot) > no additional files 1`] = `
+
+`
diff --git a/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs
new file mode 100644
index 0000000..d8f1e97
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateAlbumAdditionalFilesList.js.test.cjs
@@ -0,0 +1,56 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateAlbumAdditionalFilesList.js > TAP > generateAlbumAdditionalFilesList (snapshot) > basic behavior 1`] = `
+<ul class="additional-files-list">
+    <li>
+        <details>
+            <summary><span><span class="group-name">SBURB Wallpaper</span></span></summary>
+            <ul>
+                <li><a href="media/album-additional/exciting-album/sburbwp_1280x1024.jpg">sburbwp_1280x1024.jpg</a></li>
+                <li><a href="media/album-additional/exciting-album/sburbwp_1440x900.jpg">sburbwp_1440x900.jpg</a></li>
+                <li><a href="media/album-additional/exciting-album/sburbwp_1920x1080.jpg">sburbwp_1920x1080.jpg</a></li>
+            </ul>
+        </details>
+    </li>
+    <li>
+        <details>
+            <summary><span><span class="group-name">Fake Section</span></span></summary>
+            <ul>
+                <li class="entry-description">No sizes for these files</li>
+                <li><a href="media/album-additional/exciting-album/oops.mp3">oops.mp3</a></li>
+                <li><a href="media/album-additional/exciting-album/Internet%20Explorer.gif">Internet Explorer.gif</a></li>
+                <li><a href="media/album-additional/exciting-album/daisy.mp3">daisy.mp3</a></li>
+            </ul>
+        </details>
+    </li>
+    <li>
+        <details open>
+            <summary><span><span class="group-name">Empty Section</span></span></summary>
+            <ul>
+                <li class="entry-description">These files haven&#39;t been made available.</li>
+                <li>There are no files available or listed for this entry.</li>
+            </ul>
+        </details>
+    </li>
+    <li>
+        <details>
+            <summary><span><span class="group-name">Alternate Covers</span></span></summary>
+            <ul>
+                <li class="entry-description">This is just an example description.</li>
+                <li><a href="media/album-additional/exciting-album/Homestuck_Vol4_alt1.jpg">Homestuck_Vol4_alt1.jpg</a></li>
+                <li><a href="media/album-additional/exciting-album/Homestuck_Vol4_alt2.jpg">Homestuck_Vol4_alt2.jpg</a></li>
+                <li><a href="media/album-additional/exciting-album/Homestuck_Vol4_alt3.jpg">Homestuck_Vol4_alt3.jpg</a></li>
+            </ul>
+        </details>
+    </li>
+</ul>
+`
+
+exports[`test/snapshot/generateAlbumAdditionalFilesList.js > TAP > generateAlbumAdditionalFilesList (snapshot) > no additional files 1`] = `
+<ul class="additional-files-list"></ul>
+`
diff --git a/tap-snapshots/test/snapshot/generateAlbumBanner.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumBanner.js.test.cjs
new file mode 100644
index 0000000..b23df5c
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateAlbumBanner.js.test.cjs
@@ -0,0 +1,18 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateAlbumBanner.js > TAP > generateAlbumBanner (snapshot) > basic behavior 1`] = `
+<div id="banner"><img src="media/album-art/cool-album/banner.png" width="800" height="200" alt="album banner"></div>
+`
+
+exports[`test/snapshot/generateAlbumBanner.js > TAP > generateAlbumBanner (snapshot) > no banner 1`] = `
+
+`
+
+exports[`test/snapshot/generateAlbumBanner.js > TAP > generateAlbumBanner (snapshot) > no dimensions 1`] = `
+<div id="banner"><img src="media/album-art/cool-album/banner.png" width="1100" height="200" alt="album banner"></div>
+`
diff --git a/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs
new file mode 100644
index 0000000..71d9c55
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs
@@ -0,0 +1,37 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateAlbumCoverArtwork.js > TAP > generateAlbumCoverArtwork (snapshot) > display: primary 1`] = `
+[mocked: image
+ args: [
+   [
+     { name: 'Damara', directory: 'damara', isContentWarning: false },
+     { name: 'Cronus', directory: 'cronus', isContentWarning: false },
+     { name: 'Bees', directory: 'bees', isContentWarning: false },
+     { name: 'creepy crawlies', isContentWarning: true }
+   ]
+ ]
+ slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#f28514', thumb: 'medium', reveal: true, link: true, square: true }]
+<ul class="image-details">
+    <li><a href="tag/damara/">Damara</a></li>
+    <li><a href="tag/cronus/">Cronus</a></li>
+    <li><a href="tag/bees/">Bees</a></li>
+</ul>
+`
+
+exports[`test/snapshot/generateAlbumCoverArtwork.js > TAP > generateAlbumCoverArtwork (snapshot) > display: thumbnail 1`] = `
+[mocked: image
+ args: [
+   [
+     { name: 'Damara', directory: 'damara', isContentWarning: false },
+     { name: 'Cronus', directory: 'cronus', isContentWarning: false },
+     { name: 'Bees', directory: 'bees', isContentWarning: false },
+     { name: 'creepy crawlies', isContentWarning: true }
+   ]
+ ]
+ slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#f28514', thumb: 'small', reveal: false, link: false, square: true }]
+`
diff --git a/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
new file mode 100644
index 0000000..f9fc025
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateAlbumReleaseInfo.js.test.cjs
@@ -0,0 +1,40 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > basic behavior 1`] = `
+<p>
+    By <span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (music probably)</span> and <span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/tensei/">Tensei</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://tenseimusic.bandcamp.com/">
+                        <svg><use href="static/icons.svg#icon-bandcamp"></use></svg>
+                        <span class="icon-text">tenseimusic</span>
+                    </a></span></span></span> (hot jams)</span>.
+    <br>
+    Cover art by <a href="artist/hb/">Hanni Brosh</a>.
+    <br>
+    Wallpaper art by <a href="artist/hb/">Hanni Brosh</a> and <span class="contribution nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
+    <br>
+    Banner art by <a href="artist/hb/">Hanni Brosh</a> and <span class="contribution nowrap"><a href="artist/niklink/">Niklink</a> (edits)</span>.
+    <br>
+    Released 3/14/2011.
+    <br>
+    Art released 4/1/1991.
+    <br>
+    Duration: ~10:25.
+</p>
+<p>Listen on <a href="https://homestuck.bandcamp.com/album/alterniabound-with-alternia" class="nowrap">Bandcamp</a>, <a href="https://www.youtube.com/playlist?list=PLnVpmehyaOFZWO9QOZmD6A3TIK0wZ6xE2" class="nowrap">YouTube (playlist)</a>, or <a href="https://www.youtube.com/watch?v=HO5V2uogkYc" class="nowrap">YouTube (full album)</a>.</p>
+`
+
+exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > equal cover art date 1`] = `
+<p>Released 4/12/2020.</p>
+`
+
+exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > reduced details 1`] = `
+
+`
+
+exports[`test/snapshot/generateAlbumReleaseInfo.js > TAP > generateAlbumReleaseInfo (snapshot) > URLs only 1`] = `
+<p>Listen on <a href="https://homestuck.bandcamp.com/foo" class="nowrap">Bandcamp</a> or <a href="https://soundcloud.com/bar" class="nowrap">SoundCloud</a>.</p>
+`
diff --git a/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs
new file mode 100644
index 0000000..f2b51cb
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs
@@ -0,0 +1,33 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateAlbumSecondaryNav.js > TAP > generateAlbumSecondaryNav (snapshot) > basic behavior, mode: album 1`] = `
+<nav id="secondary-nav" class="nav-links-groups">
+    <span style="--primary-color: #abcdef">
+        <a href="group/vcg/">VCG</a>
+        (<a title="First" href="album/first/">Previous</a>, <a title="Last" href="album/last/">Next</a>)
+    </span>
+    <span style="--primary-color: #123456">
+        <a href="group/bepis/">Bepis</a>
+        (<a title="Second" href="album/second/">Next</a>)
+    </span>
+</nav>
+`
+
+exports[`test/snapshot/generateAlbumSecondaryNav.js > TAP > generateAlbumSecondaryNav (snapshot) > basic behavior, mode: track 1`] = `
+<nav id="secondary-nav" class="nav-links-groups">
+    <a style="--primary-color: #abcdef" href="group/vcg/">VCG</a>
+    <a style="--primary-color: #123456" href="group/bepis/">Bepis</a>
+</nav>
+`
+
+exports[`test/snapshot/generateAlbumSecondaryNav.js > TAP > generateAlbumSecondaryNav (snapshot) > dateless album in mixed group 1`] = `
+<nav id="secondary-nav" class="nav-links-groups">
+    <a style="--primary-color: #abcdef" href="group/vcg/">VCG</a>
+    <a style="--primary-color: #123456" href="group/bepis/">Bepis</a>
+</nav>
+`
diff --git a/tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs
new file mode 100644
index 0000000..0b7a0f7
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateAlbumSidebarGroupBox.js.test.cjs
@@ -0,0 +1,31 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateAlbumSidebarGroupBox.js > TAP > generateAlbumSidebarGroupBox (snapshot) > basic behavior, mode: album 1`] = `
+<div class="sidebar individual-group-sidebar-box">
+    <h1><a href="group/vcg/">VCG</a></h1>
+    Very cool group.
+    <p>Visit on <a class="external-link" href="https://vcg.bandcamp.com/">Bandcamp</a> or <a class="external-link" href="https://youtube.com/@vcg">YouTube</a>.</p>
+    <p class="group-chronology-link">Next: <a href="album/last/">Last</a></p>
+    <p class="group-chronology-link">Previous: <a href="album/first/">First</a></p>
+</div>
+`
+
+exports[`test/snapshot/generateAlbumSidebarGroupBox.js > TAP > generateAlbumSidebarGroupBox (snapshot) > basic behavior, mode: track 1`] = `
+<div class="sidebar individual-group-sidebar-box">
+    <h1><a href="group/vcg/">VCG</a></h1>
+    <p>Visit on <a class="external-link" href="https://vcg.bandcamp.com/">Bandcamp</a> or <a class="external-link" href="https://youtube.com/@vcg">YouTube</a>.</p>
+</div>
+`
+
+exports[`test/snapshot/generateAlbumSidebarGroupBox.js > TAP > generateAlbumSidebarGroupBox (snapshot) > dateless album in mixed group 1`] = `
+<div class="sidebar individual-group-sidebar-box">
+    <h1><a href="group/vcg/">VCG</a></h1>
+    Very cool group.
+    <p>Visit on <a class="external-link" href="https://vcg.bandcamp.com/">Bandcamp</a> or <a class="external-link" href="https://youtube.com/@vcg">YouTube</a>.</p>
+</div>
+`
diff --git a/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
new file mode 100644
index 0000000..3b6676f
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateAlbumTrackList.js.test.cjs
@@ -0,0 +1,130 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > basic behavior, default track section 1`] = `
+<ul>
+    <li>(0:20) <a href="track/t1/">Track 1</a></li>
+    <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
+    <li>(0:40) <a href="track/t3/">Track 3</a></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+</ul>
+`
+
+exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > basic behavior, with track sections 1`] = `
+<dl class="album-group-list">
+    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">First section: (~1:00)</span></dt>
+    <dd>
+        <ul>
+            <li>(0:20) <a href="track/t1/">Track 1</a></li>
+            <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
+            <li>(0:40) <a href="track/t3/">Track 3</a></li>
+        </ul>
+    </dd>
+    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
+    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+</dl>
+`
+
+exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > collapseDurationScope: album 1`] = `
+<dl class="album-group-list">
+    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">First section: (~1:00)</span></dt>
+    <dd>
+        <ul>
+            <li>(0:20) <a href="track/t1/">Track 1</a></li>
+            <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
+            <li>(0:40) <a href="track/t3/">Track 3</a></li>
+        </ul>
+    </dd>
+    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
+    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+</dl>
+<ul>
+    <li>(0:20) <a href="track/t1/">Track 1</a></li>
+    <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
+    <li>(0:40) <a href="track/t3/">Track 3</a></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+</ul>
+<ul>
+    <li><a href="track/t2/">Track 2</a></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+</ul>
+`
+
+exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > collapseDurationScope: never 1`] = `
+<dl class="album-group-list">
+    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">First section: (~1:00)</span></dt>
+    <dd>
+        <ul>
+            <li>(0:20) <a href="track/t1/">Track 1</a></li>
+            <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
+            <li>(0:40) <a href="track/t3/">Track 3</a></li>
+        </ul>
+    </dd>
+    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
+    <dd><ul><li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+</dl>
+<ul>
+    <li>(0:20) <a href="track/t1/">Track 1</a></li>
+    <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
+    <li>(0:40) <a href="track/t3/">Track 3</a></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+</ul>
+<ul>
+    <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+</ul>
+`
+
+exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > collapseDurationScope: section 1`] = `
+<dl class="album-group-list">
+    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">First section: (~1:00)</span></dt>
+    <dd>
+        <ul>
+            <li>(0:20) <a href="track/t1/">Track 1</a></li>
+            <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
+            <li>(0:40) <a href="track/t3/">Track 3</a></li>
+        </ul>
+    </dd>
+    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
+    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+</dl>
+<ul>
+    <li>(0:20) <a href="track/t1/">Track 1</a></li>
+    <li>[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t2/">Track 2</a></li>
+    <li>(0:40) <a href="track/t3/">Track 3</a></li>
+    <li style="--primary-color: #ea2e83">[mocked: generateAlbumTrackListMissingDuration - slots: {}] <a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+</ul>
+<ul>
+    <li><a href="track/t2/">Track 2</a></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+</ul>
+`
+
+exports[`test/snapshot/generateAlbumTrackList.js > TAP > generateAlbumTrackList (snapshot) > collapseDurationScope: track 1`] = `
+<dl class="album-group-list">
+    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">First section: (~1:00)</span></dt>
+    <dd>
+        <ul>
+            <li>(0:20) <a href="track/t1/">Track 1</a></li>
+            <li><a href="track/t2/">Track 2</a></li>
+            <li>(0:40) <a href="track/t3/">Track 3</a></li>
+        </ul>
+    </dd>
+    <dt class="content-heading" tabindex="0"><span class="content-heading-main-title">Second section:</span></dt>
+    <dd><ul><li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li></ul></dd>
+</dl>
+<ul>
+    <li>(0:20) <a href="track/t1/">Track 1</a></li>
+    <li><a href="track/t2/">Track 2</a></li>
+    <li>(0:40) <a href="track/t3/">Track 3</a></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+</ul>
+<ul>
+    <li><a href="track/t2/">Track 2</a></li>
+    <li style="--primary-color: #ea2e83"><a href="track/t4/">Track 4</a> <span class="by">by <a href="artist/apricot/">Apricot</a> and <a href="artist/peach/">Peach</a></span></li>
+</ul>
+`
diff --git a/tap-snapshots/test/snapshot/generateBanner.js.test.cjs b/tap-snapshots/test/snapshot/generateBanner.js.test.cjs
new file mode 100644
index 0000000..870097c
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateBanner.js.test.cjs
@@ -0,0 +1,14 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateBanner.js > TAP > generateBanner (snapshot) > basic behavior 1`] = `
+<div id="banner"><img src="media/album-art/cool-album/banner.png" width="800" height="200" alt="Very cool banner art."></div>
+`
+
+exports[`test/snapshot/generateBanner.js > TAP > generateBanner (snapshot) > no dimensions 1`] = `
+<div id="banner"><img src="media/album-art/cool-album/banner.png" width="1100" height="200"></div>
+`
diff --git a/tap-snapshots/test/snapshot/generateCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateCoverArtwork.js.test.cjs
new file mode 100644
index 0000000..f16923d
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateCoverArtwork.js.test.cjs
@@ -0,0 +1,37 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateCoverArtwork.js > TAP > generateCoverArtwork (snapshot) > display: primary 1`] = `
+[mocked: image
+ args: [
+   [
+     { name: 'Damara', directory: 'damara', isContentWarning: false },
+     { name: 'Cronus', directory: 'cronus', isContentWarning: false },
+     { name: 'Bees', directory: 'bees', isContentWarning: false },
+     { name: 'creepy crawlies', isContentWarning: true }
+   ]
+ ]
+ slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'medium', reveal: true, link: true, square: true }]
+<ul class="image-details">
+    <li><a href="tag/damara/">Damara</a></li>
+    <li><a href="tag/cronus/">Cronus</a></li>
+    <li><a href="tag/bees/">Bees</a></li>
+</ul>
+`
+
+exports[`test/snapshot/generateCoverArtwork.js > TAP > generateCoverArtwork (snapshot) > display: thumbnail 1`] = `
+[mocked: image
+ args: [
+   [
+     { name: 'Damara', directory: 'damara', isContentWarning: false },
+     { name: 'Cronus', directory: 'cronus', isContentWarning: false },
+     { name: 'Bees', directory: 'bees', isContentWarning: false },
+     { name: 'creepy crawlies', isContentWarning: true }
+   ]
+ ]
+ slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'small', reveal: false, link: false, square: true }]
+`
diff --git a/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs b/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs
new file mode 100644
index 0000000..b2c4c64
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs
@@ -0,0 +1,28 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generatePreviousNextLinks.js > TAP > generatePreviousNextLinks (snapshot) > basic behavior 1`] = `
+previous: { tooltipStyle: 'browser', color: false, attributes: { id: 'previous-button' }, content: Tag (no name, 1 items) }
+next: { tooltipStyle: 'browser', color: false, attributes: { id: 'next-button' }, content: Tag (no name, 1 items) }
+`
+
+exports[`test/snapshot/generatePreviousNextLinks.js > TAP > generatePreviousNextLinks (snapshot) > disable id 1`] = `
+previous: { tooltipStyle: 'browser', color: false, attributes: { id: false }, content: Tag (no name, 1 items) }
+next: { tooltipStyle: 'browser', color: false, attributes: { id: false }, content: Tag (no name, 1 items) }
+`
+
+exports[`test/snapshot/generatePreviousNextLinks.js > TAP > generatePreviousNextLinks (snapshot) > neither link present 1`] = `
+
+`
+
+exports[`test/snapshot/generatePreviousNextLinks.js > TAP > generatePreviousNextLinks (snapshot) > next missing 1`] = `
+previous: { tooltipStyle: 'browser', color: false, attributes: { id: 'previous-button' }, content: Tag (no name, 1 items) }
+`
+
+exports[`test/snapshot/generatePreviousNextLinks.js > TAP > generatePreviousNextLinks (snapshot) > previous missing 1`] = `
+next: { tooltipStyle: 'browser', color: false, attributes: { id: 'next-button' }, content: Tag (no name, 1 items) }
+`
diff --git a/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs
new file mode 100644
index 0000000..3a22266
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs
@@ -0,0 +1,99 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > inferred additional names only 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Baz Baz', from: [ { directory: 'the-pyrenees' } ] }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > multiple own 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Apple Time!' },
+     { name: 'Pterodactyl Time!' },
+     { name: 'Banana Time!' }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > no additional names 1`] = `
+
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own additional names only 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [ [ { name: 'Foo Bar', annotation: 'the Alps' } ] ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and inferred, some overlap 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Ke$halo Strike Back', annotation: 'own annotation' },
+     { name: 'Ironic Mania', annotation: 'own annotation' },
+     {
+       name: 'ANARCHY::MEGASTRIFE',
+       from: [ { directory: 'inferred-from' } ]
+     }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and shared and inferred, various overlap 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Own!', annotation: 'own annotation' },
+     { name: 'Own! Shared!', annotation: 'own annotation' },
+     { name: 'Own! Inferred!', annotation: 'own annotation' },
+     { name: 'Own! Shared! Inferred!', annotation: 'own annotation' },
+     { name: 'Shared!', annotation: 'shared annotation' },
+     { name: 'Shared! Inferred!', annotation: 'shared annotation' },
+     { name: 'Inferred!', from: [ { directory: 'inferred-from' } ] }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and shared, some overlap 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'weed dreams..', annotation: 'own annotation' },
+     { name: '夜間のMOON汗', annotation: 'own annotation' },
+     { name: 'GAMINGブラザー96', annotation: 'shared annotation' }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > shared additional names only 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [ [ { name: 'Bar Foo', annotation: 'the Rockies' } ] ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > shared and inferred, some overlap 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Coruscate', annotation: 'shared annotation' },
+     { name: 'Arbroath', annotation: 'shared annotation' },
+     { name: 'Prana Ferox', from: [ { directory: 'inferred-from' } ] }
+   ]
+ ]
+ slots: {}]
+`
diff --git a/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs
new file mode 100644
index 0000000..1d21e47
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs
@@ -0,0 +1,50 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverArtwork (snapshot) > display: primary - no unique art 1`] = `
+[mocked: image
+ args: [
+   [
+     { name: 'Damara', directory: 'damara', isContentWarning: false },
+     { name: 'Cronus', directory: 'cronus', isContentWarning: false },
+     { name: 'Bees', directory: 'bees', isContentWarning: false },
+     { name: 'creepy crawlies', isContentWarning: true }
+   ]
+ ]
+ slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#abcdef', thumb: 'medium', reveal: true, link: true, square: true }]
+<ul class="image-details">
+    <li><a href="tag/damara/">Damara</a></li>
+    <li><a href="tag/cronus/">Cronus</a></li>
+    <li><a href="tag/bees/">Bees</a></li>
+</ul>
+`
+
+exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverArtwork (snapshot) > display: primary - unique art 1`] = `
+[mocked: image
+ args: [ [ { name: 'Bees', directory: 'bees', isContentWarning: false } ] ]
+ slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], color: '#f28514', thumb: 'medium', reveal: true, link: true, square: true }]
+<ul class="image-details"><li><a href="tag/bees/">Bees</a></li></ul>
+`
+
+exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverArtwork (snapshot) > display: thumbnail - no unique art 1`] = `
+[mocked: image
+ args: [
+   [
+     { name: 'Damara', directory: 'damara', isContentWarning: false },
+     { name: 'Cronus', directory: 'cronus', isContentWarning: false },
+     { name: 'Bees', directory: 'bees', isContentWarning: false },
+     { name: 'creepy crawlies', isContentWarning: true }
+   ]
+ ]
+ slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], color: '#abcdef', thumb: 'small', reveal: false, link: false, square: true }]
+`
+
+exports[`test/snapshot/generateTrackCoverArtwork.js > TAP > generateTrackCoverArtwork (snapshot) > display: thumbnail - unique art 1`] = `
+[mocked: image
+ args: [ [ { name: 'Bees', directory: 'bees', isContentWarning: false } ] ]
+ slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], color: '#f28514', thumb: 'small', reveal: false, link: false, square: true }]
+`
diff --git a/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
new file mode 100644
index 0000000..3d988dc
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateTrackReleaseInfo.js.test.cjs
@@ -0,0 +1,36 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseInfo (snapshot) > basic behavior 1`] = `
+<p>
+    By <a href="artist/toby-fox/">Toby Fox</a>.
+    <br>
+    Released 11/29/2011.
+    <br>
+    Duration: 0:58.
+</p>
+<p>Listen on <a href="https://soundcloud.com/foo" class="nowrap">SoundCloud</a> or <a href="https://youtube.com/watch?v=bar" class="nowrap">YouTube</a>.</p>
+`
+
+exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseInfo (snapshot) > cover artist contribs, non-unique 1`] = `
+<p>By <a href="artist/toby-fox/">Toby Fox</a>.</p>
+<p>This wiki doesn't have any listening links for <i>Suspicious Track</i>.</p>
+`
+
+exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseInfo (snapshot) > cover artist contribs, unique 1`] = `
+<p>
+    By <a href="artist/toby-fox/">Toby Fox</a>.
+    <br>
+    Cover art by <span class="contribution nowrap"><a href="artist/alpaca/">Alpaca</a> (&#x1F525;)</span>.
+</p>
+<p>This wiki doesn't have any listening links for <i>Suspicious Track</i>.</p>
+`
+
+exports[`test/snapshot/generateTrackReleaseInfo.js > TAP > generateTrackReleaseInfo (snapshot) > reduced details 1`] = `
+<p>By <a href="artist/toby-fox/">Toby Fox</a>.</p>
+<p>This wiki doesn't have any listening links for <i>Suspicious Track</i>.</p>
+`
diff --git a/tap-snapshots/test/snapshot/image.js.test.cjs b/tap-snapshots/test/snapshot/image.js.test.cjs
new file mode 100644
index 0000000..ddfb3e6
--- /dev/null
+++ b/tap-snapshots/test/snapshot/image.js.test.cjs
@@ -0,0 +1,70 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/image.js > TAP > image (snapshot) > content warnings via tags 1`] = `
+<div class="image-container reveal">
+    <div class="image-outer-area">
+        <div class="image-inner-area">
+            <img class="image" src="media/album-art/beyond-canon/cover.png">
+            <span class="reveal-text-container">
+                <span class="reveal-text">
+                    <img class="reveal-symbol" src="static/warning.svg?413">
+                    <br>
+                    <span class="reveal-warnings">too cool for school</span>
+                    <br>
+                    <span class="reveal-interaction">click to show</span>
+                </span>
+            </span>
+        </div>
+    </div>
+</div>
+`
+
+exports[`test/snapshot/image.js > TAP > image (snapshot) > lazy with square 1`] = `
+<noscript><div class="image-container square"><div class="image-outer-area square-content"><div class="image-inner-area"><img class="image" src="foobar"></div></div></div></noscript>
+<div class="image-container square js-hide"><div class="image-outer-area square-content"><div class="image-inner-area"><img class="image lazy" data-original="foobar"></div></div></div>
+`
+
+exports[`test/snapshot/image.js > TAP > image (snapshot) > link with file size 1`] = `
+<div class="image-container has-link"><div class="image-outer-area"><a class="image-link" href="media/album-art/pingas/cover.png"><div class="image-inner-area"><img class="image" src="media/album-art/pingas/cover.png"></div></a></div></div>
+`
+
+exports[`test/snapshot/image.js > TAP > image (snapshot) > missing image path 1`] = `
+<div class="image-container placeholder-image"><div class="image-outer-area"><div class="image-inner-area"><div class="image-text-area">(This image file is missing)</div></div></div></div>
+`
+
+exports[`test/snapshot/image.js > TAP > image (snapshot) > missing image path w/ missingSourceContent 1`] = `
+<div class="image-container placeholder-image"><div class="image-outer-area"><div class="image-inner-area"><div class="image-text-area">Cover's missing, whoops</div></div></div></div>
+`
+
+exports[`test/snapshot/image.js > TAP > image (snapshot) > source missing 1`] = `
+<div class="image-container placeholder-image"><div class="image-outer-area"><div class="image-inner-area"><div class="image-text-area">Example of missing source message.</div></div></div></div>
+`
+
+exports[`test/snapshot/image.js > TAP > image (snapshot) > source via path 1`] = `
+<div class="image-container"><div class="image-outer-area"><div class="image-inner-area"><img class="image" src="media/album-art/beyond-canon/cover.png"></div></div></div>
+`
+
+exports[`test/snapshot/image.js > TAP > image (snapshot) > source via src 1`] = `
+<div class="image-container"><div class="image-outer-area"><div class="image-inner-area"><img class="image" src="https://example.com/bananas.gif"></div></div></div>
+`
+
+exports[`test/snapshot/image.js > TAP > image (snapshot) > square 1`] = `
+<div class="image-container square"><div class="image-outer-area square-content"><div class="image-inner-area"><img class="image" src="foobar"></div></div></div>
+`
+
+exports[`test/snapshot/image.js > TAP > image (snapshot) > thumb requested but source is gif 1`] = `
+<div class="image-container"><div class="image-outer-area"><div class="image-inner-area"><img class="image" src="media/flash-art/5426.gif"></div></div></div>
+`
+
+exports[`test/snapshot/image.js > TAP > image (snapshot) > thumbnail details 1`] = `
+<div class="image-container"><div class="image-outer-area"><div class="image-inner-area"><img class="image" data-original-length="1200" data-thumbs="voluminous:1200 middling:900 petite:20" src="thumb/album-art/beyond-canon/cover.voluminous.jpg"></div></div></div>
+`
+
+exports[`test/snapshot/image.js > TAP > image (snapshot) > width & height 1`] = `
+<div class="image-container"><div class="image-outer-area"><div class="image-inner-area"><img class="image" width="600" height="400" src="foobar"></div></div></div>
+`
diff --git a/tap-snapshots/test/snapshot/linkArtist.js.test.cjs b/tap-snapshots/test/snapshot/linkArtist.js.test.cjs
new file mode 100644
index 0000000..ae3677c
--- /dev/null
+++ b/tap-snapshots/test/snapshot/linkArtist.js.test.cjs
@@ -0,0 +1,14 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/linkArtist.js > TAP > linkArtist (snapshot) > basic behavior 1`] = `
+<a href="artist/toby-fox/">Toby Fox</a>
+`
+
+exports[`test/snapshot/linkArtist.js > TAP > linkArtist (snapshot) > prefer short name 1`] = `
+<span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/55gore/">55gore</a></span><span class="tooltip thing-name-tooltip"><span class="tooltip-content">ICCTTCMDMIROTMCWMWFTPFTDDOTARHPOESWGBTWEATFCWSEBTSSFOFG</span></span></span>
+`
diff --git a/tap-snapshots/test/snapshot/linkContribution.js.test.cjs b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
new file mode 100644
index 0000000..20f5adc
--- /dev/null
+++ b/tap-snapshots/test/snapshot/linkContribution.js.test.cjs
@@ -0,0 +1,163 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (inline) 1`] = `
+<span class="contribution nowrap"><a href="artist/lorem-ipsum-lover/">Lorem Ipsum Lover</a> (<span class="icons icons-inline"><a class="icon" href="https://loremipsum.io">
+            <svg>
+                <title>External (loremipsum.io)</title>
+                <use href="static/icons.svg#icon-globe"></use>
+            </svg>
+        </a>, <a class="icon" href="https://loremipsum.io/generator/">
+            <svg>
+                <title>External (loremipsum.io)</title>
+                <use href="static/icons.svg#icon-globe"></use>
+            </svg>
+        </a>, <a class="icon" href="https://loremipsum.io/#meaning">
+            <svg>
+                <title>External (loremipsum.io)</title>
+                <use href="static/icons.svg#icon-globe"></use>
+            </svg>
+        </a>, <a class="icon" href="https://loremipsum.io/#usage-and-examples">
+            <svg>
+                <title>External (loremipsum.io)</title>
+                <use href="static/icons.svg#icon-globe"></use>
+            </svg>
+        </a></span>)</span>
+`
+
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > loads of links (tooltip) 1`] = `
+<span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/lorem-ipsum-lover/">Lorem Ipsum Lover</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://loremipsum.io">
+                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                    <span class="icon-text">loremipsum.io</span>
+                </a><a class="icon has-text" href="https://loremipsum.io/generator/">
+                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                    <span class="icon-text">loremipsum.io</span>
+                </a><a class="icon has-text" href="https://loremipsum.io/#meaning">
+                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                    <span class="icon-text">loremipsum.io</span>
+                </a><a class="icon has-text" href="https://loremipsum.io/#usage-and-examples">
+                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                    <span class="icon-text">loremipsum.io</span>
+                </a><a class="icon has-text" href="https://loremipsum.io/#controversy">
+                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                    <span class="icon-text">loremipsum.io</span>
+                </a><a class="icon has-text" href="https://loremipsum.io/#when-to-use-lorem-ipsum">
+                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                    <span class="icon-text">loremipsum.io</span>
+                </a><a class="icon has-text" href="https://loremipsum.io/#lorem-ipsum-all-the-things">
+                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                    <span class="icon-text">loremipsum.io</span>
+                </a><a class="icon has-text" href="https://loremipsum.io/#original-source">
+                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                    <span class="icon-text">loremipsum.io</span>
+                </a></span></span></span></span>
+`
+
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > no accents 1`] = `
+<a href="artist/clark-powell/">Clark Powell</a>
+<a href="artist/the-big-baddies/">Grounder &amp; Scratch</a>
+<a href="artist/toby-fox/">Toby Fox</a>
+`
+
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > no preventWrapping 1`] = `
+<span class="contribution"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons icons-inline"><a class="icon" href="https://soundcloud.com/plazmataz">
+            <svg>
+                <title>SoundCloud</title>
+                <use href="static/icons.svg#icon-soundcloud"></use>
+            </svg>
+        </a></span>)</span>
+<span class="contribution"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
+<span class="contribution"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement) (<span class="icons icons-inline"><a class="icon" href="https://tobyfox.bandcamp.com/">
+            <svg>
+                <title>Bandcamp</title>
+                <use href="static/icons.svg#icon-bandcamp"></use>
+            </svg>
+        </a>, <a class="icon" href="https://toby.fox/">
+            <svg>
+                <title>External (toby.fox)</title>
+                <use href="static/icons.svg#icon-globe"></use>
+            </svg>
+        </a></span>)</span>
+`
+
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showContribution 1`] = `
+<a href="artist/clark-powell/">Clark Powell</a>
+<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
+<span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement)</span>
+`
+
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (inline) 1`] = `
+<span class="contribution nowrap"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons icons-inline"><a class="icon" href="https://soundcloud.com/plazmataz">
+            <svg>
+                <title>SoundCloud</title>
+                <use href="static/icons.svg#icon-soundcloud"></use>
+            </svg>
+        </a></span>)</span>
+<a href="artist/the-big-baddies/">Grounder &amp; Scratch</a>
+<span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (<span class="icons icons-inline"><a class="icon" href="https://tobyfox.bandcamp.com/">
+            <svg>
+                <title>Bandcamp</title>
+                <use href="static/icons.svg#icon-bandcamp"></use>
+            </svg>
+        </a>, <a class="icon" href="https://toby.fox/">
+            <svg>
+                <title>External (toby.fox)</title>
+                <use href="static/icons.svg#icon-globe"></use>
+            </svg>
+        </a></span>)</span>
+`
+
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > only showIcons (tooltip) 1`] = `
+<span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://soundcloud.com/plazmataz">
+                    <svg><use href="static/icons.svg#icon-soundcloud"></use></svg>
+                    <span class="icon-text">plazmataz</span>
+                </a></span></span></span></span>
+<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
+<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://tobyfox.bandcamp.com/">
+                    <svg><use href="static/icons.svg#icon-bandcamp"></use></svg>
+                    <span class="icon-text">tobyfox</span>
+                </a><a class="icon has-text" href="https://toby.fox/">
+                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                    <span class="icon-text">toby.fox</span>
+                </a></span></span></span> (Arrangement)</span>
+`
+
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (inline) 1`] = `
+<span class="contribution nowrap"><a href="artist/clark-powell/">Clark Powell</a> (<span class="icons icons-inline"><a class="icon" href="https://soundcloud.com/plazmataz">
+            <svg>
+                <title>SoundCloud</title>
+                <use href="static/icons.svg#icon-soundcloud"></use>
+            </svg>
+        </a></span>)</span>
+<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
+<span class="contribution nowrap"><a href="artist/toby-fox/">Toby Fox</a> (Arrangement) (<span class="icons icons-inline"><a class="icon" href="https://tobyfox.bandcamp.com/">
+            <svg>
+                <title>Bandcamp</title>
+                <use href="static/icons.svg#icon-bandcamp"></use>
+            </svg>
+        </a>, <a class="icon" href="https://toby.fox/">
+            <svg>
+                <title>External (toby.fox)</title>
+                <use href="static/icons.svg#icon-globe"></use>
+            </svg>
+        </a></span>)</span>
+`
+
+exports[`test/snapshot/linkContribution.js > TAP > linkContribution (snapshot) > showContribution & showIcons (tooltip) 1`] = `
+<span class="contribution"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/clark-powell/">Clark Powell</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://soundcloud.com/plazmataz">
+                    <svg><use href="static/icons.svg#icon-soundcloud"></use></svg>
+                    <span class="icon-text">plazmataz</span>
+                </a></span></span></span></span>
+<span class="contribution nowrap"><a href="artist/the-big-baddies/">Grounder &amp; Scratch</a> (Snooping)</span>
+<span class="contribution nowrap"><span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="artist/toby-fox/">Toby Fox</a></span><span class="tooltip icons icons-tooltip"><span class="tooltip-content"><a class="icon has-text" href="https://tobyfox.bandcamp.com/">
+                    <svg><use href="static/icons.svg#icon-bandcamp"></use></svg>
+                    <span class="icon-text">tobyfox</span>
+                </a><a class="icon has-text" href="https://toby.fox/">
+                    <svg><use href="static/icons.svg#icon-globe"></use></svg>
+                    <span class="icon-text">toby.fox</span>
+                </a></span></span></span> (Arrangement)</span>
+`
diff --git a/tap-snapshots/test/snapshot/linkExternal.js.test.cjs b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs
new file mode 100644
index 0000000..03192e8
--- /dev/null
+++ b/tap-snapshots/test/snapshot/linkExternal.js.test.cjs
@@ -0,0 +1,228 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: album, style: handle 1`] = `
+<a class="external-link" href="https://youtu.be/abc">YouTube</a>
+<a class="external-link" href="https://youtube.com/watch?v=abc">YouTube</a>
+<a class="external-link" href="https://youtube.com/Playlist?list=kweh">YouTube</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: album, style: platform 1`] = `
+<a class="external-link" href="https://youtu.be/abc">YouTube</a>
+<a class="external-link" href="https://youtube.com/watch?v=abc">YouTube</a>
+<a class="external-link" href="https://youtube.com/Playlist?list=kweh">YouTube</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: albumMultipleTracks, style: handle 1`] = `
+<a class="external-link" href="https://youtu.be/abc">YouTube (full album)</a>
+<a class="external-link" href="https://youtube.com/watch?v=abc">YouTube (full album)</a>
+<a class="external-link" href="https://youtube.com/Playlist?list=kweh">YouTube</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: albumMultipleTracks, style: platform 1`] = `
+<a class="external-link" href="https://youtu.be/abc">YouTube (full album)</a>
+<a class="external-link" href="https://youtube.com/watch?v=abc">YouTube (full album)</a>
+<a class="external-link" href="https://youtube.com/Playlist?list=kweh">YouTube</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: albumNoTracks, style: handle 1`] = `
+<a class="external-link" href="https://youtu.be/abc">YouTube</a>
+<a class="external-link" href="https://youtube.com/watch?v=abc">YouTube</a>
+<a class="external-link" href="https://youtube.com/Playlist?list=kweh">YouTube</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: albumNoTracks, style: platform 1`] = `
+<a class="external-link" href="https://youtu.be/abc">YouTube</a>
+<a class="external-link" href="https://youtube.com/watch?v=abc">YouTube</a>
+<a class="external-link" href="https://youtube.com/Playlist?list=kweh">YouTube</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: albumOneTrack, style: handle 1`] = `
+<a class="external-link" href="https://youtu.be/abc">YouTube</a>
+<a class="external-link" href="https://youtube.com/watch?v=abc">YouTube</a>
+<a class="external-link" href="https://youtube.com/Playlist?list=kweh">YouTube</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: albumOneTrack, style: platform 1`] = `
+<a class="external-link" href="https://youtu.be/abc">YouTube</a>
+<a class="external-link" href="https://youtube.com/watch?v=abc">YouTube</a>
+<a class="external-link" href="https://youtube.com/Playlist?list=kweh">YouTube</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: flash, style: handle 1`] = `
+<a class="external-link" href="https://www.bgreco.net/hsflash/002238.html">bgreco.net (high quality audio)</a>
+<a class="external-link" href="https://homestuck.com/story/1234">Homestuck (page 1234)</a>
+<a class="external-link" href="https://homestuck.com/story/pony">Homestuck (secret page)</a>
+<a class="external-link" href="https://www.youtube.com/watch?v=wKgOp3Kg2wI">YouTube (on any device)</a>
+<a class="external-link" href="https://youtu.be/IOcvkkklWmY">YouTube (on any device)</a>
+<a class="external-link" href="https://some.external.site/foo/bar/">some.external.site</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: flash, style: platform 1`] = `
+<a class="external-link" href="https://www.bgreco.net/hsflash/002238.html">bgreco.net (high quality audio)</a>
+<a class="external-link" href="https://homestuck.com/story/1234">Homestuck (page 1234)</a>
+<a class="external-link" href="https://homestuck.com/story/pony">Homestuck (secret page)</a>
+<a class="external-link" href="https://www.youtube.com/watch?v=wKgOp3Kg2wI">YouTube (on any device)</a>
+<a class="external-link" href="https://youtu.be/IOcvkkklWmY">YouTube (on any device)</a>
+<a class="external-link" href="https://some.external.site/foo/bar/">some.external.site</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: generic, style: handle 1`] = `
+<a class="external-link" href="https://music.apple.com/us/artist/system-of-a-down/462715">Apple Music</a>
+<a class="external-link" href="https://www.artstation.com/eevaningtea">eevaningtea</a>
+<a class="external-link" href="https://witnesstheabsurd.artstation.com/">witnesstheabsurd</a>
+<a class="external-link" href="https://music.solatrus.com/">music.solatrus.com</a>
+<a class="external-link" href="https://homestuck.bandcamp.com/">homestuck</a>
+<a class="external-link" href="https://bsky.app/profile/jacobtheloofah.bsky.social">jacobtheloofah</a>
+<a class="external-link" href="https://aliceflare.carrd.co">aliceflare</a>
+<a class="external-link" href="https://bigchaslappa.carrd.co/">bigchaslappa</a>
+<a class="external-link" href="https://cohost.org/cosmoptera">cosmoptera</a>
+<a class="external-link" href="https://music.deconreconstruction.com/albums/catch-322">MUSIC@DCRC</a>
+<a class="external-link" href="https://music.deconreconstruction.com/albums/catch-322?track=arcjecs-theme">MUSIC@DCRC</a>
+<a class="external-link" href="https://www.deconreconstruction.com/">Deconreconstruction</a>
+<a class="external-link" href="https://culdhira.deviantart.com">culdhira</a>
+<a class="external-link" href="https://www.deviantart.com/chesswanderlust-sama">chesswanderlust-sama</a>
+<a class="external-link" href="https://www.deviantart.com/shilloshilloh/art/Homestuck-Jake-English-268874606">DeviantArt</a>
+<a class="external-link" href="https://www.facebook.com/DoomedCloud/">DoomedCloud</a>
+<a class="external-link" href="https://www.facebook.com/pages/WoodenToaster/280642235307371">WoodenToaster</a>
+<a class="external-link" href="https://www.facebook.com/Svixy/posts/400018786702633">Facebook</a>
+<a class="external-link" href="https://mspaintadventures.fandom.com/wiki/Draconian_Dignitary">MSPA Wiki (Draconian Dignitary)</a>
+<a class="external-link" href="https://mspaintadventures.fandom.com/wiki/">MSPA Wiki</a>
+<a class="external-link" href="https://mspaintadventures.fandom.com/">MSPA Wiki</a>
+<a class="external-link" href="https://community.fandom.com/">Fandom</a>
+<a class="external-link" href="https://community.fandom.com/wiki/">Fandom</a>
+<a class="external-link" href="https://community.fandom.com/wiki/Community_Central">Fandom</a>
+<a class="external-link" href="https://gamebanana.com/members/2028092">GameBanana</a>
+<a class="external-link" href="https://gamebanana.com/mods/459476">GameBanana</a>
+<a class="external-link" href="https://homestuck.com/">Homestuck</a>
+<a class="external-link" href="https://hsmusic.wiki/media/misc/archive/Firefly%20Cloud%20Remix.mp3">HSMusic (wiki archive)</a>
+<a class="external-link" href="https://hsmusic.wiki/feedback/">HSMusic</a>
+<a class="external-link" href="https://archive.org/details/a-life-well-lived">Internet Archive</a>
+<a class="external-link" href="https://archive.org/details/VastError_Volume1/11+Renaissance.mp3">Internet Archive</a>
+<a class="external-link" href="https://instagram.com/bass.and.noises">bass.and.noises</a>
+<a class="external-link" href="https://www.instagram.com/levc_egm/">levc_egm</a>
+<a class="external-link" href="https://tuyoki.itch.io/">tuyoki</a>
+<a class="external-link" href="https://itch.io/profile/bravelittletoreador">bravelittletoreador</a>
+<a class="external-link" href="https://ko-fi.com/gnaach">gnaach</a>
+<a class="external-link" href="https://linktr.ee/bbpanzu">bbpanzu</a>
+<a class="external-link" href="https://types.pl/">types.pl</a>
+<a class="external-link" href="https://canwc.mspfa.com/">MSPFA</a>
+<a class="external-link" href="https://mspfa.com/?s=12003&p=1045">MSPFA</a>
+<a class="external-link" href="https://mspfa.com/user/?u=103334508819793669241">MSPFA</a>
+<a class="external-link" href="https://wodaro.neocities.org">wodaro.neocities.org</a>
+<a class="external-link" href="https://neomints.neocities.org/">neomints.neocities.org</a>
+<a class="external-link" href="https://buzinkai.newgrounds.com/">buzinkai</a>
+<a class="external-link" href="https://www.newgrounds.com/audio/listen/1256058">Newgrounds</a>
+<a class="external-link" href="https://www.patreon.com/CecilyRenns">CecilyRenns</a>
+<a class="external-link" href="https://www.poetryfoundation.org/poets/christina-rossetti">Poetry Foundation</a>
+<a class="external-link" href="https://www.poetryfoundation.org/poems/45000/remember-56d224509b7ae">Poetry Foundation</a>
+<a class="external-link" href="https://soundcloud.com/plazmataz">plazmataz</a>
+<a class="external-link" href="https://soundcloud.com/worthikids/1-i-accidentally-broke-my">SoundCloud</a>
+<a class="external-link" href="https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3">Spotify</a>
+<a class="external-link" href="https://open.spotify.com/album/0iHvPD8rM3hQa0qeVtPQ3t">Spotify</a>
+<a class="external-link" href="https://open.spotify.com/track/6YEGQH32aAXb9vQQbBrPlw">Spotify</a>
+<a class="external-link" href="https://www.tiktok.com/@richaadeb">richaadeb</a>
+<a class="external-link" href="https://toyhou.se/ghastaboo">ghastaboo</a>
+<a class="external-link" href="https://aeritus.tumblr.com/">aeritus</a>
+<a class="external-link" href="https://vol5anthology.tumblr.com/post/159528808107/hey-everyone-its-413-and-that-means-we-have">vol5anthology</a>
+<a class="external-link" href="https://www.tumblr.com/electricwestern">electricwestern</a>
+<a class="external-link" href="https://www.tumblr.com/spellmynamewithabang/142767566733/happy-413-this-is-the-first-time-anyones-heard">Tumblr</a>
+<a class="external-link" href="https://www.twitch.tv/ajhebard">ajhebard</a>
+<a class="external-link" href="https://www.twitch.tv/vargskelethor/">vargskelethor/</a>
+<a class="external-link" href="https://twitter.com/awkwarddoesart">awkwarddoesart</a>
+<a class="external-link" href="https://twitter.com/purenonsens/">purenonsens</a>
+<a class="external-link" href="https://twitter.com/circlejourney/status/1202265927183548416">Twitter</a>
+<a class="external-link" href="https://web.archive.org/web/20120405160556/https://homestuck.bandcamp.com/album/colours-and-mayhem-universe-a">Wayback Machine</a>
+<a class="external-link" href="https://web.archive.org/web/20160807111207/http://griffinspacejam.com:80/">Wayback Machine</a>
+<a class="external-link" href="https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)">Wikipedia</a>
+<a class="external-link" href="https://youtube.com/@bani-chan8949">bani-chan8949</a>
+<a class="external-link" href="https://www.youtube.com/@Razzie16">Razzie16</a>
+<a class="external-link" href="https://www.youtube.com/channel/UCQXfvlKkpbOqEz4BepHqK7g">YouTube</a>
+<a class="external-link" href="https://www.youtube.com/watch?v=6ekVnZm29kw">YouTube</a>
+<a class="external-link" href="https://youtu.be/WBkC038wSio">YouTube</a>
+<a class="external-link" href="https://www.youtube.com/playlist?list=PLy5UGIMKOXpONMExgI7lVYFwQa54QFp_H">YouTube</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > context: generic, style: platform 1`] = `
+<a class="external-link" href="https://music.apple.com/us/artist/system-of-a-down/462715">Apple Music</a>
+<a class="external-link" href="https://www.artstation.com/eevaningtea">ArtStation</a>
+<a class="external-link" href="https://witnesstheabsurd.artstation.com/">ArtStation</a>
+<a class="external-link" href="https://music.solatrus.com/">Bandcamp (music.solatrus.com)</a>
+<a class="external-link" href="https://homestuck.bandcamp.com/">Bandcamp</a>
+<a class="external-link" href="https://bsky.app/profile/jacobtheloofah.bsky.social">Bluesky</a>
+<a class="external-link" href="https://aliceflare.carrd.co">Carrd</a>
+<a class="external-link" href="https://bigchaslappa.carrd.co/">Carrd</a>
+<a class="external-link" href="https://cohost.org/cosmoptera">Cohost</a>
+<a class="external-link" href="https://music.deconreconstruction.com/albums/catch-322">MUSIC@DCRC</a>
+<a class="external-link" href="https://music.deconreconstruction.com/albums/catch-322?track=arcjecs-theme">MUSIC@DCRC</a>
+<a class="external-link" href="https://www.deconreconstruction.com/">Deconreconstruction</a>
+<a class="external-link" href="https://culdhira.deviantart.com">DeviantArt</a>
+<a class="external-link" href="https://www.deviantart.com/chesswanderlust-sama">DeviantArt</a>
+<a class="external-link" href="https://www.deviantart.com/shilloshilloh/art/Homestuck-Jake-English-268874606">DeviantArt</a>
+<a class="external-link" href="https://www.facebook.com/DoomedCloud/">Facebook</a>
+<a class="external-link" href="https://www.facebook.com/pages/WoodenToaster/280642235307371">Facebook</a>
+<a class="external-link" href="https://www.facebook.com/Svixy/posts/400018786702633">Facebook</a>
+<a class="external-link" href="https://mspaintadventures.fandom.com/wiki/Draconian_Dignitary">MSPA Wiki (Draconian Dignitary)</a>
+<a class="external-link" href="https://mspaintadventures.fandom.com/wiki/">MSPA Wiki</a>
+<a class="external-link" href="https://mspaintadventures.fandom.com/">MSPA Wiki</a>
+<a class="external-link" href="https://community.fandom.com/">Fandom</a>
+<a class="external-link" href="https://community.fandom.com/wiki/">Fandom</a>
+<a class="external-link" href="https://community.fandom.com/wiki/Community_Central">Fandom</a>
+<a class="external-link" href="https://gamebanana.com/members/2028092">GameBanana</a>
+<a class="external-link" href="https://gamebanana.com/mods/459476">GameBanana</a>
+<a class="external-link" href="https://homestuck.com/">Homestuck</a>
+<a class="external-link" href="https://hsmusic.wiki/media/misc/archive/Firefly%20Cloud%20Remix.mp3">HSMusic (wiki archive)</a>
+<a class="external-link" href="https://hsmusic.wiki/feedback/">HSMusic</a>
+<a class="external-link" href="https://archive.org/details/a-life-well-lived">Internet Archive</a>
+<a class="external-link" href="https://archive.org/details/VastError_Volume1/11+Renaissance.mp3">Internet Archive</a>
+<a class="external-link" href="https://instagram.com/bass.and.noises">Instagram</a>
+<a class="external-link" href="https://www.instagram.com/levc_egm/">Instagram</a>
+<a class="external-link" href="https://tuyoki.itch.io/">itch.io</a>
+<a class="external-link" href="https://itch.io/profile/bravelittletoreador">itch.io</a>
+<a class="external-link" href="https://ko-fi.com/gnaach">Ko-fi</a>
+<a class="external-link" href="https://linktr.ee/bbpanzu">Linktree</a>
+<a class="external-link" href="https://types.pl/">Mastodon (types.pl)</a>
+<a class="external-link" href="https://canwc.mspfa.com/">MSPFA</a>
+<a class="external-link" href="https://mspfa.com/?s=12003&p=1045">MSPFA</a>
+<a class="external-link" href="https://mspfa.com/user/?u=103334508819793669241">MSPFA</a>
+<a class="external-link" href="https://wodaro.neocities.org">Neocities</a>
+<a class="external-link" href="https://neomints.neocities.org/">Neocities</a>
+<a class="external-link" href="https://buzinkai.newgrounds.com/">Newgrounds</a>
+<a class="external-link" href="https://www.newgrounds.com/audio/listen/1256058">Newgrounds</a>
+<a class="external-link" href="https://www.patreon.com/CecilyRenns">Patreon</a>
+<a class="external-link" href="https://www.poetryfoundation.org/poets/christina-rossetti">Poetry Foundation</a>
+<a class="external-link" href="https://www.poetryfoundation.org/poems/45000/remember-56d224509b7ae">Poetry Foundation</a>
+<a class="external-link" href="https://soundcloud.com/plazmataz">SoundCloud</a>
+<a class="external-link" href="https://soundcloud.com/worthikids/1-i-accidentally-broke-my">SoundCloud</a>
+<a class="external-link" href="https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3">Spotify</a>
+<a class="external-link" href="https://open.spotify.com/album/0iHvPD8rM3hQa0qeVtPQ3t">Spotify</a>
+<a class="external-link" href="https://open.spotify.com/track/6YEGQH32aAXb9vQQbBrPlw">Spotify</a>
+<a class="external-link" href="https://www.tiktok.com/@richaadeb">TikTok</a>
+<a class="external-link" href="https://toyhou.se/ghastaboo">Toyhouse</a>
+<a class="external-link" href="https://aeritus.tumblr.com/">Tumblr</a>
+<a class="external-link" href="https://vol5anthology.tumblr.com/post/159528808107/hey-everyone-its-413-and-that-means-we-have">Tumblr</a>
+<a class="external-link" href="https://www.tumblr.com/electricwestern">Tumblr</a>
+<a class="external-link" href="https://www.tumblr.com/spellmynamewithabang/142767566733/happy-413-this-is-the-first-time-anyones-heard">Tumblr</a>
+<a class="external-link" href="https://www.twitch.tv/ajhebard">Twitch</a>
+<a class="external-link" href="https://www.twitch.tv/vargskelethor/">Twitch</a>
+<a class="external-link" href="https://twitter.com/awkwarddoesart">Twitter</a>
+<a class="external-link" href="https://twitter.com/purenonsens/">Twitter</a>
+<a class="external-link" href="https://twitter.com/circlejourney/status/1202265927183548416">Twitter</a>
+<a class="external-link" href="https://web.archive.org/web/20120405160556/https://homestuck.bandcamp.com/album/colours-and-mayhem-universe-a">Wayback Machine</a>
+<a class="external-link" href="https://web.archive.org/web/20160807111207/http://griffinspacejam.com:80/">Wayback Machine</a>
+<a class="external-link" href="https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)">Wikipedia</a>
+<a class="external-link" href="https://youtube.com/@bani-chan8949">YouTube</a>
+<a class="external-link" href="https://www.youtube.com/@Razzie16">YouTube</a>
+<a class="external-link" href="https://www.youtube.com/channel/UCQXfvlKkpbOqEz4BepHqK7g">YouTube</a>
+<a class="external-link" href="https://www.youtube.com/watch?v=6ekVnZm29kw">YouTube</a>
+<a class="external-link" href="https://youtu.be/WBkC038wSio">YouTube</a>
+<a class="external-link" href="https://www.youtube.com/playlist?list=PLy5UGIMKOXpONMExgI7lVYFwQa54QFp_H">YouTube</a>
+`
+
+exports[`test/snapshot/linkExternal.js > TAP > linkExternal (snapshot) > unknown domain (arbitrary world wide web path) 1`] = `
+<a class="external-link" href="https://snoo.ping.as/usual/i/see/">snoo.ping.as</a>
+`
diff --git a/tap-snapshots/test/snapshot/linkTemplate.js.test.cjs b/tap-snapshots/test/snapshot/linkTemplate.js.test.cjs
new file mode 100644
index 0000000..b5acde9
--- /dev/null
+++ b/tap-snapshots/test/snapshot/linkTemplate.js.test.cjs
@@ -0,0 +1,32 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/linkTemplate.js > TAP > linkTemplate (snapshot) > fill many slots 1`] = `
+<a class="dog" id="cat1" href="https://hsmusic.wiki/media/cool%20file.pdf#fooey">My Cool Link</a>
+`
+
+exports[`test/snapshot/linkTemplate.js > TAP > linkTemplate (snapshot) > fill path slot & provide appendIndexHTML 1`] = `
+<a href="/c*lzone/myCoolPath/ham/pineapple/tomato/index.html">delish</a>
+`
+
+exports[`test/snapshot/linkTemplate.js > TAP > linkTemplate (snapshot) > link in content 1`] = `
+<a href="#the-more-ye-know">
+    Oh geez oh heck
+    There's a link in here!!
+    But here's <b>a normal tag.</b>
+    <div>Gotta keep them normal tags.</div>
+    <div>But not... NESTED LINKS, OOO.</div>
+</a>
+`
+
+exports[`test/snapshot/linkTemplate.js > TAP > linkTemplate (snapshot) > missing content 1`] = `
+<a href="banana">(Missing link content)</a>
+`
+
+exports[`test/snapshot/linkTemplate.js > TAP > linkTemplate (snapshot) > special characters in path argument 1`] = `
+<a href="media/album-additional/homestuck-vol-1/Showtime%20(Piano%20Refrain)%20-%20%23xXxAwesomeSheetMusick%3FrxXx%23.pdf">Damn, that's some good sheet music</a>
+`
diff --git a/tap-snapshots/test/snapshot/linkThing.js.test.cjs b/tap-snapshots/test/snapshot/linkThing.js.test.cjs
new file mode 100644
index 0000000..7dd8b00
--- /dev/null
+++ b/tap-snapshots/test/snapshot/linkThing.js.test.cjs
@@ -0,0 +1,45 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/linkThing.js > TAP > linkThing (snapshot) > basic behavior 1`] = `
+<a style="--primary-color: #abcdef" href="track/foo/">Cool track!</a>
+`
+
+exports[`test/snapshot/linkThing.js > TAP > linkThing (snapshot) > color 1`] = `
+<a href="track/showtime-piano-refrain/">Showtime (Piano Refrain)</a>
+<a style="--primary-color: #38f43d" href="track/showtime-piano-refrain/">Showtime (Piano Refrain)</a>
+<a style="--primary-color: #aaccff" href="track/showtime-piano-refrain/">Showtime (Piano Refrain)</a>
+<span style="--primary-color: #aaccff" class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="track/showtime-piano-refrain/">Showtime (Piano Refrain)</a></span><span class="tooltip thing-name-tooltip"><span class="tooltip-content">Showtime (Piano Refrain)</span></span></span>
+`
+
+exports[`test/snapshot/linkThing.js > TAP > linkThing (snapshot) > nested links in content stripped 1`] = `
+<a href="foo/"><b>Oooo! Very spooky.</b></a>
+`
+
+exports[`test/snapshot/linkThing.js > TAP > linkThing (snapshot) > preferShortName 1`] = `
+<span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="tag/five-oceanfalls/">Five</a></span><span class="tooltip thing-name-tooltip"><span class="tooltip-content">Five (Oceanfalls)</span></span></span>
+`
+
+exports[`test/snapshot/linkThing.js > TAP > linkThing (snapshot) > tags in name escaped 1`] = `
+<a href="track/foo/">&lt;a href=&quot;SNOOPING&quot;&gt;AS USUAL&lt;/a&gt; I SEE</a>
+<a href="track/bar/">&lt;b&gt;boldface&lt;/b&gt;</a>
+<a href="album/exile/">&gt;Exile&lt;</a>
+<a href="track/heart/">&lt;3</a>
+`
+
+exports[`test/snapshot/linkThing.js > TAP > linkThing (snapshot) > tooltip & content 1`] = `
+<a href="album/beyond-canon/">Beyond Canon</a>
+<a title="Beyond Canon" href="album/beyond-canon/">Beyond Canon</a>
+<a title="Beyond Canon" href="album/beyond-canon/">Next</a>
+<a href="album/beyond-canon/">Beyond Canon</a>
+<span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="album/beyond-canon/">BC</a></span><span class="tooltip thing-name-tooltip"><span class="tooltip-content">Beyond Canon</span></span></span>
+<span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="album/beyond-canon/">Next</a></span><span class="tooltip thing-name-tooltip"><span class="tooltip-content">Beyond Canon</span></span></span>
+<a href="album/beyond-canon/">Next</a>
+<span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="album/beyond-canon/">Beyond Canon</a></span><span class="tooltip thing-name-tooltip"><span class="tooltip-content">Beyond Canon</span></span></span>
+<span class="text-with-tooltip"><span class="hoverable"><a class="text-with-tooltip-interaction-cue" href="album/beyond-canon/">Next</a></span><span class="tooltip thing-name-tooltip"><span class="tooltip-content">Beyond Canon</span></span></span>
+<a href="album/beyond-canon/">Banana</a>
+`
diff --git a/tap-snapshots/test/snapshot/transformContent.js.test.cjs b/tap-snapshots/test/snapshot/transformContent.js.test.cjs
new file mode 100644
index 0000000..8d29d96
--- /dev/null
+++ b/tap-snapshots/test/snapshot/transformContent.js.test.cjs
@@ -0,0 +1,132 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > basic markdown 1`] = `
+<p>Hello <em>world!</em> This is <strong>SO COOL.</strong></p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > dates 1`] = `
+<p><time datetime="Thu, 13 Apr 2023 00:00:00 GMT">4/12/2023</time> Yep!</p>
+<p>Very nice: <time datetime="Fri, 25 Oct 2413 03:00:00 GMT">10/25/2413</time></p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > escape end of tag 1`] = `
+<p>My favorite album is <a style="--primary-color: #123456" href="to-localized.album/cool-album">[Tactical Omission]</a>.</p>
+<p>Your favorite album is <a style="--primary-color: #123456" href="to-localized.album/cool-album">[Tactical Wha-Huh-Now</a>].</p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > escape entire tag 1`] = `
+<p>[[album:cool-album|spooky]] <a style="--primary-color: #123456" href="to-localized.album/cool-album">scary</a></p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > escape markdown 1`] = `
+<p>What will it be, <em>ye fool?</em> *arr*</p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > hanging indent list 1`] = `
+<p>Hello!</p>
+<ul>
+<li><p>I am a list item and I
+go on and on and on
+and on and on and on.</p>
+</li>
+<li><p>I am another list item.
+Yeah.</p>
+</li>
+</ul>
+<p>In-between!</p>
+<ul>
+<li>Spooky,
+spooky, I say!</li>
+<li>Following list item.
+No empty line around me.</li>
+<li>Very cool.
+So, so cool.</li>
+</ul>
+<p>Goodbye!</p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > indent on a directly following line 1`] = `
+<div>
+    <span>Wow!</span>
+</div>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > indent on an indierctly following line 1`] = `
+<p>Some text.</p>
+<p>Yes, some more text.</p>
+<pre><code>I am hax0rz!!
+All yor base r blong 2 us.
+</code></pre>
+<p>Aye.</p>
+<p>Aye aye aye.</p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > inline images 1`] = `
+<p><img src="snooping.png"> as USUAL...</p>
+<p>What do you know? <img src="cowabunga.png" width="24" height="32"></p>
+<p><a style="--primary-color: #123456" href="to-localized.album/cool-album">I'm on the left.</a><img src="im-on-the-right.jpg"></p>
+<p><img src="im-on-the-left.jpg"><a style="--primary-color: #123456" href="to-localized.album/cool-album">I'm on the right.</a></p>
+<p>Media time! <img src="to-media.path/misc/interesting.png"> Oh yeah!</p>
+<p><img src="must.png"><img src="stick.png"><img src="together.png"></p>
+<p>And... all done! <img src="end-of-source.png"></p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > links to a thing 1`] = `
+<p>This is <a style="--primary-color: #123456" href="to-localized.album/cool-album">my favorite album</a>.</p>
+<p>That&#39;s right, <a style="--primary-color: #123456" href="to-localized.album/cool-album">Cool Album</a>!</p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > lyrics - basic line breaks 1`] = `
+<p>Hey, ho<br>
+And away we go<br>
+Truly, music</p>
+<p>(Oh yeah)<br>
+(That&#39;s right)</p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > lyrics - line breaks around tags 1`] = `
+<p>The date be <time datetime="Tue, 13 Apr 2004 03:00:00 GMT">4/13/2004</time><br>
+I say, the date be <time datetime="Tue, 13 Apr 2004 03:00:00 GMT">4/13/2004</time><br>
+<time datetime="Tue, 13 Apr 2004 03:00:00 GMT">4/13/2004</time><br>
+<time datetime="Tue, 13 Apr 2004 03:00:00 GMT">4/13/2004</time><time datetime="Tue, 13 Apr 2004 03:00:00 GMT">4/13/2004</time><time datetime="Tue, 13 Apr 2004 03:00:00 GMT">4/13/2004</time><br>
+(Aye!)</p>
+<p><time datetime="Tue, 13 Apr 2004 03:00:00 GMT">4/13/2004</time><br>
+<time datetime="Tue, 13 Apr 2004 03:00:00 GMT">4/13/2004</time><time datetime="Tue, 13 Apr 2004 03:00:00 GMT">4/13/2004</time><br>
+<time datetime="Tue, 13 Apr 2004 03:00:00 GMT">4/13/2004</time><br></p>
+<p><time datetime="Tue, 13 Apr 2004 03:00:00 GMT">4/13/2004</time><br>
+<time datetime="Tue, 13 Apr 2004 03:00:00 GMT">4/13/2004</time>, and don&#39;t ye forget it</p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > lyrics - repeated and edge line breaks 1`] = `
+<p>Well, you know<br>
+How it goes</p>
+<p>Yessiree</p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > non-inline image #1 1`] = `
+<div class="content-image-container">[mocked: image - slots: { src: 'spark.png', link: true, thumb: 'large', attributes: [ { class: 'content-image' }, undefined ] }]</div>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > non-inline image #2 1`] = `
+<p>Rad.</p>
+<div class="content-image-container">[mocked: image - slots: { src: 'spark.png', link: true, thumb: 'large', attributes: [ { class: 'content-image' }, undefined ] }]</div>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > non-inline image #3 1`] = `
+<div class="content-image-container">[mocked: image - slots: { src: 'spark.png', link: true, thumb: 'large', attributes: [ { class: 'content-image' }, undefined ] }]</div>
+<p>Baller.</p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > super basic string 1`] = `
+<p>Neat listing: Albums - by Date</p>
+`
+
+exports[`test/snapshot/transformContent.js > TAP > transformContent (snapshot) > two text paragraphs 1`] = `
+<p>Hello, world!</p>
+<p>Wow, this is very cool.</p>
+`
diff --git a/test/cacheable-object.js b/test/cacheable-object.js
deleted file mode 100644
index dd93343..0000000
--- a/test/cacheable-object.js
+++ /dev/null
@@ -1,274 +0,0 @@
-import test from 'tape';
-
-import CacheableObject from '../src/data/cacheable-object.js';
-
-// Utility
-
-function newCacheableObject(PD) {
-    return new (class extends CacheableObject {
-        static propertyDescriptors = PD;
-    });
-}
-
-// Tests
-
-test(`CacheableObject simple separate update & expose`, t => {
-    const obj = newCacheableObject({
-        number: {
-            flags: {
-                update: true
-            }
-        },
-
-        timesTwo: {
-            flags: {
-                expose: true
-            },
-
-            expose: {
-                dependencies: ['number'],
-                compute: ({ number }) => number * 2
-            }
-        }
-    });
-
-    t.plan(1);
-    obj.number = 5;
-    t.equal(obj.timesTwo, 10);
-});
-
-test(`CacheableObject basic cache behavior`, t => {
-    let computeCount = 0;
-
-    const obj = newCacheableObject({
-        string: {
-            flags: {
-                update: true
-            }
-        },
-
-        karkat: {
-            flags: {
-                expose: true
-            },
-
-            expose: {
-                dependencies: ['string'],
-                compute: ({ string }) => {
-                    computeCount++;
-                    return string.toUpperCase();
-                }
-            }
-        }
-    });
-
-    t.plan(8);
-
-    t.is(computeCount, 0);
-
-    obj.string = 'hello world';
-    t.is(computeCount, 0);
-
-    obj.karkat;
-    t.is(computeCount, 1);
-
-    obj.karkat;
-    t.is(computeCount, 1);
-
-    obj.string = 'testing once again';
-    t.is(computeCount, 1);
-
-    obj.karkat;
-    t.is(computeCount, 2);
-
-    obj.string = 'testing once again';
-    t.is(computeCount, 2);
-
-    obj.karkat;
-    t.is(computeCount, 2);
-});
-
-test(`CacheableObject combined update & expose (no transform)`, t => {
-    const obj = newCacheableObject({
-        directory: {
-            flags: {
-                update: true,
-                expose: true
-            }
-        }
-    });
-
-    t.plan(2);
-
-    t.directory = 'the-world-revolving';
-    t.is(t.directory, 'the-world-revolving');
-
-    t.directory = 'chaos-king';
-    t.is(t.directory, 'chaos-king');
-});
-
-test(`CacheableObject combined update & expose (basic transform)`, t => {
-    const obj = newCacheableObject({
-        getsRepeated: {
-            flags: {
-                update: true,
-                expose: true
-            },
-
-            expose: {
-                transform: value => value.repeat(2)
-            }
-        }
-    });
-
-    t.plan(1);
-
-    obj.getsRepeated = 'dog';
-    t.is(obj.getsRepeated, 'dogdog');
-});
-
-test(`CacheableObject combined update & expose (transform with dependency)`, t => {
-    const obj = newCacheableObject({
-        customRepeat: {
-            flags: {
-                update: true,
-                expose: true
-            },
-
-            expose: {
-                dependencies: ['times'],
-                transform: (value, { times }) => value.repeat(times)
-            }
-        },
-
-        times: {
-            flags: {
-                update: true
-            }
-        }
-    });
-
-    t.plan(3);
-
-    obj.customRepeat = 'dog';
-    obj.times = 1;
-    t.is(obj.customRepeat, 'dog');
-
-    obj.times = 5;
-    t.is(obj.customRepeat, 'dogdogdogdogdog');
-
-    obj.customRepeat = 'cat';
-    t.is(obj.customRepeat, 'catcatcatcatcat');
-});
-
-test(`CacheableObject validate on update`, t => {
-    const mockError = new TypeError(`Expected a string, not ${typeof value}`);
-
-    const obj = newCacheableObject({
-        directory: {
-            flags: {
-                update: true,
-                expose: true
-            },
-
-            update: {
-                validate: value => {
-                    if (typeof value !== 'string') {
-                        throw mockError;
-                    }
-                    return true;
-                }
-            }
-        },
-
-        date: {
-            flags: {
-                update: true,
-                expose: true
-            },
-
-            update: {
-                validate: value => (value instanceof Date)
-            }
-        }
-    });
-
-    let thrownError;
-    t.plan(6);
-
-    obj.directory = 'megalovania';
-    t.is(obj.directory, 'megalovania');
-
-    try {
-        obj.directory = 25;
-    } catch (err) {
-        thrownError = err;
-    }
-
-    t.is(thrownError, mockError);
-    t.is(obj.directory, 'megalovania');
-
-    const date = new Date(`25 December 2009`);
-
-    obj.date = date;
-    t.is(obj.date, date);
-
-    try {
-        obj.date = `TWELFTH PERIGEE'S EVE`;
-    } catch (err) {
-        thrownError = err;
-    }
-
-    t.is(thrownError?.constructor, TypeError);
-    t.is(obj.date, date);
-});
-
-test(`CacheableObject default update property value`, t => {
-    const obj = newCacheableObject({
-        fruit: {
-            flags: {
-                update: true,
-                expose: true
-            },
-
-            update: {
-                default: 'potassium'
-            }
-        }
-    });
-
-    t.plan(1);
-    t.is(obj.fruit, 'potassium');
-});
-
-test(`CacheableObject default property throws if invalid`, t => {
-    const mockError = new TypeError(`Expected a string, not ${typeof value}`);
-
-    t.plan(1);
-
-    let thrownError;
-
-    try {
-        newCacheableObject({
-            string: {
-                flags: {
-                    update: true
-                },
-
-                update: {
-                    default: 123,
-                    validate: value => {
-                        if (typeof value !== 'string') {
-                            throw mockError;
-                        }
-                        return true;
-                    }
-                }
-            }
-        });
-    } catch (err) {
-        thrownError = err;
-    }
-
-    t.is(thrownError, mockError);
-});
diff --git a/test/data-validators.js b/test/data-validators.js
deleted file mode 100644
index a7b9b48..0000000
--- a/test/data-validators.js
+++ /dev/null
@@ -1,277 +0,0 @@
-import _test from 'tape';
-import { showAggregate } from '../src/util/sugar.js';
-
-import {
-    // Basic types
-    isBoolean,
-    isCountingNumber,
-    isNumber,
-    isString,
-    isStringNonEmpty,
-
-    // Complex types
-    isArray,
-    isObject,
-    validateArrayItems,
-
-    // Wiki data
-    isDimensions,
-    isDirectory,
-    isDuration,
-    isFileExtension,
-    validateReference,
-    validateReferenceList,
-
-    // Compositional utilities
-    oneOf,
-} from '../src/data/validators.js';
-
-function test(msg, fn) {
-    _test(msg, t => {
-        try {
-            fn(t);
-        } catch (error) {
-            if (error instanceof AggregateError) {
-                showAggregate(error);
-            }
-            throw error;
-        }
-    });
-}
-
-test.skip = _test.skip;
-
-// Basic types
-
-test('isBoolean', t => {
-    t.plan(4);
-    t.ok(isBoolean(true));
-    t.ok(isBoolean(false));
-    t.throws(() => isBoolean(1), TypeError);
-    t.throws(() => isBoolean('yes'), TypeError);
-});
-
-test('isNumber', t => {
-    t.plan(6);
-    t.ok(isNumber(123));
-    t.ok(isNumber(0.05));
-    t.ok(isNumber(0));
-    t.ok(isNumber(-10));
-    t.throws(() => isNumber('413'), TypeError);
-    t.throws(() => isNumber(true), TypeError);
-});
-
-test('isCountingNumber', t => {
-    t.plan(6);
-    t.ok(isCountingNumber(3));
-    t.ok(isCountingNumber(1));
-    t.throws(() => isCountingNumber(1.75), TypeError);
-    t.throws(() => isCountingNumber(0), TypeError);
-    t.throws(() => isCountingNumber(-1), TypeError);
-    t.throws(() => isCountingNumber('612'), TypeError);
-});
-
-test('isString', t => {
-    t.plan(3);
-    t.ok(isString('hello!'));
-    t.ok(isString(''));
-    t.throws(() => isString(100), TypeError);
-});
-
-test('isStringNonEmpty', t => {
-    t.plan(4);
-    t.ok(isStringNonEmpty('hello!'));
-    t.throws(() => isStringNonEmpty(''), TypeError);
-    t.throws(() => isStringNonEmpty('     '), TypeError);
-    t.throws(() => isStringNonEmpty(100), TypeError);
-});
-
-// Complex types
-
-test('isArray', t => {
-    t.plan(3);
-    t.ok(isArray([]));
-    t.throws(() => isArray({}), TypeError);
-    t.throws(() => isArray('1, 2, 3'), TypeError);
-});
-
-test.skip('isDate', t => {
-    // TODO
-});
-
-test('isObject', t => {
-    t.plan(3);
-    t.ok(isObject({}));
-    t.ok(isObject([]));
-    t.throws(() => isObject(null), TypeError);
-});
-
-test('validateArrayItems', t => {
-    t.plan(6);
-
-    t.ok(validateArrayItems(isNumber)([3, 4, 5]));
-    t.ok(validateArrayItems(validateArrayItems(isNumber))([[3, 4], [4, 5], [6, 7]]));
-
-    let caughtError = null;
-    try {
-        validateArrayItems(isNumber)([10, 20, 'one hundred million consorts', 30]);
-    } catch (err) {
-        caughtError = err;
-    }
-
-    t.isNot(caughtError, null);
-    t.true(caughtError instanceof AggregateError);
-    t.is(caughtError.errors.length, 1);
-    t.true(caughtError.errors[0] instanceof TypeError);
-});
-
-// Wiki data
-
-test.skip('isColor', t => {
-    // TODO
-});
-
-test.skip('isCommentary', t => {
-    // TODO
-});
-
-test.skip('isContribution', t => {
-    // TODO
-});
-
-test.skip('isContributionList', t => {
-    // TODO
-});
-
-test('isDimensions', t => {
-    t.plan(6);
-    t.ok(isDimensions([1, 1]));
-    t.ok(isDimensions([50, 50]));
-    t.ok(isDimensions([5000, 1]));
-    t.throws(() => isDimensions([1]), TypeError);
-    t.throws(() => isDimensions([413, 612, 1025]), TypeError);
-    t.throws(() => isDimensions('800x200'), TypeError);
-});
-
-test('isDirectory', t => {
-    t.plan(6);
-    t.ok(isDirectory('savior-of-the-waking-world'));
-    t.ok(isDirectory('MeGaLoVania'));
-    t.ok(isDirectory('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'));
-    t.throws(() => isDirectory(123), TypeError);
-    t.throws(() => isDirectory(''), TypeError);
-    t.throws(() => isDirectory('troll saint nicholas and the quest for the holy pail'), TypeError);
-});
-
-test('isDuration', t => {
-    t.plan(5);
-    t.ok(isDuration(60));
-    t.ok(isDuration(0.02));
-    t.ok(isDuration(0));
-    t.throws(() => isDuration(-1), TypeError);
-    t.throws(() => isDuration('10:25'), TypeError);
-});
-
-test('isFileExtension', t => {
-    t.plan(6);
-    t.ok(isFileExtension('png'));
-    t.ok(isFileExtension('jpg'));
-    t.ok(isFileExtension('sub_loc'));
-    t.throws(() => isFileExtension(''), TypeError);
-    t.throws(() => isFileExtension('.jpg'), TypeError);
-    t.throws(() => isFileExtension('just an image bro!!!!'), TypeError);
-});
-
-test.skip('isName', t => {
-    // TODO
-});
-
-test.skip('isURL', t => {
-    // TODO
-});
-
-test('validateReference', t => {
-    t.plan(16);
-
-    const typeless = validateReference();
-    const track = validateReference('track');
-    const album = validateReference('album');
-
-    t.ok(track('track:doctor'));
-    t.ok(track('track:MeGaLoVania'));
-    t.ok(track('Showtime (Imp Strife Mix)'));
-    t.throws(() => track('track:troll saint nic'), TypeError);
-    t.throws(() => track('track:'), TypeError);
-    t.throws(() => track('album:homestuck-vol-1'), TypeError);
-
-    t.ok(album('album:sburb'));
-    t.ok(album('album:the-wanderers'));
-    t.ok(album('Homestuck Vol. 8'));
-    t.throws(() => album('album:Hiveswap Friendsim'), TypeError);
-    t.throws(() => album('album:'), TypeError);
-    t.throws(() => album('track:showtime-piano-refrain'), TypeError);
-
-    t.ok(typeless('Hopes and Dreams'));
-    t.ok(typeless('track:snowdin-town'));
-    t.throws(() => typeless(''), TypeError);
-    t.throws(() => typeless('album:undertale-soundtrack'));
-});
-
-test('validateReferenceList', t => {
-    const track = validateReferenceList('track');
-    const artist = validateReferenceList('artist');
-
-    t.plan(9);
-
-    t.ok(track(['track:fallen-down', 'Once Upon a Time']));
-    t.ok(artist(['artist:toby-fox', 'Mark Hadley']));
-    t.ok(track(['track:amalgam']));
-    t.ok(track([]));
-
-    let caughtError = null;
-    try {
-        track(['Dog', 'album:vaporwave-2016', 'Cat', 'artist:john-madden']);
-    } catch (err) {
-        caughtError = err;
-    }
-
-    t.isNot(caughtError, null);
-    t.true(caughtError instanceof AggregateError);
-    t.is(caughtError.errors.length, 2);
-    t.true(caughtError.errors[0] instanceof TypeError);
-    t.true(caughtError.errors[1] instanceof TypeError);
-});
-
-test('oneOf', t => {
-    t.plan(11);
-
-    const isStringOrNumber = oneOf(isString, isNumber);
-
-    t.ok(isStringOrNumber('hello world'));
-    t.ok(isStringOrNumber(42));
-    t.throws(() => isStringOrNumber(false));
-
-    const mockError = new Error();
-    const neverSucceeds = () => {
-        throw mockError;
-    };
-
-    const isStringOrGetRekt = oneOf(isString, neverSucceeds);
-
-    t.ok(isStringOrGetRekt('phew!'));
-
-    let caughtError = null;
-    try {
-        isStringOrGetRekt(0xdeadbeef);
-    } catch (err) {
-        caughtError = err;
-    }
-
-    t.isNot(caughtError, null);
-    t.true(caughtError instanceof AggregateError);
-    t.is(caughtError.errors.length, 2);
-    t.true(caughtError.errors[0] instanceof TypeError);
-    t.is(caughtError.errors[0].check, isString);
-    t.is(caughtError.errors[1], mockError);
-    t.is(caughtError.errors[1].check, neverSucceeds);
-});
diff --git a/test/lib/content-function.js b/test/lib/content-function.js
new file mode 100644
index 0000000..7bc6213
--- /dev/null
+++ b/test/lib/content-function.js
@@ -0,0 +1,255 @@
+import * as path from 'node:path';
+import {fileURLToPath} from 'node:url';
+import {inspect} from 'node:util';
+
+import chroma from 'chroma-js';
+
+import {showAggregate} from '#aggregate';
+import {getColors} from '#colors';
+import {quickLoadContentDependencies} from '#content-dependencies';
+import {quickEvaluate} from '#content-function';
+import * as html from '#html';
+import {internalDefaultStringsFile, processLanguageFile} from '#language';
+import {empty} from '#sugar';
+import {generateURLs, thumb, urlSpec} from '#urls';
+
+import mock from './generic-mock.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+export function testContentFunctions(t, message, fn) {
+  const urls = generateURLs(urlSpec);
+
+  t.test(message, async t => {
+    let loadedContentDependencies;
+
+    const language = await processLanguageFile(internalDefaultStringsFile);
+    const mocks = [];
+
+    const evaluate = ({
+      from = 'localized.home',
+      contentDependencies = {},
+      extraDependencies = {},
+      ...opts
+    }) => {
+      if (!loadedContentDependencies) {
+        throw new Error(`Await .load() before performing tests`);
+      }
+
+      const {to} = urls.from(from);
+
+      return cleanCatchAggregate(() => {
+        return quickEvaluate({
+          ...opts,
+          contentDependencies: {
+            ...contentDependencies,
+            ...loadedContentDependencies,
+          },
+          extraDependencies: {
+            html,
+            language,
+            thumb,
+            to,
+            urls,
+
+            cachebust: 413,
+            pagePath: ['home'],
+            appendIndexHTML: false,
+            getColors: c => getColors(c, {chroma}),
+
+            wikiData: {
+              wikiInfo: {},
+            },
+
+            ...extraDependencies,
+          },
+        });
+      });
+    };
+
+    evaluate.load = async (opts) => {
+      if (loadedContentDependencies) {
+        throw new Error(`Already loaded!`);
+      }
+
+      loadedContentDependencies = await asyncCleanCatchAggregate(() =>
+        quickLoadContentDependencies({
+          logging: false,
+          ...opts,
+        }));
+    };
+
+    evaluate.snapshot = (...args) => {
+      if (!loadedContentDependencies) {
+        throw new Error(`Await .load() before performing tests`);
+      }
+
+      const [description, opts] =
+        (typeof args[0] === 'string'
+          ? args
+          : ['output', ...args]);
+
+      let result = evaluate(opts);
+
+      if (opts.multiple) {
+        result = result.map(item => item.toString()).join('\n');
+      } else {
+        result = result.toString();
+      }
+
+      t.matchSnapshot(result, description);
+    };
+
+    evaluate.stubTemplate = name =>
+      // Creates a particularly permissable template, allowing any slot values
+      // to be stored and just outputting the contents of those slots as-are.
+      _stubTemplate(name, false);
+
+    evaluate.stubContentFunction = name =>
+      // Like stubTemplate, but instead of a template directly, returns
+      // an object describing a content function - suitable for passing
+      // into evaluate.mock.
+      _stubTemplate(name, true);
+
+    const _stubTemplate = (name, mockContentFunction) => {
+      const inspectNicely = (value, opts = {}) =>
+        inspect(value, {
+          ...opts,
+          colors: false,
+          sort: true,
+        });
+
+      const makeTemplate = formatContentFn =>
+        new (class extends html.Template {
+          #slotValues = {};
+
+          constructor() {
+            super({
+              content: () => this.#getContent(formatContentFn),
+            });
+          }
+
+          setSlots(slotNamesToValues) {
+            Object.assign(this.#slotValues, slotNamesToValues);
+          }
+
+          setSlot(slotName, slotValue) {
+            this.#slotValues[slotName] = slotValue;
+          }
+
+          #getContent(formatContentFn) {
+            const toInspect =
+              Object.fromEntries(
+                Object.entries(this.#slotValues)
+                  .filter(([key, value]) => value !== null));
+
+            const inspected =
+              inspectNicely(toInspect, {
+                breakLength: Infinity,
+                compact: true,
+                depth: Infinity,
+              });
+
+            return formatContentFn(inspected); `${name}: ${inspected}`;
+          }
+        });
+
+      if (mockContentFunction) {
+        return {
+          data: (...args) => ({args}),
+          generate: (data) =>
+            makeTemplate(slots => {
+              const argsLines =
+                (empty(data.args)
+                  ? []
+                  : inspectNicely(data.args, {depth: Infinity})
+                      .split('\n'));
+
+              return (`[mocked: ${name}` +
+
+                (empty(data.args)
+                  ? ``
+               : argsLines.length === 1
+                  ? `\n args: ${argsLines[0]}`
+                  : `\n args: ${argsLines[0]}\n` +
+                    argsLines.slice(1).join('\n').replace(/^/gm, ' ')) +
+
+                (!empty(data.args)
+                  ? `\n `
+                  : ` - `) +
+
+                (slots
+                  ? `slots: ${slots}]`
+                  : `slots: none]`));
+            }),
+        };
+      } else {
+        return makeTemplate(slots => `${name}: ${slots}`);
+      }
+    };
+
+    evaluate.mock = (...opts) => {
+      const {value, close} = mock(...opts);
+      mocks.push({close});
+      return value;
+    };
+
+    evaluate.mock.transformContent = {
+      transformContent: {
+        extraDependencies: ['html'],
+        data: content => ({content}),
+        slots: {mode: {type: 'string'}},
+        generate: ({content}) => content,
+      },
+    };
+
+    await fn(t, evaluate);
+
+    if (!empty(mocks)) {
+      cleanCatchAggregate(() => {
+        const errors = [];
+        for (const {close} of mocks) {
+          try {
+            close();
+          } catch (error) {
+            errors.push(error);
+          }
+        }
+        if (!empty(errors)) {
+          throw new AggregateError(errors, `Errors closing mocks`);
+        }
+      });
+    }
+  });
+}
+
+function printAggregate(error) {
+  if (error instanceof AggregateError) {
+    const message = showAggregate(error, {
+      showTraces: true,
+      print: false,
+      pathToFileURL: f => path.relative(path.join(__dirname, '../..'), fileURLToPath(f)),
+    });
+    for (const line of message.split('\n')) {
+      console.error(line);
+    }
+  }
+}
+
+function cleanCatchAggregate(fn) {
+  try {
+    return fn();
+  } catch (error) {
+    printAggregate(error);
+    throw error;
+  }
+}
+
+async function asyncCleanCatchAggregate(fn) {
+  try {
+    return await fn();
+  } catch (error) {
+    printAggregate(error);
+    throw error;
+  }
+}
diff --git a/test/lib/generic-mock.js b/test/lib/generic-mock.js
new file mode 100644
index 0000000..28309ab
--- /dev/null
+++ b/test/lib/generic-mock.js
@@ -0,0 +1,314 @@
+import {same} from 'tcompare';
+
+import {empty} from '#sugar';
+
+export default function mock(callback) {
+  const mocks = [];
+
+  const track = callback => (...args) => {
+    const {value, close} = callback(...args);
+    mocks.push({close});
+    return value;
+  };
+
+  const mock = {
+    function: track(mockFunction),
+  };
+
+  return {
+    value: callback(mock),
+    close: () => {
+      const errors = [];
+      for (const mock of mocks) {
+        try {
+          mock.close();
+        } catch (error) {
+          errors.push(error);
+        }
+      }
+      if (!empty(errors)) {
+        throw new AggregateError(errors, `Errors closing sub-mocks`);
+      }
+    },
+  };
+}
+
+export function mockFunction(...args) {
+  let name = '(anonymous)';
+  let behavior = null;
+
+  if (args.length === 2) {
+    if (
+      typeof args[0] === 'string' &&
+      typeof args[1] === 'function'
+    ) {
+      name = args[0];
+      behavior = args[1];
+    } else {
+      throw new TypeError(`Expected name to be a string`);
+    }
+  } else if (args.length === 1) {
+    if (typeof args[0] === 'string') {
+      name = args[0];
+    } else if (typeof args[0] === 'function') {
+      behavior = args[0];
+    } else if (args[0] !== null) {
+      throw new TypeError(`Expected string (name), function (behavior), both, or null / no arguments`);
+    }
+  } else if (args.length > 2) {
+    throw new TypeError(`Expected string (name), function (behavior), both, or null / no arguments`);
+  }
+
+  let currentCallDescription = newCallDescription();
+  const allCallDescriptions = [currentCallDescription];
+
+  const topLevelErrors = [];
+  let runningCallCount = 0;
+  let limitCallCount = false;
+  let markedAsOnce = false;
+
+  const fn = (...args) => {
+    const description = processCall(...args);
+    return description.behavior(...args);
+  };
+
+  fn.behavior = value => {
+    if (!(value === null || (
+      typeof value === 'function'
+    ))) {
+      throw new TypeError(`Expected function or null`);
+    }
+
+    currentCallDescription.behavior = behavior;
+    currentCallDescription.described = true;
+
+    return fn;
+  }
+
+  fn.argumentCount = value => {
+    if (!(value === null || (
+      typeof value === 'number' &&
+      value === parseInt(value) &&
+      value >= 0
+    ))) {
+      throw new TypeError(`Expected whole number or null`);
+    }
+
+    if (currentCallDescription.argsPattern) {
+      throw new TypeError(`Unexpected .argumentCount() when .args() has been called`);
+    }
+
+    currentCallDescription.argsPattern = {length: value};
+    currentCallDescription.described = true;
+
+    return fn;
+  };
+
+  fn.args = (...args) => {
+    const value = args[0];
+
+    if (args.length > 1 || !(value === null || Array.isArray(value))) {
+      throw new TypeError(`Expected one array or null`);
+    }
+
+    currentCallDescription.argsPattern = Object.fromEntries(
+      value
+        .map((v, i) => v === undefined ? false : [i, v])
+        .filter(Boolean)
+        .concat([['length', value.length]]));
+
+    currentCallDescription.described = true;
+
+    return fn;
+  };
+
+  fn.neverCalled = (...args) => {
+    if (!empty(args)) {
+      throw new TypeError(`Didn't expect any arguments`);
+    }
+
+    if (allCallDescriptions[0].described) {
+      throw new TypeError(`Unexpected .neverCalled() when any descriptions provided`);
+    }
+
+    limitCallCount = true;
+    allCallDescriptions.splice(0, allCallDescriptions.length);
+
+    currentCallDescription = new Proxy({}, {
+      set() {
+        throw new Error(`Unexpected description when .neverCalled() has been called`);
+      },
+    });
+
+    return fn;
+  };
+
+  fn.once = (...args) => {
+    if (!empty(args)) {
+      throw new TypeError(`Didn't expect any arguments`);
+    }
+
+    if (allCallDescriptions.length > 1) {
+      throw new TypeError(`Unexpected .once() when providing multiple descriptions`);
+    }
+
+    currentCallDescription.described = true;
+    limitCallCount = true;
+    markedAsOnce = true;
+
+    return fn;
+  };
+
+  fn.next = (...args) => {
+    if (!empty(args)) {
+      throw new TypeError(`Didn't expect any arguments`);
+    }
+
+    if (markedAsOnce) {
+      throw new TypeError(`Unexpected .next() when .once() has been called`);
+    }
+
+    currentCallDescription = newCallDescription();
+    allCallDescriptions.push(currentCallDescription);
+
+    limitCallCount = true;
+
+    return fn;
+  };
+
+  fn.repeat = times => {
+    // Note: This function should be called AFTER filling out the
+    // call description which is being repeated.
+
+    if (!(
+      typeof times === 'number' &&
+      times === parseInt(times) &&
+      times >= 2
+    )) {
+      throw new TypeError(`Expected whole number of at least 2`);
+    }
+
+    if (markedAsOnce) {
+      throw new TypeError(`Unexpected .repeat() when .once() has been called`);
+    }
+
+    // The current call description is already in the full list,
+    // so skip the first push.
+    for (let i = 2; i <= times; i++) {
+      allCallDescriptions.push(currentCallDescription);
+    }
+
+    // Prep a new description like when calling .next().
+    currentCallDescription = newCallDescription();
+    allCallDescriptions.push(currentCallDescription);
+
+    limitCallCount = true;
+
+    return fn;
+  };
+
+  return {
+    value: fn,
+    close: () => {
+      const totalCallCount = runningCallCount;
+      const expectedCallCount = countDescribedCalls();
+
+      if (limitCallCount && totalCallCount !== expectedCallCount) {
+        if (expectedCallCount > 1) {
+          topLevelErrors.push(new Error(`Expected ${expectedCallCount} calls, got ${totalCallCount}`));
+        } else if (expectedCallCount === 1) {
+          topLevelErrors.push(new Error(`Expected 1 call, got ${totalCallCount}`));
+        } else {
+          topLevelErrors.push(new Error(`Expected no calls, got ${totalCallCount}`));
+        }
+      }
+
+      if (topLevelErrors.length) {
+        throw new AggregateError(topLevelErrors, `Errors in mock ${name}`);
+      }
+    },
+  };
+
+  function newCallDescription() {
+    return {
+      described: false,
+      behavior: behavior ?? null,
+      argumentCount: null,
+      argsPattern: null,
+    };
+  }
+
+  function processCall(...args) {
+    const callErrors = [];
+
+    runningCallCount++;
+
+    // No further processing, this indicates the function shouldn't have been
+    // called at all and there aren't any descriptions to match this call with.
+    if (empty(allCallDescriptions)) {
+      return newCallDescription();
+    }
+
+    const currentCallNumber = runningCallCount;
+    const currentDescription = selectCallDescription(currentCallNumber);
+
+    const {
+      argumentCount,
+      argsPattern,
+    } = currentDescription;
+
+    if (argumentCount !== null && args.length !== argumentCount) {
+      callErrors.push(
+        new Error(`Argument count mismatch: expected ${argumentCount}, got ${args.length}`));
+    }
+
+    if (argsPattern !== null) {
+      const keysToCheck = Object.keys(argsPattern);
+      const argsAsObject = Object.fromEntries(
+        args
+          .map((v, i) => [i.toString(), v])
+          .filter(([i]) => keysToCheck.includes(i))
+          .concat([['length', args.length]]));
+
+      const {match, diff} = same(argsAsObject, argsPattern);
+      if (!match) {
+        callErrors.push(new Error(`Argument pattern mismatch:\n` + diff));
+      }
+    }
+
+    if (!empty(callErrors)) {
+      const aggregate = new AggregateError(callErrors, `Errors in call #${currentCallNumber}`);
+      topLevelErrors.push(aggregate);
+    }
+
+    return currentDescription;
+  }
+
+  function selectCallDescription(currentCallNumber) {
+    if (currentCallNumber > countDescribedCalls()) {
+      const lastDescription = lastCallDescription();
+      if (lastDescription.described) {
+        return newCallDescription();
+      } else {
+        return lastDescription;
+      }
+    } else {
+      return allCallDescriptions[currentCallNumber - 1];
+    }
+  }
+
+  function countDescribedCalls() {
+    if (empty(allCallDescriptions)) {
+      return 0;
+    }
+
+    return (
+      (lastCallDescription().described
+        ? allCallDescriptions.length
+        : allCallDescriptions.length - 1));
+  }
+
+  function lastCallDescription() {
+    return allCallDescriptions[allCallDescriptions.length - 1];
+  }
+}
diff --git a/test/lib/index.js b/test/lib/index.js
new file mode 100644
index 0000000..5fb5bf7
--- /dev/null
+++ b/test/lib/index.js
@@ -0,0 +1,6 @@
+Error.stackTraceLimit = Infinity;
+
+export * from './content-function.js';
+export * from './generic-mock.js';
+export * from './wiki-data.js';
+export * from './strict-match-error.js';
diff --git a/test/lib/strict-match-error.js b/test/lib/strict-match-error.js
new file mode 100644
index 0000000..e3b36e9
--- /dev/null
+++ b/test/lib/strict-match-error.js
@@ -0,0 +1,50 @@
+export function strictlyThrows(t, fn, pattern) {
+  const error = catchErrorOrNull(fn);
+
+  t.currentAssert = strictlyThrows;
+
+  if (error === null) {
+    t.fail(`expected to throw`);
+    return;
+  }
+
+  const nameAndMessage = `${pattern.constructor.name} ${pattern.message}`;
+  t.match(
+    prepareErrorForMatch(error),
+    prepareErrorForMatch(pattern),
+    (pattern instanceof AggregateError
+      ? `expected to throw: ${nameAndMessage} (${pattern.errors.length} error(s))`
+      : `expected to throw: ${nameAndMessage}`));
+}
+
+function prepareErrorForMatch(error) {
+  if (error instanceof RegExp) {
+    return {
+      message: error,
+    };
+  }
+
+  if (!(error instanceof Error)) {
+    return error;
+  }
+
+  const matchable = {
+    name: error.constructor.name,
+    message: error.message,
+  };
+
+  if (error instanceof AggregateError) {
+    matchable.errors = error.errors.map(prepareErrorForMatch);
+  }
+
+  return matchable;
+}
+
+function catchErrorOrNull(fn) {
+  try {
+    fn();
+    return null;
+  } catch (error) {
+    return error;
+  }
+}
diff --git a/test/lib/wiki-data.js b/test/lib/wiki-data.js
new file mode 100644
index 0000000..d2d860c
--- /dev/null
+++ b/test/lib/wiki-data.js
@@ -0,0 +1,72 @@
+import CacheableObject from '#cacheable-object';
+import find from '#find';
+import {withEntries} from '#sugar';
+import {linkWikiDataArrays} from '#yaml';
+
+export function linkAndBindWikiData(wikiData, {
+  inferAlbumsOwnTrackData = true,
+} = {}) {
+  function customLinkWikiDataArrays(wikiData, options = {}) {
+    linkWikiDataArrays(
+      (options.XXX_decacheWikiData
+        ? withEntries(wikiData, entries => entries
+            .map(([key, value]) => [key, value.slice()]))
+        : wikiData));
+
+    // If albumData is present, automatically set albums' ownTrackData values
+    // by resolving track sections' references against the full array. This is
+    // just a nicety for working with albums throughout tests.
+    if (inferAlbumsOwnTrackData && wikiData.albumData && wikiData.trackData) {
+      for (const album of wikiData.albumData) {
+        const trackSections =
+          CacheableObject.getUpdateValue(album, 'trackSections');
+
+        const trackRefs =
+          trackSections.flatMap(section => section.tracks);
+
+        album.ownTrackData =
+          trackRefs.map(ref =>
+            find.track(ref, wikiData.trackData, {mode: 'error'}));
+      }
+    }
+  }
+
+  customLinkWikiDataArrays(wikiData);
+
+  return {
+    // Mutate to make the below functions aware of new data objects, or of
+    // reordering the existing ones. Don't mutate arrays such as trackData
+    // in-place; assign completely new arrays to this wikiData object instead.
+    wikiData,
+
+    // Use this after you've mutated wikiData to assign new data arrays.
+    // It'll automatically relink everything on wikiData so all the objects
+    // are caught up to date.
+    linkWikiDataArrays:
+      customLinkWikiDataArrays
+        .bind(null, wikiData),
+
+    // Use this if you HAVEN'T mutated wikiData and just need to decache
+    // indirect dependencies on exposed properties of other data objects.
+    //
+    // XXX_decacheWikiData option should be used specifically to mark points
+    // where you *aren't* replacing any of the arrays under wikiData with
+    // new values, and are using linkWikiDataArrays to instead "decache" data
+    // properties which depend on any of them. It's currently not possible for
+    // a CacheableObject to depend directly on the value of a property exposed
+    // on some other CacheableObject, so when those values change, you have to
+    // manually decache before the object will realize its cache isn't valid
+    // anymore.
+    //
+    // The previous implementation for this involved overwriting the relevant
+    // wikiData properties with null, then replacing it with the original
+    // array, which effectively cleared a CacheableObject cache. But it isn't
+    // enough to clear other caches that depend on the identity of wikiData
+    // arrays, such as withReverseReferenceList, so now it replaces with fresh
+    // copies of the data arrays instead; the original identities don't get
+    // reused.
+    XXX_decacheWikiData:
+      customLinkWikiDataArrays
+        .bind(null, wikiData, {XXX_decacheWikiData: true}),
+  };
+}
diff --git a/test/snapshot/generateAdditionalFilesShortcut.js b/test/snapshot/generateAdditionalFilesShortcut.js
new file mode 100644
index 0000000..9825efa
--- /dev/null
+++ b/test/snapshot/generateAdditionalFilesShortcut.js
@@ -0,0 +1,36 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateAdditionalFilesShortcut (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('no additional files', {
+    name: 'generateAdditionalFilesShortcut',
+    args: [[]],
+  });
+
+  evaluate.snapshot('basic behavior', {
+    name: 'generateAdditionalFilesShortcut',
+    args: [
+      [
+        {
+          title: 'SBURB Wallpaper',
+          files: [
+            'sburbwp_1280x1024.jpg',
+            'sburbwp_1440x900.jpg',
+            'sburbwp_1920x1080.jpg',
+          ],
+        },
+        {
+          title: 'Alternate Covers',
+          description: 'This is just an example description.',
+          files: [
+            'Homestuck_Vol4_alt1.jpg',
+            'Homestuck_Vol4_alt2.jpg',
+            'Homestuck_Vol4_alt3.jpg',
+          ],
+        },
+      ],
+    ],
+  });
+});
diff --git a/test/snapshot/generateAlbumAdditionalFilesList.js b/test/snapshot/generateAlbumAdditionalFilesList.js
new file mode 100644
index 0000000..c25e568
--- /dev/null
+++ b/test/snapshot/generateAlbumAdditionalFilesList.js
@@ -0,0 +1,84 @@
+import t from 'tap';
+
+import {testContentFunctions} from '#test-lib';
+import thingConstructors from '#things';
+
+const {Album} = thingConstructors;
+
+testContentFunctions(t, 'generateAlbumAdditionalFilesList (snapshot)', async (t, evaluate) => {
+  const sizeMap = {
+    'sburbwp_1280x1024.jpg': 2500,
+    'sburbwp_1440x900.jpg': null,
+    'sburbwp_1920x1080.jpg': null,
+    'Internet Explorer.gif': 1,
+    'Homestuck_Vol4_alt1.jpg': 1234567,
+    'Homestuck_Vol4_alt2.jpg': 1234567,
+    'Homestuck_Vol4_alt3.jpg': 1234567,
+  };
+
+  const extraDependencies = {
+    getSizeOfAdditionalFile: file =>
+      Object.entries(sizeMap)
+        .find(key => file.includes(key))
+        ?.at(1) ?? null,
+  };
+
+  await evaluate.load({
+    mock: {
+      image: evaluate.stubContentFunction('image'),
+    },
+  });
+
+  const album = new Album();
+  album.directory = 'exciting-album';
+
+  evaluate.snapshot('no additional files', {
+    extraDependencies,
+    name: 'generateAlbumAdditionalFilesList',
+    args: [album, []],
+  });
+
+  try {
+    evaluate.snapshot('basic behavior', {
+      extraDependencies,
+      name: 'generateAlbumAdditionalFilesList',
+      args: [
+        album,
+        [
+          {
+            title: 'SBURB Wallpaper',
+            files: [
+              'sburbwp_1280x1024.jpg',
+              'sburbwp_1440x900.jpg',
+              'sburbwp_1920x1080.jpg',
+            ],
+          },
+          {
+            title: 'Fake Section',
+            description: 'No sizes for these files',
+            files: [
+              'oops.mp3',
+              'Internet Explorer.gif',
+              'daisy.mp3',
+            ],
+          },
+          {
+            title: `Empty Section`,
+            description: `These files haven't been made available.`,
+          },
+          {
+            title: 'Alternate Covers',
+            description: 'This is just an example description.',
+            files: [
+              'Homestuck_Vol4_alt1.jpg',
+              'Homestuck_Vol4_alt2.jpg',
+              'Homestuck_Vol4_alt3.jpg',
+            ],
+          },
+        ],
+      ],
+    });
+  } catch (error) {
+    console.log(error);
+  }
+});
diff --git a/test/snapshot/generateAlbumBanner.js b/test/snapshot/generateAlbumBanner.js
new file mode 100644
index 0000000..8e63308
--- /dev/null
+++ b/test/snapshot/generateAlbumBanner.js
@@ -0,0 +1,34 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateAlbumBanner (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('basic behavior', {
+    name: 'generateAlbumBanner',
+    args: [{
+      directory: 'cool-album',
+      hasBannerArt: true,
+      bannerDimensions: [800, 200],
+      bannerFileExtension: 'png',
+    }],
+  });
+
+  evaluate.snapshot('no dimensions', {
+    name: 'generateAlbumBanner',
+    args: [{
+      directory: 'cool-album',
+      hasBannerArt: true,
+      bannerDimensions: null,
+      bannerFileExtension: 'png',
+    }],
+  });
+
+  evaluate.snapshot('no banner', {
+    name: 'generateAlbumBanner',
+    args: [{
+      directory: 'cool-album',
+      hasBannerArt: false,
+    }],
+  });
+});
diff --git a/test/snapshot/generateAlbumCoverArtwork.js b/test/snapshot/generateAlbumCoverArtwork.js
new file mode 100644
index 0000000..9244c03
--- /dev/null
+++ b/test/snapshot/generateAlbumCoverArtwork.js
@@ -0,0 +1,36 @@
+import t from 'tap';
+
+import contentFunction from '#content-function';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateAlbumCoverArtwork (snapshot)', async (t, evaluate) => {
+  await evaluate.load({
+    mock: {
+      image: evaluate.stubContentFunction('image'),
+    },
+  });
+
+  const album = {
+    directory: 'bee-forus-seatbelt-safebee',
+    coverArtFileExtension: 'png',
+    color: '#f28514',
+    artTags: [
+      {name: 'Damara', directory: 'damara', isContentWarning: false},
+      {name: 'Cronus', directory: 'cronus', isContentWarning: false},
+      {name: 'Bees', directory: 'bees', isContentWarning: false},
+      {name: 'creepy crawlies', isContentWarning: true},
+    ],
+  };
+
+  evaluate.snapshot('display: primary', {
+    name: 'generateAlbumCoverArtwork',
+    args: [album],
+    slots: {mode: 'primary'},
+  });
+
+  evaluate.snapshot('display: thumbnail', {
+    name: 'generateAlbumCoverArtwork',
+    args: [album],
+    slots: {mode: 'thumbnail'},
+  });
+});
diff --git a/test/snapshot/generateAlbumReleaseInfo.js b/test/snapshot/generateAlbumReleaseInfo.js
new file mode 100644
index 0000000..3dea119
--- /dev/null
+++ b/test/snapshot/generateAlbumReleaseInfo.js
@@ -0,0 +1,74 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateAlbumReleaseInfo (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('basic behavior', {
+    name: 'generateAlbumReleaseInfo',
+    args: [{
+      artistContribs: [
+        {who: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, what: 'music probably'},
+        {who: {name: 'Tensei', directory: 'tensei', urls: ['https://tenseimusic.bandcamp.com/']}, what: 'hot jams'},
+      ],
+
+      coverArtistContribs: [
+        {who: {name: 'Hanni Brosh', directory: 'hb', urls: null}, what: null},
+      ],
+
+      wallpaperArtistContribs: [
+        {who: {name: 'Hanni Brosh', directory: 'hb', urls: null}, what: null},
+        {who: {name: 'Niklink', directory: 'niklink', urls: null}, what: 'edits'},
+      ],
+
+      bannerArtistContribs: [
+        {who: {name: 'Hanni Brosh', directory: 'hb', urls: null}, what: null},
+        {who: {name: 'Niklink', directory: 'niklink', urls: null}, what: 'edits'},
+      ],
+
+      name: 'AlterniaBound',
+      date: new Date('March 14, 2011'),
+      coverArtDate: new Date('April 1, 1991'),
+      urls: [
+        'https://homestuck.bandcamp.com/album/alterniabound-with-alternia',
+        'https://www.youtube.com/playlist?list=PLnVpmehyaOFZWO9QOZmD6A3TIK0wZ6xE2',
+        'https://www.youtube.com/watch?v=HO5V2uogkYc',
+      ],
+
+      tracks: [{duration: 253}, {duration: 372}],
+    }],
+  });
+
+  const sparse = {
+    artistContribs: [],
+    coverArtistContribs: [],
+    wallpaperArtistContribs: [],
+    bannerArtistContribs: [],
+
+    name: 'Suspicious Album',
+    urls: [],
+    tracks: [],
+  };
+
+  evaluate.snapshot('reduced details', {
+    name: 'generateAlbumReleaseInfo',
+    args: [sparse],
+  });
+
+  evaluate.snapshot('URLs only', {
+    name: 'generateAlbumReleaseInfo',
+    args: [{
+      ...sparse,
+      urls: ['https://homestuck.bandcamp.com/foo', 'https://soundcloud.com/bar'],
+    }],
+  });
+
+  evaluate.snapshot('equal cover art date', {
+    name: 'generateAlbumReleaseInfo',
+    args: [{
+      ...sparse,
+      date: new Date('2020-04-13'),
+      coverArtDate: new Date('2020-04-13'),
+    }],
+  });
+});
diff --git a/test/snapshot/generateAlbumSecondaryNav.js b/test/snapshot/generateAlbumSecondaryNav.js
new file mode 100644
index 0000000..709b062
--- /dev/null
+++ b/test/snapshot/generateAlbumSecondaryNav.js
@@ -0,0 +1,55 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateAlbumSecondaryNav (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  let album, group1, group2;
+
+  group1 = {name: 'VCG', directory: 'vcg', color: '#abcdef'};
+  group2 = {name: 'Bepis', directory: 'bepis', color: '#123456'};
+
+  album = {
+    date: new Date('2010-04-13'),
+    groups: [group1, group2],
+  };
+
+  group1.albums = [
+    {name: 'First', directory: 'first', date: new Date('2010-04-10')},
+    album,
+    {name: 'Last', directory: 'last', date: new Date('2010-06-12')},
+  ];
+
+  group2.albums = [
+    album,
+    {name: 'Second', directory: 'second', date: new Date('2011-04-13')},
+  ];
+
+  evaluate.snapshot('basic behavior, mode: album', {
+    name: 'generateAlbumSecondaryNav',
+    args: [album],
+    slots: {mode: 'album'},
+  });
+
+  evaluate.snapshot('basic behavior, mode: track', {
+    name: 'generateAlbumSecondaryNav',
+    args: [album],
+    slots: {mode: 'track'},
+  });
+
+  album = {
+    date: null,
+    groups: [group1, group2],
+  };
+
+  group1.albums = [
+    ...group1.albums,
+    album,
+  ];
+
+  evaluate.snapshot('dateless album in mixed group', {
+    name: 'generateAlbumSecondaryNav',
+    args: [album],
+    slots: {mode: 'album'},
+  });
+});
diff --git a/test/snapshot/generateAlbumSidebarGroupBox.js b/test/snapshot/generateAlbumSidebarGroupBox.js
new file mode 100644
index 0000000..f920bd9
--- /dev/null
+++ b/test/snapshot/generateAlbumSidebarGroupBox.js
@@ -0,0 +1,57 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateAlbumSidebarGroupBox (snapshot)', async (t, evaluate) => {
+  await evaluate.load({
+    mock: {
+      ...evaluate.mock.transformContent,
+    },
+  });
+
+  let album, group;
+
+  album = {
+    name: 'Middle',
+    directory: 'middle',
+    date: new Date('2010-04-13'),
+  };
+
+  group = {
+    name: 'VCG',
+    directory: 'vcg',
+    descriptionShort: 'Very cool group.',
+    urls: ['https://vcg.bandcamp.com/', 'https://youtube.com/@vcg'],
+    albums: [
+      {name: 'First', directory: 'first', date: new Date('2010-04-10')},
+      album,
+      {name: 'Last', directory: 'last', date: new Date('2010-06-12')},
+    ],
+  };
+
+  evaluate.snapshot('basic behavior, mode: album', {
+    name: 'generateAlbumSidebarGroupBox',
+    args: [album, group],
+    slots: {mode: 'album'},
+  });
+
+  evaluate.snapshot('basic behavior, mode: track', {
+    name: 'generateAlbumSidebarGroupBox',
+    args: [album, group],
+    slots: {mode: 'track'},
+  });
+
+  album = {
+    date: null,
+  };
+
+  group.albums = [
+    ...group.albums,
+    album,
+  ];
+
+  evaluate.snapshot('dateless album in mixed group', {
+    name: 'generateAlbumSidebarGroupBox',
+    args: [album, group],
+    slots: {mode: 'album'},
+  });
+});
diff --git a/test/snapshot/generateAlbumTrackList.js b/test/snapshot/generateAlbumTrackList.js
new file mode 100644
index 0000000..181cc1d
--- /dev/null
+++ b/test/snapshot/generateAlbumTrackList.js
@@ -0,0 +1,104 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateAlbumTrackList (snapshot)', async (t, evaluate) => {
+  await evaluate.load({
+    mock: {
+      generateAlbumTrackListMissingDuration:
+        evaluate.stubContentFunction('generateAlbumTrackListMissingDuration'),
+    },
+  });
+
+  const contribs1 = [
+    {who: {name: 'Apricot', directory: 'apricot', urls: null}},
+  ];
+
+  const contribs2 = [
+    {who: {name: 'Apricot', directory: 'apricot', urls: null}},
+    {who: {name: 'Peach', directory: 'peach', urls: ['https://peach.bandcamp.com/']}},
+  ];
+
+  const color1 = '#fb07ff';
+  const color2 = '#ea2e83';
+
+  const tracks = [
+    {name: 'Track 1', directory: 't1', duration: 20, artistContribs: contribs1, color: color1},
+    {name: 'Track 2', directory: 't2', duration: 0, artistContribs: contribs1, color: color1},
+    {name: 'Track 3', directory: 't3', duration: 40, artistContribs: contribs1, color: color1},
+    {name: 'Track 4', directory: 't4', duration: 0, artistContribs: contribs2, color: color2},
+  ];
+
+  const albumWithTrackSections = {
+    color: color1,
+    artistContribs: contribs1,
+    trackSections: [
+      {name: 'First section', tracks: tracks.slice(0, 3)},
+      {name: 'Second section', tracks: tracks.slice(3)},
+    ],
+    tracks,
+  };
+
+  const albumWithoutTrackSections = {
+    color: color1,
+    artistContribs: contribs1,
+    trackSections: [{isDefaultTrackSection: true, tracks}],
+    tracks,
+  };
+
+  const albumWithNoDuration = {
+    color: color1,
+    artistContribs: contribs1,
+    trackSections: [{isDefaultTrackSection: true, tracks: [tracks[1], tracks[3]]}],
+    tracks: [tracks[1], tracks[3]],
+  };
+
+  evaluate.snapshot(`basic behavior, with track sections`, {
+    name: 'generateAlbumTrackList',
+    args: [albumWithTrackSections],
+  });
+
+  evaluate.snapshot(`basic behavior, default track section`, {
+    name: 'generateAlbumTrackList',
+    args: [albumWithoutTrackSections],
+  });
+
+  evaluate.snapshot(`collapseDurationScope: never`, {
+    name: 'generateAlbumTrackList',
+    slots: {collapseDurationScope: 'never'},
+    multiple: [
+      {args: [albumWithTrackSections]},
+      {args: [albumWithoutTrackSections]},
+      {args: [albumWithNoDuration]},
+    ],
+  });
+
+  evaluate.snapshot(`collapseDurationScope: track`, {
+    name: 'generateAlbumTrackList',
+    slots: {collapseDurationScope: 'track'},
+    multiple: [
+      {args: [albumWithTrackSections]},
+      {args: [albumWithoutTrackSections]},
+      {args: [albumWithNoDuration]},
+    ],
+  });
+
+  evaluate.snapshot(`collapseDurationScope: section`, {
+    name: 'generateAlbumTrackList',
+    slots: {collapseDurationScope: 'section'},
+    multiple: [
+      {args: [albumWithTrackSections]},
+      {args: [albumWithoutTrackSections]},
+      {args: [albumWithNoDuration]},
+    ],
+  });
+
+  evaluate.snapshot(`collapseDurationScope: album`, {
+    name: 'generateAlbumTrackList',
+    slots: {collapseDurationScope: 'album'},
+    multiple: [
+      {args: [albumWithTrackSections]},
+      {args: [albumWithoutTrackSections]},
+      {args: [albumWithNoDuration]},
+    ],
+  });
+});
diff --git a/test/snapshot/generateBanner.js b/test/snapshot/generateBanner.js
new file mode 100644
index 0000000..ab57c3c
--- /dev/null
+++ b/test/snapshot/generateBanner.js
@@ -0,0 +1,22 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateBanner (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('basic behavior', {
+    name: 'generateBanner',
+    slots: {
+      path: ['media.albumBanner', 'cool-album', 'png'],
+      alt: 'Very cool banner art.',
+      dimensions: [800, 200],
+    },
+  });
+
+  evaluate.snapshot('no dimensions', {
+    name: 'generateBanner',
+    slots: {
+      path: ['media.albumBanner', 'cool-album', 'png'],
+    },
+  });
+});
diff --git a/test/snapshot/generateCoverArtwork.js b/test/snapshot/generateCoverArtwork.js
new file mode 100644
index 0000000..e35dd8d
--- /dev/null
+++ b/test/snapshot/generateCoverArtwork.js
@@ -0,0 +1,31 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateCoverArtwork (snapshot)', async (t, evaluate) => {
+  await evaluate.load({
+    mock: {
+      image: evaluate.stubContentFunction('image', {mock: true}),
+    },
+  });
+
+  const artTags = [
+    {name: 'Damara', directory: 'damara', isContentWarning: false},
+    {name: 'Cronus', directory: 'cronus', isContentWarning: false},
+    {name: 'Bees', directory: 'bees', isContentWarning: false},
+    {name: 'creepy crawlies', isContentWarning: true},
+  ];
+
+  const path = ['media.albumCover', 'bee-forus-seatbelt-safebee', 'png'];
+
+  evaluate.snapshot('display: primary', {
+    name: 'generateCoverArtwork',
+    args: [artTags],
+    slots: {path, mode: 'primary'},
+  });
+
+  evaluate.snapshot('display: thumbnail', {
+    name: 'generateCoverArtwork',
+    args: [artTags],
+    slots: {path, mode: 'thumbnail'},
+  });
+});
diff --git a/test/snapshot/generatePreviousNextLinks.js b/test/snapshot/generatePreviousNextLinks.js
new file mode 100644
index 0000000..0d952f5
--- /dev/null
+++ b/test/snapshot/generatePreviousNextLinks.js
@@ -0,0 +1,35 @@
+import t from 'tap';
+import * as html from '#html';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generatePreviousNextLinks (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  const quickSnapshot = (message, slots) =>
+    evaluate.snapshot(message, {
+      name: 'generatePreviousNextLinks',
+      slots,
+      postprocess: template => template.content.join('\n'),
+    });
+
+  quickSnapshot('basic behavior', {
+    previousLink: evaluate.stubTemplate('previous'),
+    nextLink: evaluate.stubTemplate('next'),
+  });
+
+  quickSnapshot('previous missing', {
+    nextLink: evaluate.stubTemplate('next'),
+  });
+
+  quickSnapshot('next missing', {
+    previousLink: evaluate.stubTemplate('previous'),
+  });
+
+  quickSnapshot('neither link present', {});
+
+  quickSnapshot('disable id', {
+    previousLink: evaluate.stubTemplate('previous'),
+    nextLink: evaluate.stubTemplate('next'),
+    id: false,
+  });
+});
diff --git a/test/snapshot/generateTrackAdditionalNamesBox.js b/test/snapshot/generateTrackAdditionalNamesBox.js
new file mode 100644
index 0000000..9c1e359
--- /dev/null
+++ b/test/snapshot/generateTrackAdditionalNamesBox.js
@@ -0,0 +1,107 @@
+import t from 'tap';
+
+import contentFunction from '#content-function';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateTrackAdditionalNamesBox (snapshot)', async (t, evaluate) => {
+  await evaluate.load({
+    mock: {
+      generateAdditionalNamesBox:
+        evaluate.stubContentFunction('generateAdditionalNamesBox'),
+    },
+  });
+
+  const stubTrack = {
+    additionalNames: [],
+    sharedAdditionalNames: [],
+    inferredAdditionalNames: [],
+  };
+
+  const quickSnapshot = (message, trackProperties) =>
+    evaluate.snapshot(message, {
+      name: 'generateTrackAdditionalNamesBox',
+      args: [{...stubTrack, ...trackProperties}],
+    });
+
+  quickSnapshot(`no additional names`, {});
+
+  quickSnapshot(`own additional names only`, {
+    additionalNames: [
+      {name: `Foo Bar`, annotation: `the Alps`},
+    ],
+  });
+
+  quickSnapshot(`shared additional names only`, {
+    sharedAdditionalNames: [
+      {name: `Bar Foo`, annotation: `the Rockies`},
+    ],
+  });
+
+  quickSnapshot(`inferred additional names only`, {
+    inferredAdditionalNames: [
+      {name: `Baz Baz`, from: [{directory: `the-pyrenees`}]},
+    ],
+  });
+
+  quickSnapshot(`multiple own`, {
+    additionalNames: [
+      {name: `Apple Time!`},
+      {name: `Pterodactyl Time!`},
+      {name: `Banana Time!`},
+    ],
+  });
+
+  quickSnapshot(`own and shared, some overlap`, {
+    additionalNames: [
+      {name: `weed dreams..`, annotation: `own annotation`},
+      {name: `夜間のMOON汗`, annotation: `own annotation`},
+    ],
+    sharedAdditionalNames: [
+      {name: `weed dreams..`, annotation: `shared annotation`},
+      {name: `GAMINGブラザー96`, annotation: `shared annotation`},
+    ],
+  });
+
+  quickSnapshot(`shared and inferred, some overlap`, {
+    sharedAdditionalNames: [
+      {name: `Coruscate`, annotation: `shared annotation`},
+      {name: `Arbroath`, annotation: `shared annotation`},
+    ],
+    inferredAdditionalNames: [
+      {name: `Arbroath`, from: [{directory: `inferred-from`}]},
+      {name: `Prana Ferox`, from: [{directory: `inferred-from`}]},
+    ],
+  });
+
+  quickSnapshot(`own and inferred, some overlap`, {
+    additionalNames: [
+      {name: `Ke$halo Strike Back`, annotation: `own annotation`},
+      {name: `Ironic Mania`, annotation: `own annotation`},
+    ],
+    inferredAdditionalNames: [
+      {name: `Ironic Mania`, from: [{directory: `inferred-from`}]},
+      {name: `ANARCHY::MEGASTRIFE`, from: [{directory: `inferred-from`}]},
+    ],
+  });
+
+  quickSnapshot(`own and shared and inferred, various overlap`, {
+    additionalNames: [
+      {name: `Own!`, annotation: `own annotation`},
+      {name: `Own! Shared!`, annotation: `own annotation`},
+      {name: `Own! Inferred!`, annotation: `own annotation`},
+      {name: `Own! Shared! Inferred!`, annotation: `own annotation`},
+    ],
+    sharedAdditionalNames: [
+      {name: `Shared!`, annotation: `shared annotation`},
+      {name: `Own! Shared!`, annotation: `shared annotation`},
+      {name: `Shared! Inferred!`, annotation: `shared annotation`},
+      {name: `Own! Shared! Inferred!`, annotation: `shared annotation`},
+    ],
+    inferredAdditionalNames: [
+      {name: `Inferred!`, from: [{directory: `inferred-from`}]},
+      {name: `Own! Inferred!`, from: [{directory: `inferred-from`}]},
+      {name: `Shared! Inferred!`, from: [{directory: `inferred-from`}]},
+      {name: `Own! Shared! Inferred!`, from: [{directory: `inferred-from`}]},
+    ],
+  });
+});
diff --git a/test/snapshot/generateTrackCoverArtwork.js b/test/snapshot/generateTrackCoverArtwork.js
new file mode 100644
index 0000000..1e651eb
--- /dev/null
+++ b/test/snapshot/generateTrackCoverArtwork.js
@@ -0,0 +1,61 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateTrackCoverArtwork (snapshot)', async (t, evaluate) => {
+  await evaluate.load({
+    mock: {
+      image: evaluate.stubContentFunction('image'),
+    },
+  });
+
+  const album = {
+    directory: 'bee-forus-seatbelt-safebee',
+    coverArtFileExtension: 'png',
+    artTags: [
+      {name: 'Damara', directory: 'damara', isContentWarning: false},
+      {name: 'Cronus', directory: 'cronus', isContentWarning: false},
+      {name: 'Bees', directory: 'bees', isContentWarning: false},
+      {name: 'creepy crawlies', isContentWarning: true},
+    ],
+  };
+
+  const track1 = {
+    directory: 'beesmp3',
+    hasUniqueCoverArt: true,
+    coverArtFileExtension: 'jpg',
+    color: '#f28514',
+    artTags: [{name: 'Bees', directory: 'bees', isContentWarning: false}],
+    album,
+  };
+
+  const track2 = {
+    directory: 'fake-bonus-track',
+    hasUniqueCoverArt: false,
+    color: '#abcdef',
+    album,
+  };
+
+  evaluate.snapshot('display: primary - unique art', {
+    name: 'generateTrackCoverArtwork',
+    args: [track1],
+    slots: {mode: 'primary'},
+  });
+
+  evaluate.snapshot('display: thumbnail - unique art', {
+    name: 'generateTrackCoverArtwork',
+    args: [track1],
+    slots: {mode: 'thumbnail'},
+  });
+
+  evaluate.snapshot('display: primary - no unique art', {
+    name: 'generateTrackCoverArtwork',
+    args: [track2],
+    slots: {mode: 'primary'},
+  });
+
+  evaluate.snapshot('display: thumbnail - no unique art', {
+    name: 'generateTrackCoverArtwork',
+    args: [track2],
+    slots: {mode: 'thumbnail'},
+  });
+});
diff --git a/test/snapshot/generateTrackReleaseInfo.js b/test/snapshot/generateTrackReleaseInfo.js
new file mode 100644
index 0000000..c72344b
--- /dev/null
+++ b/test/snapshot/generateTrackReleaseInfo.js
@@ -0,0 +1,51 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateTrackReleaseInfo (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  const artistContribs = [{who: {name: 'Toby Fox', directory: 'toby-fox', urls: null}, what: null}];
+  const coverArtistContribs = [{who: {name: 'Alpaca', directory: 'alpaca', urls: null}, what: '🔥'}];
+
+  evaluate.snapshot('basic behavior', {
+    name: 'generateTrackReleaseInfo',
+    args: [{
+      artistContribs,
+      name: 'An Apple Disaster!!',
+      date: new Date('2011-11-30'),
+      duration: 58,
+      urls: ['https://soundcloud.com/foo', 'https://youtube.com/watch?v=bar'],
+    }],
+  });
+
+  const sparse = {
+    artistContribs,
+    name: 'Suspicious Track',
+    date: null,
+    duration: null,
+    urls: [],
+  };
+
+  evaluate.snapshot('reduced details', {
+    name: 'generateTrackReleaseInfo',
+    args: [sparse],
+  });
+
+  evaluate.snapshot('cover artist contribs, non-unique', {
+    name: 'generateTrackReleaseInfo',
+    args: [{
+      ...sparse,
+      coverArtistContribs,
+      hasUniqueCoverArt: false,
+    }],
+  });
+
+  evaluate.snapshot('cover artist contribs, unique', {
+    name: 'generateTrackReleaseInfo',
+    args: [{
+      ...sparse,
+      coverArtistContribs,
+      hasUniqueCoverArt: true,
+    }],
+  });
+});
diff --git a/test/snapshot/image.js b/test/snapshot/image.js
new file mode 100644
index 0000000..447e7fa
--- /dev/null
+++ b/test/snapshot/image.js
@@ -0,0 +1,125 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  const quickSnapshot = (message, {extraDependencies, ...opts}) =>
+    evaluate.snapshot(message, {
+      name: 'image',
+      extraDependencies: {
+        checkIfImagePathHasCachedThumbnails: path => !path.endsWith('.gif'),
+        getSizeOfImagePath: () => 0,
+        getDimensionsOfImagePath: () => [600, 600],
+        getThumbnailEqualOrSmaller: () => 'medium',
+        getThumbnailsAvailableForDimensions: () =>
+          [['large', 800], ['medium', 400], ['small', 250]],
+        missingImagePaths: ['album-art/missing/cover.png'],
+        ...extraDependencies,
+      },
+      ...opts,
+    });
+
+  quickSnapshot('source via path', {
+    slots: {
+      path: ['media.albumCover', 'beyond-canon', 'png'],
+    },
+  });
+
+  quickSnapshot('source via src', {
+    slots: {
+      src: 'https://example.com/bananas.gif',
+    },
+  });
+
+  quickSnapshot('source missing', {
+    slots: {
+      missingSourceContent: 'Example of missing source message.',
+    },
+  });
+
+  quickSnapshot('width & height', {
+    slots: {
+      src: 'foobar',
+      width: 600,
+      height: 400,
+    },
+  });
+
+  quickSnapshot('square', {
+    slots: {
+      src: 'foobar',
+      square: true,
+    },
+  });
+
+  quickSnapshot('lazy with square', {
+    slots: {
+      src: 'foobar',
+      lazy: true,
+      square: true,
+    },
+  });
+
+  quickSnapshot('link with file size', {
+    extraDependencies: {
+      getSizeOfImagePath: () => 10 ** 6,
+    },
+    slots: {
+      path: ['media.albumCover', 'pingas', 'png'],
+      link: true,
+    },
+  });
+
+  quickSnapshot('content warnings via tags', {
+    args: [
+      [
+        {name: 'Dirk Strider', directory: 'dirk'},
+        {name: 'too cool for school', isContentWarning: true},
+      ],
+    ],
+    slots: {
+      path: ['media.albumCover', 'beyond-canon', 'png'],
+    },
+  });
+
+  evaluate.snapshot('thumbnail details', {
+    name: 'image',
+    extraDependencies: {
+      checkIfImagePathHasCachedThumbnails: () => true,
+      getSizeOfImagePath: () => 0,
+      getDimensionsOfImagePath: () => [900, 1200],
+      getThumbnailsAvailableForDimensions: () =>
+        [['voluminous', 1200], ['middling', 900], ['petite', 20]],
+      getThumbnailEqualOrSmaller: () => 'voluminous',
+      missingImagePaths: [],
+    },
+    slots: {
+      thumb: 'gargantuan',
+      path: ['media.albumCover', 'beyond-canon', 'png'],
+    },
+  });
+
+  quickSnapshot('thumb requested but source is gif', {
+    slots: {
+      thumb: 'medium',
+      path: ['media.flashArt', '5426', 'gif'],
+    },
+  });
+
+  quickSnapshot('missing image path', {
+    slots: {
+      thumb: 'medium',
+      path: ['media.albumCover', 'missing', 'png'],
+      link: true,
+    },
+  });
+
+  quickSnapshot('missing image path w/ missingSourceContent', {
+    slots: {
+      thumb: 'medium',
+      path: ['media.albumCover', 'missing', 'png'],
+      missingSourceContent: `Cover's missing, whoops`,
+    },
+  });
+});
diff --git a/test/snapshot/linkArtist.js b/test/snapshot/linkArtist.js
new file mode 100644
index 0000000..7b2114b
--- /dev/null
+++ b/test/snapshot/linkArtist.js
@@ -0,0 +1,30 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'linkArtist (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('basic behavior', {
+    name: 'linkArtist',
+    args: [
+      {
+        name: `Toby Fox`,
+        directory: `toby-fox`,
+      }
+    ],
+  });
+
+  evaluate.snapshot('prefer short name', {
+    name: 'linkArtist',
+    args: [
+      {
+        name: 'ICCTTCMDMIROTMCWMWFTPFTDDOTARHPOESWGBTWEATFCWSEBTSSFOFG',
+        nameShort: '55gore',
+        directory: '55gore',
+      },
+    ],
+    slots: {
+      preferShortName: true,
+    },
+  });
+});
diff --git a/test/snapshot/linkContribution.js b/test/snapshot/linkContribution.js
new file mode 100644
index 0000000..ebd3be5
--- /dev/null
+++ b/test/snapshot/linkContribution.js
@@ -0,0 +1,104 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'linkContribution (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  const quickSnapshot = (message, slots) =>
+    evaluate.snapshot(message, {
+      name: 'linkContribution',
+      multiple: [
+        {args: [
+          {who: {
+            name: 'Clark Powell',
+            directory: 'clark-powell',
+            urls: ['https://soundcloud.com/plazmataz'],
+          }, what: null},
+        ]},
+        {args: [
+          {who: {
+            name: 'Grounder & Scratch',
+            directory: 'the-big-baddies',
+            urls: [],
+          }, what: 'Snooping'},
+        ]},
+        {args: [
+          {who: {
+            name: 'Toby Fox',
+            directory: 'toby-fox',
+            urls: ['https://tobyfox.bandcamp.com/', 'https://toby.fox/'],
+          }, what: 'Arrangement'},
+        ]},
+      ],
+      slots,
+    });
+
+  quickSnapshot('showContribution & showIcons (inline)', {
+    showContribution: true,
+    showIcons: true,
+    iconMode: 'inline',
+  });
+
+  quickSnapshot('showContribution & showIcons (tooltip)', {
+    showContribution: true,
+    showIcons: true,
+    iconMode: 'tooltip',
+  });
+
+  quickSnapshot('only showContribution', {
+    showContribution: true,
+  });
+
+  quickSnapshot('only showIcons (inline)', {
+    showIcons: true,
+    iconMode: 'inline',
+  });
+
+  quickSnapshot('only showIcons (tooltip)', {
+    showContribution: true,
+    showIcons: true,
+    iconMode: 'tooltip',
+  });
+
+  quickSnapshot('no accents', {});
+
+  evaluate.snapshot('loads of links (inline)', {
+    name: 'linkContribution',
+    args: [
+      {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
+        'https://loremipsum.io',
+        'https://loremipsum.io/generator/',
+        'https://loremipsum.io/#meaning',
+        'https://loremipsum.io/#usage-and-examples',
+        'https://loremipsum.io/#controversy',
+        'https://loremipsum.io/#when-to-use-lorem-ipsum',
+        'https://loremipsum.io/#lorem-ipsum-all-the-things',
+        'https://loremipsum.io/#original-source',
+      ]}, what: null},
+    ],
+    slots: {showIcons: true},
+  });
+
+  evaluate.snapshot('loads of links (tooltip)', {
+    name: 'linkContribution',
+    args: [
+      {who: {name: 'Lorem Ipsum Lover', directory: 'lorem-ipsum-lover', urls: [
+        'https://loremipsum.io',
+        'https://loremipsum.io/generator/',
+        'https://loremipsum.io/#meaning',
+        'https://loremipsum.io/#usage-and-examples',
+        'https://loremipsum.io/#controversy',
+        'https://loremipsum.io/#when-to-use-lorem-ipsum',
+        'https://loremipsum.io/#lorem-ipsum-all-the-things',
+        'https://loremipsum.io/#original-source',
+      ]}, what: null},
+    ],
+    slots: {showIcons: true, iconMode: 'tooltip'},
+  });
+
+  quickSnapshot('no preventWrapping', {
+    showContribution: true,
+    showIcons: true,
+    preventWrapping: false,
+  });
+});
diff --git a/test/snapshot/linkExternal.js b/test/snapshot/linkExternal.js
new file mode 100644
index 0000000..90c98f4
--- /dev/null
+++ b/test/snapshot/linkExternal.js
@@ -0,0 +1,225 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'linkExternal (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('unknown domain (arbitrary world wide web path)', {
+    name: 'linkExternal',
+    args: ['https://snoo.ping.as/usual/i/see/'],
+  });
+
+  const urlsToArgs = urls =>
+    urls.map(url => ({args: [url]}));
+
+  const quickSnapshot = (message, urls, slots) =>
+    evaluate.snapshot(message, {
+      name: 'linkExternal',
+      slots,
+      multiple: urlsToArgs(urls),
+    });
+
+  const quickSnapshotAllStyles = (context, urls) => {
+    for (const style of ['platform', 'handle']) {
+      const message = `context: ${context}, style: ${style}`;
+      quickSnapshot(message, urls, {context, style});
+    }
+  };
+
+  // Try to comprehensively test every regular expression
+  // (in `match` and extractions like `handle` or `details`).
+
+  // Try to *also* represent a reasonable variety of what kinds
+  // of URLs appear throughout the wiki. (This should serve to
+  // identify areas which #external-links is expected to
+  // accommodate, regardless whether or not there is special
+  // attention given in the actual descriptors.)
+
+  // For normal custom-domain matches (e.g. Mastodon),
+  // it's OK to just test one custom domain in the list.
+
+  // Generally match the sorting order in externalLinkSpec,
+  // so corresponding and missing test cases are easy to locate.
+
+  quickSnapshotAllStyles('generic', [
+    // platform: appleMusic
+    'https://music.apple.com/us/artist/system-of-a-down/462715',
+
+    // platform: artstation
+    'https://www.artstation.com/eevaningtea',
+    'https://witnesstheabsurd.artstation.com/',
+
+    // platform: bandcamp
+    'https://music.solatrus.com/',
+    'https://homestuck.bandcamp.com/',
+
+    // platform: bluesky
+    'https://bsky.app/profile/jacobtheloofah.bsky.social',
+
+    // platform: carrd
+    'https://aliceflare.carrd.co',
+    'https://bigchaslappa.carrd.co/',
+
+    // platform: cohost
+    'https://cohost.org/cosmoptera',
+
+    // platform: deconreconstruction.music
+    'https://music.deconreconstruction.com/albums/catch-322',
+    'https://music.deconreconstruction.com/albums/catch-322?track=arcjecs-theme',
+
+    // platform: deconreconstruction
+    'https://www.deconreconstruction.com/',
+
+    // platform: deviantart
+    'https://culdhira.deviantart.com',
+    'https://www.deviantart.com/chesswanderlust-sama',
+    'https://www.deviantart.com/shilloshilloh/art/Homestuck-Jake-English-268874606',
+
+    // platform: facebook
+    'https://www.facebook.com/DoomedCloud/',
+    'https://www.facebook.com/pages/WoodenToaster/280642235307371',
+    'https://www.facebook.com/Svixy/posts/400018786702633',
+
+    // platform: fandom.mspaintadventures
+    'https://mspaintadventures.fandom.com/wiki/Draconian_Dignitary',
+    'https://mspaintadventures.fandom.com/wiki/',
+    'https://mspaintadventures.fandom.com/',
+
+    // platform: fandom
+    'https://community.fandom.com/',
+    'https://community.fandom.com/wiki/',
+    'https://community.fandom.com/wiki/Community_Central',
+
+    // platform: gamebanana
+    'https://gamebanana.com/members/2028092',
+    'https://gamebanana.com/mods/459476',
+
+    // platform: homestuck
+    'https://homestuck.com/',
+
+    // platform: hsmusic.archive
+    'https://hsmusic.wiki/media/misc/archive/Firefly%20Cloud%20Remix.mp3',
+
+    // platform: hsmusic
+    'https://hsmusic.wiki/feedback/',
+
+    // platform: internetArchive
+    'https://archive.org/details/a-life-well-lived',
+    'https://archive.org/details/VastError_Volume1/11+Renaissance.mp3',
+
+    // platform: instagram
+    'https://instagram.com/bass.and.noises',
+    'https://www.instagram.com/levc_egm/',
+
+    // platform: itch
+    'https://tuyoki.itch.io/',
+    'https://itch.io/profile/bravelittletoreador',
+
+    // platform: ko-fi
+    'https://ko-fi.com/gnaach',
+
+    // platform: linktree
+    'https://linktr.ee/bbpanzu',
+
+    // platform: mastodon
+    'https://types.pl/',
+
+    // platform: mspfa
+    'https://canwc.mspfa.com/',
+    'https://mspfa.com/?s=12003&p=1045',
+    'https://mspfa.com/user/?u=103334508819793669241',
+
+    // platform: neocities
+    'https://wodaro.neocities.org',
+    'https://neomints.neocities.org/',
+
+    // platform: newgrounds
+    'https://buzinkai.newgrounds.com/',
+    'https://www.newgrounds.com/audio/listen/1256058',
+
+    // platform: patreon
+    'https://www.patreon.com/CecilyRenns',
+
+    // platform: poetryFoundation
+    'https://www.poetryfoundation.org/poets/christina-rossetti',
+    'https://www.poetryfoundation.org/poems/45000/remember-56d224509b7ae',
+
+    // platform: soundcloud
+    'https://soundcloud.com/plazmataz',
+    'https://soundcloud.com/worthikids/1-i-accidentally-broke-my',
+
+    // platform: spotify
+    'https://open.spotify.com/artist/63SNNpNOicDzG3LY82G4q3',
+    'https://open.spotify.com/album/0iHvPD8rM3hQa0qeVtPQ3t',
+    'https://open.spotify.com/track/6YEGQH32aAXb9vQQbBrPlw',
+
+    // platform: tiktok
+    'https://www.tiktok.com/@richaadeb',
+
+    // platform: toyhouse
+    'https://toyhou.se/ghastaboo',
+
+    // platform: tumblr
+    'https://aeritus.tumblr.com/',
+    'https://vol5anthology.tumblr.com/post/159528808107/hey-everyone-its-413-and-that-means-we-have',
+    'https://www.tumblr.com/electricwestern',
+    'https://www.tumblr.com/spellmynamewithabang/142767566733/happy-413-this-is-the-first-time-anyones-heard',
+
+    // platform: twitch
+    'https://www.twitch.tv/ajhebard',
+    'https://www.twitch.tv/vargskelethor/',
+
+    // platform: twitter
+    'https://twitter.com/awkwarddoesart',
+    'https://twitter.com/purenonsens/',
+    'https://twitter.com/circlejourney/status/1202265927183548416',
+
+    // platform: waybackMachine
+    'https://web.archive.org/web/20120405160556/https://homestuck.bandcamp.com/album/colours-and-mayhem-universe-a',
+    'https://web.archive.org/web/20160807111207/http://griffinspacejam.com:80/',
+
+    // platform: wikipedia
+    'https://en.wikipedia.org/wiki/Haydn_Quartet_(vocal_ensemble)',
+
+    // platform: youtube
+    'https://youtube.com/@bani-chan8949',
+    'https://www.youtube.com/@Razzie16',
+    'https://www.youtube.com/channel/UCQXfvlKkpbOqEz4BepHqK7g',
+    'https://www.youtube.com/watch?v=6ekVnZm29kw',
+    'https://youtu.be/WBkC038wSio',
+    'https://www.youtube.com/playlist?list=PLy5UGIMKOXpONMExgI7lVYFwQa54QFp_H',
+  ]);
+
+  quickSnapshotAllStyles('album', [
+    'https://youtu.be/abc',
+    'https://youtube.com/watch?v=abc',
+    'https://youtube.com/Playlist?list=kweh',
+  ]);
+
+  quickSnapshotAllStyles('albumNoTracks', [
+    'https://youtu.be/abc',
+    'https://youtube.com/watch?v=abc',
+    'https://youtube.com/Playlist?list=kweh',
+  ]);
+
+  quickSnapshotAllStyles('albumOneTrack', [
+    'https://youtu.be/abc',
+    'https://youtube.com/watch?v=abc',
+    'https://youtube.com/Playlist?list=kweh',
+  ]);
+
+  quickSnapshotAllStyles('albumMultipleTracks', [
+    'https://youtu.be/abc',
+    'https://youtube.com/watch?v=abc',
+    'https://youtube.com/Playlist?list=kweh',
+  ]);
+
+  quickSnapshotAllStyles('flash', [
+    'https://www.bgreco.net/hsflash/002238.html',
+    'https://homestuck.com/story/1234',
+    'https://homestuck.com/story/pony',
+    'https://www.youtube.com/watch?v=wKgOp3Kg2wI',
+    'https://youtu.be/IOcvkkklWmY',
+    'https://some.external.site/foo/bar/',
+  ]);
+});
diff --git a/test/snapshot/linkTemplate.js b/test/snapshot/linkTemplate.js
new file mode 100644
index 0000000..300065e
--- /dev/null
+++ b/test/snapshot/linkTemplate.js
@@ -0,0 +1,63 @@
+import t from 'tap';
+import * as html from '#html';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'linkTemplate (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  evaluate.snapshot('fill many slots', {
+    name: 'linkTemplate',
+
+    slots: {
+      'href': 'https://hsmusic.wiki/media/cool file.pdf',
+      'hash': 'fooey',
+      'attributes': {class: 'dog', id: 'cat1'},
+      'content': 'My Cool Link',
+    },
+  });
+
+  evaluate.snapshot('fill path slot & provide appendIndexHTML', {
+    name: 'linkTemplate',
+
+    extraDependencies: {
+      to: (...path) => '/c*lzone/' + path.join('/') + '/',
+      appendIndexHTML: true,
+    },
+
+    slots: {
+      path: ['myCoolPath', 'ham', 'pineapple', 'tomato'],
+      content: 'delish',
+    },
+  });
+
+  evaluate.snapshot('special characters in path argument', {
+    name: 'linkTemplate',
+    slots: {
+      path: [
+        'media.albumAdditionalFile',
+        'homestuck-vol-1',
+        'Showtime (Piano Refrain) - #xXxAwesomeSheetMusick?rxXx#.pdf',
+      ],
+      content: `Damn, that's some good sheet music`,
+    },
+  });
+
+  evaluate.snapshot('missing content', {
+    name: 'linkTemplate',
+    slots: {href: 'banana'},
+  });
+
+  evaluate.snapshot('link in content', {
+    name: 'linkTemplate',
+    slots: {
+      hash: 'the-more-ye-know',
+      content: [
+        `Oh geez oh heck`,
+        html.tag('a', {href: 'dogs'}, `There's a link in here!!`),
+        `But here's <b>a normal tag.</b>`,
+        html.tag('div', `Gotta keep them normal tags.`),
+        html.tag('div', `But not... <a href="#">NESTED LINKS, OOO.</a>`),
+      ],
+    },
+  });
+});
diff --git a/test/snapshot/linkThing.js b/test/snapshot/linkThing.js
new file mode 100644
index 0000000..502db6d
--- /dev/null
+++ b/test/snapshot/linkThing.js
@@ -0,0 +1,94 @@
+import t from 'tap';
+import * as html from '#html';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'linkThing (snapshot)', async (t, evaluate) => {
+  await evaluate.load();
+
+  const quickSnapshot = (message, oneOrMultiple) =>
+    evaluate.snapshot(message,
+      (Array.isArray(oneOrMultiple)
+        ? {name: 'linkThing', multiple: oneOrMultiple}
+        : {name: 'linkThing', ...oneOrMultiple}));
+
+  quickSnapshot('basic behavior', {
+    args: ['localized.track', {
+      directory: 'foo',
+      color: '#abcdef',
+      name: `Cool track!`,
+    }],
+  });
+
+  quickSnapshot('preferShortName', {
+    args: ['localized.tag', {
+      directory: 'five-oceanfalls',
+      name: 'Five (Oceanfalls)',
+      nameShort: 'Five',
+    }],
+    slots: {preferShortName: true},
+  });
+
+  quickSnapshot('tooltip & content', {
+    args: ['localized.album', {
+      directory: 'beyond-canon',
+      name: 'Beyond Canon',
+      nameShort: 'BC',
+    }],
+    multiple: [
+      {slots: {tooltipStyle: 'none'}},
+      {slots: {tooltipStyle: 'browser'}},
+      {slots: {tooltipStyle: 'browser', content: 'Next'}},
+      {slots: {tooltipStyle: 'auto'}},
+      {slots: {tooltipStyle: 'auto', preferShortName: true}},
+      {slots: {tooltipStyle: 'auto', preferShortName: true, content: 'Next'}},
+      {slots: {tooltipStyle: 'auto', content: 'Next'}},
+      {slots: {tooltipStyle: 'wiki'}},
+      {slots: {tooltipStyle: 'wiki', content: 'Next'}},
+      {slots: {content: 'Banana'}},
+    ],
+  });
+
+  quickSnapshot('color', {
+    args: ['localized.track', {
+      directory: 'showtime-piano-refrain',
+      name: 'Showtime (Piano Refrain)',
+      color: '#38f43d',
+    }],
+    multiple: [
+      {slots: {color: false}},
+      {slots: {color: true}},
+      {slots: {color: '#aaccff'}},
+      {slots: {color: '#aaccff', tooltipStyle: 'wiki'}},
+    ],
+  });
+
+  quickSnapshot('tags in name escaped', [
+    {args: ['localized.track', {
+      directory: 'foo',
+      name: `<a href="SNOOPING">AS USUAL</a> I SEE`,
+    }]},
+    {args: ['localized.track', {
+      directory: 'bar',
+      name: `<b>boldface</b>`,
+    }]},
+    {args: ['localized.album', {
+      directory: 'exile',
+      name: '>Exile<',
+    }]},
+    {args: ['localized.track', {
+      directory: 'heart',
+      name: '<3',
+    }]},
+  ]);
+
+  quickSnapshot('nested links in content stripped', {
+    args: ['localized.staticPage', {directory: 'foo', name: 'Foo'}],
+    slots: {
+      content:
+        html.tag('b', {[html.joinChildren]: ''}, [
+          html.tag('a', {href: 'bar'}, `Oooo!`),
+          ` Very spooky.`,
+        ]),
+    },
+  });
+});
diff --git a/test/snapshot/transformContent.js b/test/snapshot/transformContent.js
new file mode 100644
index 0000000..87e337e
--- /dev/null
+++ b/test/snapshot/transformContent.js
@@ -0,0 +1,161 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'transformContent (snapshot)', async (t, evaluate) => {
+  await evaluate.load({
+    mock: {
+      image: evaluate.stubContentFunction('image'),
+    },
+  });
+
+  const extraDependencies = {
+    wikiData: {
+      albumData: [
+        {directory: 'cool-album', name: 'Cool Album', color: '#123456'},
+      ],
+    },
+
+    to: (key, ...args) => `to-${key}/${args.join('/')}`,
+  };
+
+  const quickSnapshot = (message, content, slots) =>
+    evaluate.snapshot(message, {
+      name: 'transformContent',
+      args: [content],
+      extraDependencies,
+      slots,
+    });
+
+  quickSnapshot(
+    'two text paragraphs',
+      `Hello, world!\n` +
+      `Wow, this is very cool.`);
+
+  quickSnapshot(
+    'links to a thing',
+      `This is [[album:cool-album|my favorite album]].\n` +
+      `That's right, [[album:cool-album]]!`);
+
+  quickSnapshot(
+    'indent on a directly following line',
+      `<div>\n` +
+      `    <span>Wow!</span>\n` +
+      `</div>`);
+
+  quickSnapshot(
+    'indent on an indierctly following line',
+      `Some text.\n` +
+      `Yes, some more text.\n` +
+      `\n` +
+      `    I am hax0rz!!\n` +
+      `    All yor base r blong 2 us.\n` +
+      `\n` +
+      `Aye.\n` +
+      `Aye aye aye.`);
+
+  quickSnapshot(
+    'hanging indent list',
+      `Hello!\n` +
+      `\n` +
+      `* I am a list item and I\n` +
+      `  go on and on and on\n` +
+      `  and on and on and on.\n` +
+      `\n` +
+      `* I am another list item.\n` +
+      `  Yeah.\n` +
+      `\n` +
+      `In-between!\n` +
+      `\n` +
+      `* Spooky,\n` +
+      `  spooky, I say!\n` +
+      `* Following list item.\n` +
+      `  No empty line around me.\n` +
+      `* Very cool.\n` +
+      `  So, so cool.\n` +
+      `\n` +
+      `Goodbye!`);
+
+  quickSnapshot(
+    'inline images',
+      `<img src="snooping.png"> as USUAL...\n` +
+      `What do you know? <img src="cowabunga.png" width="24" height="32">\n` +
+      `[[album:cool-album|I'm on the left.]]<img src="im-on-the-right.jpg">\n` +
+      `<img src="im-on-the-left.jpg">[[album:cool-album|I'm on the right.]]\n` +
+      `Media time! <img src="media/misc/interesting.png"> Oh yeah!\n` +
+      `<img src="must.png"><img src="stick.png"><img src="together.png">\n` +
+      `And... all done! <img src="end-of-source.png">`);
+
+  quickSnapshot(
+    'non-inline image #1',
+      `<img src="spark.png">`);
+
+  quickSnapshot(
+    'non-inline image #2',
+      `Rad.\n` +
+      `<img src="spark.png">`);
+
+  quickSnapshot(
+    'non-inline image #3',
+      `<img src="spark.png">\n` +
+      `Baller.`);
+
+  quickSnapshot(
+    'dates',
+      `[[date:2023-04-13]] Yep!\n` +
+      `Very nice: [[date:25 October 2413]]`);
+
+  quickSnapshot(
+    'super basic string',
+      `Neat listing: [[string:listingPage.listAlbums.byDate.title]]`);
+
+  quickSnapshot(
+    'basic markdown',
+      `Hello *world!* This is **SO COOL.**`);
+
+  quickSnapshot(
+    'escape entire tag',
+      `\\[[album:cool-album|spooky]] [[album:cool-album|scary]]`);
+
+  quickSnapshot(
+    'escape end of tag',
+      `My favorite album is [[album:cool-album|[Tactical Omission\\]]].\n` +
+      `Your favorite album is [[album:cool-album|[Tactical Wha-Huh-Now]]].`);
+
+  quickSnapshot(
+    'escape markdown',
+      `What will it be, *ye fool?* \\*arr*`);
+
+  quickSnapshot(
+    'lyrics - basic line breaks',
+      `Hey, ho\n` +
+      `And away we go\n` +
+      `Truly, music\n` +
+      `\n` +
+      `(Oh yeah)\n` +
+      `(That's right)`,
+      {mode: 'lyrics'});
+
+  quickSnapshot(
+    'lyrics - repeated and edge line breaks',
+      `\n\nWell, you know\nHow it goes\n\n\nYessiree\n\n\n`,
+      {mode: 'lyrics'});
+
+  quickSnapshot(
+    'lyrics - line breaks around tags',
+      `The date be [[date:13 April 2004]]\n` +
+      `I say, the date be [[date:13 April 2004]]\n` +
+      `[[date:13 April 2004]]\n` +
+      `[[date:13 April 2004]][[date:13 April 2004]][[date:13 April 2004]]\n` +
+      `(Aye!)\n` +
+      `\n` +
+      `[[date:13 April 2004]]\n` +
+      `[[date:13 April 2004]][[date:13 April 2004]]\n` +
+      `[[date:13 April 2004]]\n` +
+      `\n` +
+      `[[date:13 April 2004]]\n` +
+      `[[date:13 April 2004]], and don't ye forget it`,
+      {mode: 'lyrics'});
+
+  // TODO: Snapshots for mode: inline
+  // TODO: Snapshots for mode: single-link
+});
diff --git a/test/things.js b/test/things.js
deleted file mode 100644
index 7274515..0000000
--- a/test/things.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import test from 'tape';
-
-import {
-    Album,
-    Thing,
-    Track,
-    TrackGroup,
-} from '../src/data/things.js';
-
-function stubAlbum(tracks) {
-    const album = new Album();
-    const trackGroup = new TrackGroup();
-    trackGroup.tracksByRef = tracks.map(t => Thing.getReference(t));
-    album.trackGroups = [trackGroup];
-    album.trackData = tracks;
-    return album;
-}
-
-test(`Track.coverArtDate`, t => {
-    t.plan(5);
-
-    // Priority order is as follows, with the last (trackCoverArtDate) being
-    // greatest priority.
-    const albumDate = new Date('2010-10-10');
-    const albumTrackArtDate = new Date('2012-12-12');
-    const trackDateFirstReleased = new Date('2008-08-08');
-    const trackCoverArtDate = new Date('2009-09-09');
-
-    const track = new Track();
-    track.directory = 'foo';
-
-    const album = stubAlbum([track]);
-
-    track.albumData = [album];
-
-    // 1. coverArtDate defaults to null
-
-    t.is(track.coverArtDate, null);
-
-    // 2. coverArtDate inherits album release date
-
-    album.date = albumDate;
-
-    // XXX clear cache so change in album's property is reflected
-    track.albumData = [];
-    track.albumData = [album];
-
-    t.is(track.coverArtDate, albumDate);
-
-    // 3. coverArtDate inherits album trackArtDate
-
-    album.trackArtDate = albumTrackArtDate;
-
-    // XXX clear cache again
-    track.albumData = [];
-    track.albumData = [album];
-
-    t.is(track.coverArtDate, albumTrackArtDate);
-
-    // 4. coverArtDate is overridden dateFirstReleased
-
-    track.dateFirstReleased = trackDateFirstReleased;
-
-    t.is(track.coverArtDate, trackDateFirstReleased);
-
-    // 5. coverArtDate is overridden coverArtDate
-
-    track.coverArtDate = trackCoverArtDate;
-
-    t.is(track.coverArtDate, trackCoverArtDate);
-});
diff --git a/test/unit/content/dependencies/generateAlbumTrackList.js b/test/unit/content/dependencies/generateAlbumTrackList.js
new file mode 100644
index 0000000..7b3ecd3
--- /dev/null
+++ b/test/unit/content/dependencies/generateAlbumTrackList.js
@@ -0,0 +1,40 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateAlbumTrackList (unit)', async (t, evaluate) => {
+  await evaluate.load({
+    mock: {
+      generateAlbumTrackListItem: {
+        extraDependencies: ['html'],
+        data: track => track.name,
+        generate: (name, {html}) =>
+          html.tag('li', `Item: ${name}`),
+      },
+    },
+  });
+
+  let readDuration = false;
+
+  const track = (name, duration) => ({
+    name,
+    get duration() {
+      readDuration = true;
+      return duration;
+    },
+  });
+
+  const tracks = [
+    track('Track 1', 30),
+    track('Track 2', 15),
+  ];
+
+  evaluate({
+    name: 'generateAlbumTrackList',
+    args: [{
+      trackSections: [{isDefaultTrackSection: true, tracks}],
+      tracks,
+    }],
+  });
+
+  t.notOk(readDuration, 'expect no access to track.duration property');
+});
diff --git a/test/unit/content/dependencies/linkArtist.js b/test/unit/content/dependencies/linkArtist.js
new file mode 100644
index 0000000..e6e19d2
--- /dev/null
+++ b/test/unit/content/dependencies/linkArtist.js
@@ -0,0 +1,31 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'linkArtist (unit)', async (t, evaluate) => {
+  const artistObject = {};
+  const linkTemplate = {};
+
+  await evaluate.load({
+    mock: evaluate.mock(mock => ({
+      linkThing: {
+        relations: mock.function('linkThing.relations', () => ({}))
+          .args([undefined, 'localized.artist', artistObject])
+          .once(),
+
+        data: mock.function('linkThing.data', () => ({}))
+          .args(['localized.artist', artistObject])
+          .once(),
+
+        generate: mock.function('linkThing.data', () => linkTemplate)
+          .once(),
+      }
+    })),
+  });
+
+  const result = evaluate({
+    name: 'linkArtist',
+    args: [artistObject],
+  });
+
+  t.equal(result, linkTemplate);
+});
diff --git a/test/unit/content/dependencies/linkContribution.js b/test/unit/content/dependencies/linkContribution.js
new file mode 100644
index 0000000..9490890
--- /dev/null
+++ b/test/unit/content/dependencies/linkContribution.js
@@ -0,0 +1,122 @@
+import t from 'tap';
+import {testContentFunctions} from '#test-lib';
+
+t.test('generateContributionLinks (unit)', async t => {
+  const who1 = {
+    name: 'Clark Powell',
+    directory: 'clark-powell',
+    urls: ['https://soundcloud.com/plazmataz'],
+  };
+
+  const who2 = {
+    name: 'Grounder & Scratch',
+    directory: 'the-big-baddies',
+    urls: [],
+  };
+
+  const who3 = {
+    name: 'Toby Fox',
+    directory: 'toby-fox',
+    urls: ['https://tobyfox.bandcamp.com/', 'https://toby.fox/'],
+  };
+
+  const what1 = null;
+  const what2 = 'Snooping';
+  const what3 = 'Arrangement';
+
+  await testContentFunctions(t, 'generateContributionLinks (unit 1)', async (t, evaluate) => {
+    const slots = {
+      showContribution: true,
+      showIcons: true,
+    };
+
+    await evaluate.load({
+      mock: evaluate.mock(mock => ({
+        linkArtist: {
+          relations: mock.function('linkArtist.relations', () => ({}))
+            .args([undefined, who1]).next()
+            .args([undefined, who2]).next()
+            .args([undefined, who3]),
+
+          data: mock.function('linkArtist.data', () => ({}))
+            .args([who1]).next()
+            .args([who2]).next()
+            .args([who3]),
+
+          // This can be tweaked to return a specific (mocked) template
+          // for each artist if we need to test for slots in the future.
+          generate: mock.function('linkArtist.generate', () => 'artist link')
+            .repeat(3),
+        },
+
+        linkExternalAsIcon: {
+          data: mock.function('linkExternalAsIcon.data', () => ({}))
+            .args([who1.urls[0]]).next()
+            .args([who3.urls[0]]).next()
+            .args([who3.urls[1]]),
+
+          generate: mock.function('linkExternalAsIcon.generate', () => 'icon')
+            .repeat(3),
+        }
+      })),
+    });
+
+    evaluate({
+      name: 'linkContribution',
+      multiple: [
+        {args: [{who: who1, what: what1}]},
+        {args: [{who: who2, what: what2}]},
+        {args: [{who: who3, what: what3}]},
+      ],
+      slots,
+    });
+  });
+
+  await testContentFunctions(t, 'generateContributionLinks (unit 2)', async (t, evaluate) => {
+    const slots = {
+      showContribution: false,
+      showIcons: false,
+    };
+
+    await evaluate.load({
+      mock: evaluate.mock(mock => ({
+        linkArtist: {
+          relations: mock.function('linkArtist.relations', () => ({}))
+            .args([undefined, who1]).next()
+            .args([undefined, who2]).next()
+            .args([undefined, who3]),
+
+          data: mock.function('linkArtist.data', () => ({}))
+            .args([who1]).next()
+            .args([who2]).next()
+            .args([who3]),
+
+          generate: mock.function(() => 'artist link')
+            .repeat(3),
+        },
+
+        // Even though icons are hidden, these are still called! The dependency
+        // tree is the same since whether or not the external icon links are
+        // shown is dependent on a slot, which is undefined and arbitrary at
+        // relations/data time (it might change on a whim at generate time).
+        linkExternalAsIcon: {
+          data: mock.function('linkExternalAsIcon.data', () => ({}))
+            .repeat(3),
+
+          generate: mock.function('linkExternalAsIcon.generate', () => 'icon')
+            .repeat(3),
+        },
+      })),
+    });
+
+    evaluate({
+      name: 'linkContribution',
+      multiple: [
+        {args: [{who: who1, what: what1}]},
+        {args: [{who: who2, what: what2}]},
+        {args: [{who: who3, what: what3}]},
+      ],
+      slots,
+    });
+  });
+});
diff --git a/test/unit/data/cacheable-object.js b/test/unit/data/cacheable-object.js
new file mode 100644
index 0000000..8c31a5b
--- /dev/null
+++ b/test/unit/data/cacheable-object.js
@@ -0,0 +1,355 @@
+import t from 'tap';
+
+import CacheableObject from '#cacheable-object';
+
+function newCacheableObject(PD) {
+  return new (class extends CacheableObject {
+    static propertyDescriptors = PD;
+  });
+}
+
+t.test(`CacheableObject simple separate update & expose`, t => {
+  const obj = newCacheableObject({
+    number: {
+      flags: {
+        update: true
+      }
+    },
+
+    timesTwo: {
+      flags: {
+        expose: true
+      },
+
+      expose: {
+        dependencies: ['number'],
+        compute: ({ number }) => number * 2
+      }
+    }
+  });
+
+  t.plan(1);
+  obj.number = 5;
+  t.equal(obj.timesTwo, 10);
+});
+
+t.test(`CacheableObject basic cache behavior`, t => {
+  let computeCount = 0;
+
+  const obj = newCacheableObject({
+    string: {
+      flags: {
+        update: true
+      }
+    },
+
+    karkat: {
+      flags: {
+        expose: true
+      },
+
+      expose: {
+        dependencies: ['string'],
+        compute: ({ string }) => {
+          computeCount++;
+          return string.toUpperCase();
+        }
+      }
+    }
+  });
+
+  t.plan(8);
+
+  t.equal(computeCount, 0);
+
+  obj.string = 'hello world';
+  t.equal(computeCount, 0);
+
+  obj.karkat;
+  t.equal(computeCount, 1);
+
+  obj.karkat;
+  t.equal(computeCount, 1);
+
+  obj.string = 'testing once again';
+  t.equal(computeCount, 1);
+
+  obj.karkat;
+  t.equal(computeCount, 2);
+
+  obj.string = 'testing once again';
+  t.equal(computeCount, 2);
+
+  obj.karkat;
+  t.equal(computeCount, 2);
+});
+
+t.test(`CacheableObject combined update & expose (no transform)`, t => {
+  const obj = newCacheableObject({
+    directory: {
+      flags: {
+        update: true,
+        expose: true
+      }
+    }
+  });
+
+  t.plan(2);
+
+  obj.directory = 'the-world-revolving';
+  t.equal(obj.directory, 'the-world-revolving');
+
+  obj.directory = 'chaos-king';
+  t.equal(obj.directory, 'chaos-king');
+});
+
+t.test(`CacheableObject combined update & expose (basic transform)`, t => {
+  const obj = newCacheableObject({
+    getsRepeated: {
+      flags: {
+        update: true,
+        expose: true
+      },
+
+      expose: {
+        transform: value => value.repeat(2)
+      }
+    }
+  });
+
+  t.plan(1);
+
+  obj.getsRepeated = 'dog';
+  t.equal(obj.getsRepeated, 'dogdog');
+});
+
+t.test(`CacheableObject combined update & expose (transform with dependency)`, t => {
+  const obj = newCacheableObject({
+    customRepeat: {
+      flags: {
+        update: true,
+        expose: true
+      },
+
+      expose: {
+        dependencies: ['times'],
+        transform: (value, { times }) => value.repeat(times)
+      }
+    },
+
+    times: {
+      flags: {
+        update: true
+      }
+    }
+  });
+
+  t.plan(3);
+
+  obj.customRepeat = 'dog';
+  obj.times = 1;
+  t.equal(obj.customRepeat, 'dog');
+
+  obj.times = 5;
+  t.equal(obj.customRepeat, 'dogdogdogdogdog');
+
+  obj.customRepeat = 'cat';
+  t.equal(obj.customRepeat, 'catcatcatcatcat');
+});
+
+t.test(`CacheableObject validate on update`, t => {
+  const mockError = new TypeError(`Expected a string, not ${typeof value}`);
+
+  const obj = newCacheableObject({
+    directory: {
+      flags: {
+        update: true,
+        expose: true
+      },
+
+      update: {
+        validate: value => {
+          if (typeof value !== 'string') {
+            throw mockError;
+          }
+          return true;
+        }
+      }
+    },
+
+    date: {
+      flags: {
+        update: true,
+        expose: true
+      },
+
+      update: {
+        validate: value => (value instanceof Date)
+      }
+    }
+  });
+
+  let thrownError;
+  t.plan(6);
+
+  obj.directory = 'megalovania';
+  t.equal(obj.directory, 'megalovania');
+
+  t.throws(
+    () => { obj.directory = 25; },
+    {cause: mockError});
+
+  t.equal(obj.directory, 'megalovania');
+
+  const date = new Date(`25 December 2009`);
+
+  obj.date = date;
+  t.equal(obj.date, date);
+
+  t.throws(
+    () => { obj.date = `TWELFTH PERIGEE'S EVE`; },
+    {cause: TypeError});
+
+  t.equal(obj.date, date);
+});
+
+t.test(`CacheableObject transform on null value`, t => {
+  let computed = false;
+
+  const obj = newCacheableObject({
+    spookyFactor: {
+      flags: {
+        update: true,
+        expose: true,
+      },
+
+      expose: {
+        transform: value => {
+          computed = true;
+          return (value ? 2 * value : -1);
+        },
+      },
+    },
+  });
+
+  t.plan(4);
+
+  t.equal(obj.spookyFactor, -1);
+  t.ok(computed);
+
+  computed = false;
+  obj.spookyFactor = 1;
+
+  t.equal(obj.spookyFactor, 2);
+  t.ok(computed);
+});
+
+t.test(`CacheableObject don't transform on successful update`, t => {
+  let computed = false;
+
+  const obj = newCacheableObject({
+    original: {
+      flags: {
+        update: true,
+        expose: true,
+      },
+
+      update: {
+        validate: value => value.startsWith('track:'),
+      },
+
+      expose: {
+        transform: value => {
+          computed = true;
+          return (value ? value.split(':')[1] : null);
+        },
+      },
+    },
+  });
+
+  t.plan(4);
+
+  t.doesNotThrow(() => obj.original = 'track:foo');
+  t.notOk(computed);
+
+  t.equal(obj.original, 'foo');
+  t.ok(computed);
+});
+
+t.test(`CacheableObject don't transform on failed update`, t => {
+  let computed = false;
+
+  const obj = newCacheableObject({
+    original: {
+      flags: {
+        update: true,
+        expose: true,
+      },
+
+      update: {
+        validate: value => value.startsWith('track:'),
+      },
+
+      expose: {
+        transform: value => {
+          computed = true;
+          return (value ? value.split(':')[1] : null);
+        },
+      },
+    },
+  });
+
+  t.plan(4);
+
+  t.throws(() => obj.original = 'album:foo');
+  t.notOk(computed);
+
+  t.equal(obj.original, null);
+  t.ok(computed);
+});
+
+t.test(`CacheableObject default update property value`, t => {
+  const obj = newCacheableObject({
+    fruit: {
+      flags: {
+        update: true,
+        expose: true
+      },
+
+      update: {
+        default: 'potassium'
+      }
+    }
+  });
+
+  t.plan(1);
+  t.equal(obj.fruit, 'potassium');
+});
+
+t.test(`CacheableObject default property throws if invalid`, t => {
+  const mockError = new TypeError(`Expected a string, not ${typeof value}`);
+
+  t.plan(1);
+
+  let thrownError;
+
+  t.throws(
+    () => newCacheableObject({
+      string: {
+        flags: {
+          update: true
+        },
+
+        update: {
+          default: 123,
+          validate: value => {
+            if (typeof value !== 'string') {
+              throw mockError;
+            }
+            return true;
+          }
+        }
+      }
+    }),
+    {cause: mockError});
+});
diff --git a/test/unit/data/composite/control-flow/exposeConstant.js b/test/unit/data/composite/control-flow/exposeConstant.js
new file mode 100644
index 0000000..0c75894
--- /dev/null
+++ b/test/unit/data/composite/control-flow/exposeConstant.js
@@ -0,0 +1,42 @@
+import t from 'tap';
+
+import {compositeFrom, continuationSymbol, input} from '#composite';
+import {exposeConstant} from '#composite/control-flow';
+
+t.test(`exposeConstant: basic behavior`, t => {
+  t.plan(2);
+
+  const composite1 = compositeFrom({
+    compose: false,
+
+    steps: [
+      exposeConstant({
+        value: input.value('foo'),
+      }),
+    ],
+  });
+
+  t.match(composite1, {
+    expose: {
+      dependencies: [],
+    },
+  });
+
+  t.equal(composite1.expose.compute(), 'foo');
+});
+
+t.test(`exposeConstant: validate inputs`, t => {
+  t.plan(2);
+
+  t.throws(
+    () => exposeConstant({}),
+    {message: `Errors in input options passed to exposeConstant`, errors: [
+      {message: `Required these inputs: value`},
+    ]});
+
+  t.throws(
+    () => exposeConstant({value: 'some dependency'}),
+    {message: `Errors in input options passed to exposeConstant`, errors: [
+      {message: `value: Expected input.value() call, got dependency name`},
+    ]});
+});
diff --git a/test/unit/data/composite/control-flow/exposeDependency.js b/test/unit/data/composite/control-flow/exposeDependency.js
new file mode 100644
index 0000000..8f6bfd0
--- /dev/null
+++ b/test/unit/data/composite/control-flow/exposeDependency.js
@@ -0,0 +1,64 @@
+import t from 'tap';
+
+import {compositeFrom, continuationSymbol, input} from '#composite';
+import {exposeDependency} from '#composite/control-flow';
+
+t.test(`exposeDependency: basic behavior`, t => {
+  t.plan(4);
+
+  const composite1 = compositeFrom({
+    compose: false,
+
+    steps: [
+      exposeDependency({dependency: 'foo'}),
+    ],
+  });
+
+  t.match(composite1, {
+    expose: {
+      dependencies: ['foo'],
+    },
+  });
+
+  t.equal(composite1.expose.compute({foo: 'bar'}), 'bar');
+
+  const composite2 = compositeFrom({
+    compose: false,
+
+    steps: [
+      {
+        dependencies: ['foo'],
+        compute: (continuation, {foo}) =>
+          continuation({'#bar': foo.toUpperCase()}),
+      },
+
+      exposeDependency({dependency: '#bar'}),
+    ],
+  });
+
+  t.match(composite2, {
+    expose: {
+      dependencies: ['foo'],
+    },
+  });
+
+  t.equal(composite2.expose.compute({foo: 'bar'}), 'BAR');
+});
+
+t.test(`exposeDependency: validate inputs`, t => {
+  t.plan(2);
+
+  t.throws(
+    () => exposeDependency({}),
+    {message: `Errors in input options passed to exposeDependency`, errors: [
+      {message: `Required these inputs: dependency`},
+    ]});
+
+  t.throws(
+    () => exposeDependency({
+      dependency: input.value('some static value'),
+    }),
+    {message: `Errors in input options passed to exposeDependency`, errors: [
+      {message: `dependency: Expected dependency name, got input.value() call`},
+    ]});
+});
diff --git a/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js b/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js
new file mode 100644
index 0000000..2bcabb4
--- /dev/null
+++ b/test/unit/data/composite/control-flow/withResultOfAvailabilityCheck.js
@@ -0,0 +1,195 @@
+import t from 'tap';
+
+import {compositeFrom, continuationSymbol, input} from '#composite';
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+
+const composite = compositeFrom({
+  compose: false,
+
+  steps: [
+    withResultOfAvailabilityCheck({
+      from: 'from',
+      mode: 'mode',
+    }).outputs({
+      ['#availability']: '#result',
+    }),
+
+    {
+      dependencies: ['#result'],
+      compute: ({'#result': result}) => result,
+    },
+  ],
+});
+
+t.test(`withResultOfAvailabilityCheck: basic behavior`, t => {
+  t.plan(1);
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['from', 'mode'],
+    },
+  });
+});
+
+const quickCompare = (t, expect, {from, mode}) =>
+  t.equal(composite.expose.compute({from, mode}), expect);
+
+const quickThrows = (t, {from, mode}) =>
+  t.throws(() => composite.expose.compute({from, mode}));
+
+t.test(`withResultOfAvailabilityCheck: mode = null`, t => {
+  t.plan(11);
+
+  quickCompare(t, true,  {mode: 'null', from: 'truthy string'});
+  quickCompare(t, true,  {mode: 'null', from: 123});
+  quickCompare(t, true,  {mode: 'null', from: true});
+
+  quickCompare(t, true,  {mode: 'null', from: ''});
+  quickCompare(t, true,  {mode: 'null', from: 0});
+  quickCompare(t, true,  {mode: 'null', from: -1});
+  quickCompare(t, true,  {mode: 'null', from: false});
+
+  quickCompare(t, true,  {mode: 'null', from: [1, 2, 3]});
+  quickCompare(t, true,  {mode: 'null', from: []});
+
+  quickCompare(t, false, {mode: 'null', from: null});
+  quickCompare(t, false, {mode: 'null', from: undefined});
+});
+
+t.test(`withResultOfAvailabilityCheck: mode = empty`, t => {
+  t.plan(11);
+
+  quickThrows(t, {mode: 'empty', from: 'truthy string'});
+  quickThrows(t, {mode: 'empty', from: 123});
+  quickThrows(t, {mode: 'empty', from: true});
+
+  quickThrows(t, {mode: 'empty', from: ''});
+  quickThrows(t, {mode: 'empty', from: 0});
+  quickThrows(t, {mode: 'empty', from: -1});
+  quickThrows(t, {mode: 'empty', from: false});
+
+  quickCompare(t, true,  {mode: 'empty', from: [1, 2, 3]});
+  quickCompare(t, false, {mode: 'empty', from: []});
+
+  quickCompare(t, false, {mode: 'empty', from: null});
+  quickCompare(t, false, {mode: 'empty', from: undefined});
+});
+
+t.test(`withResultOfAvailabilityCheck: mode = falsy`, t => {
+  t.plan(11);
+
+  quickCompare(t, true,  {mode: 'falsy', from: 'truthy string'});
+  quickCompare(t, true,  {mode: 'falsy', from: 123});
+  quickCompare(t, true,  {mode: 'falsy', from: true});
+
+  quickCompare(t, false, {mode: 'falsy', from: ''});
+  quickCompare(t, false, {mode: 'falsy', from: 0});
+  quickCompare(t, true,  {mode: 'falsy', from: -1});
+  quickCompare(t, false, {mode: 'falsy', from: false});
+
+  quickCompare(t, true,  {mode: 'falsy', from: [1, 2, 3]});
+  quickCompare(t, false, {mode: 'falsy', from: []});
+
+  quickCompare(t, false, {mode: 'falsy', from: null});
+  quickCompare(t, false, {mode: 'falsy', from: undefined});
+});
+
+t.test(`withResultOfAvailabilityCheck: mode = index`, t => {
+  t.plan(11);
+
+  quickCompare(t, false, {mode: 'index', from: 'truthy string'});
+  quickCompare(t, true,  {mode: 'index', from: 123});
+  quickCompare(t, false, {mode: 'index', from: true});
+
+  quickCompare(t, false, {mode: 'index', from: ''});
+  quickCompare(t, true,  {mode: 'index', from: 0});
+  quickCompare(t, false, {mode: 'index', from: -1});
+  quickCompare(t, false, {mode: 'index', from: false});
+
+  quickCompare(t, false, {mode: 'index', from: [1, 2, 3]});
+  quickCompare(t, false, {mode: 'index', from: []});
+
+  quickCompare(t, false, {mode: 'index', from: null});
+  quickCompare(t, false, {mode: 'index', from: undefined});
+});
+
+t.test(`withResultOfAvailabilityCheck: default mode`, t => {
+  t.plan(1);
+
+  const template = withResultOfAvailabilityCheck({
+    from: 'foo',
+  });
+
+  t.match(template.toDescription(), {
+    inputMapping: {
+      from: input.dependency('foo'),
+      mode: input.value('null'),
+    },
+  });
+});
+
+t.test(`withResultOfAvailabilityCheck: validate static inputs`, t => {
+  t.plan(5);
+
+  t.throws(
+    () => withResultOfAvailabilityCheck({}),
+    {message: `Errors in input options passed to withResultOfAvailabilityCheck`, errors: [
+      {message: `Required these inputs: from`},
+    ]});
+
+  t.doesNotThrow(() =>
+    withResultOfAvailabilityCheck({
+      from: 'dependency1',
+      mode: 'dependency2',
+    }));
+
+  t.doesNotThrow(() =>
+    withResultOfAvailabilityCheck({
+      from: input.value('some static value'),
+      mode: input.value('null'),
+    }));
+
+  t.throws(
+    () => withResultOfAvailabilityCheck({
+      from: 'foo',
+      mode: input.value('invalid'),
+    }),
+    {message: `Errors in input options passed to withResultOfAvailabilityCheck`, errors: [
+      {message: `mode: Expected one of null empty falsy index, got invalid`},
+    ]});
+
+  t.throws(() =>
+    withResultOfAvailabilityCheck({
+      from: input.value(null),
+      mode: input.value(null),
+    }),
+    {message: `Errors in input options passed to withResultOfAvailabilityCheck`, errors: [
+      {message: `mode: Expected a value, got null`},
+    ]});
+});
+
+t.test(`withResultOfAvailabilityCheck: validate dynamic inputs`, t => {
+  t.plan(2);
+
+  t.throws(
+    () => composite.expose.compute({
+      from: 'apple',
+      mode: 'banana',
+    }),
+    {message: `Error computing composition`, cause:
+      {message: `Error computing composition withResultOfAvailabilityCheck`, cause:
+        {message: `Errors in input values provided to withResultOfAvailabilityCheck`, errors: [
+          {message: `mode: Expected one of null empty falsy index, got banana`},
+        ]}}});
+
+  t.throws(
+    () => composite.expose.compute({
+      from: null,
+      mode: null,
+    }),
+    {message: `Error computing composition`, cause:
+      {message: `Error computing composition withResultOfAvailabilityCheck`, cause:
+        {message: `Errors in input values provided to withResultOfAvailabilityCheck`, errors: [
+          {message: `mode: Expected a value, got null`},
+        ]}}});
+});
diff --git a/test/unit/data/composite/data/withPropertiesFromObject.js b/test/unit/data/composite/data/withPropertiesFromObject.js
new file mode 100644
index 0000000..750dc8c
--- /dev/null
+++ b/test/unit/data/composite/data/withPropertiesFromObject.js
@@ -0,0 +1,254 @@
+import t from 'tap';
+
+import {compositeFrom, input} from '#composite';
+import {exposeDependency} from '#composite/control-flow';
+import {withPropertiesFromObject} from '#composite/data';
+
+const composite = compositeFrom({
+  compose: false,
+
+  steps: [
+    withPropertiesFromObject({
+      object: 'object',
+      properties: 'properties',
+    }),
+
+    exposeDependency({dependency: '#object'}),
+  ],
+});
+
+t.test(`withPropertiesFromObject: basic behavior`, t => {
+  t.plan(4);
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['object', 'properties'],
+    },
+  });
+
+  t.same(
+    composite.expose.compute({
+      object: {foo: 'bar', bim: 'BOOM', bam: 'baz'},
+      properties: ['foo', 'bim'],
+    }),
+    {foo: 'bar', bim: 'BOOM'});
+
+  t.same(
+    composite.expose.compute({
+      object: {value1: 'uwah', value2: 'arah'},
+      properties: ['value1', 'value3'],
+    }),
+    {value1: 'uwah', value3: null});
+
+  t.same(
+    composite.expose.compute({
+      object: null,
+      properties: ['ohMe', 'ohMy', 'ohDear'],
+    }),
+    {ohMe: null, ohMy: null, ohDear: null});
+});
+
+t.test(`withPropertiesFromObject: output shapes & values`, t => {
+  t.plan(2 * 2 * 3 ** 2);
+
+  const dependencies = {
+    ['object_dependency']:
+      {foo: 'apple', bar: 'banana', baz: 'orange'},
+    [input('object_neither')]:
+      {foo: 'koala', bar: 'okapi', baz: 'mongoose'},
+    ['properties_dependency']:
+      ['foo', 'bar', 'missing1'],
+    [input('properties_neither')]:
+      ['foo', 'baz', 'missing3'],
+  };
+
+  const mapLevel1 = [
+    [input.value('prefix_value'), [
+      ['object_dependency', [
+        ['properties_dependency', {
+          '#object': {foo: 'apple', bar: 'banana', missing1: null},
+        }],
+        [input.value(['bar', 'baz', 'missing2']), {
+          '#prefix_value.bar': 'banana',
+          '#prefix_value.baz': 'orange',
+          '#prefix_value.missing2': null,
+        }],
+        [input('properties_neither'), {
+          '#object': {foo: 'apple', baz: 'orange', missing3: null},
+        }]]],
+
+      [input.value({foo: 'ouh', bar: 'rah', baz: 'nyu'}), [
+        ['properties_dependency', {
+          '#object': {foo: 'ouh', bar: 'rah', missing1: null},
+        }],
+        [input.value(['bar', 'baz', 'missing2']), {
+          '#prefix_value.bar': 'rah',
+          '#prefix_value.baz': 'nyu',
+          '#prefix_value.missing2': null,
+        }],
+        [input('properties_neither'), {
+          '#object': {foo: 'ouh', baz: 'nyu', missing3: null},
+        }]]],
+
+      [input('object_neither'), [
+        ['properties_dependency', {
+          '#object': {foo: 'koala', bar: 'okapi', missing1: null},
+        }],
+        [input.value(['bar', 'baz', 'missing2']), {
+          '#prefix_value.bar': 'okapi',
+          '#prefix_value.baz': 'mongoose',
+          '#prefix_value.missing2': null,
+        }],
+        [input('properties_neither'), {
+          '#object': {foo: 'koala', baz: 'mongoose', missing3: null},
+        }]]]]],
+
+    [input.value(null), [
+      ['object_dependency', [
+        ['properties_dependency', {
+          '#object': {foo: 'apple', bar: 'banana', missing1: null},
+        }],
+        [input.value(['bar', 'baz', 'missing2']), {
+          '#object_dependency.bar': 'banana',
+          '#object_dependency.baz': 'orange',
+          '#object_dependency.missing2': null,
+        }],
+        [input('properties_neither'), {
+          '#object': {foo: 'apple', baz: 'orange', missing3: null},
+        }]]],
+
+      [input.value({foo: 'ouh', bar: 'rah', baz: 'nyu'}), [
+        ['properties_dependency', {
+          '#object': {foo: 'ouh', bar: 'rah', missing1: null},
+        }],
+        [input.value(['bar', 'baz', 'missing2']), {
+          '#object.bar': 'rah',
+          '#object.baz': 'nyu',
+          '#object.missing2': null,
+        }],
+        [input('properties_neither'), {
+          '#object': {foo: 'ouh', baz: 'nyu', missing3: null},
+        }]]],
+
+      [input('object_neither'), [
+        ['properties_dependency', {
+          '#object': {foo: 'koala', bar: 'okapi', missing1: null},
+        }],
+        [input.value(['bar', 'baz', 'missing2']), {
+          '#object.bar': 'okapi',
+          '#object.baz': 'mongoose',
+          '#object.missing2': null,
+        }],
+        [input('properties_neither'), {
+          '#object': {foo: 'koala', baz: 'mongoose', missing3: null},
+        }]]]]],
+  ];
+
+  for (const [prefixInput, mapLevel2] of mapLevel1) {
+    for (const [objectInput, mapLevel3] of mapLevel2) {
+      for (const [propertiesInput, outputDict] of mapLevel3) {
+        const step = withPropertiesFromObject({
+          prefix: prefixInput,
+          object: objectInput,
+          properties: propertiesInput,
+        });
+
+        quickCheckOutputs(step, outputDict);
+      }
+    }
+  }
+
+  function quickCheckOutputs(step, outputDict) {
+    t.same(
+      Object.keys(step.toDescription().outputs),
+      Object.keys(outputDict));
+
+    const composite = compositeFrom({
+      compose: false,
+      steps: [step, {
+        dependencies: Object.keys(outputDict),
+        compute: dependencies => dependencies,
+      }],
+    });
+
+    t.same(
+      composite.expose.compute(dependencies),
+      outputDict);
+  }
+});
+
+t.test(`withPropertiesFromObject: validate static inputs`, t => {
+  t.plan(3);
+
+  t.throws(
+    () => withPropertiesFromObject({}),
+    {message: `Errors in input options passed to withPropertiesFromObject`, errors: [
+      {message: `Required these inputs: object, properties`},
+    ]});
+
+  t.throws(
+    () => withPropertiesFromObject({
+      object: input.value('intriguing'),
+      properties: input.value('very'),
+      prefix: input.value({yes: 'yup'}),
+    }),
+    {message: `Errors in input options passed to withPropertiesFromObject`, errors: [
+      {message: `object: Expected an object, got string`},
+      {message: `properties: Expected an array, got string`},
+      {message: `prefix: Expected a string, got object`},
+    ]});
+
+  t.throws(
+    () => withPropertiesFromObject({
+      object: input.value([['abc', 1], ['def', 2], [123, 3]]),
+      properties: input.value(['abc', 'def', 123]),
+    }),
+    {message: `Errors in input options passed to withPropertiesFromObject`, errors: [
+      {message: `object: Expected an object, got array`},
+      {message: `properties: Errors validating array items`, errors: [
+        {
+          [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: 2,
+          message: `Error at zero-index 2: 123`,
+          cause: {
+            message: `Expected a string, got number`,
+          },
+        },
+      ]},
+    ]});
+});
+
+t.test(`withPropertiesFromObject: validate dynamic inputs`, t => {
+  t.plan(2);
+
+  t.throws(
+    () => composite.expose.compute({
+      object: 'intriguing',
+      properties: 'onceMore',
+    }),
+    {message: `Error computing composition`, cause:
+      {message: `Error computing composition withPropertiesFromObject`, cause:
+        {message: `Errors in input values provided to withPropertiesFromObject`, errors: [
+          {message: `object: Expected an object, got string`},
+          {message: `properties: Expected an array, got string`},
+        ]}}});
+
+  t.throws(
+    () => composite.expose.compute({
+      object: [['abc', 1], ['def', 2], [123, 3]],
+      properties: ['abc', 'def', 123],
+    }),
+    {message: `Error computing composition`, cause:
+      {message: `Error computing composition withPropertiesFromObject`, cause:
+        {message: `Errors in input values provided to withPropertiesFromObject`, errors: [
+          {message: `object: Expected an object, got array`},
+          {message: `properties: Errors validating array items`, errors: [
+            {
+              [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: 2,
+              message: `Error at zero-index 2: 123`,
+              cause: {
+                message: `Expected a string, got number`,
+              },
+            },
+          ]},
+        ]}}});
+});
diff --git a/test/unit/data/composite/data/withPropertyFromObject.js b/test/unit/data/composite/data/withPropertyFromObject.js
new file mode 100644
index 0000000..6a772c3
--- /dev/null
+++ b/test/unit/data/composite/data/withPropertyFromObject.js
@@ -0,0 +1,122 @@
+import t from 'tap';
+
+import {compositeFrom, input} from '#composite';
+import {exposeDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+t.test(`withPropertyFromObject: basic behavior`, t => {
+  t.plan(4);
+
+  const composite = compositeFrom({
+    compose: false,
+
+    steps: [
+      withPropertyFromObject({
+        object: 'object',
+        property: 'property',
+      }),
+
+      exposeDependency({dependency: '#value'}),
+    ],
+  });
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['object', 'property'],
+    },
+  });
+
+  t.equal(composite.expose.compute({
+    object: {foo: 'bar', bim: 'BOOM'},
+    property: 'bim',
+  }), 'BOOM');
+
+  t.equal(composite.expose.compute({
+    object: {value1: 'uwah'},
+    property: 'value2',
+  }), null);
+
+  t.equal(composite.expose.compute({
+    object: null,
+    property: 'oml where did me object go',
+  }), null);
+});
+
+t.test(`withPropertyFromObject: output shapes & values`, t => {
+  t.plan(2 * 3 ** 2);
+
+  const dependencies = {
+    ['object_dependency']:
+      {foo: 'apple', bar: 'banana', baz: 'orange'},
+    [input('object_neither')]:
+      {foo: 'koala', bar: 'okapi', baz: 'mongoose'},
+    ['property_dependency']:
+      'foo',
+    [input('property_neither')]:
+      'baz',
+  };
+
+  const mapLevel1 = [
+    ['object_dependency', [
+      ['property_dependency', {
+        '#value': 'apple',
+      }],
+      [input.value('bar'), {
+        '#object_dependency.bar': 'banana',
+      }],
+      [input('property_neither'), {
+        '#value': 'orange',
+      }]]],
+
+    [input.value({foo: 'ouh', bar: 'rah', baz: 'nyu'}), [
+      ['property_dependency', {
+        '#value': 'ouh',
+      }],
+      [input.value('bar'), {
+        '#value': 'rah',
+      }],
+      [input('property_neither'), {
+        '#value': 'nyu',
+      }]]],
+
+    [input('object_neither'), [
+      ['property_dependency', {
+        '#value': 'koala',
+      }],
+      [input.value('bar'), {
+        '#value': 'okapi',
+      }],
+      [input('property_neither'), {
+        '#value': 'mongoose',
+      }]]],
+  ];
+
+  for (const [objectInput, mapLevel2] of mapLevel1) {
+    for (const [propertyInput, outputDict] of mapLevel2) {
+      const step = withPropertyFromObject({
+        object: objectInput,
+        property: propertyInput,
+      });
+
+      quickCheckOutputs(step, outputDict);
+    }
+  }
+
+  function quickCheckOutputs(step, outputDict) {
+    t.same(
+      Object.keys(step.toDescription().outputs),
+      Object.keys(outputDict));
+
+    const composite = compositeFrom({
+      compose: false,
+      steps: [step, {
+        dependencies: Object.keys(outputDict),
+        compute: dependencies => dependencies,
+      }],
+    });
+
+    t.same(
+      composite.expose.compute(dependencies),
+      outputDict);
+  }
+});
diff --git a/test/unit/data/composite/data/withUniqueItemsOnly.js b/test/unit/data/composite/data/withUniqueItemsOnly.js
new file mode 100644
index 0000000..965b14b
--- /dev/null
+++ b/test/unit/data/composite/data/withUniqueItemsOnly.js
@@ -0,0 +1,84 @@
+import t from 'tap';
+
+import {compositeFrom, input} from '#composite';
+import {exposeDependency} from '#composite/control-flow';
+import {withUniqueItemsOnly} from '#composite/data';
+
+t.test(`withUniqueItemsOnly: basic behavior`, t => {
+  t.plan(3);
+
+  const composite = compositeFrom({
+    compose: false,
+
+    steps: [
+      withUniqueItemsOnly({
+        list: 'list',
+      }),
+
+      exposeDependency({dependency: '#list'}),
+    ],
+  });
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['list'],
+    },
+  });
+
+  t.same(composite.expose.compute({
+    list: ['apple', 'banana', 'banana', 'banana', 'apple', 'watermelon'],
+  }), ['apple', 'banana', 'watermelon']);
+
+  t.same(composite.expose.compute({
+    list: [],
+  }), []);
+});
+
+t.test(`withUniqueItemsOnly: output shapes & values`, t => {
+  t.plan(2 * 3 ** 1);
+
+  const dependencies = {
+    ['list_dependency']:
+      [1, 1, 2, 3, 3, 4, 'foo', false, false, 4],
+    [input('list_neither')]:
+      [8, 8, 7, 6, 6, 5, 'bar', true, true, 5],
+  };
+
+  const mapLevel1 = [
+    ['list_dependency', {
+      '#list_dependency': [1, 2, 3, 4, 'foo', false],
+    }],
+    [input.value([-1, -1, 'interesting', 'very', 'interesting']), {
+      '#uniqueItems': [-1, 'interesting', 'very'],
+    }],
+    [input('list_neither'), {
+      '#uniqueItems': [8, 7, 6, 5, 'bar', true],
+    }],
+  ];
+
+  for (const [listInput, outputDict] of mapLevel1) {
+    const step = withUniqueItemsOnly({
+      list: listInput,
+    });
+
+    quickCheckOutputs(step, outputDict);
+  }
+
+  function quickCheckOutputs(step, outputDict) {
+    t.same(
+      Object.keys(step.toDescription().outputs),
+      Object.keys(outputDict));
+
+    const composite = compositeFrom({
+      compose: false,
+      steps: [step, {
+        dependencies: Object.keys(outputDict),
+        compute: dependencies => dependencies,
+      }],
+    });
+
+    t.same(
+      composite.expose.compute(dependencies),
+      outputDict);
+  }
+});
diff --git a/test/unit/data/composite/things/track/withAlbum.js b/test/unit/data/composite/things/track/withAlbum.js
new file mode 100644
index 0000000..d822f31
--- /dev/null
+++ b/test/unit/data/composite/things/track/withAlbum.js
@@ -0,0 +1,91 @@
+import t from 'tap';
+
+import {compositeFrom, input} from '#composite';
+import {exposeConstant, exposeDependency} from '#composite/control-flow';
+import {withAlbum} from '#composite/things/track';
+
+t.test(`withAlbum: basic behavior`, t => {
+  t.plan(3);
+
+  const composite = compositeFrom({
+    compose: false,
+    steps: [
+      withAlbum(),
+      exposeDependency({dependency: '#album'}),
+    ],
+  });
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['albumData', 'this'],
+    },
+  });
+
+  const fakeTrack1 = {directory: 'foo'};
+  const fakeTrack2 = {directory: 'bar'};
+  const fakeAlbum = {directory: 'baz', tracks: [fakeTrack1]};
+
+  t.equal(
+    composite.expose.compute({
+      albumData: [fakeAlbum],
+      this: fakeTrack1,
+    }),
+    fakeAlbum);
+
+  t.equal(
+    composite.expose.compute({
+      albumData: [fakeAlbum],
+      this: fakeTrack2,
+    }),
+    null);
+});
+
+t.test(`withAlbum: early exit conditions`, t => {
+  t.plan(4);
+
+  const composite = compositeFrom({
+    compose: false,
+    steps: [
+      withAlbum(),
+      exposeConstant({
+        value: input.value('bimbam'),
+      }),
+    ],
+  });
+
+  const fakeTrack1 = {directory: 'foo'};
+  const fakeTrack2 = {directory: 'bar'};
+  const fakeAlbum = {directory: 'baz', tracks: [fakeTrack1]};
+
+  t.equal(
+    composite.expose.compute({
+      albumData: [fakeAlbum],
+      this: fakeTrack1,
+    }),
+    'bimbam',
+    `does not early exit if albumData is present and contains the track`);
+
+  t.equal(
+    composite.expose.compute({
+      albumData: [fakeAlbum],
+      this: fakeTrack2,
+    }),
+    'bimbam',
+    `does not early exit if albumData is present and does not contain the track`);
+
+  t.equal(
+    composite.expose.compute({
+      albumData: [],
+      this: fakeTrack1,
+    }),
+    'bimbam',
+    `does not early exit if albumData is empty array`);
+
+  t.equal(
+    composite.expose.compute({
+      albumData: null,
+      this: fakeTrack1,
+    }),
+    null,
+    `early exits if albumData is null`);
+});
diff --git a/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js
new file mode 100644
index 0000000..babe4fa
--- /dev/null
+++ b/test/unit/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -0,0 +1,102 @@
+import t from 'tap';
+
+import {compositeFrom, input} from '#composite';
+import thingConstructors from '#things';
+
+import {exposeDependency} from '#composite/control-flow';
+import {withParsedCommentaryEntries} from '#composite/wiki-data';
+
+const {Artist} = thingConstructors;
+
+const composite = compositeFrom({
+  compose: false,
+
+  steps: [
+    withParsedCommentaryEntries({
+      from: 'from',
+    }),
+
+    exposeDependency({dependency: '#parsedCommentaryEntries'}),
+  ],
+});
+
+function stubArtist(artistName = `Test Artist`) {
+  const artist = new Artist();
+  artist.name = artistName;
+
+  return artist;
+}
+
+t.test(`withParsedCommentaryEntries: basic behavior`, t => {
+  t.plan(3);
+
+  const artist1 = stubArtist(`Mobius Trip`);
+  const artist2 = stubArtist(`Hadron Kaleido`);
+
+  const artistData = [artist1, artist2];
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['from', 'artistData'],
+    },
+  });
+
+  t.same(composite.expose.compute({
+    artistData,
+    from:
+      `<i>Mobius Trip:</i>\n` +
+      `Some commentary.\n` +
+      `Very cool.\n`,
+  }), [
+    {
+      artists: [artist1],
+      artistDisplayText: null,
+      annotation: null,
+      date: null,
+      body: `Some commentary.\nVery cool.`,
+    },
+  ]);
+
+  t.same(composite.expose.compute({
+    artistData,
+    from:
+      `<i>Mobius Trip|Moo-bius Trip:</i> (music, art, 12 January 2015)\n` +
+      `First commentary entry.\n` +
+      `Very cool.\n` +
+      `<i>Hadron Kaleido|<b>[[artist:hadron-kaleido|The Ol' Hadron]]</b>:</i> (moral support, 4/4/2022)\n` +
+      `Second commentary entry. Yes. So cool.\n` +
+      `<i>Mystery Artist:</i> (pingas, August 25, 2023)\n` +
+      `Oh no.. Oh dear...\n` +
+      `<i>Mobius Trip, Hadron Kaleido:</i>\n` +
+      `And back around we go.`,
+  }), [
+    {
+      artists: [artist1],
+      artistDisplayText: `Moo-bius Trip`,
+      annotation: `music, art`,
+      date: new Date('12 January 2015'),
+      body: `First commentary entry.\nVery cool.`,
+    },
+    {
+      artists: [artist2],
+      artistDisplayText: `<b>[[artist:hadron-kaleido|The Ol' Hadron]]</b>`,
+      annotation: `moral support`,
+      date: new Date('4 April 2022'),
+      body: `Second commentary entry. Yes. So cool.`,
+    },
+    {
+      artists: [],
+      artistDisplayText: null,
+      annotation: `pingas`,
+      date: new Date('25 August 2023'),
+      body: `Oh no.. Oh dear...`,
+    },
+    {
+      artists: [artist1, artist2],
+      artistDisplayText: null,
+      annotation: null,
+      date: null,
+      body: `And back around we go.`,
+    },
+  ]);
+});
diff --git a/test/unit/data/compositeFrom.js b/test/unit/data/compositeFrom.js
new file mode 100644
index 0000000..0029667
--- /dev/null
+++ b/test/unit/data/compositeFrom.js
@@ -0,0 +1,345 @@
+import t from 'tap';
+
+import {compositeFrom, continuationSymbol, input} from '#composite';
+import {isString} from '#validators';
+
+t.test(`compositeFrom: basic behavior`, t => {
+  t.plan(2);
+
+  const composite = compositeFrom({
+    annotation: `myComposite`,
+    compose: false,
+
+    steps: [
+      {
+        dependencies: ['foo'],
+        compute: (continuation, {foo}) =>
+          continuation({'#bar': foo * 2}),
+      },
+
+      {
+        dependencies: ['#bar', 'baz', 'suffix'],
+        compute: ({'#bar': bar, baz, suffix}) =>
+          baz.repeat(bar) + suffix,
+      },
+    ],
+  });
+
+  t.match(composite, {
+    annotation: `myComposite`,
+
+    flags: {expose: true, compose: false, update: false},
+
+    expose: {
+      dependencies: ['foo', 'baz', 'suffix'],
+      compute: Function,
+      transform: null,
+    },
+
+    update: null,
+  });
+
+  t.equal(
+    composite.expose.compute({
+      foo: 3,
+      baz: 'ba',
+      suffix: 'BOOM',
+    }),
+    'babababababaBOOM');
+});
+
+t.test(`compositeFrom: input-shaped step dependencies`, t => {
+  t.plan(2);
+
+  const composite = compositeFrom({
+    compose: false,
+    steps: [
+      {
+        dependencies: [
+          input.myself(),
+          input.updateValue(),
+        ],
+
+        transform: (updateValue1, {
+          [input.myself()]: me,
+          [input.updateValue()]: updateValue2,
+        }) => ({me, updateValue1, updateValue2}),
+      },
+    ],
+  });
+
+  t.match(composite, {
+    expose: {
+      dependencies: ['this'],
+      transform: Function,
+      compute: null,
+    },
+  });
+
+  const myself = {foo: 'bar'};
+
+  t.same(
+    composite.expose.transform('banana', {
+      this: myself,
+      pomelo: 'delicious',
+    }),
+    {
+      me: myself,
+      updateValue1: 'banana',
+      updateValue2: 'banana',
+    });
+});
+
+t.test(`compositeFrom: dependencies from inputs`, t => {
+  t.plan(3);
+
+  const composite = compositeFrom({
+    annotation: `myComposite`,
+
+    compose: true,
+
+    inputMapping: {
+      foo:      input('bar'),
+      pomelo:   input.value('delicious'),
+      humorous: input.dependency('#mammal'),
+      data:     input.dependency('albumData'),
+      ref:      input.updateValue(),
+    },
+
+    inputDescriptions: {
+      foo:      input(),
+      pomelo:   input(),
+      humorous: input(),
+      data:     input(),
+      ref:      input(),
+    },
+
+    steps: [
+      {
+        dependencies: [
+          input('foo'),
+          input('pomelo'),
+          input('humorous'),
+          input('data'),
+          input('ref'),
+        ],
+
+        compute: (continuation, {
+          [input('foo')]: foo,
+          [input('pomelo')]: pomelo,
+          [input('humorous')]: humorous,
+          [input('data')]: data,
+          [input('ref')]: ref,
+        }) => continuation.exit({foo, pomelo, humorous, data, ref}),
+      },
+    ],
+  });
+
+  t.match(composite, {
+    expose: {
+      dependencies: [
+        input('bar'),
+        '#mammal',
+        'albumData',
+      ],
+
+      transform: Function,
+      compute: null,
+    },
+  });
+
+  const exitData = {};
+  const continuation = {
+    exit(value) {
+      Object.assign(exitData, value);
+      return continuationSymbol;
+    },
+  };
+
+  t.equal(
+    composite.expose.transform('album:bepis', continuation, {
+      [input('bar')]: 'squid time',
+      '#mammal': 'fox',
+      'albumData': ['album1', 'album2'],
+    }),
+    continuationSymbol);
+
+  t.same(exitData, {
+    foo: 'squid time',
+    pomelo: 'delicious',
+    humorous: 'fox',
+    data: ['album1', 'album2'],
+    ref: 'album:bepis',
+  });
+});
+
+t.test(`compositeFrom: update from various sources`, t => {
+  t.plan(3);
+
+  const match = {
+    flags: {update: true, expose: true, compose: false},
+
+    update: {
+      validate: isString,
+      default: 'foo',
+    },
+
+    expose: {
+      transform: Function,
+      compute: null,
+    },
+  };
+
+  t.test(`compositeFrom: update from composition description`, t => {
+    t.plan(2);
+
+    const composite = compositeFrom({
+      compose: false,
+
+      update: {
+        validate: isString,
+        default: 'foo',
+      },
+
+      steps: [
+        {transform: (value, continuation) => continuation(value.repeat(2))},
+        {transform: (value) => `Xx_${value}_xX`},
+      ],
+    });
+
+    t.match(composite, match);
+    t.equal(composite.expose.transform('foo'), `Xx_foofoo_xX`);
+  });
+
+  t.test(`compositeFrom: update from step dependencies`, t => {
+    t.plan(2);
+
+    const composite = compositeFrom({
+      compose: false,
+
+      steps: [
+        {
+          dependencies: [
+            input.updateValue({
+              validate: isString,
+              default: 'foo',
+            }),
+          ],
+
+          compute: ({
+            [input.updateValue()]: value,
+          }) => `Xx_${value.repeat(2)}_xX`,
+        },
+      ],
+    });
+
+    t.match(composite, match);
+    t.equal(composite.expose.transform('foo'), 'Xx_foofoo_xX');
+  });
+
+  t.test(`compositeFrom: update from inputs`, t => {
+    t.plan(3);
+
+    const composite = compositeFrom({
+      inputMapping: {
+        myInput: input.updateValue({
+          validate: isString,
+          default: 'foo',
+        }),
+      },
+
+      inputDescriptions: {
+        myInput: input(),
+      },
+
+      steps: [
+        {
+          dependencies: [input('myInput')],
+          compute: (continuation, {
+            [input('myInput')]: value,
+          }) => continuation({
+            '#value': `Xx_${value.repeat(2)}_xX`,
+          }),
+        },
+
+        {
+          dependencies: ['#value'],
+          transform: (_value, continuation, {'#value': value}) =>
+            continuation(value),
+        },
+      ],
+    });
+
+    let continuationValue = null;
+    const continuation = value => {
+      continuationValue = value;
+      return continuationSymbol;
+    };
+
+    t.match(composite, {
+      ...match,
+
+      flags: {update: true, expose: true, compose: true},
+    });
+
+    t.equal(
+      composite.expose.transform('foo', continuation),
+      continuationSymbol);
+
+    t.equal(continuationValue, 'Xx_foofoo_xX');
+  });
+});
+
+t.test(`compositeFrom: dynamic input validation from type`, t => {
+  t.plan(2);
+
+  const composite = compositeFrom({
+    inputMapping: {
+      string:   input('string'),
+      number:   input('number'),
+      boolean:  input('boolean'),
+      function: input('function'),
+      object:   input('object'),
+      array:    input('array'),
+    },
+
+    inputDescriptions: {
+      string:   input({null: true, type: 'string'}),
+      number:   input({null: true, type: 'number'}),
+      boolean:  input({null: true, type: 'boolean'}),
+      function: input({null: true, type: 'function'}),
+      object:   input({null: true, type: 'object'}),
+      array:    input({null: true, type: 'array'}),
+    },
+
+    outputs: {'#result': '#result'},
+
+    steps: [
+      {compute: continuation => continuation({'#result': 'OK'})},
+    ],
+  });
+
+  const notCalledSymbol = Symbol('continuation not called');
+
+  let continuationValue;
+  const continuation = value => {
+    continuationValue = value;
+    return continuationSymbol;
+  };
+
+  let thrownError;
+
+  try {
+    continuationValue = notCalledSymbol;
+    thrownError = null;
+    composite.expose.compute(continuation, {
+      [input('string')]: 123,
+    });
+  } catch (error) {
+    thrownError = error;
+  }
+
+  t.equal(continuationValue, notCalledSymbol);
+  t.match(thrownError, {
+  });
+});
diff --git a/test/unit/data/templateCompositeFrom.js b/test/unit/data/templateCompositeFrom.js
new file mode 100644
index 0000000..2de1873
--- /dev/null
+++ b/test/unit/data/templateCompositeFrom.js
@@ -0,0 +1,209 @@
+import t from 'tap';
+
+import {isString} from '#validators';
+
+import {
+  compositeFrom,
+  continuationSymbol,
+  input,
+  templateCompositeFrom,
+} from '#composite';
+
+t.test(`templateCompositeFrom: basic behavior`, t => {
+  t.plan(1);
+
+  const myCoolUtility = templateCompositeFrom({
+    annotation: `myCoolUtility`,
+
+    inputs: {
+      foo: input(),
+    },
+
+    outputs: ['#bar'],
+
+    steps: () => [
+      {
+        dependencies: [input('foo')],
+        compute: (continuation, {
+          [input('foo')]: foo,
+        }) => continuation({
+          ['#bar']: (typeof foo).toUpperCase()
+        }),
+      },
+    ],
+  });
+
+  const instantiatedTemplate = myCoolUtility({
+    foo: 'color',
+  });
+
+  t.match(instantiatedTemplate.toDescription(), {
+    annotation: `myCoolUtility`,
+
+    inputMapping: {
+      foo: input.dependency('color'),
+    },
+
+    inputDescriptions: {
+      foo: input(),
+    },
+
+    outputs: {
+      '#bar': '#bar',
+    },
+
+    steps: Function,
+  });
+});
+
+t.test(`templateCompositeFrom: validate static input values`, t => {
+  t.plan(3);
+
+  const stub = {
+    annotation: 'stubComposite',
+    outputs: ['#result'],
+    steps: () => [{compute: continuation => continuation({'#result': 'OK'})}],
+  };
+
+  const quickThrows = (t, composite, inputOptions, ...errorMessages) =>
+    t.throws(
+      () => composite(inputOptions),
+      {
+        message: `Errors in input options passed to stubComposite`,
+        errors: errorMessages.map(message => ({message})),
+      });
+
+  t.test(`templateCompositeFrom: validate input token shapes`, t => {
+    t.plan(15);
+
+    const template1 = templateCompositeFrom({
+      ...stub, inputs: {
+        foo: input(),
+      },
+    });
+
+    t.doesNotThrow(
+      () => template1({foo: 'dependency'}));
+
+    t.doesNotThrow(
+      () => template1({foo: input.dependency('dependency')}));
+
+    t.doesNotThrow(
+      () => template1({foo: input.value('static value')}));
+
+    t.doesNotThrow(
+      () => template1({foo: input('outerInput')}));
+
+    t.doesNotThrow(
+      () => template1({foo: input.updateValue()}));
+
+    t.doesNotThrow(
+      () => template1({foo: input.myself()}));
+
+    quickThrows(t, template1,
+      {foo: input.staticValue()},
+      `foo: Expected dependency name or value-providing input() call, got input.staticValue`);
+
+    quickThrows(t, template1,
+      {foo: input.staticDependency()},
+      `foo: Expected dependency name or value-providing input() call, got input.staticDependency`);
+
+    const template2 = templateCompositeFrom({
+      ...stub, inputs: {
+        bar: input.staticDependency(),
+      },
+    });
+
+    t.doesNotThrow(
+      () => template2({bar: 'dependency'}));
+
+    t.doesNotThrow(
+      () => template2({bar: input.dependency('dependency')}));
+
+    quickThrows(t, template2,
+      {bar: input.value(123)},
+      `bar: Expected dependency name, got input.value`);
+
+    quickThrows(t, template2,
+      {bar: input('outOfPlace')},
+      `bar: Expected dependency name, got input`);
+
+    const template3 = templateCompositeFrom({
+      ...stub, inputs: {
+        baz: input.staticValue(),
+      },
+    });
+
+    t.doesNotThrow(
+      () => template3({baz: input.value(1025)}));
+
+    quickThrows(t, template3,
+      {baz: 'dependency'},
+      `baz: Expected input.value() call, got dependency name`);
+
+    quickThrows(t, template3,
+      {baz: input('outOfPlace')},
+      `baz: Expected input.value() call, got input() call`);
+  });
+
+  t.test(`templateCompositeFrom: validate missing / misplaced inputs`, t => {
+    t.plan(1);
+
+    const template = templateCompositeFrom({
+      ...stub, inputs: {
+        foo: input(),
+        bar: input(),
+      },
+    });
+
+    t.throws(
+      () => template({
+        baz: 'aeiou',
+        raz: input.value(123),
+      }),
+      {message: `Errors in input options passed to stubComposite`, errors: [
+        {message: `Unexpected input names: baz, raz`},
+        {message: `Required these inputs: foo, bar`},
+      ]});
+  });
+
+  t.test(`templateCompositeFrom: validate acceptsNull / defaultValue: null`, t => {
+    t.plan(3);
+
+    const template1 = templateCompositeFrom({
+      ...stub, inputs: {
+        foo: input(),
+      },
+    });
+
+    t.throws(
+      () => template1({}),
+      {message: `Errors in input options passed to stubComposite`, errors: [
+        {message: `Required these inputs: foo`},
+      ]},
+      `throws if input missing and not marked specially`);
+
+    const template2 = templateCompositeFrom({
+      ...stub, inputs: {
+        bar: input({acceptsNull: true}),
+      },
+    });
+
+    t.throws(
+      () => template2({}),
+      {message: `Errors in input options passed to stubComposite`, errors: [
+        {message: `Required these inputs: bar`},
+      ]},
+      `throws if input missing even if marked {acceptsNull}`);
+
+    const template3 = templateCompositeFrom({
+      ...stub, inputs: {
+        baz: input({defaultValue: null}),
+      },
+    });
+
+    t.doesNotThrow(
+      () => template3({}),
+      `does not throw if input missing if marked {defaultValue: null}`);
+  });
+});
diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js
new file mode 100644
index 0000000..46ea83b
--- /dev/null
+++ b/test/unit/data/things/album.js
@@ -0,0 +1,451 @@
+import t from 'tap';
+
+import {linkAndBindWikiData} from '#test-lib';
+import thingConstructors from '#things';
+
+const {
+  Album,
+  ArtTag,
+  Artist,
+  Track,
+} = thingConstructors;
+
+function stubArtTag(tagName = `Test Art Tag`) {
+  const tag = new ArtTag();
+  tag.name = tagName;
+
+  return tag;
+}
+
+function stubArtistAndContribs() {
+  const artist = new Artist();
+  artist.name = `Test Artist`;
+
+  const contribs = [{who: `Test Artist`, what: null}];
+  const badContribs = [{who: `Figment of Your Imagination`, what: null}];
+
+  return {artist, contribs, badContribs};
+}
+
+function stubTrack(directory = 'foo') {
+  const track = new Track();
+  track.directory = directory;
+
+  return track;
+}
+
+t.test(`Album.artTags`, t => {
+  t.plan(3);
+
+  const {artist, contribs} = stubArtistAndContribs();
+  const album = new Album();
+  const tag1 = stubArtTag(`Tag 1`);
+  const tag2 = stubArtTag(`Tag 2`);
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+    artTagData: [tag1, tag2],
+  });
+
+  t.same(album.artTags, [],
+    `artTags #1: defaults to empty array`);
+
+  album.artTags = [`Tag 1`, `Tag 2`];
+
+  t.same(album.artTags, [],
+    `artTags #2: is empty if album doesn't have cover artists`);
+
+  album.coverArtistContribs = contribs;
+
+  t.same(album.artTags, [tag1, tag2],
+    `artTags #3: resolves if album has cover artists`);
+});
+
+t.test(`Album.bannerDimensions`, t => {
+  t.plan(4);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.bannerDimensions, null,
+    `Album.bannerDimensions #1: defaults to null`);
+
+  album.bannerDimensions = [1200, 275];
+
+  t.equal(album.bannerDimensions, null,
+    `Album.bannerDimensions #2: is null if bannerArtistContribs empty`);
+
+  album.bannerArtistContribs = badContribs;
+
+  t.equal(album.bannerDimensions, null,
+    `Album.bannerDimensions #3: is null if bannerArtistContribs resolves empty`);
+
+  album.bannerArtistContribs = contribs;
+
+  t.same(album.bannerDimensions, [1200, 275],
+    `Album.bannerDimensions #4: is own value`);
+});
+
+t.test(`Album.bannerFileExtension`, t => {
+  t.plan(5);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.bannerFileExtension, null,
+    `Album.bannerFileExtension #1: defaults to null`);
+
+  album.bannerFileExtension = 'png';
+
+  t.equal(album.bannerFileExtension, null,
+    `Album.bannerFileExtension #2: is null if bannerArtistContribs empty`);
+
+  album.bannerArtistContribs = badContribs;
+
+  t.equal(album.bannerFileExtension, null,
+    `Album.bannerFileExtension #3: is null if bannerArtistContribs resolves empty`);
+
+  album.bannerArtistContribs = contribs;
+
+  t.equal(album.bannerFileExtension, 'png',
+    `Album.bannerFileExtension #4: is own value`);
+
+  album.bannerFileExtension = null;
+
+  t.equal(album.bannerFileExtension, 'jpg',
+    `Album.bannerFileExtension #5: defaults to jpg`);
+});
+
+t.test(`Album.bannerStyle`, t => {
+  t.plan(4);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.bannerStyle, null,
+    `Album.bannerStyle #1: defaults to null`);
+
+  album.bannerStyle = `opacity: 0.5`;
+
+  t.equal(album.bannerStyle, null,
+    `Album.bannerStyle #2: is null if bannerArtistContribs empty`);
+
+  album.bannerArtistContribs = badContribs;
+
+  t.equal(album.bannerStyle, null,
+    `Album.bannerStyle #3: is null if bannerArtistContribs resolves empty`);
+
+  album.bannerArtistContribs = contribs;
+
+  t.equal(album.bannerStyle, `opacity: 0.5`,
+    `Album.bannerStyle #4: is own value`);
+});
+
+t.test(`Album.coverArtDate`, t => {
+  t.plan(6);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.coverArtDate, null,
+    `Album.coverArtDate #1: defaults to null`);
+
+  album.date = new Date('2012-10-25');
+
+  t.equal(album.coverArtDate, null,
+    `Album.coverArtDate #2: is null if coverArtistContribs empty (1/2)`);
+
+  album.coverArtDate = new Date('2011-04-13');
+
+  t.equal(album.coverArtDate, null,
+    `Album.coverArtDate #3: is null if coverArtistContribs empty (2/2)`);
+
+  album.coverArtistContribs = contribs;
+
+  t.same(album.coverArtDate, new Date('2011-04-13'),
+    `Album.coverArtDate #4: is own value`);
+
+  album.coverArtDate = null;
+
+  t.same(album.coverArtDate, new Date(`2012-10-25`),
+    `Album.coverArtDate #5: inherits album release date`);
+
+  album.coverArtistContribs = badContribs;
+
+  t.equal(album.coverArtDate, null,
+    `Album.coverArtDate #6: is null if coverArtistContribs resolves empty`);
+});
+
+t.test(`Album.coverArtFileExtension`, t => {
+  t.plan(5);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.coverArtFileExtension, null,
+    `Album.coverArtFileExtension #1: is null if coverArtistContribs empty (1/2)`);
+
+  album.coverArtFileExtension = 'png';
+
+  t.equal(album.coverArtFileExtension, null,
+    `Album.coverArtFileExtension #2: is null if coverArtistContribs empty (2/2)`);
+
+  album.coverArtFileExtension = null;
+  album.coverArtistContribs = contribs;
+
+  t.equal(album.coverArtFileExtension, 'jpg',
+    `Album.coverArtFileExtension #3: defaults to jpg`);
+
+  album.coverArtFileExtension = 'png';
+
+  t.equal(album.coverArtFileExtension, 'png',
+    `Album.coverArtFileExtension #4: is own value`);
+
+  album.coverArtistContribs = badContribs;
+
+  t.equal(album.coverArtFileExtension, null,
+    `Album.coverArtFileExtension #5: is null if coverArtistContribs resolves empty`);
+});
+
+t.test(`Album.tracks`, t => {
+  t.plan(5);
+
+  const album = new Album();
+  const track1 = stubTrack('track1');
+  const track2 = stubTrack('track2');
+  const track3 = stubTrack('track3');
+  const tracks = [track1, track2, track3];
+
+  album.ownTrackData = tracks;
+
+  for (const track of tracks) {
+    track.albumData = [album];
+  }
+
+  t.same(album.tracks, [],
+    `Album.tracks #1: defaults to empty array`);
+
+  album.trackSections = [
+    {tracks: ['track:track1', 'track:track2', 'track:track3']},
+  ];
+
+  t.same(album.tracks, [track1, track2, track3],
+    `Album.tracks #2: pulls tracks from one track section`);
+
+  album.trackSections = [
+    {tracks: ['track:track1']},
+    {tracks: ['track:track2', 'track:track3']},
+  ];
+
+  t.same(album.tracks, [track1, track2, track3],
+    `Album.tracks #3: pulls tracks from multiple track sections`);
+
+  album.trackSections = [
+    {tracks: ['track:track1', 'track:does-not-exist']},
+    {tracks: ['track:this-one-neither', 'track:track2']},
+    {tracks: ['track:effectively-empty-section']},
+    {tracks: ['track:track3']},
+  ];
+
+  t.same(album.tracks, [track1, track2, track3],
+    `Album.tracks #4: filters out references without matches`);
+
+  album.trackSections = [
+    {tracks: ['track:track1']},
+    {},
+    {tracks: ['track:track2']},
+    {},
+    {},
+    {tracks: ['track:track3']},
+  ];
+
+  t.same(album.tracks, [track1, track2, track3],
+    `Album.tracks #5: skips missing tracks property`);
+});
+
+t.test(`Album.trackSections`, t => {
+  t.plan(7);
+
+  const album = new Album();
+  const track1 = stubTrack('track1');
+  const track2 = stubTrack('track2');
+  const track3 = stubTrack('track3');
+  const track4 = stubTrack('track4');
+  const tracks = [track1, track2, track3, track4];
+
+  album.ownTrackData = tracks;
+
+  for (const track of tracks) {
+    track.albumData = [album];
+  }
+
+  album.trackSections = [
+    {tracks: ['track:track1', 'track:track2']},
+    {tracks: ['track:track3', 'track:track4']},
+  ];
+
+  t.match(album.trackSections, [
+    {tracks: [track1, track2]},
+    {tracks: [track3, track4]},
+  ], `Album.trackSections #1: exposes tracks`);
+
+  t.match(album.trackSections, [
+    {tracks: [track1, track2], startIndex: 0},
+    {tracks: [track3, track4], startIndex: 2},
+  ], `Album.trackSections #2: exposes startIndex`);
+
+  album.trackSections = [
+    {name: 'First section', tracks: ['track:track1']},
+    {name: 'Second section', tracks: ['track:track2']},
+    {tracks: ['track:track3']},
+  ];
+
+  t.match(album.trackSections, [
+    {name: 'First section', tracks: [track1]},
+    {name: 'Second section', tracks: [track2]},
+    {name: 'Unnamed Track Section', tracks: [track3]},
+  ], `Album.trackSections #3: exposes name, with fallback value`);
+
+  album.color = '#123456';
+
+  album.trackSections = [
+    {tracks: ['track:track1'], color: null},
+    {tracks: ['track:track2'], color: '#abcdef'},
+    {tracks: ['track:track3'], color: null},
+  ];
+
+  t.match(album.trackSections, [
+    {tracks: [track1], color: '#123456'},
+    {tracks: [track2], color: '#abcdef'},
+    {tracks: [track3], color: '#123456'},
+  ], `Album.trackSections #4: exposes color, inherited from album`);
+
+  album.trackSections = [
+    {tracks: ['track:track1'], dateOriginallyReleased: null},
+    {tracks: ['track:track2'], dateOriginallyReleased: new Date('2009-04-11')},
+    {tracks: ['track:track3'], dateOriginallyReleased: null},
+  ];
+
+  t.match(album.trackSections, [
+    {tracks: [track1], dateOriginallyReleased: null},
+    {tracks: [track2], dateOriginallyReleased: new Date('2009-04-11')},
+    {tracks: [track3], dateOriginallyReleased: null},
+  ], `Album.trackSections #5: exposes dateOriginallyReleased, if present`);
+
+  album.trackSections = [
+    {tracks: ['track:track1'], isDefaultTrackSection: true},
+    {tracks: ['track:track2'], isDefaultTrackSection: false},
+    {tracks: ['track:track3'], isDefaultTrackSection: null},
+  ];
+
+  t.match(album.trackSections, [
+    {tracks: [track1], isDefaultTrackSection: true},
+    {tracks: [track2], isDefaultTrackSection: false},
+    {tracks: [track3], isDefaultTrackSection: false},
+  ], `Album.trackSections #6: exposes isDefaultTrackSection, defaults to false`);
+
+  album.trackSections = [
+    {tracks: ['track:track1', 'track:track2', 'track:snooping'], color: '#112233'},
+    {tracks: ['track:track3', 'track:as-usual'],                 color: '#334455'},
+    {tracks: [],                                                 color: '#bbbbba'},
+    {tracks: ['track:icy', 'track:chilly', 'track:frigid'],      color: '#556677'},
+    {tracks: ['track:track4'],                                   color: '#778899'},
+  ];
+
+  t.match(album.trackSections, [
+    {tracks: [track1, track2], color: '#112233'},
+    {tracks: [track3],         color: '#334455'},
+    {tracks: [track4],         color: '#778899'},
+  ], `Album.trackSections #7: filters out references without matches & empty sections`);
+});
+
+t.test(`Album.wallpaperFileExtension`, t => {
+  t.plan(5);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.wallpaperFileExtension, null,
+    `Album.wallpaperFileExtension #1: defaults to null`);
+
+  album.wallpaperFileExtension = 'png';
+
+  t.equal(album.wallpaperFileExtension, null,
+    `Album.wallpaperFileExtension #2: is null if wallpaperArtistContribs empty`);
+
+  album.wallpaperArtistContribs = contribs;
+
+  t.equal(album.wallpaperFileExtension, 'png',
+    `Album.wallpaperFileExtension #3: is own value`);
+
+  album.wallpaperFileExtension = null;
+
+  t.equal(album.wallpaperFileExtension, 'jpg',
+    `Album.wallpaperFileExtension #4: defaults to jpg`);
+
+  album.wallpaperArtistContribs = badContribs;
+
+  t.equal(album.wallpaperFileExtension, null,
+    `Album.wallpaperFileExtension #5: is null if wallpaperArtistContribs resolves empty`);
+});
+
+t.test(`Album.wallpaperStyle`, t => {
+  t.plan(4);
+
+  const album = new Album();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+  });
+
+  t.equal(album.wallpaperStyle, null,
+    `Album.wallpaperStyle #1: defaults to null`);
+
+  album.wallpaperStyle = `opacity: 0.5`;
+
+  t.equal(album.wallpaperStyle, null,
+    `Album.wallpaperStyle #2: is null if wallpaperArtistContribs empty`);
+
+  album.wallpaperArtistContribs = badContribs;
+
+  t.equal(album.wallpaperStyle, null,
+    `Album.wallpaperStyle #3: is null if wallpaperArtistContribs resolves empty`);
+
+  album.wallpaperArtistContribs = contribs;
+
+  t.equal(album.wallpaperStyle, `opacity: 0.5`,
+    `Album.wallpaperStyle #4: is own value`);
+});
diff --git a/test/unit/data/things/art-tag.js b/test/unit/data/things/art-tag.js
new file mode 100644
index 0000000..561c93e
--- /dev/null
+++ b/test/unit/data/things/art-tag.js
@@ -0,0 +1,71 @@
+import t from 'tap';
+
+import {linkAndBindWikiData} from '#test-lib';
+import thingConstructors from '#things';
+
+const {
+  Album,
+  Artist,
+  ArtTag,
+  Track,
+} = thingConstructors;
+
+function stubAlbum(tracks, directory = 'bar') {
+  const album = new Album();
+  album.directory = directory;
+
+  const trackRefs = tracks.map(t => Thing.getReference(t));
+  album.trackSections = [{tracks: trackRefs}];
+
+  return album;
+}
+
+function stubTrack(directory = 'foo') {
+  const track = new Track();
+  track.directory = directory;
+
+  return track;
+}
+
+function stubTrackAndAlbum(trackDirectory = 'foo', albumDirectory = 'bar') {
+  const track = stubTrack(trackDirectory);
+  const album = stubAlbum([track], albumDirectory);
+
+  return {track, album};
+}
+
+function stubArtist(artistName = `Test Artist`) {
+  const artist = new Artist();
+  artist.name = artistName;
+
+  return artist;
+}
+
+function stubArtistAndContribs(artistName = `Test Artist`) {
+  const artist = stubArtist(artistName);
+  const contribs = [{who: artistName, what: null}];
+  const badContribs = [{who: `Figment of Your Imagination`, what: null}];
+
+  return {artist, contribs, badContribs};
+}
+
+t.test(`ArtTag.nameShort`, t => {
+  t.plan(3);
+
+  const artTag = new ArtTag();
+
+  artTag.name = `Dave Strider`;
+
+  t.equal(artTag.nameShort, `Dave Strider`,
+    `ArtTag #1: defaults to name`);
+
+  artTag.name = `Dave Strider (Homestuck)`;
+
+  t.equal(artTag.nameShort, `Dave Strider`,
+    `ArtTag #2: trims parenthical part at end`);
+
+  artTag.name = `This (And) That (Then)`;
+
+  t.equal(artTag.nameShort, `This (And) That`,
+    `ArtTag #2: doesn't trim midlde parenthical part`);
+});
diff --git a/test/unit/data/things/flash.js b/test/unit/data/things/flash.js
new file mode 100644
index 0000000..6205960
--- /dev/null
+++ b/test/unit/data/things/flash.js
@@ -0,0 +1,55 @@
+import t from 'tap';
+
+import {linkAndBindWikiData} from '#test-lib';
+import thingConstructors from '#things';
+
+const {
+  Flash,
+  FlashAct,
+  Thing,
+} = thingConstructors;
+
+function stubFlash(directory = 'foo') {
+  const flash = new Flash();
+  flash.directory = directory;
+
+  return flash;
+}
+
+function stubFlashAct(flashes, directory = 'bar') {
+  const flashAct = new FlashAct();
+  flashAct.directory = directory;
+  flashAct.flashes = flashes.map(flash => Thing.getReference(flash));
+
+  return flashAct;
+}
+
+t.test(`Flash.color`, t => {
+  t.plan(4);
+
+  const flash = stubFlash();
+  const flashAct = stubFlashAct([flash]);
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    flashData: [flash],
+    flashActData: [flashAct],
+  });
+
+  t.equal(flash.color, null,
+    `color #1: defaults to null`);
+
+  flashAct.color = '#abcdef';
+  XXX_decacheWikiData();
+
+  t.equal(flash.color, '#abcdef',
+    `color #2: inherits from flash act`);
+
+  flash.color = '#123456';
+
+  t.equal(flash.color, '#123456',
+    `color #3: is own value`);
+
+  t.throws(() => { flash.color = '#aeiouw'; },
+    {cause: TypeError},
+    `color #4: must be set to valid color`);
+});
diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js
new file mode 100644
index 0000000..b1c1611
--- /dev/null
+++ b/test/unit/data/things/track.js
@@ -0,0 +1,759 @@
+import t from 'tap';
+
+import {linkAndBindWikiData} from '#test-lib';
+import thingConstructors from '#things';
+
+const {
+  Album,
+  ArtTag,
+  Artist,
+  Flash,
+  FlashAct,
+  Thing,
+  Track,
+} = thingConstructors;
+
+function stubAlbum(tracks, directory = 'bar') {
+  const album = new Album();
+  album.directory = directory;
+
+  const trackRefs = tracks.map(t => Thing.getReference(t));
+  album.trackSections = [{tracks: trackRefs}];
+
+  return album;
+}
+
+function stubTrack(directory = 'foo') {
+  const track = new Track();
+  track.directory = directory;
+
+  return track;
+}
+
+function stubTrackAndAlbum(trackDirectory = 'foo', albumDirectory = 'bar') {
+  const track = stubTrack(trackDirectory);
+  const album = stubAlbum([track], albumDirectory);
+
+  return {track, album};
+}
+
+function stubArtist(artistName = `Test Artist`) {
+  const artist = new Artist();
+  artist.name = artistName;
+
+  return artist;
+}
+
+function stubArtistAndContribs(artistName = `Test Artist`) {
+  const artist = stubArtist(artistName);
+  const contribs = [{who: artistName, what: null}];
+  const badContribs = [{who: `Figment of Your Imagination`, what: null}];
+
+  return {artist, contribs, badContribs};
+}
+
+function stubArtTag(tagName = `Test Art Tag`) {
+  const tag = new ArtTag();
+  tag.name = tagName;
+
+  return tag;
+}
+
+function stubFlashAndAct(directory = 'zam') {
+  const flash = new Flash();
+  flash.directory = directory;
+
+  const flashAct = new FlashAct();
+  flashAct.flashes = [Thing.getReference(flash)];
+
+  return {flash, flashAct};
+}
+
+t.test(`Track.album`, t => {
+  t.plan(6);
+
+  // Note: These asserts use manual albumData/trackData relationships
+  // to illustrate more specifically the properties which are expected to
+  // be relevant for this case. Other properties use the same underlying
+  // get-album behavior as Track.album so aren't tested as aggressively.
+
+  const track1 = stubTrack('track1');
+  const track2 = stubTrack('track2');
+  const album1 = new Album();
+  const album2 = new Album();
+
+  t.equal(track1.album, null,
+    `album #1: defaults to null`);
+
+  track1.albumData = [album1, album2];
+  track2.albumData = [album1, album2];
+  album1.ownTrackData = [track1, track2];
+  album2.ownTrackData = [track1, track2];
+  album1.trackSections = [{tracks: ['track:track1']}];
+  album2.trackSections = [{tracks: ['track:track2']}];
+
+  t.equal(track1.album, album1,
+    `album #2: is album when album's trackSections matches track`);
+
+  track1.albumData = [album2, album1];
+
+  t.equal(track1.album, album1,
+    `album #3: is album when albumData is in different order`);
+
+  track1.albumData = [];
+
+  t.equal(track1.album, null,
+    `album #4: is null when track missing albumData`);
+
+  album1.ownTrackData = [];
+  track1.albumData = [album1, album2];
+
+  t.equal(track1.album, null,
+    `album #5: is null when album missing ownTrackData`);
+
+  album1.ownTrackData = [track1, track2];
+  album1.trackSections = [{tracks: ['track:track2']}];
+
+  // XXX_decacheWikiData
+  track1.albumData = [];
+  track1.albumData = [album1, album2];
+
+  t.equal(track1.album, null,
+    `album #6: is null when album's trackSections don't match track`);
+});
+
+t.test(`Track.artTags`, t => {
+  t.plan(6);
+
+  const {track, album} = stubTrackAndAlbum();
+  const {artist, contribs} = stubArtistAndContribs();
+  const tag1 = stubArtTag(`Tag 1`);
+  const tag2 = stubArtTag(`Tag 2`);
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+    artTagData: [tag1, tag2],
+    trackData: [track],
+  });
+
+  t.same(track.artTags, [],
+    `artTags #1: defaults to empty array`);
+
+  track.artTags = [`Tag 1`, `Tag 2`];
+
+  t.same(track.artTags, [],
+    `artTags #2: is empty if track doesn't have cover artists`);
+
+  track.coverArtistContribs = contribs;
+
+  t.same(track.artTags, [tag1, tag2],
+    `artTags #3: resolves if track has cover artists`);
+
+  track.coverArtistContribs = null;
+  album.trackCoverArtistContribs = contribs;
+
+  XXX_decacheWikiData();
+
+  t.same(track.artTags, [tag1, tag2],
+    `artTags #4: resolves if track inherits cover artists`);
+
+  track.disableUniqueCoverArt = true;
+
+  t.same(track.artTags, [],
+    `artTags #5: is empty if track disables unique cover artwork`);
+
+  album.coverArtistContribs = contribs;
+  album.artTags = [`Tag 2`];
+
+  XXX_decacheWikiData();
+
+  t.notSame(track.artTags, [tag2],
+    `artTags #6: doesn't inherit from album's art tags`);
+});
+
+t.test(`Track.artistContribs`, t => {
+  t.plan(4);
+
+  const {track, album} = stubTrackAndAlbum();
+  const artist1 = stubArtist(`Artist 1`);
+  const artist2 = stubArtist(`Artist 2`);
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist1, artist2],
+    trackData: [track],
+  });
+
+  t.same(track.artistContribs, [],
+    `artistContribs #1: defaults to empty array`);
+
+  album.artistContribs = [
+    {who: `Artist 1`, what: `composition`},
+    {who: `Artist 2`, what: null},
+  ];
+
+  XXX_decacheWikiData();
+
+  t.same(track.artistContribs,
+    [{who: artist1, what: `composition`}, {who: artist2, what: null}],
+    `artistContribs #2: inherits album artistContribs`);
+
+  track.artistContribs = [
+    {who: `Artist 1`, what: `arrangement`},
+  ];
+
+  t.same(track.artistContribs, [{who: artist1, what: `arrangement`}],
+    `artistContribs #3: resolves from own value`);
+
+  track.artistContribs = [
+    {who: `Artist 1`, what: `snooping`},
+    {who: `Artist 413`, what: `as`},
+    {who: `Artist 2`, what: `usual`},
+  ];
+
+  t.same(track.artistContribs,
+    [{who: artist1, what: `snooping`}, {who: artist2, what: `usual`}],
+    `artistContribs #4: filters out names without matches`);
+});
+
+t.test(`Track.color`, t => {
+  t.plan(5);
+
+  const {track, album} = stubTrackAndAlbum();
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    trackData: [track],
+  });
+
+  t.equal(track.color, null,
+    `color #1: defaults to null`);
+
+  album.color = '#abcdef';
+  album.trackSections = [{
+    color: '#beeeef',
+    tracks: [Thing.getReference(track)],
+  }];
+  XXX_decacheWikiData();
+
+  t.equal(track.color, '#beeeef',
+    `color #2: inherits from track section before album`);
+
+  // Replace the album with a completely fake one. This isn't realistic, since
+  // in correct data, Album.tracks depends on Albums.trackSections and so the
+  // track's album will always have a corresponding track section. But if that
+  // connection breaks for some future reason (with the album still present),
+  // Track.color should still inherit directly from the album.
+  track.albumData = [
+    {
+      constructor: {[Thing.referenceType]: 'album'},
+      color: '#abcdef',
+      tracks: [track],
+      trackSections: [
+        {color: '#baaaad', tracks: []},
+      ],
+    },
+  ];
+
+  t.equal(track.color, '#abcdef',
+    `color #3: inherits from album without matching track section`);
+
+  track.color = '#123456';
+
+  t.equal(track.color, '#123456',
+    `color #4: is own value`);
+
+  t.throws(() => { track.color = '#aeiouw'; },
+    {cause: TypeError},
+    `color #5: must be set to valid color`);
+});
+
+t.test(`Track.commentatorArtists`, t => {
+  t.plan(8);
+
+  const track = new Track();
+  const artist1 = stubArtist(`SnooPING`);
+  const artist2 = stubArtist(`ASUsual`);
+  const artist3 = stubArtist(`Icy`);
+
+  linkAndBindWikiData({
+    trackData: [track],
+    artistData: [artist1, artist2, artist3],
+  });
+
+  // Keep track of the last commentary string in a separate value, since
+  // the track.commentary property exposes as a completely different format
+  // (i.e. an array of objects, one for each entry), and so isn't compatible
+  // with the += operator on its own.
+  let commentary;
+
+  track.commentary = commentary =
+    `<i>SnooPING:</i>\n` +
+    `Wow.\n`;
+
+  t.same(track.commentatorArtists, [artist1],
+    `Track.commentatorArtists #1: works with one commentator`);
+
+  track.commentary = commentary +=
+    `<i>ASUsual:</i>\n` +
+    `Yes!\n`;
+
+  t.same(track.commentatorArtists, [artist1, artist2],
+    `Track.commentatorArtists #2: works with two commentators`);
+
+  track.commentary = commentary +=
+    `<i>Icy|<b>Icy What You Did There</b>:</i>\n` +
+    `Incredible.\n`;
+
+  t.same(track.commentatorArtists, [artist1, artist2, artist3],
+    `Track.commentatorArtists #3: works with custom artist text`);
+
+  track.commentary = commentary =
+    `<i>Icy:</i> (project manager)\n` +
+    `Very good track.\n`;
+
+  t.same(track.commentatorArtists, [artist3],
+    `Track.commentatorArtists #4: works with annotation`);
+
+  track.commentary = commentary =
+    `<i>Icy:</i> (project manager, 08/15/2023)\n` +
+    `Very very good track.\n`;
+
+  t.same(track.commentatorArtists, [artist3],
+    `Track.commentatorArtists #5: works with date`);
+
+  track.commentary = commentary +=
+    `<i>Ohohohoho:</i>\n` +
+    `OHOHOHOHOHOHO...\n`;
+
+  t.same(track.commentatorArtists, [artist3],
+    `Track.commentatorArtists #6: ignores artist names not found`);
+
+  track.commentary = commentary +=
+    `<i>Icy:</i>\n` +
+    `I'm back!\n`;
+
+  t.same(track.commentatorArtists, [artist3],
+    `Track.commentatorArtists #7: ignores duplicate artist`);
+
+  track.commentary = commentary +=
+    `<i>SNooPING, ASUsual, Icy:</i>\n` +
+    `WITH ALL THREE POWERS COMBINED...`;
+
+  t.same(track.commentatorArtists, [artist3, artist1, artist2],
+    `Track.commentatorArtists #8: works with more than one artist in one entry`);
+});
+
+t.test(`Track.coverArtistContribs`, t => {
+  t.plan(5);
+
+  const {track, album} = stubTrackAndAlbum();
+  const artist1 = stubArtist(`Artist 1`);
+  const artist2 = stubArtist(`Artist 2`);
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist1, artist2],
+    trackData: [track],
+  });
+
+  t.same(track.coverArtistContribs, [],
+    `coverArtistContribs #1: defaults to empty array`);
+
+  album.trackCoverArtistContribs = [
+    {who: `Artist 1`, what: `lines`},
+    {who: `Artist 2`, what: null},
+  ];
+
+  XXX_decacheWikiData();
+
+  t.same(track.coverArtistContribs,
+    [{who: artist1, what: `lines`}, {who: artist2, what: null}],
+    `coverArtistContribs #2: inherits album trackCoverArtistContribs`);
+
+  track.coverArtistContribs = [
+    {who: `Artist 1`, what: `collage`},
+  ];
+
+  t.same(track.coverArtistContribs, [{who: artist1, what: `collage`}],
+    `coverArtistContribs #3: resolves from own value`);
+
+  track.coverArtistContribs = [
+    {who: `Artist 1`, what: `snooping`},
+    {who: `Artist 413`, what: `as`},
+    {who: `Artist 2`, what: `usual`},
+  ];
+
+  t.same(track.coverArtistContribs,
+    [{who: artist1, what: `snooping`}, {who: artist2, what: `usual`}],
+    `coverArtistContribs #4: filters out names without matches`);
+
+  track.disableUniqueCoverArt = true;
+
+  t.same(track.coverArtistContribs, [],
+    `coverArtistContribs #5: is empty if track disables unique cover artwork`);
+});
+
+t.test(`Track.coverArtDate`, t => {
+  t.plan(8);
+
+  const {track, album} = stubTrackAndAlbum();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+    trackData: [track],
+  });
+
+  track.coverArtistContribs = contribs;
+
+  t.equal(track.coverArtDate, null,
+    `coverArtDate #1: defaults to null`);
+
+  album.trackArtDate = new Date('2012-12-12');
+
+  XXX_decacheWikiData();
+
+  t.same(track.coverArtDate, new Date('2012-12-12'),
+    `coverArtDate #2: inherits album trackArtDate`);
+
+  track.coverArtDate = new Date('2009-09-09');
+
+  t.same(track.coverArtDate, new Date('2009-09-09'),
+    `coverArtDate #3: is own value`);
+
+  track.coverArtistContribs = [];
+
+  t.equal(track.coverArtDate, null,
+    `coverArtDate #4: is null if track coverArtistContribs empty`);
+
+  album.trackCoverArtistContribs = contribs;
+
+  XXX_decacheWikiData();
+
+  t.same(track.coverArtDate, new Date('2009-09-09'),
+    `coverArtDate #5: is not null if album trackCoverArtistContribs specified`);
+
+  album.trackCoverArtistContribs = badContribs;
+
+  XXX_decacheWikiData();
+
+  t.equal(track.coverArtDate, null,
+    `coverArtDate #6: is null if album trackCoverArtistContribs resolves empty`);
+
+  track.coverArtistContribs = badContribs;
+
+  t.equal(track.coverArtDate, null,
+    `coverArtDate #7: is null if track coverArtistContribs resolves empty`);
+
+  track.coverArtistContribs = contribs;
+  track.disableUniqueCoverArt = true;
+
+  t.equal(track.coverArtDate, null,
+    `coverArtDate #8: is null if track disables unique cover artwork`);
+});
+
+t.test(`Track.coverArtFileExtension`, t => {
+  t.plan(8);
+
+  const {track, album} = stubTrackAndAlbum();
+  const {artist, contribs} = stubArtistAndContribs();
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+    trackData: [track],
+  });
+
+  t.equal(track.coverArtFileExtension, null,
+    `coverArtFileExtension #1: defaults to null`);
+
+  track.coverArtistContribs = contribs;
+
+  t.equal(track.coverArtFileExtension, 'jpg',
+    `coverArtFileExtension #2: is jpg if has cover art and not further specified`);
+
+  track.coverArtistContribs = [];
+
+  album.coverArtistContribs = contribs;
+  XXX_decacheWikiData();
+
+  t.equal(track.coverArtFileExtension, null,
+    `coverArtFileExtension #3: only has value for unique cover art`);
+
+  track.coverArtistContribs = contribs;
+
+  album.trackCoverArtFileExtension = 'png';
+  XXX_decacheWikiData();
+
+  t.equal(track.coverArtFileExtension, 'png',
+    `coverArtFileExtension #4: inherits album trackCoverArtFileExtension (1/2)`);
+
+  track.coverArtFileExtension = 'gif';
+
+  t.equal(track.coverArtFileExtension, 'gif',
+    `coverArtFileExtension #5: is own value (1/2)`);
+
+  track.coverArtistContribs = [];
+
+  album.trackCoverArtistContribs = contribs;
+  XXX_decacheWikiData();
+
+  t.equal(track.coverArtFileExtension, 'gif',
+    `coverArtFileExtension #6: is own value (2/2)`);
+
+  track.coverArtFileExtension = null;
+
+  t.equal(track.coverArtFileExtension, 'png',
+    `coverArtFileExtension #7: inherits album trackCoverArtFileExtension (2/2)`);
+
+  track.disableUniqueCoverArt = true;
+
+  t.equal(track.coverArtFileExtension, null,
+    `coverArtFileExtension #8: is null if track disables unique cover art`);
+});
+
+t.test(`Track.date`, t => {
+  t.plan(3);
+
+  const {track, album} = stubTrackAndAlbum();
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    trackData: [track],
+  });
+
+  t.equal(track.date, null,
+    `date #1: defaults to null`);
+
+  album.date = new Date('2012-12-12');
+  XXX_decacheWikiData();
+
+  t.same(track.date, album.date,
+    `date #2: inherits from album`);
+
+  track.dateFirstReleased = new Date('2009-09-09');
+
+  t.same(track.date, new Date('2009-09-09'),
+    `date #3: is own dateFirstReleased`);
+});
+
+t.test(`Track.featuredInFlashes`, t => {
+  t.plan(2);
+
+  const {track, album} = stubTrackAndAlbum('track1');
+
+  const {flash: flash1, flashAct: flashAct1} = stubFlashAndAct('flash1');
+  const {flash: flash2, flashAct: flashAct2} = stubFlashAndAct('flash2');
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    trackData: [track],
+    flashData: [flash1, flash2],
+    flashActData: [flashAct1, flashAct2],
+  });
+
+  t.same(track.featuredInFlashes, [],
+    `featuredInFlashes #1: defaults to empty array`);
+
+  flash1.featuredTracks = ['track:track1'];
+  flash2.featuredTracks = ['track:track1'];
+  XXX_decacheWikiData();
+
+  t.same(track.featuredInFlashes, [flash1, flash2],
+    `featuredInFlashes #2: matches flashes' featuredTracks`);
+});
+
+t.test(`Track.hasUniqueCoverArt`, t => {
+  t.plan(7);
+
+  const {track, album} = stubTrackAndAlbum();
+  const {artist, contribs, badContribs} = stubArtistAndContribs();
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album],
+    artistData: [artist],
+    trackData: [track],
+  });
+
+  t.equal(track.hasUniqueCoverArt, false,
+    `hasUniqueCoverArt #1: defaults to false`);
+
+  album.trackCoverArtistContribs = contribs;
+  XXX_decacheWikiData();
+
+  t.equal(track.hasUniqueCoverArt, true,
+    `hasUniqueCoverArt #2: is true if album specifies trackCoverArtistContribs`);
+
+  track.disableUniqueCoverArt = true;
+
+  t.equal(track.hasUniqueCoverArt, false,
+    `hasUniqueCoverArt #3: is false if disableUniqueCoverArt is true (1/2)`);
+
+  track.disableUniqueCoverArt = false;
+
+  album.trackCoverArtistContribs = badContribs;
+  XXX_decacheWikiData();
+
+  t.equal(track.hasUniqueCoverArt, false,
+    `hasUniqueCoverArt #4: is false if album's trackCoverArtistContribs resolve empty`);
+
+  track.coverArtistContribs = contribs;
+
+  t.equal(track.hasUniqueCoverArt, true,
+    `hasUniqueCoverArt #5: is true if track specifies coverArtistContribs`);
+
+  track.disableUniqueCoverArt = true;
+
+  t.equal(track.hasUniqueCoverArt, false,
+    `hasUniqueCoverArt #6: is false if disableUniqueCoverArt is true (2/2)`);
+
+  track.disableUniqueCoverArt = false;
+
+  track.coverArtistContribs = badContribs;
+
+  t.equal(track.hasUniqueCoverArt, false,
+    `hasUniqueCoverArt #7: is false if track's coverArtistContribs resolve empty`);
+});
+
+t.test(`Track.originalReleaseTrack`, t => {
+  t.plan(3);
+
+  const {track: track1, album: album1} = stubTrackAndAlbum('track1');
+  const {track: track2, album: album2} = stubTrackAndAlbum('track2');
+
+  const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album1, album2],
+    trackData: [track1, track2],
+  });
+
+  t.equal(track2.originalReleaseTrack, null,
+    `originalReleaseTrack #1: defaults to null`);
+
+  track2.originalReleaseTrack = 'track:track1';
+
+  t.equal(track2.originalReleaseTrack, track1,
+    `originalReleaseTrack #2: is resolved from own value`);
+
+  track2.trackData = [];
+
+  t.equal(track2.originalReleaseTrack, null,
+    `originalReleaseTrack #3: is null when track missing trackData`);
+});
+
+t.test(`Track.otherReleases`, t => {
+  t.plan(6);
+
+  const {track: track1, album: album1} = stubTrackAndAlbum('track1');
+  const {track: track2, album: album2} = stubTrackAndAlbum('track2');
+  const {track: track3, album: album3} = stubTrackAndAlbum('track3');
+  const {track: track4, album: album4} = stubTrackAndAlbum('track4');
+
+  const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album1, album2, album3, album4],
+    trackData: [track1, track2, track3, track4],
+  });
+
+  t.same(track1.otherReleases, [],
+    `otherReleases #1: defaults to empty array`);
+
+  track2.originalReleaseTrack = 'track:track1';
+  track3.originalReleaseTrack = 'track:track1';
+  track4.originalReleaseTrack = 'track:track1';
+  XXX_decacheWikiData();
+
+  t.same(track1.otherReleases, [track2, track3, track4],
+    `otherReleases #2: otherReleases of original release are its rereleases`);
+
+  wikiData.trackData = [track1, track3, track2, track4];
+  linkWikiDataArrays();
+
+  t.same(track1.otherReleases, [track3, track2, track4],
+    `otherReleases #3: otherReleases matches trackData order`);
+
+  wikiData.trackData = [track3, track2, track1, track4];
+  linkWikiDataArrays();
+
+  t.same(track2.otherReleases, [track1, track3, track4],
+    `otherReleases #4: otherReleases of rerelease are original track then other rereleases (1/3)`);
+
+  t.same(track3.otherReleases, [track1, track2, track4],
+    `otherReleases #5: otherReleases of rerelease are original track then other rereleases (2/3)`);
+
+  t.same(track4.otherReleases, [track1, track3, track2],
+    `otherReleases #6: otherReleases of rerelease are original track then other rereleases (3/3)`);
+});
+
+t.test(`Track.referencedByTracks`, t => {
+  t.plan(4);
+
+  const {track: track1, album: album1} = stubTrackAndAlbum('track1');
+  const {track: track2, album: album2} = stubTrackAndAlbum('track2');
+  const {track: track3, album: album3} = stubTrackAndAlbum('track3');
+  const {track: track4, album: album4} = stubTrackAndAlbum('track4');
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album1, album2, album3, album4],
+    trackData: [track1, track2, track3, track4],
+  });
+
+  t.same(track1.referencedByTracks, [],
+    `referencedByTracks #1: defaults to empty array`);
+
+  track2.referencedTracks = ['track:track1'];
+  track3.referencedTracks = ['track:track1'];
+  XXX_decacheWikiData();
+
+  t.same(track1.referencedByTracks, [track2, track3],
+    `referencedByTracks #2: matches tracks' referencedTracks`);
+
+  track4.sampledTracks = ['track:track1'];
+  XXX_decacheWikiData();
+
+  t.same(track1.referencedByTracks, [track2, track3],
+    `referencedByTracks #3: doesn't match tracks' sampledTracks`);
+
+  track3.originalReleaseTrack = 'track:track2';
+  XXX_decacheWikiData();
+
+  t.same(track1.referencedByTracks, [track2],
+    `referencedByTracks #4: doesn't include rereleases`);
+});
+
+t.test(`Track.sampledByTracks`, t => {
+  t.plan(4);
+
+  const {track: track1, album: album1} = stubTrackAndAlbum('track1');
+  const {track: track2, album: album2} = stubTrackAndAlbum('track2');
+  const {track: track3, album: album3} = stubTrackAndAlbum('track3');
+  const {track: track4, album: album4} = stubTrackAndAlbum('track4');
+
+  const {XXX_decacheWikiData} = linkAndBindWikiData({
+    albumData: [album1, album2, album3, album4],
+    trackData: [track1, track2, track3, track4],
+  });
+
+  t.same(track1.sampledByTracks, [],
+    `sampledByTracks #1: defaults to empty array`);
+
+  track2.sampledTracks = ['track:track1'];
+  track3.sampledTracks = ['track:track1'];
+  XXX_decacheWikiData();
+
+  t.same(track1.sampledByTracks, [track2, track3],
+    `sampledByTracks #2: matches tracks' sampledTracks`);
+
+  track4.referencedTracks = ['track:track1'];
+  XXX_decacheWikiData();
+
+  t.same(track1.sampledByTracks, [track2, track3],
+    `sampledByTracks #3: doesn't match tracks' referencedTracks`);
+
+  track3.originalReleaseTrack = 'track:track2';
+  XXX_decacheWikiData();
+
+  t.same(track1.sampledByTracks, [track2],
+    `sampledByTracks #4: doesn't include rereleases`);
+});
diff --git a/test/unit/data/things/validators.js b/test/unit/data/things/validators.js
new file mode 100644
index 0000000..11134a9
--- /dev/null
+++ b/test/unit/data/things/validators.js
@@ -0,0 +1,440 @@
+import t from 'tap';
+import {showAggregate} from '#sugar';
+
+import {
+  // Basic types
+  isBoolean,
+  isCountingNumber,
+  isDate,
+  isNumber,
+  isString,
+  isStringNonEmpty,
+
+  // Complex types
+  isArray,
+  isObject,
+  validateArrayItems,
+
+  // Wiki data
+  isColor,
+  isCommentary,
+  isContentString,
+  isContribution,
+  isContributionList,
+  isDimensions,
+  isDirectory,
+  isDuration,
+  isFileExtension,
+  isName,
+  isURL,
+  validateReference,
+  validateReferenceList,
+
+  // Compositional utilities
+  anyOf,
+} from '#validators';
+
+function test(t, msg, fn) {
+  t.test(msg, t => {
+    try {
+      fn(t);
+    } catch (error) {
+      if (error instanceof AggregateError) {
+        showAggregate(error);
+      }
+      throw error;
+    }
+  });
+}
+
+// Basic types
+
+test(t, 'isBoolean', t => {
+  t.plan(4);
+  t.ok(isBoolean(true));
+  t.ok(isBoolean(false));
+  t.throws(() => isBoolean(1), TypeError);
+  t.throws(() => isBoolean('yes'), TypeError);
+});
+
+test(t, 'isNumber', t => {
+  t.plan(6);
+  t.ok(isNumber(123));
+  t.ok(isNumber(0.05));
+  t.ok(isNumber(0));
+  t.ok(isNumber(-10));
+  t.throws(() => isNumber('413'), TypeError);
+  t.throws(() => isNumber(true), TypeError);
+});
+
+test(t, 'isCountingNumber', t => {
+  t.plan(6);
+  t.ok(isCountingNumber(3));
+  t.ok(isCountingNumber(1));
+  t.throws(() => isCountingNumber(1.75), TypeError);
+  t.throws(() => isCountingNumber(0), TypeError);
+  t.throws(() => isCountingNumber(-1), TypeError);
+  t.throws(() => isCountingNumber('612'), TypeError);
+});
+
+test(t, 'isString', t => {
+  t.plan(3);
+  t.ok(isString('hello!'));
+  t.ok(isString(''));
+  t.throws(() => isString(100), TypeError);
+});
+
+test(t, 'isStringNonEmpty', t => {
+  t.plan(4);
+  t.ok(isStringNonEmpty('hello!'));
+  t.throws(() => isStringNonEmpty(''), TypeError);
+  t.throws(() => isStringNonEmpty('     '), TypeError);
+  t.throws(() => isStringNonEmpty(100), TypeError);
+});
+
+// Complex types
+
+test(t, 'isArray', t => {
+  t.plan(3);
+  t.ok(isArray([]));
+  t.throws(() => isArray({}), TypeError);
+  t.throws(() => isArray('1, 2, 3'), TypeError);
+});
+
+test(t, 'isDate', t => {
+  t.plan(3);
+  t.ok(isDate(new Date('2023-03-27 09:24:15')));
+  t.throws(() => isDate(new Date(Infinity)), TypeError);
+  t.throws(() => isDimensions('2023-03-27 09:24:15'), TypeError);
+});
+
+test(t, 'isObject', t => {
+  t.plan(3);
+  t.ok(isObject({}));
+  t.ok(isObject([]));
+  t.throws(() => isObject(null), TypeError);
+});
+
+test(t, 'validateArrayItems', t => {
+  t.plan(9);
+
+  t.ok(validateArrayItems(isNumber)([3, 4, 5]));
+  t.ok(validateArrayItems(validateArrayItems(isNumber))([[3, 4], [4, 5], [6, 7]]));
+
+  let caughtError = null;
+  try {
+    validateArrayItems(isNumber)([10, 20, 'one hundred million consorts', 30]);
+  } catch (err) {
+    caughtError = err;
+  }
+
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 1);
+  t.ok(caughtError.errors[0] instanceof Error);
+  t.equal(caughtError.errors[0][Symbol.for('hsmusic.annotateError.indexInSourceArray')], 2);
+  t.not(caughtError.errors[0].cause, null);
+  t.ok(caughtError.errors[0].cause instanceof TypeError);
+});
+
+// Wiki data
+
+t.test('isColor', t => {
+  t.plan(9);
+  t.ok(isColor('#123'));
+  t.ok(isColor('#1234'));
+  t.ok(isColor('#112233'));
+  t.ok(isColor('#11223344'));
+  t.ok(isColor('#abcdef00'));
+  t.ok(isColor('#ABCDEF'));
+  t.throws(() => isColor('#ggg'), TypeError);
+  t.throws(() => isColor('red'), TypeError);
+  t.throws(() => isColor('hsl(150deg 30% 60%)'), TypeError);
+});
+
+t.test('isCommentary', t => {
+  t.plan(9);
+
+  // TODO: Test specific error messages.
+  t.ok(isCommentary(`<i>Toby Fox:</i>\ndogsong.mp3`));
+  t.ok(isCommentary(`<i>Toby Fox:</i> (music)\ndogsong.mp3`));
+  t.throws(() => isCommentary(`dogsong.mp3\n<i>Toby Fox:</i>\ndogsong.mp3`));
+  t.throws(() => isCommentary(`<i>Toby Fox:</i> dogsong.mp3`));
+  t.throws(() => isCommentary(`<i>Toby Fox:</i> (music) dogsong.mp3`));
+  t.throws(() => isCommentary(`<i>I Have Nothing To Say:</i>`));
+  t.throws(() => isCommentary(123));
+  t.throws(() => isCommentary(``));
+  t.throws(() => isCommentary(`Technically, ah, er:</i>\nCorrect`));
+});
+
+t.test('isContentString', t => {
+  t.plan(12);
+
+  t.ok(isContentString(`Hello, world!`));
+  t.ok(isContentString(`Hello...\nWorld!`));
+
+  const quickThrows = (string, description) =>
+    t.throws(() => isContentString(string), description);
+
+  quickThrows(
+    `Snooping\xa0as usual, I\xa0\xa0\xa0SEE.`,
+    Object.assign(
+      new AggregateError([
+        new AggregateError([
+          new TypeError(`Replace "\xa0" (non-breaking space) with " " (normal space) between "ing" and "as " (pos: 9)`),
+          new TypeError(`Replace "\xa0\xa0\xa0" (non-breaking space) with "   " (normal space) between ", I" and "SEE" (pos: 21)`),
+        ], `Illegal characters found in content string`),
+      ], `Errors validating content string`),
+      {[Symbol.for(`hsmusic.aggregate.translucent`)]: 'single'}));
+
+  quickThrows(
+    `Oh\u200bdear,\n` +
+    `Oh dear,\n` +
+    `oh-dear-oh-dear-oh\u200bdear.`,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Delete "\u200b" (zero-width space) between "Oh" and "dea" (line: 1, col: 3)`),
+        new TypeError(`Delete "\u200b" (zero-width space) between "-oh" and "dea" (line: 3, col: 19)`),
+      ]),
+    ]));
+
+  quickThrows(
+    `Well the days start comin'\xa0\xa0\xa0\xa0\u200b\u200b\xa0\xa0\xa0\u200b\u200b\u200band they don't stop comin'`,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Replace "\xa0\xa0\xa0\xa0" (non-breaking space) with "    " (normal space) after "in'" (pos: 27)`),
+        new TypeError(`Delete "\u200b\u200b" (zero-width space) (pos: 31)`),
+        new TypeError(`Replace "\xa0\xa0\xa0" (non-breaking space) with "   " (normal space) (pos: 33)`),
+        new TypeError(`Delete "\u200b\u200b\u200b" (zero-width space) before "and" (pos: 36)`),
+      ]),
+    ]));
+
+  quickThrows(
+    `It's go-\u200bin',\n` +
+    `\u200bIt's goin',\u200b\n` +
+    `\u200b\u200bIt's going!`,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Delete "\u200b" (zero-width space) between "go-" and "in'" (line: 1, col: 9)`),
+        new TypeError(`Delete "\u200b" (zero-width space) before "It'" (line: 2, col: 1)`),
+        new TypeError(`Delete "\u200b" (zero-width space) after "n'," (line: 2, col: 13)`),
+        new TypeError(`Delete "\u200b\u200b" (zero-width space) before "It'" (line: 3, col: 1)`),
+      ]),
+    ]));
+
+  quickThrows(
+    `  Room at the start.`,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Matched "  " at start`),
+      ], `Whitespace found at start or end`),
+    ]));
+
+  quickThrows(
+    `Room at the end.      `,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Matched "      " at end`),
+      ], `Whitespace found at start or end`),
+    ]));
+
+  quickThrows(
+    `      Room on both sides. `,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Matched "      " at start`),
+        new TypeError(`Matched " " at end`),
+      ], `Whitespace found at start or end`),
+    ]));
+
+  quickThrows(
+    `We're going multiline! \n` +
+    `That we are, aye.    \n` +
+    `      \n`,
+    `Yessir.`,
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`Matched " " at end of line 1`),
+        new TypeError(`Matched "    " at end of line 2`),
+        new TypeError(`Matched "      " as all of line 3`),
+      ], `Whitespace found at end of line`),
+    ]));
+
+  t.doesNotThrow(() =>
+    isContentString(
+      `It's cool.\n` +
+      `  It's cool.\n` +
+      `    It's cool.\n` +
+      `      It's so cool.`));
+
+  t.doesNotThrow(() =>
+    isContentString(
+      `\n` +
+      `\n` +
+      `It's okay for\n` +
+      `blank lines\n` +
+      `\n` +
+      `just about anywhere.\n` +
+      ``));
+});
+
+t.test('isContribution', t => {
+  t.plan(4);
+  t.ok(isContribution({who: 'artist:toby-fox', what: 'Music'}));
+  t.ok(isContribution({who: 'Toby Fox'}));
+  t.throws(() => isContribution(({who: 'group:umspaf', what: 'Organizing'})),
+    {errors: /who/});
+  t.throws(() => isContribution(({who: 'artist:toby-fox', what: 123})),
+    {errors: /what/});
+});
+
+t.test('isContributionList', t => {
+  t.plan(4);
+  t.ok(isContributionList([{who: 'Beavis'}, {who: 'Butthead', what: 'Wrangling'}]));
+  t.ok(isContributionList([]));
+  t.throws(() => isContributionList(2));
+  t.throws(() => isContributionList(['Charlie', 'Woodstock']));
+});
+
+test(t, 'isDimensions', t => {
+  t.plan(6);
+  t.ok(isDimensions([1, 1]));
+  t.ok(isDimensions([50, 50]));
+  t.ok(isDimensions([5000, 1]));
+  t.throws(() => isDimensions([1]), TypeError);
+  t.throws(() => isDimensions([413, 612, 1025]), TypeError);
+  t.throws(() => isDimensions('800x200'), TypeError);
+});
+
+test(t, 'isDirectory', t => {
+  t.plan(6);
+  t.ok(isDirectory('savior-of-the-waking-world'));
+  t.ok(isDirectory('MeGaLoVania'));
+  t.ok(isDirectory('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'));
+  t.throws(() => isDirectory(123), TypeError);
+  t.throws(() => isDirectory(''), TypeError);
+  t.throws(() => isDirectory('troll saint nicholas and the quest for the holy pail'), TypeError);
+});
+
+test(t, 'isDuration', t => {
+  t.plan(5);
+  t.ok(isDuration(60));
+  t.ok(isDuration(0.02));
+  t.ok(isDuration(0));
+  t.throws(() => isDuration(-1), TypeError);
+  t.throws(() => isDuration('10:25'), TypeError);
+});
+
+test(t, 'isFileExtension', t => {
+  t.plan(6);
+  t.ok(isFileExtension('png'));
+  t.ok(isFileExtension('jpg'));
+  t.ok(isFileExtension('sub_loc'));
+  t.throws(() => isFileExtension(''), TypeError);
+  t.throws(() => isFileExtension('.jpg'), TypeError);
+  t.throws(() => isFileExtension('just an image bro!!!!'), TypeError);
+});
+
+t.test('isName', t => {
+  t.plan(4);
+  t.ok(isName('Dogz 2.0'));
+  t.ok(isName('album:this-track-is-only-named-thusly-to-give-niklink-a-headache'));
+  t.throws(() => isName(''));
+  t.throws(() => isName(612));
+});
+
+t.test('isURL', t => {
+  t.plan(4);
+  t.ok(isURL(`https://hsmusic.wiki/foo/bar/hi?baz=25#hash`));
+  t.throws(() => isURL(`/the/dog/zone/`));
+  t.throws(() => isURL(25));
+  t.throws(() => isURL(new URL(`https://hsmusic.wiki/perfectly/reasonable/`)));
+});
+
+test(t, 'validateReference', t => {
+  t.plan(16);
+
+  const typeless = validateReference();
+  const track = validateReference('track');
+  const album = validateReference('album');
+
+  t.ok(track('track:doctor'));
+  t.ok(track('track:MeGaLoVania'));
+  t.ok(track('Showtime (Imp Strife Mix)'));
+  t.throws(() => track('track:troll saint nic'), TypeError);
+  t.throws(() => track('track:'), TypeError);
+  t.throws(() => track('album:homestuck-vol-1'), TypeError);
+
+  t.ok(album('album:sburb'));
+  t.ok(album('album:the-wanderers'));
+  t.ok(album('Homestuck Vol. 8'));
+  t.throws(() => album('album:Hiveswap Friendsim'), TypeError);
+  t.throws(() => album('album:'), TypeError);
+  t.throws(() => album('track:showtime-piano-refrain'), TypeError);
+
+  t.ok(typeless('Hopes and Dreams'));
+  t.ok(typeless('track:snowdin-town'));
+  t.throws(() => typeless(''), TypeError);
+  t.throws(() => typeless('album:undertale-soundtrack'));
+});
+
+test(t, 'validateReferenceList', t => {
+  const track = validateReferenceList('track');
+  const artist = validateReferenceList('artist');
+
+  t.plan(11);
+
+  t.ok(track(['track:fallen-down', 'Once Upon a Time']));
+  t.ok(artist(['artist:toby-fox', 'Mark Hadley']));
+  t.ok(track(['track:amalgam']));
+  t.ok(track([]));
+
+  let caughtError = null;
+  try {
+    track(['Dog', 'album:vaporwave-2016', 'Cat', 'artist:john-madden']);
+  } catch (err) {
+    caughtError = err;
+  }
+
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 2);
+  t.ok(caughtError.errors[0] instanceof Error);
+  t.ok(caughtError.errors[0].cause instanceof TypeError);
+  t.ok(caughtError.errors[1] instanceof Error);
+  t.ok(caughtError.errors[0].cause instanceof TypeError);
+});
+
+test(t, 'anyOf', t => {
+  t.plan(11);
+
+  const isStringOrNumber = anyOf(isString, isNumber);
+
+  t.ok(isStringOrNumber('hello world'));
+  t.ok(isStringOrNumber(42));
+  t.throws(() => isStringOrNumber(false));
+
+  const mockError = new Error();
+  const neverSucceeds = () => {
+    throw mockError;
+  };
+
+  const isStringOrGetRekt = anyOf(isString, neverSucceeds);
+
+  t.ok(isStringOrGetRekt('phew!'));
+
+  let caughtError = null;
+  try {
+    isStringOrGetRekt(0xdeadbeef);
+  } catch (err) {
+    caughtError = err;
+  }
+
+  t.not(caughtError, null);
+  t.ok(caughtError instanceof AggregateError);
+  t.equal(caughtError.errors.length, 2);
+  t.ok(caughtError.errors[0] instanceof TypeError);
+  t.equal(caughtError.errors[0].check, isString);
+  t.equal(caughtError.errors[1], mockError);
+  t.equal(caughtError.errors[1].check, neverSucceeds);
+});
diff --git a/test/unit/util/html.js b/test/unit/util/html.js
new file mode 100644
index 0000000..1652aee
--- /dev/null
+++ b/test/unit/util/html.js
@@ -0,0 +1,927 @@
+import t from 'tap';
+
+import * as html from '#html';
+import {strictlyThrows} from '#test-lib';
+
+const {Tag, Attributes, Template} = html;
+
+t.test(`html.tag`, t => {
+  t.plan(14);
+
+  const tag1 =
+    html.tag('div',
+      {[html.onlyIfContent]: true, foo: 'bar'},
+      'child');
+
+  // 1-5: basic behavior when passing attributes
+  t.ok(tag1 instanceof Tag);
+  t.ok(tag1.onlyIfContent);
+  t.equal(tag1.attributes.get('foo'), 'bar');
+  t.equal(tag1.content.length, 1);
+  t.equal(tag1.content[0], 'child');
+
+  const tag2 = html.tag('div', ['two', 'children']);
+
+  // 6-8: basic behavior when not passing attributes
+  t.equal(tag2.content.length, 2);
+  t.equal(tag2.content[0], 'two');
+  t.equal(tag2.content[1], 'children');
+
+  const genericTag = html.tag('div');
+  const genericTemplate = html.template({
+    content: () => html.blank(),
+  });
+
+  // 9-10: tag treated as content, not attributes
+  const tag3 = html.tag('div', genericTag);
+  t.equal(tag3.content.length, 1);
+  t.equal(tag3.content[0], genericTag);
+
+  // 11-12: template treated as content, not attributes
+  const tag4 = html.tag('div', genericTemplate);
+  t.equal(tag4.content.length, 1);
+  t.equal(tag4.content[0], genericTemplate);
+
+  // 13-14: deep flattening support
+  const tag6 =
+    html.tag('div', [
+      true &&
+        [[[[[[
+          true &&
+            [[[[[`That's deep.`]]]]],
+        ]]]]]],
+    ]);
+  t.equal(tag6.content.length, 1);
+  t.equal(tag6.content[0], `That's deep.`);
+});
+
+t.test(`Tag (basic interface)`, t => {
+  t.plan(11);
+
+  const tag1 = new Tag();
+
+  // 1-5: essential properties & no arguments provided
+  t.equal(tag1.tagName, '');
+  t.ok(Array.isArray(tag1.content));
+  t.equal(tag1.content.length, 0);
+  t.ok(tag1.attributes instanceof Attributes);
+  t.equal(tag1.attributes.toString(), '');
+
+  const tag2 = new Tag('div', {id: 'banana'}, ['one', 'two', tag1]);
+
+  // 6-11: properties on basic usage
+  t.equal(tag2.tagName, 'div');
+  t.equal(tag2.content.length, 3);
+  t.equal(tag2.content[0], 'one');
+  t.equal(tag2.content[1], 'two');
+  t.equal(tag2.content[2], tag1);
+  t.equal(tag2.attributes.get('id'), 'banana');
+});
+
+t.test(`Tag (self-closing)`, t => {
+  t.plan(10);
+
+  const tag1 = new Tag('br');
+  const tag2 = new Tag('div');
+  const tag3 = new Tag('div');
+  tag3.tagName = 'br';
+
+  // 1-3: selfClosing depends on tagName
+  t.ok(tag1.selfClosing);
+  t.notOk(tag2.selfClosing);
+  t.ok(tag3.selfClosing);
+
+  // 4: constructing self-closing tag with content throws
+  t.throws(() => new Tag('br', null, 'bananas'), /self-closing/);
+
+  // 5: setting content on self-closing tag throws
+  t.throws(() => { tag1.content = ['suspicious']; }, /self-closing/);
+
+  // 6-9: setting empty content on self-closing tag doesn't throw
+  t.doesNotThrow(() => { tag1.content = null; });
+  t.doesNotThrow(() => { tag1.content = undefined; });
+  t.doesNotThrow(() => { tag1.content = ''; });
+  t.doesNotThrow(() => { tag1.content = [null, '', false]; });
+
+  const tag4 = new Tag('div', null, 'bananas');
+
+  // 10: changing tagName to self-closing when tag has content throws
+  t.throws(() => { tag4.tagName = 'br'; }, /self-closing/);
+});
+
+t.test(`Tag (properties from attributes - from constructor)`, t => {
+  t.plan(6);
+
+  const tag = new Tag('div', {
+    [html.onlyIfContent]: true,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '<br>',
+  });
+
+  // 1-3: basic exposed properties from attributes in constructor
+  t.ok(tag.onlyIfContent);
+  t.ok(tag.noEdgeWhitespace);
+  t.equal(tag.joinChildren, '<br>');
+
+  // 4-6: property values stored on attributes with public symbols
+  t.equal(tag.attributes.get(html.onlyIfContent), true);
+  t.equal(tag.attributes.get(html.noEdgeWhitespace), true);
+  t.equal(tag.attributes.get(html.joinChildren), '<br>');
+});
+
+t.test(`Tag (properties from attributes - mutating)`, t => {
+  t.plan(12);
+
+  // 1-3: exposed properties reflect reasonable attribute values
+
+  const tag1 = new Tag('div', {
+    [html.onlyIfContent]: true,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '<br>',
+  });
+
+  tag1.attributes.set(html.onlyIfContent, false);
+  tag1.attributes.remove(html.noEdgeWhitespace);
+  tag1.attributes.set(html.joinChildren, '🍇');
+
+  t.equal(tag1.onlyIfContent, false);
+  t.equal(tag1.noEdgeWhitespace, false);
+  t.equal(tag1.joinChildren, '🍇');
+
+  // 4-6: exposed properties reflect unreasonable attribute values
+
+  const tag2 = new Tag('div', {
+    [html.onlyIfContent]: true,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '<br>',
+  });
+
+  tag2.attributes.set(html.onlyIfContent, '');
+  tag2.attributes.set(html.noEdgeWhitespace, 12345);
+  tag2.attributes.set(html.joinChildren, 0.0001);
+
+  t.equal(tag2.onlyIfContent, false);
+  t.equal(tag2.noEdgeWhitespace, true);
+  t.equal(tag2.joinChildren, '0.0001');
+
+  // 7-9: attribute values reflect reasonable mutated properties
+
+  const tag3 = new Tag('div', null, {
+    [html.onlyIfContent]: false,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '🍜',
+  })
+
+  tag3.onlyIfContent = true;
+  tag3.noEdgeWhitespace = false;
+  tag3.joinChildren = '🦑';
+
+  t.equal(tag3.attributes.get(html.onlyIfContent), true);
+  t.equal(tag3.attributes.get(html.noEdgeWhitespace), undefined);
+  t.equal(tag3.joinChildren, '🦑');
+
+  // 10-12: attribute values reflect unreasonable mutated properties
+
+  const tag4 = new Tag('div', null, {
+    [html.onlyIfContent]: false,
+    [html.noEdgeWhitespace]: true,
+    [html.joinChildren]: '🍜',
+  });
+
+  tag4.onlyIfContent = 'armadillo';
+  tag4.noEdgeWhitespace = 0;
+  tag4.joinChildren = Infinity;
+
+  t.equal(tag4.attributes.get(html.onlyIfContent), true);
+  t.equal(tag4.attributes.get(html.noEdgeWhitespace), undefined);
+  t.equal(tag4.attributes.get(html.joinChildren), 'Infinity');
+});
+
+t.test(`Tag.toString`, t => {
+  t.plan(9);
+
+  // 1: basic behavior
+
+  const tag1 =
+    html.tag('div', 'Content');
+
+  t.equal(tag1.toString(),
+    `<div>Content</div>`);
+
+  // 2: stringifies nested element
+
+  const tag2 =
+    html.tag('div', html.tag('p', 'Content'));
+
+  t.equal(tag2.toString(),
+    `<div><p>Content</p></div>`);
+
+  // 3: stringifies attributes
+
+  const tag3 =
+    html.tag('div',
+      {
+        id: 'banana',
+        class: ['foo', 'bar'],
+        contenteditable: true,
+        biggerthanabreadbox: false,
+        saying: `"To light a candle is to cast a shadow..."`,
+        tabindex: 413,
+      },
+      'Content');
+
+  t.equal(tag3.toString(),
+    `<div id="banana" class="foo bar" contenteditable ` +
+    `saying="&quot;To light a candle is to cast a shadow...&quot;" ` +
+    `tabindex="413">Content</div>`);
+
+  // 4: attributes match input order
+
+  const tag4 =
+    html.tag('div',
+      {class: ['foo', 'bar'], id: 'banana'},
+      'Content');
+
+  t.equal(tag4.toString(),
+    `<div class="foo bar" id="banana">Content</div>`);
+
+  // 5: multiline contented indented
+
+  const tag5 =
+    html.tag('div', 'foo\nbar');
+
+  t.equal(tag5.toString(),
+    `<div>\n` +
+    `    foo\n` +
+    `    bar\n` +
+    `</div>`);
+
+  // 6: nested multiline content double-indented
+
+  const tag6 =
+    html.tag('div', [
+      html.tag('p',
+        'foo\nbar'),
+      html.tag('span', `I'm on one line!`),
+    ]);
+
+  t.equal(tag6.toString(),
+    `<div>\n` +
+    `    <p>\n` +
+    `        foo\n` +
+    `        bar\n` +
+    `    </p>\n` +
+    `    <span>I'm on one line!</span>\n` +
+    `</div>`);
+
+  // 7: self-closing (with attributes)
+
+  const tag7 =
+    html.tag('article', [
+      html.tag('h1', `Title`),
+      html.tag('hr', {style: `color: magenta`}),
+      html.tag('p', `Shenanigans!`),
+    ]);
+
+  t.equal(tag7.toString(),
+    `<article>\n` +
+    `    <h1>Title</h1>\n` +
+    `    <hr style="color: magenta">\n` +
+    `    <p>Shenanigans!</p>\n` +
+    `</article>`);
+
+  // 8-9: empty tagName passes content through directly
+
+  const tag8 =
+    html.tag(null, [
+      html.tag('h1', `Foo`),
+      html.tag(`h2`, `Bar`),
+    ]);
+
+  t.equal(tag8.toString(),
+    `<h1>Foo</h1>\n` +
+    `<h2>Bar</h2>`);
+
+  const tag9 =
+    html.tag(null, {
+      [html.joinChildren]: html.tag('br'),
+    }, [
+      `Say it with me...`,
+      `Supercalifragilisticexpialidocious!`
+    ]);
+
+  t.equal(tag9.toString(),
+    `Say it with me...\n` +
+    `<br>\n` +
+    `Supercalifragilisticexpialidocious!`);
+});
+
+t.test(`Tag.toString (onlyIfContent)`, t => {
+  t.plan(4);
+
+  // 1-2: basic behavior
+
+  const tag1 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      `Hello!`);
+
+  t.equal(tag1.toString(),
+    `<div>Hello!</div>`);
+
+  const tag2 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      '');
+
+  t.equal(tag2.toString(),
+    '');
+
+  // 3-4: nested onlyIfContent with "more" content
+
+  const tag3 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      [
+        '',
+        0,
+        html.tag('h1',
+          {[html.onlyIfContent]: true},
+          html.tag('strong',
+            {[html.onlyIfContent]: true})),
+        null,
+        false,
+      ]);
+
+  t.equal(tag3.toString(),
+    '');
+
+  const tag4 =
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+      [
+        '',
+        0,
+        html.tag('h1',
+          {[html.onlyIfContent]: true},
+          html.tag('strong')),
+        null,
+        false,
+      ]);
+
+  t.equal(tag4.toString(),
+    `<div><h1><strong></strong></h1></div>`);
+});
+
+t.test(`Tag.toString (joinChildren, noEdgeWhitespace)`, t => {
+  t.plan(6);
+
+  // 1: joinChildren: default (\n), noEdgeWhitespace: true
+
+  const tag1 =
+    html.tag('div',
+      {[html.noEdgeWhitespace]: true},
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag1.toString(),
+    `<div>Foo\n` +
+    `    Bar\n` +
+    `    Baz</div>`);
+
+  // 2: joinChildren: one-line string, noEdgeWhitespace: default (false)
+
+  const tag2 =
+    html.tag('div',
+      {
+        [html.joinChildren]:
+          html.tag('br', {location: '🍍'}),
+      },
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag2.toString(),
+    `<div>\n` +
+    `    Foo\n` +
+    `    <br location="🍍">\n` +
+    `    Bar\n` +
+    `    <br location="🍍">\n` +
+    `    Baz\n` +
+    `</div>`);
+
+  // 3-4: joinChildren: blank string, noEdgeWhitespace: default (false)
+
+  const tag3 =
+    html.tag('div',
+      {[html.joinChildren]: ''},
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag3.toString(),
+    `<div>FooBarBaz</div>`);
+
+  const tag4 =
+    html.tag('div',
+      {[html.joinChildren]: ''},
+      [
+        `Ain't I\na cute one?`,
+        `~`
+      ]);
+
+  t.equal(tag4.toString(),
+    `<div>\n` +
+    `    Ain't I\n` +
+    `    a cute one?~\n` +
+    `</div>`);
+
+  // 5: joinChildren: one-line string, noEdgeWhitespace: true
+
+  const tag5 =
+    html.tag('div',
+      {
+        [html.joinChildren]: html.tag('br'),
+        [html.noEdgeWhitespace]: true,
+      },
+      [
+        'Foo',
+        'Bar',
+        'Baz',
+      ]);
+
+  t.equal(tag5.toString(),
+    `<div>Foo\n` +
+    `    <br>\n` +
+    `    Bar\n` +
+    `    <br>\n` +
+    `    Baz</div>`);
+
+  // 6: joinChildren: empty string, noEdgeWhitespace: true
+
+  const tag6 =
+    html.tag('span',
+      {
+        [html.joinChildren]: '',
+        [html.noEdgeWhitespace]: true,
+      },
+      [
+        html.tag('i', `Oh yes~ `),
+        `You're a cute one`,
+        html.tag('sup', `💕`),
+      ]);
+
+  t.equal(tag6.toString(),
+    `<span><i>Oh yes~ </i>You're a cute one<sup>💕</sup></span>`);
+});
+
+t.test(`html.template`, t => {
+  t.plan(11);
+
+  let contentCalls;
+
+  // 1-4: basic behavior - no slots
+
+  contentCalls = 0;
+
+  const template1 = html.template({
+    content() {
+      contentCalls++;
+      return html.tag('hr');
+    },
+  });
+
+  t.equal(contentCalls, 0);
+  t.equal(template1.toString(), `<hr>`);
+  t.equal(contentCalls, 1);
+  template1.toString();
+  t.equal(contentCalls, 2);
+
+  // 5-10: basic behavior - slots
+
+  contentCalls = 0;
+
+  const template2 = html.template({
+    slots: {
+      foo: {
+        type: 'string',
+        default: 'Default Message',
+      },
+    },
+
+    content(slots) {
+      contentCalls++;
+      return html.tag('sub', slots.foo.toLowerCase());
+    },
+  });
+
+  t.equal(contentCalls, 0);
+  t.equal(template2.toString(), `<sub>default message</sub>`);
+  t.equal(contentCalls, 1);
+  template2.setSlot('foo', `R-r-really, me?`);
+  t.equal(contentCalls, 1);
+  t.equal(template2.toString(), `<sub>r-r-really, me?</sub>`);
+  t.equal(contentCalls, 2);
+
+  // 11: slot uses default only for null, not falsey
+
+  const template3 = html.template({
+    slots: {
+      slot1: {type: 'number', default: 123},
+      slot2: {type: 'number', default: 456},
+      slot3: {type: 'boolean', default: true},
+      slot4: {type: 'string', default: 'banana'},
+    },
+
+    content(slots) {
+      return html.tag('span', [
+        slots.slot1,
+        slots.slot2,
+        slots.slot3,
+        `(length: ${slots.slot4.length})`,
+      ].join(' '));
+    },
+  });
+
+  template3.setSlots({
+    slot1: null,
+    slot2: 0,
+    slot3: false,
+    slot4: '',
+  });
+
+  t.equal(template3.toString(), `<span>123 0 false (length: 0)</span>`);
+});
+
+t.test(`Template - description errors`, t => {
+  t.plan(14);
+
+  // 1-3: top-level description is object
+
+  strictlyThrows(t,
+    () => Template.validateDescription('snooping as usual'),
+    new TypeError(`Expected object, got string`));
+
+  strictlyThrows(t,
+    () => Template.validateDescription(),
+    new TypeError(`Expected object, got undefined`));
+
+  strictlyThrows(t,
+    () => Template.validateDescription(null),
+    new TypeError(`Expected object, got null`));
+
+  // 4-5: description.content is function
+
+  strictlyThrows(t,
+    () => Template.validateDescription({}),
+    new AggregateError([
+      new TypeError(`Expected description.content`),
+    ], `Errors validating template description`));
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      content: 'pingas',
+    }),
+    new AggregateError([
+      new TypeError(`Expected description.content to be function`),
+    ], `Errors validating template description`));
+
+  // 6: aggregate error includes template annotation
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      annotation: `my cool template`,
+      content: 'pingas',
+    }),
+    new AggregateError([
+      new TypeError(`Expected description.content to be function`),
+    ], `Errors validating template "my cool template" description`));
+
+  // 7: description.slots is object
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: 'pingas',
+      content: () => {},
+    }),
+    new AggregateError([
+      new TypeError(`Expected description.slots to be object`),
+    ], `Errors validating template description`));
+
+  // 8: slot description is object
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: 'pingas',
+      },
+
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(mySlot) Expected slot description to be object`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`))
+
+  // 9-10: slot description has validate or default, not both
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: {},
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(mySlot) Expected either slot validate or type`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: {
+          validate: 'pingas',
+          type: 'pingas',
+        },
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(mySlot) Don't specify both slot validate and type`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  // 11: slot validate is function
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: {
+          validate: 'pingas',
+        },
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(mySlot) Expected slot validate to be function`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  // 12: slot type is name of built-in type
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        mySlot: {
+          type: 'pingas',
+        },
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        /\(mySlot\) Expected slot type to be one of/,
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  // 13: slot type has specific errors for function & object
+
+  strictlyThrows(t,
+    () => Template.validateDescription({
+      slots: {
+        slot1: {type: 'function'},
+        slot2: {type: 'object'},
+      },
+      content: () => {},
+    }),
+    new AggregateError([
+      new AggregateError([
+        new TypeError(`(slot1) Functions shouldn't be provided to slots`),
+        new TypeError(`(slot2) Provide validate function instead of type: object`),
+      ], `Errors in slot descriptions`),
+    ], `Errors validating template description`));
+
+  // 14: all intended types are supported
+
+  t.doesNotThrow(
+    () => Template.validateDescription({
+      slots: {
+        slot1: {type: 'string'},
+        slot2: {type: 'number'},
+        slot3: {type: 'bigint'},
+        slot4: {type: 'boolean'},
+        slot5: {type: 'symbol'},
+        slot6: {type: 'html', mutable: false},
+      },
+      content: () => {},
+    }));
+});
+
+t.test(`Template - slot value errors`, t => {
+  t.plan(8);
+
+  const template1 = html.template({
+    slots: {
+      basicString: {type: 'string'},
+      basicNumber: {type: 'number'},
+      basicBigint: {type: 'bigint'},
+      basicBoolean: {type: 'boolean'},
+      basicSymbol: {type: 'symbol'},
+      basicHTML: {type: 'html', mutable: false},
+    },
+
+    content: slots =>
+      html.tag('p', [
+        `string: ${slots.basicString}`,
+        `number: ${slots.basicNumber}`,
+        `bigint: ${slots.basicBigint}`,
+        `boolean: ${slots.basicBoolean}`,
+        `symbol: ${slots.basicSymbol?.toString()   ?? 'no symbol'}`,
+
+        `html:`,
+        slots.basicHTML,
+      ]),
+  });
+
+  // 1-2: basic values match type, no error & reflected in content
+
+  t.doesNotThrow(
+    () => template1.setSlots({
+      basicString: 'pingas',
+      basicNumber: 123,
+      basicBigint: 1234567891234567n,
+      basicBoolean: true,
+      basicSymbol: Symbol(`sup`),
+      basicHTML: html.tag('span', `SnooPING AS usual, I see!`),
+    }));
+
+  t.equal(
+    template1.toString(),
+    html.tag('p', [
+      `string: pingas`,
+      `number: 123`,
+      `bigint: 1234567891234567`,
+      `boolean: true`,
+      `symbol: Symbol(sup)`,
+      `html:`,
+      html.tag('span', `SnooPING AS usual, I see!`),
+    ]).toString());
+
+  // 3-4: null matches any type, no error & reflected in content
+
+  t.doesNotThrow(
+    () => template1.setSlots({
+      basicString: null,
+      basicNumber: null,
+      basicBigint: null,
+      basicBoolean: null,
+      basicSymbol: null,
+      basicHTML: null,
+    }));
+
+  t.equal(
+    template1.toString(),
+    html.tag('p', [
+      `string: null`,
+      `number: null`,
+      `bigint: null`,
+      `boolean: null`,
+      `symbol: no symbol`,
+      `html:`,
+    ]).toString());
+
+  // 5-6: type mismatch throws error, invalidates entire setSlots call
+
+  template1.setSlots({
+    basicString: 'pingas',
+    basicNumber: 123,
+  });
+
+  strictlyThrows(t,
+    () => template1.setSlots({
+      basicBoolean: false,
+      basicSymbol: `I'm not a symbol!`,
+    }),
+    new AggregateError([
+      new TypeError(`(basicSymbol) Slot expects symbol, got string`),
+    ], `Error validating template slots`))
+
+  t.equal(
+    template1.toString(),
+    html.tag('p', [
+      `string: pingas`,
+      `number: 123`,
+      `bigint: null`,
+      `boolean: null`,
+      `symbol: no symbol`,
+      `html:`,
+    ]).toString());
+
+  const template2 = html.template({
+    slots: {
+      strictArrayOfStrings: {
+        validate: v => v.strictArrayOf(v.isString),
+        default: `Array Of Strings Fallback`.split(' '),
+      },
+
+      sparseArrayOfStrings: {
+        validate: v => v.sparseArrayOf(v.isString),
+        default: ['sparse', null, false, 'strings'],
+      },
+
+      arrayOfHTML: {
+        validate: v => v.strictArrayOf(v.isHTML),
+        default: [],
+      },
+    },
+
+    content: slots =>
+      html.tag('p', [
+        html.tag('strong', slots.strictArrayOfStrings),
+        `sparseArrayOfStrings length: ${slots.sparseArrayOfStrings.length}`,
+        `arrayOfHTML length: ${slots.arrayOfHTML.length}`,
+      ]),
+  });
+
+  // 7: isHTML behaves as it should, validate fails with validate throw
+
+  strictlyThrows(t,
+    () => template2.setSlots({
+      strictArrayOfStrings: ['you got it', 'pingas', 0xdeadbeef],
+      sparseArrayOfStrings: ['you got it', null, false, 'pingas'],
+      arrayOfHTML: [
+        html.tag('span'),
+        html.template({content: () => 'dog'}),
+        html.blank(),
+        false && 'dogs',
+        null,
+        undefined,
+        html.tags([
+          html.tag('span', 'usual'),
+          html.tag('span', 'i'),
+        ]),
+      ],
+    }),
+    new AggregateError([
+      {
+        name: 'AggregateError',
+        message: /^\(strictArrayOfStrings\)/,
+        errors: {length: 1},
+      },
+    ], `Error validating template slots`));
+
+  // 8: default slot values respected
+
+  t.equal(
+    template2.toString(),
+    html.tag('p', [
+      html.tag('strong', [
+        `Array`,
+        `Of`,
+        `Strings`,
+        `Fallback`,
+      ]),
+      `sparseArrayOfStrings length: 4`,
+      `arrayOfHTML length: 0`,
+    ]).toString());
+});
+
+t.test(`Stationery`, t => {
+  t.plan(3);
+
+  // 1-3: basic behavior
+
+  const stationery1 = new html.Stationery({
+    slots: {
+      slot1: {type: 'string', default: 'apricot'},
+      slot2: {type: 'string', default: 'disaster'},
+    },
+
+    content: ({slot1, slot2}) => html.tag('span', `${slot1} ${slot2}`),
+  });
+
+  const template1 = stationery1.template();
+  const template2 = stationery1.template();
+
+  template2.setSlots({slot1: 'aquaduct', slot2: 'dichotomy'});
+
+  const template3 = stationery1.template();
+
+  template3.setSlots({slot2: 'vinaigrette'});
+
+  t.equal(template1.toString(), `<span>apricot disaster</span>`);
+  t.equal(template2.toString(), `<span>aquaduct dichotomy</span>`);
+  t.equal(template3.toString(), `<span>apricot vinaigrette</span>`);
+});